Files
nxdns/.gitea/workflows/release.yml
T
mokhtar 32cd9b8e3e
Gates / test (push) Successful in 1m16s
Gates / test-aarch64 (push) Successful in 4m33s
Gates / frontend (push) Successful in 48s
Gates / package (push) Successful in 3m50s
Gates / container (push) Successful in 2m22s
CI / gates (push) Successful in 12m52s
Release / guard (push) Failing after 7s
Release / gates (push) Skipped
Release / publish (push) Skipped
release: refetch the annotated tag object that checkout replaces
2026-08-08 16:03:04 +02:00

1346 lines
60 KiB
YAML

name: Release
# ---------------------------------------------------------------------------
# ASSET NAMING — unresolved probe (milestone-14 ruling 13, item 4)
#
# Gitea's [attachment] ALLOWED_TYPES is extension-based. `SHA256SUMS` and
# `IMAGE-DIGEST` have no extension, and whether the attachment API accepts an
# extensionless upload has NOT been tested against the live instance. This
# workflow therefore commits to the extension-carrying names:
#
# nxdns-<version>-x86_64-linux-musl.tar.gz
# nxdns-<version>-aarch64-linux-musl.tar.gz
# SHA256SUMS.txt
# SHA256SUMS.txt.asc
# IMAGE-DIGEST.txt
#
# `.gz` and `.txt` are in Gitea's default ALLOWED_TYPES; `.asc` is added by
# manual prerequisite 3. `zig build dist` still writes `SHA256SUMS` on disk —
# this job copies it to `SHA256SUMS.txt`, appends the image-digest line, and
# signs and uploads that file.
#
# If the probe shows extensionless uploads are accepted, drop the `.txt` from
# all three names here (`SHA256SUMS`, `SHA256SUMS.asc`, `IMAGE-DIGEST`), drop
# the copy in "Assemble and verify the checksum file", and update
# docs/how-to/verify-a-release.md to match. Nothing else changes.
# ---------------------------------------------------------------------------
#
# Every action below is pinned to a full commit SHA (ruling 7): this job holds
# the signing subkey and the registry token, so a moved tag on someone else's
# server must not be able to run code here. ci.yml and gates.yml may keep
# moving tags; they hold no secrets.
on:
push:
tags: ["v*"]
# The guard's "this version exceeds the highest published release" check runs
# before the gates, so on its own it proves nothing about which run reaches the
# registry last: two tags pushed close together could interleave and leave
# `:latest` on the older one. The group is deliberately NOT ref-scoped —
# serialising two *different* tags is the whole point — and never cancels, so a
# release that already pushed an image is allowed to finish. The "Move the
# latest tag" step re-checks the invariant regardless, because a runner that
# does not implement `concurrency:` must still not be able to move `:latest`
# backwards.
concurrency:
group: release
cancel-in-progress: false
env:
ZIG_VERSION: "0.16.0"
# Exact patch, not a floating "24" (ruling 12).
NODE_VERSION: "24.19.0"
# The author's commit- and tag-signing key. `git verify-tag` alone proves
# only that *some* key in the keyring signed the tag, so the signature's
# fingerprint is compared against this pin (ruling 7, step 3).
TAG_SIGNING_FPR: "A2061F6AB24DF2C0E92346FD1509B54946D08A95"
# The release signing subkey of that same key (ruling 8). Manual
# prerequisite 1 creates it; until its fingerprint is pasted in here the
# *guard job* fails closed — before the gates, and long before anything is
# pushed to the registry. 40 uppercase hex characters, no spaces.
RELEASE_SIGNING_FPR: "B281CECC877BD36575543F0A4148C60EC18D831D"
jobs:
# Steps 1-6 of ruling 7. Everything here is cheap and refuses a bad tag
# before the gates spend a runner on it.
guard:
runs-on: ubuntu-24.04
timeout-minutes: 15
outputs:
previous_tag: ${{ steps.releases.outputs.previous_tag }}
steps:
# Every secret and pinned fingerprint the release depends on is checked
# here, first, before the checkout and before the gates spend a runner.
#
# This step exists because the format check on RELEASE_SIGNING_FPR and
# the presence check on RELEASE_GPG_PASSPHRASE used to live only in the
# signing step, which runs *after* the registry push. A placeholder
# fingerprint therefore burned the immutable version tag (ruling 9) on
# the way to failing. The late checks are still there — they guard the
# material actually loaded into GNUPGHOME — but this is the one that
# fails closed.
#
# A secret's *value* cannot be validated here without using it; presence
# is what is checkable, and an empty secret is the failure that actually
# happens (an unset repository secret expands to the empty string).
- name: Validate the release secrets and pinned fingerprints
env:
RELEASE_GPG_SUBKEY: ${{ secrets.RELEASE_GPG_SUBKEY }}
RELEASE_GPG_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
rc=0
check_fpr() {
if printf '%s\n' "$2" | grep -Eq '^[0-9A-F]{40}$'; then
echo "$1 is a well-formed fingerprint"
return 0
fi
echo "$1 is not 40 uppercase hex characters: '$2'"
echo " paste the fingerprint from manual prerequisite 1 (ruling 13) into release.yml"
return 1
}
check_secret() {
if [ -n "$2" ]; then
echo "$1 is set"
return 0
fi
echo "the $1 secret is empty or unset (ruling 13)"
return 1
}
check_fpr TAG_SIGNING_FPR "$TAG_SIGNING_FPR" || rc=1
check_fpr RELEASE_SIGNING_FPR "$RELEASE_SIGNING_FPR" || rc=1
check_secret RELEASE_GPG_SUBKEY "$RELEASE_GPG_SUBKEY" || rc=1
check_secret RELEASE_GPG_PASSPHRASE "$RELEASE_GPG_PASSPHRASE" || rc=1
check_secret REGISTRY_TOKEN "$REGISTRY_TOKEN" || rc=1
check_secret GITEA_TOKEN "$GITEA_TOKEN" || rc=1
if [ "$rc" -ne 0 ]; then
echo "refusing to start: nothing has been built, pushed or published"
exit 1
fi
# fetch-depth: 0 plus tags. The default shallow clone has no
# origin/master to test ancestry against, no previous tag to compare
# from, and no tag object to verify.
- name: Check out the tag with full history and tags
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0
fetch-tags: true
persist-credentials: false
- name: Reject a tag that is not vMAJOR.MINOR.PATCH
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
if ! printf '%s\n' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "refusing '$TAG': releases are vMAJOR.MINOR.PATCH only, with no pre-release suffix"
exit 1
fi
echo "tag $TAG accepted"
# The imported material is the *secret subkey* export, whose public half
# is the author's certificate — that is what verifies the tag. No
# passphrase is needed to import, and the temporary GNUPGHOME is scrubbed
# on every exit path.
#
# This step does two things, and the second is the one that matters for
# recovery: it proves the *artifact-signing* material actually works,
# here, before the gates and long before the registry is touched. A
# presence check on the secrets is not enough. A public-only export
# verifies the tag perfectly well; an export missing the pinned subkey
# does too; and a placeholder RELEASE_GPG_PASSPHRASE passes every check
# that does not try to sign something. All three used to fail for the
# first time in the signing step, which runs *after* the image push —
# exactly the shape of failure ruling 9 forbids.
- name: Verify the tag signature and prove the signing key is usable
env:
TAG: ${{ github.ref_name }}
RELEASE_GPG_SUBKEY: ${{ secrets.RELEASE_GPG_SUBKEY }}
RELEASE_GPG_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
run: |
set -euo pipefail
# actions/checkout on a tag ref fetches the *commit* SHA into
# refs/tags/<tag>, silently replacing the annotated tag object with a
# lightweight tag. Without this refetch, every signed tag reads as
# unannotated and the check below refuses it. --force because that
# wrong local ref already exists.
git fetch --force --no-tags origin "refs/tags/$TAG:refs/tags/$TAG"
if [ "$(git cat-file -t "refs/tags/$TAG")" != "tag" ]; then
echo "refusing '$TAG': not an annotated tag, so it carries no signature"
exit 1
fi
if [ -z "$RELEASE_GPG_SUBKEY" ]; then
echo "the RELEASE_GPG_SUBKEY secret is empty; see manual prerequisite 1 (ruling 13)"
exit 1
fi
GNUPGHOME=$(mktemp -d "${RUNNER_TEMP:-/tmp}/gnupg.XXXXXXXX")
export GNUPGHOME
chmod 700 "$GNUPGHOME"
cleanup() {
gpgconf --kill gpg-agent >/dev/null 2>&1 || true
rm -rf "${GNUPGHOME:?}"
}
trap cleanup EXIT
printf '%s' "$RELEASE_GPG_SUBKEY" | gpg --batch --quiet --import
printf '%s:6:\n' "$TAG_SIGNING_FPR" | gpg --batch --quiet --import-ownertrust
if ! status=$(git verify-tag --raw "$TAG" 2>&1); then
printf '%s\n' "$status"
echo "git verify-tag failed for $TAG"
exit 1
fi
printf '%s\n' "$status"
# gpg's DETAILS gives the status line as
#
# VALIDSIG <fpr> <sig_creation_date> <sig-timestamp>
# <expire-timestamp> <sig-version> <reserved> <pubkey-algo>
# <hash-algo> <sig-class> <primary-key-fpr>
#
# so on `git verify-tag --raw` output (prefixed "[GNUPG:] VALIDSIG")
# field 3 is the fingerprint of the key that MADE the signature and
# the LAST field is the primary key of the certificate it belongs to.
# Those differ whenever a signing subkey exists — and manual
# prerequisite 1 adds one to this very certificate, after which gpg
# selects it for `git tag -s`. Comparing field 3 against the primary
# fingerprint pinned below would reject every real release.
#
# Reproduced with a throwaway keyring on 2026-08-07 (gpg 2.4.9,
# primary + added signing subkey, `git tag -s`): NF is 12, $3 is the
# subkey, $12 is the primary.
#
# TAG_SIGNING_FPR stays the *primary certificate* fingerprint, so
# adding or rotating a signing subkey does not break verification.
fpr=$(printf '%s\n' "$status" \
| awk '$2 == "VALIDSIG" && NF >= 12 { print $NF; exit }')
if ! printf '%s\n' "$fpr" | grep -Eq '^[0-9A-F]{40}$'; then
echo "git verify-tag emitted no VALIDSIG line carrying a primary-key fingerprint"
exit 1
fi
if [ "$fpr" != "$TAG_SIGNING_FPR" ]; then
echo "tag signed under certificate $fpr, expected $TAG_SIGNING_FPR"
exit 1
fi
echo "signature is under the pinned certificate $TAG_SIGNING_FPR"
# Everything below proves the artifact-signing material, not the tag.
# The signing step repeats these checks against the home it actually
# signs in; this copy is the one that fails closed.
if [ -z "$RELEASE_GPG_PASSPHRASE" ]; then
echo "the RELEASE_GPG_PASSPHRASE secret is empty; see manual prerequisite 1 (ruling 13)"
exit 1
fi
# Field 15 of a `sec` record is '#' when the primary secret is a stub
# and '+' when the real key is present. The runner must only ever
# hold the subkey (ruling 8).
leaked=$(gpg --list-secret-keys --with-colons | awk -F: '$1 == "sec" && $15 != "#" { print $5 }')
if [ -n "$leaked" ]; then
echo "the imported material contains a primary secret key ($leaked); export with --export-secret-subkeys"
exit 1
fi
if ! gpg --list-secret-keys --with-colons \
| awk -F: '$1 == "fpr" { print $10 }' \
| grep -qx "$RELEASE_SIGNING_FPR"; then
echo "the RELEASE_GPG_SUBKEY export carries no secret key $RELEASE_SIGNING_FPR"
echo " a public-only export verifies the tag but cannot sign SHA256SUMS (ruling 8)"
exit 1
fi
# The only check that can tell a correct passphrase from a
# placeholder is a signature. Sign a throwaway file with the exact
# invocation the signing step uses, and verify the result.
passfile="$GNUPGHOME/passphrase"
(umask 077; printf '%s' "$RELEASE_GPG_PASSPHRASE" > "$passfile")
probe="$GNUPGHOME/probe"
printf 'nxdns release key probe\n' > "$probe"
if ! gpg --batch --yes --quiet \
--pinentry-mode loopback --passphrase-file "$passfile" \
--local-user "$RELEASE_SIGNING_FPR!" \
--armor --detach-sign --output "$probe.asc" "$probe"; then
echo "signing with $RELEASE_SIGNING_FPR failed"
echo " the usual cause is a wrong RELEASE_GPG_PASSPHRASE (ruling 13)"
exit 1
fi
if ! probe_status=$(gpg --batch --status-fd 1 --verify "$probe.asc" "$probe" 2>/dev/null); then
printf '%s\n' "$probe_status"
echo "the probe signature does not verify"
exit 1
fi
probe_signer=$(printf '%s\n' "$probe_status" | awk '$2 == "VALIDSIG" { print $3; exit }')
if [ "$probe_signer" != "$RELEASE_SIGNING_FPR" ]; then
echo "the probe was signed by $probe_signer, expected $RELEASE_SIGNING_FPR"
exit 1
fi
echo "the signing subkey $RELEASE_SIGNING_FPR is present and its passphrase is correct"
- name: Assert the tag is an ancestor of master
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
tag_commit=$(git rev-parse "refs/tags/$TAG^{commit}")
master=""
for ref in refs/remotes/origin/master refs/heads/master; do
if git rev-parse --verify --quiet "$ref" >/dev/null; then
master="$ref"
break
fi
done
if [ -z "$master" ]; then
echo "no master ref in this clone; the checkout must fetch full history"
exit 1
fi
if ! git merge-base --is-ancestor "$tag_commit" "$master"; then
echo "$TAG ($tag_commit) is not an ancestor of $master"
exit 1
fi
echo "$TAG is an ancestor of $master"
# Ruling 9: the draft is the unit of work, so a re-run clears a leftover
# draft and repeats. A published release for this tag is terminal — tags
# are never reused, and the fix ships as the next patch version.
#
# That rule is only safe because publication is the LAST irreversible act
# of the publish job (see the step ordering there): the draft is created,
# the assets are uploaded, `:latest` is moved, and only then is the draft
# published. So "published" means every earlier step already succeeded,
# and there is nothing left for a re-run to repair. Publishing before
# moving `:latest` would make a failure in the `:latest` step
# unrecoverable: the release would be published, this guard would refuse
# every re-run, and `:latest` would be stuck on the previous version with
# no way forward except abandoning a tag that is already public.
- name: Refuse a published release, clear a stale draft, assert the version increases
id: releases
env:
TAG: ${{ github.ref_name }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
if ! command -v jq >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -qq -y jq
fi
api="$GITHUB_SERVER_URL/api/v1"
resp=$(mktemp)
http_code=""
call() {
http_code=$(curl -sS -o "$resp" -w '%{http_code}' -X "$1" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Accept: application/json" \
--connect-timeout 10 --max-time 120 "$2")
}
call GET "$api/repos/$GITHUB_REPOSITORY/releases/tags/$TAG"
case "$http_code" in
404)
echo "no existing release for $TAG"
;;
200)
if [ "$(jq -r '.draft' "$resp")" = "true" ]; then
id=$(jq -r '.id' "$resp")
echo "deleting leftover draft release $id"
call DELETE "$api/repos/$GITHUB_REPOSITORY/releases/$id"
case "$http_code" in
200|204) ;;
*) echo "deleting draft $id failed with $http_code"; cat "$resp"; exit 1 ;;
esac
else
echo "$TAG already has a published release; it will not be touched (ruling 9)"
exit 1
fi
;;
*)
echo "unexpected status $http_code looking up $TAG"
cat "$resp"
exit 1
;;
esac
# Highest published plain release. This is both the floor the new
# version must exceed (so a late-finishing older tag cannot move
# :latest backwards) and the comparison base for the release notes
# (ruling 10) — an abandoned tag is not published and so cannot
# become that base.
highest=""
page=1
while [ "$page" -le 20 ]; do
call GET "$api/repos/$GITHUB_REPOSITORY/releases?limit=50&page=$page"
if [ "$http_code" != "200" ]; then
echo "listing releases failed with $http_code"
cat "$resp"
exit 1
fi
# The payload must be an array before anything reads it as one. A
# 200 carrying a JSON *object* — an error body from the API or from
# something in front of it — makes the jq below fail, and the
# `|| true` that stops grep's no-match from killing the step covers
# the whole pipeline, so the failure would read as "no releases".
# That is the one wrong answer with consequences: it moves :latest
# backwards.
if ! jq -e 'type == "array"' "$resp" >/dev/null 2>&1; then
echo "the releases endpoint returned 200 with a non-array payload:"
cat "$resp"
exit 1
fi
if [ "$(jq 'length' "$resp")" -eq 0 ]; then
break
fi
tags=$(jq -r '.[] | select(.draft == false) | .tag_name' "$resp")
published=$(printf '%s\n' "$tags" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true)
for candidate in $published; do
if [ -z "$highest" ] \
|| [ "$(printf '%s\n%s\n' "${highest#v}" "${candidate#v}" | sort -V | tail -1)" = "${candidate#v}" ]; then
highest="$candidate"
fi
done
page=$((page + 1))
done
if [ -n "$highest" ]; then
new="${TAG#v}"
high="${highest#v}"
if [ "$new" = "$high" ] \
|| [ "$(printf '%s\n%s\n' "$new" "$high" | sort -V | tail -1)" != "$new" ]; then
echo "$TAG does not exceed the highest published release $highest"
exit 1
fi
echo "$TAG exceeds the highest published release $highest"
else
echo "no published release yet; this is the first"
fi
echo "previous_tag=$highest" >> "$GITHUB_OUTPUT"
# This job imports secret key material, so it gets the same backstop the
# publish job has. The EXIT trap inside the step covers a failing step;
# it does not cover a cancelled or killed runner.
- name: Scrub secret material
if: always()
run: |
set -uo pipefail
tmp="${RUNNER_TEMP:-}"
if [ -n "$tmp" ] && [ -d "$tmp" ]; then
for home in "$tmp"/gnupg.*; do
[ -d "$home" ] || continue
GNUPGHOME="$home" gpgconf --kill gpg-agent >/dev/null 2>&1 || true
done
rm -rf "$tmp"/gnupg.*
fi
exit 0
# Step 7: the identical gate set CI runs, blocking.
gates:
needs: [guard]
uses: ./.gitea/workflows/gates.yml
# Steps 8-15, with 14 and 15 swapped relative to ruling 7: `:latest` moves
# before the draft is published, not after. See the two steps at the foot of
# this job for why — publication is the act the guard treats as terminal, so
# it has to be the last one that can fail.
publish:
needs: [guard, gates]
runs-on: ubuntu-24.04
timeout-minutes: 120
steps:
- name: Check out the tag with full history and tags
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0
fetch-tags: true
persist-credentials: false
- name: Ensure the tooling this job assumes
run: |
set -euo pipefail
need=""
command -v jq >/dev/null 2>&1 || need="$need jq"
command -v gpg >/dev/null 2>&1 || need="$need gnupg"
command -v curl >/dev/null 2>&1 || need="$need curl"
if [ -n "$need" ]; then
sudo apt-get update -qq
# shellcheck disable=SC2086
sudo apt-get install -qq -y $need
fi
command -v docker >/dev/null 2>&1 || { echo "docker is not installed on this runner"; exit 1; }
docker buildx version
# One place computes every derived value the rest of the job uses. The
# tag is authoritative (ruling 2): the version, the commit and the
# timestamp all come out of it, never out of a file.
- name: Resolve the release identity
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
if ! printf '%s\n' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "refusing '$TAG': releases are vMAJOR.MINOR.PATCH only"
exit 1
fi
# Same refetch as the guard: checkout replaced the annotated tag
# object with a lightweight one, and the tagger date below needs the
# real object.
git fetch --force --no-tags origin "refs/tags/$TAG:refs/tags/$TAG"
version="${TAG#v}"
tag_commit=$(git rev-parse "refs/tags/$TAG^{commit}")
epoch=$(git for-each-ref --format='%(taggerdate:unix)' "refs/tags/$TAG")
if [ -z "$epoch" ]; then
echo "$TAG has no tagger date; it is not an annotated tag"
exit 1
fi
registry="${GITHUB_SERVER_URL#http://}"
registry="${registry#https://}"
registry="${registry%%/*}"
image="$registry/$(printf '%s' "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]')"
{
echo "TAG=$TAG"
echo "VERSION=$version"
echo "TAG_COMMIT=$tag_commit"
echo "SOURCE_DATE_EPOCH=$epoch"
echo "CREATED=$(date -u -d "@$epoch" +%Y-%m-%dT%H:%M:%SZ)"
echo "REGISTRY=$registry"
echo "IMAGE=$image"
echo "API=$GITHUB_SERVER_URL/api/v1"
echo "DIST=$GITHUB_WORKSPACE/zig-out/dist"
} >> "$GITHUB_ENV"
echo "releasing $version from $tag_commit as $image:$version"
- name: Set up Zig
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
with:
version: ${{ env.ZIG_VERSION }}
# zig 0.16.0's package fetcher creates tmp/<hex>.zip inside the global
# cache without creating tmp/ first (src/Package/Fetch.zig:1499), and
# setup-zig's restored cache never contains tmp/. Without this, every
# dependency fetch dies with "failed to create temporary zip file:
# FileNotFound" before any network I/O.
- name: Create the fetch temp dir zig assumes
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: web/package-lock.json
- name: Build the web UI
working-directory: web
run: |
npm ci
npm run build
- name: Build the release artifacts
run: |
set -euo pipefail
zig build dist \
-Dversion-string="$VERSION" \
-Dgit-commit="$TAG_COMMIT" \
-Dweb-dist=web/dist \
-Doptimize=ReleaseSafe
- name: Verify the release artifacts
run: |
set -euo pipefail
zig build verify-dist \
-Dversion-string="$VERSION" \
-Dgit-commit="$TAG_COMMIT" \
-Dweb-dist=web/dist \
-Doptimize=ReleaseSafe
ls -l "$DIST"
# Step 9. Extracted and validated before anything is pushed anywhere, so
# a missing changelog section costs nothing but the run. The body is
# assembled later, when the hashes and the image digest exist.
- name: Extract the changelog section for this version
run: |
set -euo pipefail
if [ ! -f CHANGELOG.md ]; then
echo "CHANGELOG.md is missing; the release body is its section for this version (ruling 10)"
exit 1
fi
section="$RUNNER_TEMP/changelog-section.md"
# Stops at the next section heading, and at the link-reference
# block Keep a Changelog puts at the foot of the file — those
# definitions belong to the document, not to the release notes.
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { found = 1; next }
found && /^## / { exit }
found && /^\[[^]]+\]: / { exit }
found { print }
' CHANGELOG.md > "$section"
if ! grep -q '[^[:space:]]' "$section"; then
echo "CHANGELOG.md has no '## [$VERSION]' section; write it before tagging (ruling 10)"
exit 1
fi
cat "$section"
# Step 10. --provenance=false --sbom=false: recent buildx attaches
# provenance attestations by default, which add unknown/unknown platform
# entries and change the index digest, and Gitea's OCI 1.1 support is
# unverified (go-gitea#25846).
#
# Re-run rule (ruling 9): the version tag is immutable, and that
# invariant is enforced *before* the push, not after. Gitea's container
# tags are mutable, so a push-then-compare has already overwritten the
# tag it then refuses — the check reports a violation it caused.
#
# So: probe the registry first, with a real HTTP HEAD on
# /v2/<repo>/manifests/<version>. 404 means absent and the build pushes.
# 200 means the tag already exists, which on this workflow only happens
# on a re-run of the same tag, and the step then pushes NOTHING: it
# adopts the existing digest and lets every content assertion below run
# against it. A re-run whose sources no longer match the pushed image
# fails at the binary-identity step having mutated nothing.
#
# Ruling 9 asks for "an existing tag whose digest matches exactly what it
# just built". That comparison is not available: buildx cannot report an
# index digest without pushing, and cross-machine bit-reproducibility is
# deferred (ruling 12), so a rebuilt digest is expected to differ even
# when the contents are identical. Adopting the pushed image and
# asserting its *contents* is the same invariant enforced through the
# only evidence that exists, and it can never overwrite.
#
# The probe uses curl rather than `imagetools inspect` because the
# decision turns on absent-versus-refused, and imagetools reports every
# failure as exit 1 with a human-readable message. Recognising 404 from
# that message means a proxy that hides an authorization failure behind
# "not found" reads as "the tag is free".
- name: Build and push the version tag
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REGISTRY_USER: ${{ github.repository_owner }}
run: |
set -euo pipefail
# The built-in GITEA_TOKEN cannot publish to the package registry —
# that is what this personal access token exists for (ruling 8).
if [ -z "$REGISTRY_TOKEN" ]; then
echo "the REGISTRY_TOKEN secret is empty; see manual prerequisite 2 (ruling 13)"
exit 1
fi
DOCKER_CONFIG=$(mktemp -d "${RUNNER_TEMP:-/tmp}/dockercfg.XXXXXXXX")
export DOCKER_CONFIG
builder="nxdns-release-$GITHUB_RUN_ID"
cleanup() {
docker buildx rm "$builder" >/dev/null 2>&1 || true
docker logout "$REGISTRY" >/dev/null 2>&1 || true
rm -rf "${DOCKER_CONFIG:?}"
}
trap cleanup EXIT
printf '%s' "$REGISTRY_TOKEN" \
| docker login "$REGISTRY" --username "$REGISTRY_USER" --password-stdin
# A real HTTP HEAD against the distribution API, so the decision
# rests on a status code. Sets probe_code and probe_digest.
repo_path="${IMAGE#"$REGISTRY"/}"
probe_code=""
probe_digest=""
registry_probe() {
probe_code=""
probe_digest=""
hdr=$(mktemp)
accept='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json'
url="$GITHUB_SERVER_URL/v2/$repo_path/manifests/$1"
probe_code=$(curl -sS -o /dev/null -D "$hdr" -w '%{http_code}' -I \
-u "$REGISTRY_USER:$REGISTRY_TOKEN" \
-H "Accept: $accept" \
--connect-timeout 10 --max-time 60 "$url")
# Basic auth is what Gitea accepts directly; a standards-compliant
# registry in front of it answers 401 with a bearer challenge
# instead. Follow it rather than assuming either shape.
if [ "$probe_code" = "401" ]; then
chal=$(grep -i '^www-authenticate:' "$hdr" | tr -d '\r' || true)
realm=$(printf '%s' "$chal" | sed -n 's/.*realm="\([^"]*\)".*/\1/p')
service=$(printf '%s' "$chal" | sed -n 's/.*service="\([^"]*\)".*/\1/p')
scope=$(printf '%s' "$chal" | sed -n 's/.*scope="\([^"]*\)".*/\1/p')
[ -n "$scope" ] || scope="repository:$repo_path:pull"
if [ -z "$realm" ]; then
echo "the registry answered 401 with no bearer realm: $chal"
return 1
fi
bearer=$(curl -sS --get -u "$REGISTRY_USER:$REGISTRY_TOKEN" \
--data-urlencode "service=$service" \
--data-urlencode "scope=$scope" \
--connect-timeout 10 --max-time 60 "$realm" \
| jq -r '.token // .access_token // empty')
if [ -z "$bearer" ]; then
echo "the registry token endpoint $realm returned no token"
return 1
fi
probe_code=$(curl -sS -o /dev/null -D "$hdr" -w '%{http_code}' -I \
-H "Authorization: Bearer $bearer" \
-H "Accept: $accept" \
--connect-timeout 10 --max-time 60 "$url")
fi
probe_digest=$(grep -i '^docker-content-digest:' "$hdr" \
| tr -d '\r' | awk '{ print $2 }' | tail -1 || true)
rm -f "$hdr"
return 0
}
registry_probe "$VERSION"
case "$probe_code" in
404)
pre_digest=""
echo "$IMAGE:$VERSION does not exist yet"
;;
200)
pre_digest="$probe_digest"
if ! printf '%s\n' "$pre_digest" | grep -Eq '^sha256:[0-9a-f]{64}$'; then
echo "$IMAGE:$VERSION exists but the registry sent no usable Docker-Content-Digest: '$pre_digest'"
exit 1
fi
echo "$IMAGE:$VERSION already exists at $pre_digest"
;;
*)
echo "could not determine whether $IMAGE:$VERSION exists (HTTP $probe_code)"
echo "refusing to push: an unreadable registry cannot be checked for immutability (ruling 9)"
exit 1
;;
esac
if [ -n "$pre_digest" ]; then
# Nothing is built and nothing is pushed. Every assertion below
# runs against the image that is already there, and the
# binary-identity step compares it with the tarballs this run just
# built — which is what "the same release" actually means.
digest="$pre_digest"
echo "adopting the pushed image; this re-run will not rebuild or overwrite it (ruling 9)"
else
docker buildx create --name "$builder" --driver docker-container --bootstrap >/dev/null
metadata="$RUNNER_TEMP/buildx-metadata.json"
docker buildx build \
--builder "$builder" \
--file deploy/docker/Dockerfile \
--platform linux/amd64,linux/arm64 \
--provenance=false \
--sbom=false \
--build-arg SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH" \
--build-arg VERSION="$VERSION" \
--build-arg REVISION="$TAG_COMMIT" \
--build-arg CREATED="$CREATED" \
--tag "$IMAGE:$VERSION" \
--metadata-file "$metadata" \
--push \
.
digest=$(jq -r '."containerimage.digest" // empty' "$metadata")
if ! printf '%s\n' "$digest" | grep -Eq '^sha256:[0-9a-f]{64}$'; then
echo "buildx reported no usable index digest: '$digest'"
cat "$metadata"
exit 1
fi
fi
resolved=$(docker buildx imagetools inspect "$IMAGE:$VERSION" --format '{{.Manifest.Digest}}')
if [ "$resolved" != "$digest" ]; then
echo "$IMAGE:$VERSION resolves to $resolved, not the pushed $digest"
exit 1
fi
raw=$(docker buildx imagetools inspect "$IMAGE@$digest" --raw)
count=$(printf '%s' "$raw" | jq '.manifests | length')
platforms=$(printf '%s' "$raw" \
| jq -r '[.manifests[] | "\(.platform.os // "?")/\(.platform.architecture // "?")"] | sort | join(",")')
echo "manifests: $count platforms: $platforms"
if [ "$count" -ne 2 ] || [ "$platforms" != "linux/amd64,linux/arm64" ]; then
echo "expected exactly linux/amd64 and linux/arm64"
exit 1
fi
# The OCI labels come from the build args above. Asserting them here
# turns a renamed ARG in the Dockerfile into a loud failure instead
# of a release carrying empty labels.
#
# `{{json .Image}}` is a map keyed by platform ("linux/amd64",
# "linux/arm64"), so the assertion is per platform: exactly two
# entries, each carrying exactly one version label, each equal to
# $VERSION. An earlier form accepted `length >= 1` over the flattened
# list, which passed when only one of the two configs had the label
# while claiming it had checked every platform.
if ! docker buildx imagetools inspect "$IMAGE@$digest" --format '{{json .Image}}' \
| jq -e --arg v "$VERSION" '
to_entries
| length == 2
and all(.[];
[.value | .. | objects | .Labels? // empty
| .["org.opencontainers.image.version"] // empty]
== [$v])' >/dev/null; then
echo "org.opencontainers.image.version is not $VERSION on both platforms"
docker buildx imagetools inspect "$IMAGE@$digest" --format '{{json .Image}}' | jq .
echo "check the ARG names deploy/docker/Dockerfile consumes: VERSION, REVISION, CREATED"
exit 1
fi
mkdir -p "$DIST"
printf '%s\n' "$IMAGE:$VERSION@$digest" > "$DIST/IMAGE-DIGEST.txt"
cat "$DIST/IMAGE-DIGEST.txt"
# Ruling 6 and an acceptance criterion: the binary inside each image is
# byte-identical to the binary in the matching tarball. Both platforms,
# and against the image that was actually pushed rather than a local
# rebuild — arm64 image content was previously verified against nothing.
#
# No qemu, no binfmt. `docker create` materialises a container without
# executing anything, so `docker cp` reads a foreign-architecture image
# fine; only `docker start` would need emulation. Verified on this host
# (docker 29.6.2, x86_64) on 2026-08-07 by pulling an arm64 alpine by
# index digest with `--platform`, creating a container from it and
# copying a file out.
#
# The comparison side is the extracted tarball, not zig-out/dist/stage:
# the tarball is what an operator downloads, and extracting it here also
# proves the archive that carries the binary is the archive whose hash
# goes into SHA256SUMS. /LICENSE and /THIRD-PARTY-NOTICES are compared
# too — distributing the image is distribution (ruling 3).
- name: Verify the pushed image against the tarballs on both platforms
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REGISTRY_USER: ${{ github.repository_owner }}
run: |
set -euo pipefail
DOCKER_CONFIG=$(mktemp -d "${RUNNER_TEMP:-/tmp}/dockercfg.XXXXXXXX")
export DOCKER_CONFIG
cid=""
cleanup() {
if [ -n "$cid" ]; then docker rm -f "$cid" >/dev/null 2>&1 || true; fi
docker logout "$REGISTRY" >/dev/null 2>&1 || true
rm -rf "${DOCKER_CONFIG:?}"
}
trap cleanup EXIT
printf '%s' "$REGISTRY_TOKEN" \
| docker login "$REGISTRY" --username "$REGISTRY_USER" --password-stdin
digest=$(awk -F@ '{ print $2; exit }' "$DIST/IMAGE-DIGEST.txt")
if ! printf '%s\n' "$digest" | grep -Eq '^sha256:[0-9a-f]{64}$'; then
echo "no usable digest in IMAGE-DIGEST.txt: '$digest'"
exit 1
fi
work="$RUNNER_TEMP/image-check"
rm -rf "$work"
mkdir -p "$work/tarball" "$work/image"
rc=0
for pair in "linux/amd64:x86_64-linux-musl" "linux/arm64:aarch64-linux-musl"; do
platform="${pair%%:*}"
triple="${pair#*:}"
name="nxdns-$VERSION-$triple"
echo "=== $platform ($triple) ==="
tar -xzf "$DIST/$name.tar.gz" -C "$work/tarball"
test -d "$work/tarball/$name"
docker pull --platform "$platform" "$IMAGE@$digest" >/dev/null
cid=$(docker create --platform "$platform" "$IMAGE@$digest")
out="$work/image/$triple"
mkdir -p "$out"
for member in nxdns LICENSE THIRD-PARTY-NOTICES; do
docker cp "$cid:/$member" "$out/$member"
want=$(sha256sum "$work/tarball/$name/$member" | cut -d' ' -f1)
got=$(sha256sum "$out/$member" | cut -d' ' -f1)
if [ "$want" = "$got" ]; then
echo " /$member matches the tarball ($got)"
else
echo " /$member DIFFERS: image $got, tarball $want"
rc=1
fi
done
docker rm -f "$cid" >/dev/null
cid=""
done
if [ "$rc" -ne 0 ]; then
echo "the pushed image does not carry the artifacts this release ships"
echo "nothing has been published; abandon this tag and ship the next patch (ruling 9)"
exit 1
fi
echo "both platforms match their tarballs"
# Step 11. `dist` cannot cover the image — the digest does not exist
# until buildx has pushed — so the line is appended here and the whole
# file is then checked against the files on disk.
- name: Assemble and verify the checksum file
run: |
set -euo pipefail
cd "$DIST"
test -f SHA256SUMS
test -f IMAGE-DIGEST.txt
cp SHA256SUMS SHA256SUMS.txt
sha256sum IMAGE-DIGEST.txt >> SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
cat SHA256SUMS.txt
# Step 12 and ruling 8. Temporary GNUPGHOME, no primary secret key,
# `--local-user <fpr>!` so GPG cannot fall back to another key, batch
# and loopback pinentry, signature verified before it is uploaded, and
# the home scrubbed with the agent killed on every exit path.
- name: Sign the checksum file
env:
RELEASE_GPG_SUBKEY: ${{ secrets.RELEASE_GPG_SUBKEY }}
RELEASE_GPG_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
run: |
set -euo pipefail
if ! printf '%s\n' "$RELEASE_SIGNING_FPR" | grep -Eq '^[0-9A-F]{40}$'; then
echo "RELEASE_SIGNING_FPR is not 40 uppercase hex characters: '$RELEASE_SIGNING_FPR'"
echo "paste the signing subkey fingerprint from manual prerequisite 1 into release.yml"
exit 1
fi
if [ -z "$RELEASE_GPG_SUBKEY" ] || [ -z "$RELEASE_GPG_PASSPHRASE" ]; then
echo "the RELEASE_GPG_SUBKEY / RELEASE_GPG_PASSPHRASE secrets are not both set (ruling 13)"
exit 1
fi
GNUPGHOME=$(mktemp -d "${RUNNER_TEMP:-/tmp}/gnupg.XXXXXXXX")
export GNUPGHOME
chmod 700 "$GNUPGHOME"
cleanup() {
gpgconf --kill gpg-agent >/dev/null 2>&1 || true
rm -rf "${GNUPGHOME:?}"
}
trap cleanup EXIT
passfile="$GNUPGHOME/passphrase"
(umask 077; printf '%s' "$RELEASE_GPG_PASSPHRASE" > "$passfile")
printf '%s' "$RELEASE_GPG_SUBKEY" | gpg --batch --quiet --import
# In --with-colons output, field 15 of a `sec` record is '#' when the
# primary secret is a stub and '+' when the real key is present. The
# runner must only ever hold the subkey (ruling 8).
leaked=$(gpg --list-secret-keys --with-colons | awk -F: '$1 == "sec" && $15 != "#" { print $5 }')
if [ -n "$leaked" ]; then
echo "the imported material contains a primary secret key ($leaked); export with --export-secret-subkeys"
exit 1
fi
fingerprints=$(gpg --list-secret-keys --with-colons | awk -F: '$1 == "fpr" { print $10 }')
primary=$(printf '%s\n' "$fingerprints" | head -1)
if [ "$primary" != "$TAG_SIGNING_FPR" ]; then
echo "imported certificate is $primary, expected $TAG_SIGNING_FPR"
exit 1
fi
if ! printf '%s\n' "$fingerprints" | grep -qx "$RELEASE_SIGNING_FPR"; then
echo "imported certificate does not carry the pinned signing subkey $RELEASE_SIGNING_FPR"
exit 1
fi
cd "$DIST"
gpg --batch --yes --quiet \
--pinentry-mode loopback --passphrase-file "$passfile" \
--local-user "$RELEASE_SIGNING_FPR!" \
--armor --detach-sign --output SHA256SUMS.txt.asc SHA256SUMS.txt
if ! status=$(gpg --batch --status-fd 1 --verify SHA256SUMS.txt.asc SHA256SUMS.txt 2>/dev/null); then
printf '%s\n' "$status"
echo "the signature this job just produced does not verify"
exit 1
fi
printf '%s\n' "$status"
signer=$(printf '%s\n' "$status" | awk '$2 == "VALIDSIG" { print $3; exit }')
if [ "$signer" != "$RELEASE_SIGNING_FPR" ]; then
echo "signed by $signer, expected $RELEASE_SIGNING_FPR"
exit 1
fi
# Step 13. Nothing is visible until the final step: the release is
# created as a draft, the assets are uploaded, `:latest` is moved, and
# only then is the draft published.
- name: Create the draft release and upload the assets
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
PREVIOUS_TAG: ${{ needs.guard.outputs.previous_tag }}
run: |
set -euo pipefail
# Ruling 10. PREVIOUS_TAG is the highest reachable *published* plain
# release the guard job found — deliberately not "the previous git
# tag", so an abandoned tag (ruling 9) can never become the
# comparison base. Empty means this is the first release.
#
# On the first release only the compare link is dropped; the commit
# appendix is still written, over the tag's whole history. The range
# for that case must be `git log --oneline v0.0.1` and NOT
# `git log --oneline ..v0.0.1`: an empty left-hand side of `..`
# resolves against HEAD, so the second form quietly means "commits
# reachable from HEAD but not from the tag" — normally empty, and
# never the intended "all history".
base=""
if [ -n "$PREVIOUS_TAG" ]; then
if git rev-parse -q --verify "refs/tags/$PREVIOUS_TAG^{commit}" >/dev/null; then
base="$PREVIOUS_TAG"
else
echo "published release $PREVIOUS_TAG has no tag object in this clone;"
echo "writing the full history and omitting the compare link"
fi
fi
body="$RUNNER_TEMP/release-body.md"
{
cat "$RUNNER_TEMP/changelog-section.md"
echo
echo '### Artifacts'
echo
echo '```'
cat "$DIST/SHA256SUMS.txt"
echo '```'
echo
echo '```'
cat "$DIST/IMAGE-DIGEST.txt"
echo '```'
echo
if [ -n "$base" ]; then
echo "[Compare $base...$TAG]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/$base...$TAG)"
echo
echo "<details><summary>Commits since $base</summary>"
else
echo "<details><summary>All commits up to $TAG</summary>"
fi
echo
echo '```'
if [ -n "$base" ]; then
git log --oneline "$base..$TAG"
else
git log --oneline "$TAG"
fi
echo '```'
echo
echo '</details>'
} > "$body"
resp=$(mktemp)
http_code=""
call() {
http_code=$(curl -sS -o "$resp" -w '%{http_code}' -X "$1" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Accept: application/json" \
--connect-timeout 10 --max-time 120 "$2")
}
# Re-checked here: the gates run between the guard job and this one,
# and a draft left by a concurrent run would collide with the upload.
call GET "$API/repos/$GITHUB_REPOSITORY/releases/tags/$TAG"
case "$http_code" in
404) ;;
200)
if [ "$(jq -r '.draft' "$resp")" = "true" ]; then
id=$(jq -r '.id' "$resp")
echo "deleting leftover draft release $id"
call DELETE "$API/repos/$GITHUB_REPOSITORY/releases/$id"
case "$http_code" in
200|204) ;;
*) echo "deleting draft $id failed with $http_code"; cat "$resp"; exit 1 ;;
esac
else
echo "$TAG became published while the gates ran; refusing to touch it (ruling 9)"
exit 1
fi
;;
*) echo "unexpected status $http_code looking up $TAG"; cat "$resp"; exit 1 ;;
esac
jq -n --arg tag "$TAG" --rawfile body "$body" \
'{tag_name: $tag, name: $tag, body: $body, draft: true, prerelease: false}' \
> "$RUNNER_TEMP/release.json"
http_code=$(curl -sS -o "$resp" -w '%{http_code}' -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
--connect-timeout 10 --max-time 120 \
--data-binary @"$RUNNER_TEMP/release.json" \
"$API/repos/$GITHUB_REPOSITORY/releases")
case "$http_code" in
200|201) ;;
*) echo "creating the draft release failed with $http_code"; cat "$resp"; exit 1 ;;
esac
release_id=$(jq -r '.id' "$resp")
echo "RELEASE_ID=$release_id" >> "$GITHUB_ENV"
echo "draft release $release_id created"
for asset in \
"nxdns-$VERSION-x86_64-linux-musl.tar.gz" \
"nxdns-$VERSION-aarch64-linux-musl.tar.gz" \
"SHA256SUMS.txt" \
"SHA256SUMS.txt.asc" \
"IMAGE-DIGEST.txt"
do
test -f "$DIST/$asset"
http_code=$(curl -sS -o "$resp" -w '%{http_code}' -X POST \
-H "Authorization: token $GITEA_TOKEN" \
--connect-timeout 10 --max-time 600 \
-F "attachment=@$DIST/$asset" \
"$API/repos/$GITHUB_REPOSITORY/releases/$release_id/assets?name=$asset")
case "$http_code" in
200|201) echo "uploaded $asset" ;;
*) echo "uploading $asset failed with $http_code"; cat "$resp"; exit 1 ;;
esac
done
# Step 14, and the LAST recoverable step.
#
# This runs BEFORE publication, which inverts the order ruling 7 lists.
# The reason is that publication is the one act the guard treats as
# terminal: a published release for a tag makes every re-run refuse
# (ruling 9), and tags are never reused. With `:latest` moving after
# publication, a transient registry failure in this step produced a
# published release that no re-run could repair and no fix could reach —
# a deadlock whose only exit is abandoning an already-public tag.
#
# The consequence is accepted and is the smaller harm: for the duration
# of the next step, `:latest` serves the new image while the release page
# is still a draft. A `docker pull …:latest` in that window gets the
# image this release publishes moments later, with the correct version
# label and digest — it is early, not wrong. A re-run repeats this step
# unchanged (`imagetools create` onto the same digest is idempotent) and
# then publishes.
#
# The monotonic-version invariant is re-checked here, not merely in the
# guard job. The guard runs before the gates; whichever run finishes the
# gates last is the run that reaches this point last, so the early check
# says nothing about ordering at the registry.
#
# Two checks, because they cover different things:
#
# The published-release scan repeats the guard's comparison against a
# fresher list. It does NOT close the concurrent-release race on its own:
# both runs are still drafts while they run, so neither appears in the
# other's published list and both pass. The workflow-level `concurrency`
# group is the only thing that actually serialises two tags.
#
# The `:latest` label read does close it, and is the backstop for a
# runner that ignores `concurrency:`. It asks the registry what version
# `:latest` currently serves — the exact state about to be mutated,
# rather than a proxy for it — and refuses to move backwards onto an
# older version. The window left is between that read and `imagetools
# create`, instead of the whole duration of the gates.
- name: Re-check the version invariant and move the latest tag
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REGISTRY_USER: ${{ github.repository_owner }}
run: |
set -euo pipefail
resp=$(mktemp)
http_code=""
call() {
http_code=$(curl -sS -o "$resp" -w '%{http_code}' -X "$1" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Accept: application/json" \
--connect-timeout 10 --max-time 120 "$2")
}
highest=""
page=1
while [ "$page" -le 20 ]; do
call GET "$API/repos/$GITHUB_REPOSITORY/releases?limit=50&page=$page"
if [ "$http_code" != "200" ]; then
echo "listing releases failed with $http_code"
cat "$resp"
exit 1
fi
# See the guard job: a 200 with a non-array body must not read as
# "no published releases".
if ! jq -e 'type == "array"' "$resp" >/dev/null 2>&1; then
echo "the releases endpoint returned 200 with a non-array payload:"
cat "$resp"
exit 1
fi
if [ "$(jq 'length' "$resp")" -eq 0 ]; then
break
fi
tags=$(jq -r '.[] | select(.draft == false) | .tag_name' "$resp")
published=$(printf '%s\n' "$tags" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true)
for candidate in $published; do
if [ -z "$highest" ] \
|| [ "$(printf '%s\n%s\n' "${highest#v}" "${candidate#v}" | sort -V | tail -1)" = "${candidate#v}" ]; then
highest="$candidate"
fi
done
page=$((page + 1))
done
if [ -n "$highest" ]; then
high="${highest#v}"
if [ "$VERSION" = "$high" ] \
|| [ "$(printf '%s\n%s\n' "$VERSION" "$high" | sort -V | tail -1)" != "$VERSION" ]; then
echo "$TAG no longer exceeds the highest published release $highest"
echo "another release finished first; refusing to move :latest backwards"
exit 1
fi
echo "$TAG still exceeds the highest published release $highest"
else
echo "still no published release; this is the first"
fi
DOCKER_CONFIG=$(mktemp -d "${RUNNER_TEMP:-/tmp}/dockercfg.XXXXXXXX")
export DOCKER_CONFIG
cleanup() {
docker logout "$REGISTRY" >/dev/null 2>&1 || true
rm -rf "${DOCKER_CONFIG:?}"
}
trap cleanup EXIT
printf '%s' "$REGISTRY_TOKEN" \
| docker login "$REGISTRY" --username "$REGISTRY_USER" --password-stdin
digest=$(awk -F@ '{ print $2; exit }' "$DIST/IMAGE-DIGEST.txt")
if ! printf '%s\n' "$digest" | grep -Eq '^sha256:[0-9a-f]{64}$'; then
echo "no usable digest in IMAGE-DIGEST.txt: '$digest'"
exit 1
fi
# What does :latest serve right now? An absent tag is the first
# release and is not an error; anything else that fails to read is,
# because moving a tag whose current value is unknown is exactly the
# move this check exists to prevent.
set +e
latest_out=$(docker buildx imagetools inspect "$IMAGE:latest" --format '{{json .Image}}' 2>&1)
latest_rc=$?
set -e
if [ "$latest_rc" -eq 0 ]; then
current=$(printf '%s' "$latest_out" \
| jq -r '[.. | objects | .Labels? // empty
| .["org.opencontainers.image.version"] // empty]
| map(select(. != "")) | unique | .[0] // empty')
if [ -z "$current" ]; then
echo "$IMAGE:latest carries no org.opencontainers.image.version label"
echo "refusing to move it: its current version cannot be established"
exit 1
fi
if [ "$current" = "$VERSION" ]; then
echo ":latest already serves $VERSION; re-pointing it at $digest is idempotent"
elif [ "$(printf '%s\n%s\n' "$VERSION" "$current" | sort -V | tail -1)" != "$VERSION" ]; then
echo ":latest serves $current, which is newer than $VERSION"
echo "another release moved it first; refusing to move :latest backwards"
exit 1
else
echo ":latest serves $current; $VERSION supersedes it"
fi
elif printf '%s\n' "$latest_out" | grep -qiE 'not found|manifest unknown|MANIFEST_UNKNOWN|NAME_UNKNOWN|no such manifest'; then
echo "$IMAGE:latest does not exist yet; this is the first release"
else
echo "could not read $IMAGE:latest (exit $latest_rc):"
printf '%s\n' "$latest_out"
exit 1
fi
docker buildx imagetools create --tag "$IMAGE:latest" "$IMAGE@$digest"
resolved=$(docker buildx imagetools inspect "$IMAGE:latest" --format '{{.Manifest.Digest}}')
if [ "$resolved" != "$digest" ]; then
echo "$IMAGE:latest resolves to $resolved, not $digest"
exit 1
fi
echo "$IMAGE:latest now points at $digest"
# Step 15, last, and the only irreversible act in this workflow. Every
# step above is repeatable by a re-run: the draft is deleted and rebuilt,
# an already-pushed version tag is adopted rather than rebuilt, and
# `:latest` is re-pointed at its digest. Once this succeeds the guard
# refuses every further run for this tag, so it must be last.
- name: Publish the draft
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
resp=$(mktemp)
http_code=$(curl -sS -o "$resp" -w '%{http_code}' -X PATCH \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
--connect-timeout 10 --max-time 120 \
--data-binary '{"draft":false}' \
"$API/repos/$GITHUB_REPOSITORY/releases/${RELEASE_ID:?}")
# A lost or malformed response to a PATCH that Gitea already
# committed would otherwise deadlock the tag: the release is public,
# so the guard refuses every re-run, and this step is the one that
# never reported success. Ask what the release actually is before
# concluding anything from the transport.
if [ "$http_code" != "200" ] && [ "$http_code" != "201" ]; then
echo "the publish request answered $http_code:"
cat "$resp"
echo "re-reading release $RELEASE_ID to see whether it took effect"
recheck=$(mktemp)
recheck_code=$(curl -sS -o "$recheck" -w '%{http_code}' \
-H "Authorization: token $GITEA_TOKEN" \
-H "Accept: application/json" \
--connect-timeout 10 --max-time 120 \
"$API/repos/$GITHUB_REPOSITORY/releases/${RELEASE_ID:?}")
if [ "$recheck_code" = "200" ] && [ "$(jq -r '.draft' "$recheck")" = "false" ]; then
echo "release $RELEASE_ID is published; the request took effect despite the response"
echo "published $TAG"
exit 0
fi
echo "release $RELEASE_ID is not published (re-read answered $recheck_code)"
cat "$recheck"
exit 1
fi
if [ "$(jq -r '.draft' "$resp")" != "false" ]; then
echo "release $RELEASE_ID is still a draft"
exit 1
fi
echo "published $TAG"
# Belt and braces for the traps above: cancellation and a runner that
# reuses its workspace both land here.
#
# `gpgconf --kill` acts on the agent of the GNUPGHOME it is pointed at. A
# bare call kills the runner's default agent and leaves every leaked
# temporary home's agent running — with the signing key still cached and
# unlocked — and then deletes its socket, which makes the survivor harder
# to reach rather than harmless. Each home is killed in its own home.
- name: Scrub secret material
if: always()
run: |
set -uo pipefail
tmp="${RUNNER_TEMP:-}"
if [ -n "$tmp" ] && [ -d "$tmp" ]; then
for home in "$tmp"/gnupg.*; do
[ -d "$home" ] || continue
GNUPGHOME="$home" gpgconf --kill gpg-agent >/dev/null 2>&1 || true
done
rm -rf "$tmp"/gnupg.* "$tmp"/dockercfg.*
fi
exit 0