Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 59 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,17 @@ jobs:
run: |
cat .versions >> $GITHUB_ENV

- name: Build & push build image
- name: Prepare release image refs
run: |
tag="${{ github.event.release.tag_name }}"
tmp_suffix="tmp-${{ github.run_id }}-${{ github.run_attempt }}"
echo "BUILD_IMAGE_REF=ghcr.io/${{ env.GHCR_ORG }}/supragoflow-build:${tag}" >> $GITHUB_ENV
echo "DEV_IMAGE_REF=ghcr.io/${{ env.GHCR_ORG }}/supragoflow-dev:${tag}" >> $GITHUB_ENV
echo "BUILD_IMAGE_TMP_REF=ghcr.io/${{ env.GHCR_ORG }}/supragoflow-build:${tmp_suffix}" >> $GITHUB_ENV
echo "DEV_IMAGE_TMP_REF=ghcr.io/${{ env.GHCR_ORG }}/supragoflow-dev:${tmp_suffix}" >> $GITHUB_ENV

- name: Build & push build image (temporary tag)
id: build_image_tmp
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
Expand All @@ -51,9 +61,31 @@ jobs:
GO_VERSION=${{ env.GO_VERSION }}
DEBIAN_BASE=${{ env.DEBIAN_BASE }}
tags: |
ghcr.io/${{ env.GHCR_ORG }}/supragoflow-build:${{ github.event.release.tag_name }}
${{ env.BUILD_IMAGE_TMP_REF }}

- name: Resolve existing build release tag digest
id: build_existing
run: |
existing_digest="$(./scripts/release-tag-immutability.sh resolve-existing "${BUILD_IMAGE_REF}")"
echo "existing_digest=${existing_digest}" >> "$GITHUB_OUTPUT"

- name: Enforce build image tag immutability
env:
SUPRAGOFLOW_ALLOW_TAG_OVERWRITE: ${{ vars.SUPRAGOFLOW_ALLOW_TAG_OVERWRITE || 'false' }}
run: |
./scripts/release-tag-immutability.sh evaluate \
"${BUILD_IMAGE_REF}" \
"${{ steps.build_existing.outputs.existing_digest }}" \
"${{ steps.build_image_tmp.outputs.digest }}"

- name: Publish build release tag
run: |
docker buildx imagetools create \
-t "${BUILD_IMAGE_REF}" \
"ghcr.io/${{ env.GHCR_ORG }}/supragoflow-build@${{ steps.build_image_tmp.outputs.digest }}"

- name: Build & push dev image
- name: Build & push dev image (temporary tag)
id: dev_image_tmp
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
Expand All @@ -67,7 +99,28 @@ jobs:
GOTESTSUM_VERSION=${{ env.GOTESTSUM_VERSION }}
DEBIAN_BASE=${{ env.DEBIAN_BASE }}
tags: |
ghcr.io/${{ env.GHCR_ORG }}/supragoflow-dev:${{ github.event.release.tag_name }}
${{ env.DEV_IMAGE_TMP_REF }}

- name: Resolve existing dev release tag digest
id: dev_existing
run: |
existing_digest="$(./scripts/release-tag-immutability.sh resolve-existing "${DEV_IMAGE_REF}")"
echo "existing_digest=${existing_digest}" >> "$GITHUB_OUTPUT"

- name: Enforce dev image tag immutability
env:
SUPRAGOFLOW_ALLOW_TAG_OVERWRITE: ${{ vars.SUPRAGOFLOW_ALLOW_TAG_OVERWRITE || 'false' }}
run: |
./scripts/release-tag-immutability.sh evaluate \
"${DEV_IMAGE_REF}" \
"${{ steps.dev_existing.outputs.existing_digest }}" \
"${{ steps.dev_image_tmp.outputs.digest }}"

- name: Publish dev release tag
run: |
docker buildx imagetools create \
-t "${DEV_IMAGE_REF}" \
"ghcr.io/${{ env.GHCR_ORG }}/supragoflow-dev@${{ steps.dev_image_tmp.outputs.digest }}"

- name: Build release binaries for SBOM
run: |
Expand All @@ -77,14 +130,14 @@ jobs:
- name: Generate SBOM (build image)
uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0
with:
image: ghcr.io/${{ env.GHCR_ORG }}/supragoflow-build:${{ github.event.release.tag_name }}
image: ${{ env.BUILD_IMAGE_REF }}
format: spdx-json
output-file: sbom-supragoflow-build-image.spdx.json

- name: Generate SBOM (dev image)
uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0
with:
image: ghcr.io/${{ env.GHCR_ORG }}/supragoflow-dev:${{ github.event.release.tag_name }}
image: ${{ env.DEV_IMAGE_REF }}
format: spdx-json
output-file: sbom-supragoflow-dev-image.spdx.json

Expand Down
2 changes: 2 additions & 0 deletions POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,12 @@ Typical gates for incremental development:

- Container images are built and pushed to GHCR **only** on GitHub Release (`release.published`).
- Release workflows publish explicit release tags only; `:latest` is not part of the canonical path.
- Release GHCR tags are immutable by default: publish fails on existing-tag digest mismatch.
- Users/agents should prefer GHCR release tags over local images.
- `scripts/gg` supports pull-first image reuse via explicit refs:
- Preferred: `SUPRAGOFLOW_BUILD_IMAGE_REF` and `SUPRAGOFLOW_DEV_IMAGE_REF` (digest pinning supported).
- Fallback: `SUPRAGOFLOW_IMAGE_TAG`.
- Emergency override is explicit-only via `SUPRAGOFLOW_ALLOW_TAG_OVERWRITE=true` in release workflow context.
- CI may set repository variable `SUPRAGOFLOW_IMAGE_TAG` to a canonical release tag to enable pull-first image reuse before local fallback build.
- `gg smoke-windows` requires an explicit runner image via `SUPRAGOFLOW_WINE_RUNNER_IMAGE`.

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Images published on release:
- `ghcr.io/<org>/supragoflow-dev:<tag>`

> Containers are built and pushed **only on GitHub Releases** and only as explicit release tags (no implicit `:latest` flow).
> Release tags are immutable by default; conflicting digest republishes fail unless explicit emergency override is set in release workflow policy.
> To pull release images in `gg`, set `SUPRAGOFLOW_IMAGE_TAG=<release-tag>`.

## Two images
Expand Down
1 change: 1 addition & 0 deletions scripts/check-policy-conformance.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ fi

grep -q "All GitHub Actions must be pinned to immutable commit SHAs" POLICY.md || fail "POLICY.md missing action pinning policy statement"
grep -q "Release workflows publish explicit release tags only" POLICY.md || fail "POLICY.md missing explicit release tag policy statement"
grep -q "Release GHCR tags are immutable by default" POLICY.md || fail "POLICY.md missing release tag immutability policy statement"
grep -q "Container images must default to non-root execution identity" POLICY.md || fail "POLICY.md missing non-root container execution identity policy statement"
grep -Eq 'scripts/gg.*invoker UID:GID mapping' POLICY.md || fail "POLICY.md missing invoker UID:GID mapping policy statement"

Expand Down
1 change: 1 addition & 0 deletions scripts/gg
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ case "$stage" in
"${ROOT}/scripts/test-gg-contract.sh"
"${ROOT}/scripts/test-gg-diagnose.sh"
"${ROOT}/scripts/test-gg-faults.sh"
"${ROOT}/scripts/test-release-tag-immutability.sh"
;;
test)
if [[ -n "${TEST_PARALLEL}" ]]; then
Expand Down
99 changes: 99 additions & 0 deletions scripts/release-tag-immutability.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -euo pipefail

usage() {
cat <<'EOF'
usage:
release-tag-immutability.sh resolve-existing <image_ref>
release-tag-immutability.sh evaluate <image_ref> <existing_digest_or_empty> <candidate_digest>

environment:
SUPRAGOFLOW_ALLOW_TAG_OVERWRITE=true|false (default: false)
EOF
}

log() {
printf '%s\n' "$*" >&2
}

resolve_existing() {
local image_ref="${1:-}"
[[ -n "${image_ref}" ]] || { usage; return 2; }

local out
set +e
out="$(docker buildx imagetools inspect "${image_ref}" --format '{{json .Manifest.Digest}}' 2>&1)"
local rc=$?
set -e

if [[ "${rc}" -eq 0 ]]; then
printf '%s\n' "${out}" | tr -d '"' | tail -n1
return 0
fi

# Not found is non-fatal: caller treats empty digest as "tag missing".
if printf '%s\n' "${out}" | grep -Eiq 'not found|no such manifest|manifest unknown|name unknown'; then
printf '\n'
return 0
fi

log "immutability-check: failed to resolve existing digest for ${image_ref}"
log "${out}"
return 2
}

evaluate() {
local image_ref="${1:-}"
local existing_digest="${2:-}"
local candidate_digest="${3:-}"
local allow_overwrite="${SUPRAGOFLOW_ALLOW_TAG_OVERWRITE:-false}"

[[ -n "${image_ref}" ]] || { usage; return 2; }
[[ -n "${candidate_digest}" ]] || {
log "immutability-check: candidate digest missing for ${image_ref}"
return 2
}

if [[ -z "${existing_digest}" ]]; then
log "immutability-check: ${image_ref} does not exist yet; publishing new immutable tag"
return 0
fi

if [[ "${existing_digest}" == "${candidate_digest}" ]]; then
log "immutability-check: ${image_ref} already points to digest ${candidate_digest}; idempotent publish allowed"
return 0
fi

if [[ "${allow_overwrite}" == "true" ]]; then
log "immutability-check: WARNING override enabled; updating ${image_ref} from ${existing_digest} to ${candidate_digest}"
return 0
fi

log "immutability-check: release tag conflict for ${image_ref}"
log "existing digest: ${existing_digest}"
log "candidate digest: ${candidate_digest}"
log "next steps:"
log " 1) publish a new release tag (recommended), or"
log " 2) re-run with SUPRAGOFLOW_ALLOW_TAG_OVERWRITE=true only when overwrite is explicitly approved"
return 1
}

main() {
local cmd="${1:-}"
case "${cmd}" in
resolve-existing)
shift
resolve_existing "$@"
;;
evaluate)
shift
evaluate "$@"
;;
*)
usage
return 2
;;
esac
}

main "$@"
60 changes: 60 additions & 0 deletions scripts/test-release-tag-immutability.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCRIPT="${ROOT}/scripts/release-tag-immutability.sh"

fail() {
echo "FAIL: $*" >&2
exit 1
}

assert_contains() {
local haystack="$1"
local needle="$2"
local message="$3"
[[ "$haystack" == *"$needle"* ]] || fail "$message (missing '$needle')"
}

run_case() {
local __out_var="$1"
local __status_var="$2"
shift 2

set +e
local captured
local rc
captured="$("$@" 2>&1)"
rc=$?
set -e

printf -v "$__out_var" '%s' "$captured"
printf -v "$__status_var" '%s' "$rc"
}

out=""
status=0
image="ghcr.io/example/supragoflow-build:v1.2.3"

# Missing existing digest should allow publish.
run_case out status "$SCRIPT" evaluate "$image" "" "sha256:candidate"
[[ "$status" -eq 0 ]] || fail "missing existing digest should be allowed"
assert_contains "$out" "does not exist yet" "new tag path message"

# Existing digest equals candidate digest should allow idempotent publish.
run_case out status "$SCRIPT" evaluate "$image" "sha256:same" "sha256:same"
[[ "$status" -eq 0 ]] || fail "idempotent digest should be allowed"
assert_contains "$out" "idempotent publish allowed" "idempotent path message"

# Existing digest mismatch should fail without override.
run_case out status "$SCRIPT" evaluate "$image" "sha256:old" "sha256:new"
[[ "$status" -eq 1 ]] || fail "digest mismatch should fail without override"
assert_contains "$out" "release tag conflict" "conflict message"
assert_contains "$out" "next steps" "guidance message"

# Existing digest mismatch should pass with explicit override.
run_case out status env SUPRAGOFLOW_ALLOW_TAG_OVERWRITE=true "$SCRIPT" evaluate "$image" "sha256:old" "sha256:new"
[[ "$status" -eq 0 ]] || fail "digest mismatch should pass with override"
assert_contains "$out" "WARNING override enabled" "override warning message"

echo "release-tag-immutability tests passed"