Skip to content
Draft
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
120 changes: 120 additions & 0 deletions .github/tests/capture-version-tags.test.sh
Original file line number Diff line number Diff line change
@@ -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:-<empty>}
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
3 changes: 3 additions & 0 deletions .github/workflows/selftest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
73 changes: 17 additions & 56 deletions build-and-sign-image/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
76 changes: 76 additions & 0 deletions build-and-sign-image/capture-version-tags.sh
Original file line number Diff line number Diff line change
@@ -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