From 102f5d22cdbdf4edfc57255ed8007d823f2a317a Mon Sep 17 00:00:00 2001 From: "Mark E. DeYoung" Date: Sat, 21 Feb 2026 11:07:13 +0100 Subject: [PATCH] Enforce GHCR release tag immutability in release workflow --- .github/workflows/release.yml | 65 ++++++++++++++-- POLICY.md | 2 + README.md | 1 + scripts/check-policy-conformance.sh | 1 + scripts/gg | 1 + scripts/release-tag-immutability.sh | 99 ++++++++++++++++++++++++ scripts/test-release-tag-immutability.sh | 60 ++++++++++++++ 7 files changed, 223 insertions(+), 6 deletions(-) create mode 100755 scripts/release-tag-immutability.sh create mode 100755 scripts/test-release-tag-immutability.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b4755f..1280cc6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: . @@ -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: . @@ -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: | @@ -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 diff --git a/POLICY.md b/POLICY.md index d20cf05..832fbc0 100644 --- a/POLICY.md +++ b/POLICY.md @@ -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`. diff --git a/README.md b/README.md index 80a2f10..c585a6d 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Images published on release: - `ghcr.io//supragoflow-dev:` > 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=`. ## Two images diff --git a/scripts/check-policy-conformance.sh b/scripts/check-policy-conformance.sh index bd56676..d26fdd0 100755 --- a/scripts/check-policy-conformance.sh +++ b/scripts/check-policy-conformance.sh @@ -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" diff --git a/scripts/gg b/scripts/gg index e3b31e3..62e9cfa 100755 --- a/scripts/gg +++ b/scripts/gg @@ -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 diff --git a/scripts/release-tag-immutability.sh b/scripts/release-tag-immutability.sh new file mode 100755 index 0000000..8f3344f --- /dev/null +++ b/scripts/release-tag-immutability.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +usage: + release-tag-immutability.sh resolve-existing + release-tag-immutability.sh evaluate + +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 "$@" diff --git a/scripts/test-release-tag-immutability.sh b/scripts/test-release-tag-immutability.sh new file mode 100755 index 0000000..d9de9ca --- /dev/null +++ b/scripts/test-release-tag-immutability.sh @@ -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"