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--x86_64-linux-musl.tar.gz # nxdns--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 # # # # 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/.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//manifests/. 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 !` 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 "
Commits since $base" else echo "
All commits up to $TAG" fi echo echo '```' if [ -n "$base" ]; then git log --oneline "$base..$TAG" else git log --oneline "$TAG" fi echo '```' echo echo '
' } > "$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