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 75f4825..721ca59 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,55 +93,9 @@ runs: - name: 'Capture version and tag list' id: version shell: bash - run: | - echo "protect_version=$(git rev-parse --short=7 HEAD)" >> ${GITHUB_OUTPUT} - - # 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 + env: + EVENT: '${{ inputs.event }}' + run: bash "${GITHUB_ACTION_PATH}/capture-version-tags.sh" - name: Docker meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 @@ -156,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