1334 lines
59 KiB
YAML
1334 lines
59 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: "REPLACE_WITH_RELEASE_SIGNING_SUBKEY_FINGERPRINT"
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|