From e81117eb87ef5b35b7abbcf142684bdc956d51eb Mon Sep 17 00:00:00 2001 From: Kavi <15661106+kdaula@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:37:00 -0400 Subject: [PATCH 1/2] fix(build-and-sign-image): only apply version tags on a release event The version tag block runs `git describe --exact-match --tags` against whatever commit is checked out, with no reference to how the run was triggered. Any non-release run that lands on an already-tagged commit - a push or nightly on a release branch sitting at its tag, or a manual rebuild of a released commit - therefore republishes vX.Y.Z, stable and latest onto a freshly built digest. Docker builds are not bit-for-bit reproducible, so the released tag silently moves off the artifact that was actually released, and anyone pulling the version tag gets the rebuild. The `event` input already existed for this and was passed by callers, but was never read anywhere in the action. Read it: version tags are applied only when the event is `release`, and every other event publishes the short-sha tag alone. This drops the workflow_dispatch "rebuild from a tag" behaviour added in edera-dev/protect#1248 - that path is the drift. If it is wanted back it should be an explicit opt-in input rather than implicit by describe. Refs edera-dev/protect#3692 --- build-and-sign-image/action.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/build-and-sign-image/action.yml b/build-and-sign-image/action.yml index 75f4825..7db29d7 100644 --- a/build-and-sign-image/action.yml +++ b/build-and-sign-image/action.yml @@ -10,7 +10,10 @@ inputs: description: 'Component image to build' required: true event: - description: 'Event triggering this workflow, used to determine the tags' + description: | + Event triggering this workflow. Version tags (vX.Y.Z, X.Y, X, stable, + latest) are only applied when this is 'release'; every other event + publishes the short-sha tag alone. required: true repositories: description: 'Repositories where images are pushed' @@ -90,9 +93,23 @@ runs: - name: 'Capture version and tag list' id: version shell: bash + env: + EVENT: '${{ inputs.event }}' run: | echo "protect_version=$(git rev-parse --short=7 HEAD)" >> ${GITHUB_OUTPUT} + # Version tags only ever come from a real release event. Any other + # trigger that happens to land on an already-tagged commit — a push or + # nightly on a release branch sitting at its tag, or a manual rebuild — + # would otherwise republish vX.Y.Z/stable/latest onto a freshly built + # digest. Docker builds are not bit-for-bit reproducible, so that + # silently moves a released tag off the artifact that was released. + # See https://github.com/edera-dev/protect/pull/3692 + if [ "${EVENT}" != "release" ]; then + echo "Event is '${EVENT}', not 'release': publishing only the short-sha tag." + exit 0 + fi + # This is gross, but it detects whether we're checked out into a tag # and sets the version tags accordingly. This is for a case of # rebuilding an image from a tag. The docker metadata action only version From 44e673a72c6acee76217b715b5277ae681736ec7 Mon Sep 17 00:00:00 2001 From: Kavi <15661106+kdaula@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:16:45 -0400 Subject: [PATCH 2/2] fix(build-and-sign-image): gate the semver tags too, and cover the step The gate added in the first commit only covered the `git describe` block. The `type=semver` lines feeding docker/metadata-action were still live: procSemver gates on github.ref matching refs/tags/, not on event name (src/meta.ts:156 at the pinned dc80280), and `latest` rides along via the default flavor.latest=auto. A run dispatched from a tag ref - `gh workflow run release-artifacts.yml --ref v1.12.0` - therefore still moved vX.Y.Z, X.Y, X, stable and latest onto the rebuild. Gate them per-tag with the `enable` attribute. Nothing exercised the step being changed: selftest.yml loads three actions and build-and-sign-image is not one of them, so a template error here would ship straight to protect's release workflow. Move the tag computation into capture-version-tags.sh and cover it from selftest with a fabricated event, the same move protect made in edera-dev/protect#3692. The test fails if the gate is removed. The script is invoked as `bash ` rather than executed directly, so it does not depend on the file mode surviving. Refs edera-dev/protect#3692 --- .github/tests/capture-version-tags.test.sh | 120 +++++++++++++++++++ .github/workflows/selftest.yml | 3 + build-and-sign-image/action.yml | 78 ++---------- build-and-sign-image/capture-version-tags.sh | 76 ++++++++++++ 4 files changed, 210 insertions(+), 67 deletions(-) create mode 100644 .github/tests/capture-version-tags.test.sh create mode 100644 build-and-sign-image/capture-version-tags.sh diff --git a/.github/tests/capture-version-tags.test.sh b/.github/tests/capture-version-tags.test.sh new file mode 100644 index 0000000..3487bcb --- /dev/null +++ b/.github/tests/capture-version-tags.test.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Tests for build-and-sign-image/capture-version-tags.sh. +# +# The case with teeth is a non-release event on a commit that already carries a +# release tag. Emitting version tags there republishes vX.Y.Z/stable/latest onto +# a freshly built digest, which silently moves a released tag off the artifact +# that was released. Nothing else in this repo exercises the action - it needs +# registry credentials and a real build - so this is the only thing standing +# between that regression and protect's release workflow. +set -uo pipefail + +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +ROOT=$(cd "$HERE/../.." && pwd) +SCRIPT="$ROOT/build-and-sign-image/capture-version-tags.sh" + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +FAILURES=0 +pass() { printf 'ok %s\n' "$1"; } +fail() { + printf 'FAIL %s\n' "$1" + FAILURES=$((FAILURES + 1)) +} + +# A repo whose HEAD carries a release tag: the shape that drifts. +REPO="$WORK/repo" +git init -q -b main "$REPO" +git -C "$REPO" -c commit.gpgsign=false -c user.email=t@t -c user.name=t \ + commit -q --allow-empty -m released +git -C "$REPO" tag v1.12.0 + +# Runs the script and echoes the GITHUB_OUTPUT it produced. Errors are surfaced, +# not swallowed - a non-zero exit must not look like a wrong value. +tags_for() { + local event="$1" out="$WORK/out" + : >"$out" + if ! (cd "$REPO" && EVENT="$event" GITHUB_OUTPUT="$out" bash "$SCRIPT") >"$WORK/log" 2>&1; then + echo "script exited non-zero for event '$event':" >&2 + cat "$WORK/log" >&2 + return 1 + fi + cat "$out" +} + +echo "# a release event tags the release" + +out=$(tags_for release) || out="" +for expected in \ + "protect_version_tag_full_with_v=v1.12.0" \ + "protect_version_tag_full_no_v=1.12.0" \ + "protect_version_tag_major_minor=1.12" \ + "protect_version_tag_major=1" \ + "protect_version_tag_stable=stable" \ + "protect_version_tag_latest=latest"; do + if printf '%s\n' "$out" | grep -qx "$expected"; then + pass "release emits ${expected%%=*}" + else + fail "release did not emit $expected" + fi +done + +echo +echo "# no other event may touch the release tags" + +# push, schedule and workflow_dispatch all reach this on an already-tagged +# commit: a release branch sitting at its tag, the nightly, a manual rebuild. +for event in push schedule workflow_dispatch ""; do + label=${event:-} + out=$(tags_for "$event") || out="SCRIPT-FAILED" + if printf '%s\n' "$out" | grep -q "protect_version_tag_"; then + fail "event '$label' emitted version tags on an already-tagged commit" + else + pass "event '$label' emits no version tags" + fi + # The short-sha tag is the one every build must still get. + if printf '%s\n' "$out" | grep -q "^protect_version="; then + pass "event '$label' still emits the short-sha tag" + else + fail "event '$label' did not emit the short-sha tag" + fi +done + +echo +echo "# release candidates stay narrow" + +git -C "$REPO" tag -d v1.12.0 >/dev/null +git -C "$REPO" tag v1.13.0-rc1 +out=$(tags_for release) || out="" +if printf '%s\n' "$out" | grep -qx "protect_version_tag_full_with_v=v1.13.0-rc1"; then + pass "an rc tags its own version" +else + fail "an rc did not tag its own version" +fi +if printf '%s\n' "$out" | grep -qE "protect_version_tag_(stable|latest|major)="; then + fail "an rc moved stable/latest/major" +else + pass "an rc does not move stable/latest/major" +fi + +echo +echo "# an untagged commit gets only the short sha" + +git -C "$REPO" tag -d v1.13.0-rc1 >/dev/null +git -C "$REPO" -c commit.gpgsign=false -c user.email=t@t -c user.name=t \ + commit -q --allow-empty -m untagged +out=$(tags_for release) || out="" +if printf '%s\n' "$out" | grep -q "protect_version_tag_"; then + fail "an untagged commit emitted version tags" +else + pass "an untagged commit emits no version tags" +fi + +echo +if [ "$FAILURES" -eq 0 ]; then + echo "all checks passed" +else + echo "$FAILURES check(s) failed" + exit 1 +fi diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index a104567..c9f4182 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -38,6 +38,9 @@ jobs: connection-string-rw: 'BlobEndpoint=https://example.invalid;SharedAccessSignature=fake-rw' connection-string-ro: 'BlobEndpoint=https://example.invalid;SharedAccessSignature=fake-ro' key-prefix: selftest + - name: which tags an image build publishes + run: bash .github/tests/capture-version-tags.test.sh + - name: assert action contracts env: CONFIGURED: ${{ steps.sccache.outputs.configured }} diff --git a/build-and-sign-image/action.yml b/build-and-sign-image/action.yml index 7db29d7..721ca59 100644 --- a/build-and-sign-image/action.yml +++ b/build-and-sign-image/action.yml @@ -95,67 +95,7 @@ runs: shell: bash env: EVENT: '${{ inputs.event }}' - run: | - echo "protect_version=$(git rev-parse --short=7 HEAD)" >> ${GITHUB_OUTPUT} - - # Version tags only ever come from a real release event. Any other - # trigger that happens to land on an already-tagged commit — a push or - # nightly on a release branch sitting at its tag, or a manual rebuild — - # would otherwise republish vX.Y.Z/stable/latest onto a freshly built - # digest. Docker builds are not bit-for-bit reproducible, so that - # silently moves a released tag off the artifact that was released. - # See https://github.com/edera-dev/protect/pull/3692 - if [ "${EVENT}" != "release" ]; then - echo "Event is '${EVENT}', not 'release': publishing only the short-sha tag." - exit 0 - fi - - # This is gross, but it detects whether we're checked out into a tag - # and sets the version tags accordingly. This is for a case of - # rebuilding an image from a tag. The docker metadata action only version - # tags on the "tag" event, it doesn't version tag when we checkout a tag - # on a workflow_dispatch - # See https://github.com/edera-dev/protect/issues/1248 - if git describe --exact-match --tags &>/dev/null; then - input="$(git describe --exact-match --tags)" - if [[ "$input" =~ ^v([0-9]+)(\.([0-9]+))?(\.([0-9]+))?(-rc[0-9]+)?$ ]]; then - major="${BASH_REMATCH[1]}" - minor="${BASH_REMATCH[3]}" - patch="${BASH_REMATCH[5]}" - candidate="${BASH_REMATCH[6]}" - - # Build version strings - full_with_v="v${major}" - full_no_v="${major}" - - if [[ -n "$minor" ]]; then - full_with_v+=".${minor}" - full_no_v+=".${minor}" - fi - - if [[ -n "$patch" ]]; then - full_with_v+=".${patch}" - full_no_v+=".${patch}" - fi - - if [[ -n "$candidate" ]]; then - full_with_v+="${candidate}" - full_no_v+="${candidate}" - fi - - # Output full_with_v tag and descending specificity - echo "protect_version_tag_full_with_v=$full_with_v" >> ${GITHUB_OUTPUT} - echo "protect_version_tag_full_no_v=$full_no_v" >> ${GITHUB_OUTPUT} - - # Do not output broader specificity when there is a release candidate - if [[ -z "$candidate" ]]; then - echo "protect_version_tag_major_minor=${major}.${minor}" >> ${GITHUB_OUTPUT} - echo "protect_version_tag_major=${major}" >> ${GITHUB_OUTPUT} - echo "protect_version_tag_stable=stable" >> ${GITHUB_OUTPUT} - echo "protect_version_tag_latest=latest" >> ${GITHUB_OUTPUT} - fi - fi - fi + run: bash "${GITHUB_ACTION_PATH}/capture-version-tags.sh" - name: Docker meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 @@ -173,12 +113,16 @@ runs: # events. type=raw,value=${{ steps.version.outputs.protect_version }} - # Tag version and stable on tag push - type=semver,pattern={{raw}} - type=semver,pattern={{version}} - type=semver,pattern={{major}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern=stable + # Tag version and stable on a release only. These fire whenever + # github.ref is a tag ref, regardless of event, so a run dispatched + # from a tag ref would otherwise move the release tags onto a rebuild. + # `latest` rides along with these via the metadata action's default + # `flavor.latest=auto`, so gating them gates it too. + type=semver,pattern={{raw}},enable=${{ inputs.event == 'release' }} + type=semver,pattern={{version}},enable=${{ inputs.event == 'release' }} + type=semver,pattern={{major}},enable=${{ inputs.event == 'release' }} + type=semver,pattern={{major}}.{{minor}},enable=${{ inputs.event == 'release' }} + type=semver,pattern=stable,enable=${{ inputs.event == 'release' }} # Tag nightly on schedule event type=schedule,pattern=nightly diff --git a/build-and-sign-image/capture-version-tags.sh b/build-and-sign-image/capture-version-tags.sh new file mode 100644 index 0000000..e6b2c54 --- /dev/null +++ b/build-and-sign-image/capture-version-tags.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Decides which tags an image build publishes, and writes them to GITHUB_OUTPUT. +# +# Every build gets the short-sha tag. Version tags (vX.Y.Z, X.Y, X, stable, and +# latest by way of the metadata action's `latest=auto`) are reserved for release +# events: any other trigger that lands on an already-tagged commit would +# otherwise republish them onto a freshly built digest, and Docker builds are +# not bit-for-bit reproducible, so a released tag would end up pointing at an +# artifact that was never released. +# +# Reads EVENT and GITHUB_OUTPUT from the environment; runs in the checked-out +# repo. +set -euo pipefail + +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}" +EVENT="${EVENT:-}" + +echo "protect_version=$(git rev-parse --short=7 HEAD)" >>"${GITHUB_OUTPUT}" + +if [ "${EVENT}" != "release" ]; then + echo "Event is '${EVENT}', not 'release': publishing only the short-sha tag." + exit 0 +fi + +# This is gross, but it detects whether we're checked out into a tag +# and sets the version tags accordingly. This is for a case of +# rebuilding an image from a tag. The docker metadata action only version +# tags on the "tag" event, it doesn't version tag when we checkout a tag +# on a workflow_dispatch +# See https://github.com/edera-dev/protect/issues/1248 +if ! git describe --exact-match --tags &>/dev/null; then + echo "No tag points at HEAD; publishing only the short-sha tag." + exit 0 +fi + +input="$(git describe --exact-match --tags)" +if [[ ! "$input" =~ ^v([0-9]+)(\.([0-9]+))?(\.([0-9]+))?(-rc[0-9]+)?$ ]]; then + echo "Tag '${input}' is not a version tag; publishing only the short-sha tag." + exit 0 +fi + +major="${BASH_REMATCH[1]}" +minor="${BASH_REMATCH[3]}" +patch="${BASH_REMATCH[5]}" +candidate="${BASH_REMATCH[6]}" + +# Build version strings +full_with_v="v${major}" +full_no_v="${major}" + +if [[ -n "$minor" ]]; then + full_with_v+=".${minor}" + full_no_v+=".${minor}" +fi + +if [[ -n "$patch" ]]; then + full_with_v+=".${patch}" + full_no_v+=".${patch}" +fi + +if [[ -n "$candidate" ]]; then + full_with_v+="${candidate}" + full_no_v+="${candidate}" +fi + +# Output full_with_v tag and descending specificity +echo "protect_version_tag_full_with_v=$full_with_v" >>"${GITHUB_OUTPUT}" +echo "protect_version_tag_full_no_v=$full_no_v" >>"${GITHUB_OUTPUT}" + +# Do not output broader specificity when there is a release candidate +if [[ -z "$candidate" ]]; then + echo "protect_version_tag_major_minor=${major}.${minor}" >>"${GITHUB_OUTPUT}" + echo "protect_version_tag_major=${major}" >>"${GITHUB_OUTPUT}" + echo "protect_version_tag_stable=stable" >>"${GITHUB_OUTPUT}" + echo "protect_version_tag_latest=latest" >>"${GITHUB_OUTPUT}" +fi