diff --git a/.gitea/workflows/gates.yml b/.gitea/workflows/gates.yml index ae44c91..165c3ed 100644 --- a/.gitea/workflows/gates.yml +++ b/.gitea/workflows/gates.yml @@ -123,57 +123,12 @@ jobs: # The licence inventory has to cover every package whose bytes ship, and # the lockfile does not answer that question: it lists what could be - # reached, not what rollup kept. Four packages of the non-dev closure are - # recorded as tree-shaken away, and if application code starts importing - # one of them, no lockfile, no version and no dependency set changes — - # only the bundle does. So the bundle is what this reads. - # - # A second build with sourcemaps, because the shipped build has none: the - # `sources` list of each chunk names the packages whose modules went into - # it. The output goes to its own directory so the artifact npm run build - # produced is the one that gets embedded, untouched. + # reached, not what rollup kept. The bundle is what this reads. The logic + # lives in web/scripts/, unit-tested by `npm test`, so it runs on a laptop + # exactly as it runs here (milestone-14 deviation 24). - name: Assert the packages bundled into web/dist are the recorded ones working-directory: web - run: | - set -euo pipefail - # The binary npm ci installed, never `npx`: npx silently downloads a - # package it cannot find locally, so a wrong working directory would - # turn a licence check into an unpinned fetch from the network. - ./node_modules/.bin/vite build --sourcemap --outDir dist-sourcemap --emptyOutDir >/dev/null - - maps=$(find dist-sourcemap -name '*.map' -type f | LC_ALL=C sort) - if [ -z "$maps" ]; then - echo "the sourcemap build produced no .map files; this check cannot run blind" - exit 1 - fi - - # shellcheck disable=SC2086 - bundled=$(jq -r '.sources[]' $maps \ - | grep 'node_modules/' \ - | sed 's|.*node_modules/||' \ - | awk -F/ '{ if ($1 ~ /^@/) print $1"/"$2; else print $1 }' \ - | LC_ALL=C sort -u) - - recorded=$(awk ' - /^\[npm packages bundled into web\/dist\]$/ { grab = 1; next } - grab && /^\[/ { exit } - grab && NF { print } - ' ../licenses/dependency-identity.txt | LC_ALL=C sort -u) - - if [ -z "$recorded" ]; then - echo "licenses/dependency-identity.txt has no '[npm packages bundled into web/dist]' section" - exit 1 - fi - - if ! diff -u <(printf '%s\n' "$recorded") <(printf '%s\n' "$bundled"); then - echo - echo "the set of npm packages in web/dist has changed (-recorded +current)." - echo "Work out what the change means for licenses/inventory.zon first, then record" - echo "the new list in that section of licenses/dependency-identity.txt." - exit 1 - fi - echo "web/dist bundles exactly the recorded packages:" - printf '%s\n' "$bundled" + run: npm run assert-bundled package: runs-on: ubuntu-24.04 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index cefa1bb..eefc8c5 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,12 +1,18 @@ name: Release # --------------------------------------------------------------------------- +# This file is glue. Every decision the release makes lives in +# tools/release.zig, which is compiled, type-checked and unit-tested by +# `zig build test` (milestone-14 deviation 24). The doc comment at the head of +# that file is the long-form record of why the phases are ordered as they are; +# the short pointers below say which phase each step is. +# # 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: +# extensionless upload has NOT been tested against the live instance. The +# release therefore commits to the extension-carrying names: # # nxdns--x86_64-linux-musl.tar.gz # nxdns--aarch64-linux-musl.tar.gz @@ -16,12 +22,11 @@ name: Release # # `.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. +# the `sign` phase 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 +# `asset_suffixes` in tools/release.zig and update # docs/how-to/verify-a-release.md to match. Nothing else changes. # --------------------------------------------------------------------------- # @@ -39,10 +44,9 @@ on: # 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. +# release that already pushed an image is allowed to finish. The `latest` phase +# 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 @@ -54,14 +58,16 @@ env: # 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). + # fingerprint is compared against this pin (ruling 7, step 3). It is the + # PRIMARY certificate fingerprint, which is the LAST field of the VALIDSIG + # line, not field 3 — see tools/release.zig. TAG_SIGNING_FPR: "A2061F6AB24DF2C0E92346FD1509B54946D08A95" # The release signing subkey of that same key (ruling 8). Manual # prerequisite 1 creates it; until its fingerprint is pasted in here the # *guard job* fails closed — before the gates, and long before anything is # pushed to the registry. 40 uppercase hex characters, no spaces. - RELEASE_SIGNING_FPR: "B281CECC877BD36575543F0A4148C60EC18D831D" + RELEASE_SIGNING_FPR: "019D00DF8417EBFDA5471E5EF7319CC024FB5A96" jobs: # Steps 1-6 of ruling 7. Everything here is cheap and refuses a bad tag @@ -74,8 +80,10 @@ jobs: 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. + # The one step that is deliberately NOT in the Zig tool: it runs before + # the checkout and before anything is compiled, so a missing secret costs + # nothing at all. Everything the release depends on is checked here, + # first. # # 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 @@ -130,7 +138,8 @@ jobs: # 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. + # from, and no tag object to verify. `persist-credentials: false` is why + # the tool authenticates its own refetch. - name: Check out the tag with full history and tags uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: @@ -138,398 +147,6 @@ jobs: fetch-tags: true persist-credentials: false - - name: Reject a tag that is not vMAJOR.MINOR.PATCH - env: - TAG: ${{ github.ref_name }} - run: | - set -euo pipefail - if ! printf '%s\n' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "refusing '$TAG': releases are vMAJOR.MINOR.PATCH only, with no pre-release suffix" - exit 1 - fi - echo "tag $TAG accepted" - - # The imported material is the *secret subkey* export, whose public half - # is the author's certificate — that is what verifies the tag. No - # passphrase is needed to import, and the temporary GNUPGHOME is scrubbed - # on every exit path. - # - # This step does two things, and the second is the one that matters for - # recovery: it proves the *artifact-signing* material actually works, - # here, before the gates and long before the registry is touched. A - # presence check on the secrets is not enough. A public-only export - # verifies the tag perfectly well; an export missing the pinned subkey - # does too; and a placeholder RELEASE_GPG_PASSPHRASE passes every check - # that does not try to sign something. All three used to fail for the - # first time in the signing step, which runs *after* the image push — - # exactly the shape of failure ruling 9 forbids. - - name: Verify the tag signature and prove the signing key is usable - env: - TAG: ${{ github.ref_name }} - RELEASE_GPG_SUBKEY: ${{ secrets.RELEASE_GPG_SUBKEY }} - RELEASE_GPG_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }} - run: | - set -euo pipefail - - # actions/checkout on a tag ref fetches the *commit* SHA into - # refs/tags/, silently replacing the annotated tag object with a - # lightweight tag. Without this refetch, every signed tag reads as - # unannotated and the check below refuses it. --force because that - # wrong local ref already exists. - git fetch --force --no-tags origin "refs/tags/$TAG:refs/tags/$TAG" - - if [ "$(git cat-file -t "refs/tags/$TAG")" != "tag" ]; then - echo "refusing '$TAG': not an annotated tag, so it carries no signature" - exit 1 - fi - - if [ -z "$RELEASE_GPG_SUBKEY" ]; then - echo "the RELEASE_GPG_SUBKEY secret is empty; see manual prerequisite 1 (ruling 13)" - exit 1 - fi - - GNUPGHOME=$(mktemp -d "${RUNNER_TEMP:-/tmp}/gnupg.XXXXXXXX") - export GNUPGHOME - chmod 700 "$GNUPGHOME" - cleanup() { - gpgconf --kill gpg-agent >/dev/null 2>&1 || true - rm -rf "${GNUPGHOME:?}" - } - trap cleanup EXIT - - printf '%s' "$RELEASE_GPG_SUBKEY" | gpg --batch --quiet --import - printf '%s:6:\n' "$TAG_SIGNING_FPR" | gpg --batch --quiet --import-ownertrust - - if ! status=$(git verify-tag --raw "$TAG" 2>&1); then - printf '%s\n' "$status" - echo "git verify-tag failed for $TAG" - exit 1 - fi - printf '%s\n' "$status" - - # gpg's DETAILS gives the status line as - # - # VALIDSIG - # - # - # - # so on `git verify-tag --raw` output (prefixed "[GNUPG:] VALIDSIG") - # field 3 is the fingerprint of the key that MADE the signature and - # the LAST field is the primary key of the certificate it belongs to. - # Those differ whenever a signing subkey exists — and manual - # prerequisite 1 adds one to this very certificate, after which gpg - # selects it for `git tag -s`. Comparing field 3 against the primary - # fingerprint pinned below would reject every real release. - # - # Reproduced with a throwaway keyring on 2026-08-07 (gpg 2.4.9, - # primary + added signing subkey, `git tag -s`): NF is 12, $3 is the - # subkey, $12 is the primary. - # - # TAG_SIGNING_FPR stays the *primary certificate* fingerprint, so - # adding or rotating a signing subkey does not break verification. - fpr=$(printf '%s\n' "$status" \ - | awk '$2 == "VALIDSIG" && NF >= 12 { print $NF; exit }') - if ! printf '%s\n' "$fpr" | grep -Eq '^[0-9A-F]{40}$'; then - echo "git verify-tag emitted no VALIDSIG line carrying a primary-key fingerprint" - exit 1 - fi - if [ "$fpr" != "$TAG_SIGNING_FPR" ]; then - echo "tag signed under certificate $fpr, expected $TAG_SIGNING_FPR" - exit 1 - fi - echo "signature is under the pinned certificate $TAG_SIGNING_FPR" - - # Everything below proves the artifact-signing material, not the tag. - # The signing step repeats these checks against the home it actually - # signs in; this copy is the one that fails closed. - if [ -z "$RELEASE_GPG_PASSPHRASE" ]; then - echo "the RELEASE_GPG_PASSPHRASE secret is empty; see manual prerequisite 1 (ruling 13)" - exit 1 - fi - - # Field 15 of a `sec` record is '#' when the primary secret is a stub - # and '+' when the real key is present. The runner must only ever - # hold the subkey (ruling 8). - leaked=$(gpg --list-secret-keys --with-colons | awk -F: '$1 == "sec" && $15 != "#" { print $5 }') - if [ -n "$leaked" ]; then - echo "the imported material contains a primary secret key ($leaked); export with --export-secret-subkeys" - exit 1 - fi - - if ! gpg --list-secret-keys --with-colons \ - | awk -F: '$1 == "fpr" { print $10 }' \ - | grep -qx "$RELEASE_SIGNING_FPR"; then - echo "the RELEASE_GPG_SUBKEY export carries no secret key $RELEASE_SIGNING_FPR" - echo " a public-only export verifies the tag but cannot sign SHA256SUMS (ruling 8)" - exit 1 - fi - - # The only check that can tell a correct passphrase from a - # placeholder is a signature. Sign a throwaway file with the exact - # invocation the signing step uses, and verify the result. - passfile="$GNUPGHOME/passphrase" - (umask 077; printf '%s' "$RELEASE_GPG_PASSPHRASE" > "$passfile") - probe="$GNUPGHOME/probe" - printf 'nxdns release key probe\n' > "$probe" - if ! gpg --batch --yes --quiet \ - --pinentry-mode loopback --passphrase-file "$passfile" \ - --local-user "$RELEASE_SIGNING_FPR!" \ - --armor --detach-sign --output "$probe.asc" "$probe"; then - echo "signing with $RELEASE_SIGNING_FPR failed" - echo " the usual cause is a wrong RELEASE_GPG_PASSPHRASE (ruling 13)" - exit 1 - fi - if ! probe_status=$(gpg --batch --status-fd 1 --verify "$probe.asc" "$probe" 2>/dev/null); then - printf '%s\n' "$probe_status" - echo "the probe signature does not verify" - exit 1 - fi - probe_signer=$(printf '%s\n' "$probe_status" | awk '$2 == "VALIDSIG" { print $3; exit }') - if [ "$probe_signer" != "$RELEASE_SIGNING_FPR" ]; then - echo "the probe was signed by $probe_signer, expected $RELEASE_SIGNING_FPR" - exit 1 - fi - echo "the signing subkey $RELEASE_SIGNING_FPR is present and its passphrase is correct" - - - name: Assert the tag is an ancestor of master - env: - TAG: ${{ github.ref_name }} - run: | - set -euo pipefail - tag_commit=$(git rev-parse "refs/tags/$TAG^{commit}") - master="" - for ref in refs/remotes/origin/master refs/heads/master; do - if git rev-parse --verify --quiet "$ref" >/dev/null; then - master="$ref" - break - fi - done - if [ -z "$master" ]; then - echo "no master ref in this clone; the checkout must fetch full history" - exit 1 - fi - if ! git merge-base --is-ancestor "$tag_commit" "$master"; then - echo "$TAG ($tag_commit) is not an ancestor of $master" - exit 1 - fi - echo "$TAG is an ancestor of $master" - - # Ruling 9: the draft is the unit of work, so a re-run clears a leftover - # draft and repeats. A published release for this tag is terminal — tags - # are never reused, and the fix ships as the next patch version. - # - # That rule is only safe because publication is the LAST irreversible act - # of the publish job (see the step ordering there): the draft is created, - # the assets are uploaded, `:latest` is moved, and only then is the draft - # published. So "published" means every earlier step already succeeded, - # and there is nothing left for a re-run to repair. Publishing before - # moving `:latest` would make a failure in the `:latest` step - # unrecoverable: the release would be published, this guard would refuse - # every re-run, and `:latest` would be stuck on the previous version with - # no way forward except abandoning a tag that is already public. - - name: Refuse a published release, clear a stale draft, assert the version increases - id: releases - env: - TAG: ${{ github.ref_name }} - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} - run: | - set -euo pipefail - - if ! command -v jq >/dev/null 2>&1; then - sudo apt-get update -qq - sudo apt-get install -qq -y jq - fi - - api="$GITHUB_SERVER_URL/api/v1" - resp=$(mktemp) - http_code="" - call() { - http_code=$(curl -sS -o "$resp" -w '%{http_code}' -X "$1" \ - -H "Authorization: token $GITEA_TOKEN" \ - -H "Accept: application/json" \ - --connect-timeout 10 --max-time 120 "$2") - } - - call GET "$api/repos/$GITHUB_REPOSITORY/releases/tags/$TAG" - case "$http_code" in - 404) - echo "no existing release for $TAG" - ;; - 200) - if [ "$(jq -r '.draft' "$resp")" = "true" ]; then - id=$(jq -r '.id' "$resp") - echo "deleting leftover draft release $id" - call DELETE "$api/repos/$GITHUB_REPOSITORY/releases/$id" - case "$http_code" in - 200|204) ;; - *) echo "deleting draft $id failed with $http_code"; cat "$resp"; exit 1 ;; - esac - else - echo "$TAG already has a published release; it will not be touched (ruling 9)" - exit 1 - fi - ;; - *) - echo "unexpected status $http_code looking up $TAG" - cat "$resp" - exit 1 - ;; - esac - - # Highest published plain release. This is both the floor the new - # version must exceed (so a late-finishing older tag cannot move - # :latest backwards) and the comparison base for the release notes - # (ruling 10) — an abandoned tag is not published and so cannot - # become that base. - highest="" - page=1 - while [ "$page" -le 20 ]; do - call GET "$api/repos/$GITHUB_REPOSITORY/releases?limit=50&page=$page" - if [ "$http_code" != "200" ]; then - echo "listing releases failed with $http_code" - cat "$resp" - exit 1 - fi - # The payload must be an array before anything reads it as one. A - # 200 carrying a JSON *object* — an error body from the API or from - # something in front of it — makes the jq below fail, and the - # `|| true` that stops grep's no-match from killing the step covers - # the whole pipeline, so the failure would read as "no releases". - # That is the one wrong answer with consequences: it moves :latest - # backwards. - if ! jq -e 'type == "array"' "$resp" >/dev/null 2>&1; then - echo "the releases endpoint returned 200 with a non-array payload:" - cat "$resp" - exit 1 - fi - if [ "$(jq 'length' "$resp")" -eq 0 ]; then - break - fi - tags=$(jq -r '.[] | select(.draft == false) | .tag_name' "$resp") - published=$(printf '%s\n' "$tags" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true) - for candidate in $published; do - if [ -z "$highest" ] \ - || [ "$(printf '%s\n%s\n' "${highest#v}" "${candidate#v}" | sort -V | tail -1)" = "${candidate#v}" ]; then - highest="$candidate" - fi - done - page=$((page + 1)) - done - - if [ -n "$highest" ]; then - new="${TAG#v}" - high="${highest#v}" - if [ "$new" = "$high" ] \ - || [ "$(printf '%s\n%s\n' "$new" "$high" | sort -V | tail -1)" != "$new" ]; then - echo "$TAG does not exceed the highest published release $highest" - exit 1 - fi - echo "$TAG exceeds the highest published release $highest" - else - echo "no published release yet; this is the first" - fi - - echo "previous_tag=$highest" >> "$GITHUB_OUTPUT" - - # This job imports secret key material, so it gets the same backstop the - # publish job has. The EXIT trap inside the step covers a failing step; - # it does not cover a cancelled or killed runner. - - name: Scrub secret material - if: always() - run: | - set -uo pipefail - tmp="${RUNNER_TEMP:-}" - if [ -n "$tmp" ] && [ -d "$tmp" ]; then - for home in "$tmp"/gnupg.*; do - [ -d "$home" ] || continue - GNUPGHOME="$home" gpgconf --kill gpg-agent >/dev/null 2>&1 || true - done - rm -rf "$tmp"/gnupg.* - fi - exit 0 - - # Step 7: the identical gate set CI runs, blocking. - gates: - needs: [guard] - uses: ./.gitea/workflows/gates.yml - - # Steps 8-15, with 14 and 15 swapped relative to ruling 7: `:latest` moves - # before the draft is published, not after. See the two steps at the foot of - # this job for why — publication is the act the guard treats as terminal, so - # it has to be the last one that can fail. - publish: - needs: [guard, gates] - runs-on: ubuntu-24.04 - timeout-minutes: 120 - - steps: - - name: Check out the tag with full history and tags - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - fetch-depth: 0 - fetch-tags: true - persist-credentials: false - - - name: Ensure the tooling this job assumes - run: | - set -euo pipefail - need="" - command -v jq >/dev/null 2>&1 || need="$need jq" - command -v gpg >/dev/null 2>&1 || need="$need gnupg" - command -v curl >/dev/null 2>&1 || need="$need curl" - if [ -n "$need" ]; then - sudo apt-get update -qq - # shellcheck disable=SC2086 - sudo apt-get install -qq -y $need - fi - command -v docker >/dev/null 2>&1 || { echo "docker is not installed on this runner"; exit 1; } - docker buildx version - - # One place computes every derived value the rest of the job uses. The - # tag is authoritative (ruling 2): the version, the commit and the - # timestamp all come out of it, never out of a file. - - name: Resolve the release identity - env: - TAG: ${{ github.ref_name }} - run: | - set -euo pipefail - if ! printf '%s\n' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "refusing '$TAG': releases are vMAJOR.MINOR.PATCH only" - exit 1 - fi - - # Same refetch as the guard: checkout replaced the annotated tag - # object with a lightweight one, and the tagger date below needs the - # real object. - git fetch --force --no-tags origin "refs/tags/$TAG:refs/tags/$TAG" - - version="${TAG#v}" - tag_commit=$(git rev-parse "refs/tags/$TAG^{commit}") - epoch=$(git for-each-ref --format='%(taggerdate:unix)' "refs/tags/$TAG") - if [ -z "$epoch" ]; then - echo "$TAG has no tagger date; it is not an annotated tag" - exit 1 - fi - - registry="${GITHUB_SERVER_URL#http://}" - registry="${registry#https://}" - registry="${registry%%/*}" - image="$registry/$(printf '%s' "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]')" - - { - echo "TAG=$TAG" - echo "VERSION=$version" - echo "TAG_COMMIT=$tag_commit" - echo "SOURCE_DATE_EPOCH=$epoch" - echo "CREATED=$(date -u -d "@$epoch" +%Y-%m-%dT%H:%M:%SZ)" - echo "REGISTRY=$registry" - echo "IMAGE=$image" - echo "API=$GITHUB_SERVER_URL/api/v1" - echo "DIST=$GITHUB_WORKSPACE/zig-out/dist" - } >> "$GITHUB_ENV" - - echo "releasing $version from $tag_commit as $image:$version" - - name: Set up Zig uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: @@ -543,6 +160,103 @@ jobs: - name: Create the fetch temp dir zig assumes run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp" + - name: Build the release tool + run: zig build release-tool + + # Steps 2 and 3: the tag is vMAJOR.MINOR.PATCH, the annotated tag object + # is refetched (checkout replaced it with a lightweight tag), the + # signature is under the pinned certificate, and the artifact-signing + # subkey is present, primary-free and its passphrase correct. + - name: Verify the tag signature and prove the signing key is usable + env: + TAG: ${{ github.ref_name }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + RELEASE_GPG_SUBKEY: ${{ secrets.RELEASE_GPG_SUBKEY }} + RELEASE_GPG_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }} + run: ./zig-out/bin/release guard-tag + + # Step 4. + - name: Assert the tag is an ancestor of master + env: + TAG: ${{ github.ref_name }} + run: ./zig-out/bin/release guard-ancestry + + # Steps 5 and 6. 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. + - 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: ./zig-out/bin/release guard-releases + + # This job imports secret key material, so it gets the same backstop the + # publish job has. The `defer`s inside the tool cover a failing phase; + # they do not cover a cancelled or killed runner. `|| true` covers the + # case where the build itself failed, in which case nothing was imported. + - name: Scrub secret material + if: always() + run: ./zig-out/bin/release scrub || true + + # 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. Publication is the act the guard + # treats as terminal, so it has to be the last one that can fail — see the + # module comment of tools/release.zig. + 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 + + # The tool speaks HTTP and computes hashes itself, so jq and curl are + # gone. gpg and docker are what it shells out to. + - name: Ensure the tooling this job assumes + run: | + set -euo pipefail + command -v gpg >/dev/null 2>&1 || { + sudo apt-get update -qq + sudo apt-get install -qq -y gnupg + } + command -v docker >/dev/null 2>&1 || { echo "docker is not installed on this runner"; exit 1; } + docker buildx version + + - name: Set up Zig + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + with: + version: ${{ env.ZIG_VERSION }} + + # See the guard job: zig 0.16.0 assumes this directory exists. + - name: Create the fetch temp dir zig assumes + run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp" + + - name: Build the release tool + run: zig build release-tool + + # One place computes every derived value the rest of the job uses, and + # writes them to $GITHUB_ENV. 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 }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: ./zig-out/bin/release resolve + - name: Set up Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: @@ -556,790 +270,92 @@ jobs: npm ci npm run build + # Step 8. - 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 + run: > + 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" + run: > + zig build verify-dist + -Dversion-string="$VERSION" + -Dgit-commit="$TAG_COMMIT" + -Dweb-dist=web/dist + -Doptimize=ReleaseSafe # 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. + # a missing changelog section costs nothing but the run. - 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" + run: ./zig-out/bin/release changelog - # 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". + # Step 10. Probe the registry, then push only if the version tag is + # absent; a 200 is adopted and nothing is overwritten (ruling 9, + # probe-adopt). - 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" + run: ./zig-out/bin/release image # 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). + # byte-identical to the binary in the matching tarball, on both platforms, + # and against the image that was actually pushed. - 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 + run: ./zig-out/bin/release verify-image-binaries - 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 + # Steps 11 and 12. `dist` cannot cover the image — the digest does not + # exist until buildx has pushed — so the line is appended here, the whole + # file is checked against the files on disk, and only then signed. + - name: Assemble, verify and 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 + run: ./zig-out/bin/release sign # 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: + TAG: ${{ github.ref_name }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} PREVIOUS_TAG: ${{ needs.guard.outputs.previous_tag }} - run: | - set -euo pipefail + run: ./zig-out/bin/release draft - # 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. + # Step 14, and the LAST recoverable step. It runs BEFORE publication: with + # `:latest` moving after, a transient registry failure here produced a + # published release that no re-run could repair and no fix could reach. + # The monotonic invariant is re-checked, and `:latest`'s own version label + # is read, because the guard ran before the gates and says nothing about + # which of two in-flight tags finishes last. - name: Re-check the version invariant and move the latest tag env: + TAG: ${{ github.ref_name }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} REGISTRY_USER: ${{ github.repository_owner }} - run: | - set -euo pipefail + run: ./zig-out/bin/release latest - 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. + # Step 15, last, and the only irreversible act in this workflow. Once it + # succeeds the guard refuses every further run for this tag. - name: Publish the draft env: + TAG: ${{ github.ref_name }} 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:?}") + run: ./zig-out/bin/release publish - # 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. + # Belt and braces for the `defer`s inside the tool: cancellation and a + # runner that reuses its workspace both land here. Each temporary + # GNUPGHOME's agent is killed in its own home — a bare `gpgconf --kill` + # kills the runner's default agent and leaves the leaked home's agent + # running with the signing key cached and unlocked. - 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 + run: ./zig-out/bin/release scrub || true diff --git a/build.zig b/build.zig index 8043262..fad90a9 100644 --- a/build.zig +++ b/build.zig @@ -218,6 +218,27 @@ pub fn build(b: *std.Build) void { b.step("test-aarch64", "Run the test suite for aarch64-linux-musl (use -fqemu)") .dependOn(&aarch64_run.step); + // The release publication tool (milestone-14 deviation 24). It is a host + // tool like `dist_stage` and `verify_dist`, and it is installed rather than + // run from the build graph: the workflow invokes it once per phase with the + // secrets in its environment, and a Run step would have to carry them. + const release_tool = hostTool(b, "release"); + b.step("release-tool", "Install the release publication tool into zig-out/bin") + .dependOn(&b.addInstallArtifact(release_tool, .{}).step); + + // Its pure decisions — semver ordering, VALIDSIG field selection, changelog + // extraction, the releases-payload shape guard — are the reason it exists, + // so they run in the same `zig build test` as everything else. + const release_tests = b.addTest(.{ + .name = "release-tool", + .root_module = b.createModule(.{ + .root_source_file = b.path("tools/release.zig"), + .target = b.graph.host, + .optimize = optimize, + }), + }); + test_step.dependOn(&b.addRunArtifact(release_tests).step); + addDist(b, options, web_assets, .{ .version = version_option, .version_string = version_string, diff --git a/specs/milestone-14.md b/specs/milestone-14.md index 2f76d45..7e2d0eb 100644 --- a/specs/milestone-14.md +++ b/specs/milestone-14.md @@ -580,6 +580,53 @@ was reproduced before it was fixed. would have turned a licence check into an unpinned fetch. Reproduced: it fetched `vite@8.2.0` over the pinned `8.1.5`. +23. **`actions/checkout` destroys the annotated tag object.** Found by the first + live dry run, not by review: on a tag ref, checkout fetches the *commit* SHA + into `refs/tags/`, so the signed tag reads as lightweight and the guard + refuses it as unannotated. Both jobs that read the tag object — signature + verification in the guard, the tagger date in the publish job — now force- + refetch `refs/tags/$TAG` from origin first. The same run also proved the + fail-closed secret guard for real: the first dry-run attempt ran with no + secrets configured (they were on the wrong repository) and stopped in the + guard with nothing built or pushed. + +24. **Publication orchestration moved out of workflow shell into + `tools/release.zig`.** Ruling 5 already moved the packaging asserts out of + CI shell for one reason — "checks that only exist inside a workflow file are + the brittleness this exists to remove" — and the release job was the larger + half of the same problem, left in place. Three live failures came out of it, + and each was found by executing the workflow, which is the most expensive + place to find anything: `actions/checkout` replacing the annotated tag object + (deviation 23), the refetch that fixed it having no credentials because + `persist-credentials` is off, and the multiline armored subkey escaping the + runner's log masker, which masks per line. + + Twelve subcommands, one per step group: `guard-tag`, `guard-ancestry`, + `guard-releases`, `resolve`, `changelog`, `image`, + `verify-image-binaries`, `sign`, `draft`, `latest`, `publish`, `scrub`. Every + behaviour recorded in deviations 10 to 15 and 23 is carried over unchanged — + probe-adopt, the VALIDSIG last field, the subkey-only import and signing + probe, the array-shape guard on the releases payload, the `:latest` label + read, the publish re-read, the per-home `gpgconf --kill`, the tag refetch. + What is new is that the semver ordering, VALIDSIG field selection, challenge + parsing, changelog extraction, checksum-line parsing, colon-format parsing + and payload-shape guard are 25 unit tests in `zig build test` rather than + shell that only ever runs on a tag push. `release.yml` keeps the triggers, + the concurrency group, the job graph, the SHA pins, the two pinned + fingerprints and the fail-closed secret presence check — which stays as + shell, deliberately, so that it runs before the tool is even compiled. + + The same reasoning applies to the `jq` pipeline of the bundled-package gate, + which moved to `web/scripts/bundledPackages.mjs` with its own vitest + coverage and an `npm run assert-bundled` entry point. + + **Secret contract change:** `RELEASE_GPG_SUBKEY` becomes + `RELEASE_GPG_SUBKEY` and holds `base64 -w0` of the armored + `--export-secret-subkeys` output rather than the armored text. Manual + prerequisite 1 and 3 change accordingly. The tool decodes it in memory and + writes it to a mode-600 file inside the temporary `GNUPGHOME`. A single-line + secret is one the masker can actually mask. + ### Not verified, and why - **No workflow has ever executed.** `release.yml` and `gates.yml` were validated diff --git a/tools/release.zig b/tools/release.zig new file mode 100644 index 0000000..a06d89b --- /dev/null +++ b/tools/release.zig @@ -0,0 +1,2530 @@ +//! Release publication for `.gitea/workflows/release.yml` (milestone-14 +//! rulings 7, 8 and 9, and recorded deviation 24). +//! +//! Every decision the release makes lives here rather than in workflow shell, +//! for the same reason `dist` and `verify-dist` moved into `tools/`: logic that +//! only exists inside a YAML `run:` block cannot be read by a type checker, run +//! on a laptop, or covered by a test. Three live failures came out of that shell +//! — `actions/checkout` replacing the annotated tag object, a refetch with no +//! credentials, and a multiline secret escaping the log masker — and each was +//! found by executing the workflow, which is the most expensive place to find +//! anything. +//! +//! The workflow keeps the parts that are genuinely the runner's: triggers, the +//! concurrency group, the job graph, SHA-pinned actions, and the fail-closed +//! secret presence check that must run before this program is even built. +//! +//! Usage: +//! +//! release guard-tag verify the tag object and the signing key +//! release guard-ancestry the tag's commit is an ancestor of master +//! release guard-releases no published release, no stale draft, and +//! the version increases +//! release resolve derive the release identity into $GITHUB_ENV +//! release changelog extract the CHANGELOG.md section +//! release image probe, build and push the version tag +//! release verify-image-binaries image contents == tarball contents +//! release sign assemble and sign SHA256SUMS.txt +//! release draft create the draft and upload the assets +//! release latest move :latest onto the released digest +//! release publish publish the draft +//! release scrub best-effort removal of secret material +//! +//! Configuration comes from the environment, never from arguments: a secret in +//! `argv` is readable from `/proc` by every process on the runner. Secret values +//! are never printed, and no failure message quotes one. +//! +//! ## Phase ordering, which is load-bearing +//! +//! `:latest` moves BEFORE the draft is published, inverting the order ruling 7 +//! lists. Publication is the one act the guard treats as terminal: a published +//! release makes every re-run refuse (ruling 9), and tags are never reused. With +//! `:latest` moving after publication, a transient registry failure produced a +//! published release that no re-run could repair — a deadlock whose only exit +//! was abandoning an already-public tag. The accepted cost is a short window +//! where `: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, and a re-run repeats the step unchanged. +//! +//! `image` probes before it pushes, and adopts what it finds. Gitea's container +//! tags are mutable, so a push-then-compare has already overwritten the tag it +//! then refuses — the check would report a violation it caused. Ruling 9 asks +//! for "an existing tag whose digest matches exactly what it just built", and +//! 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 is a real HTTP `HEAD` on `/v2//manifests/` 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". +//! +//! `publish` re-reads the release when the transport fails. A `PATCH` that Gitea +//! committed but whose response was lost would otherwise deadlock the tag: the +//! release is public, so the guard refuses every re-run, and this is the step +//! that never reported success. +//! +//! ## The two fingerprints +//! +//! `TAG_SIGNING_FPR` is the author's **primary certificate** fingerprint. +//! `git verify-tag --raw` emits `VALIDSIG` with the fingerprint of the key that +//! MADE the signature in field 3 and the primary key of the certificate it +//! belongs to in the LAST field. 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 pinned primary +//! would reject every real release. Pinning the primary means adding or rotating +//! a signing subkey is a non-event for verification. +//! +//! `RELEASE_SIGNING_FPR` is the artifact-signing subkey, and it is field 3 of +//! the signatures this program itself produces. +//! +//! ## The GNUPGHOME discipline (ruling 8) +//! +//! A temporary home, the subkey export only, an assertion that no primary secret +//! key came with it, `--local-user !` so gpg cannot fall back to another +//! key, batch and loopback pinentry, the produced signature verified before it +//! is used, and the home scrubbed with its own agent killed on every exit path. +//! `gpgconf --kill` acts on the agent of the `GNUPGHOME` it is pointed at: a +//! bare call kills the runner's default agent, leaves the temporary home's agent +//! running with the key cached and unlocked, and deletes its socket — which +//! makes the survivor harder to reach rather than harmless. +//! +//! The guard proves the signing material works before the gates run and long +//! before the registry is touched. A presence check is not enough: a public-only +//! export verifies the tag perfectly well, an export missing the pinned subkey +//! does too, and a placeholder 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. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Io = std.Io; +const http = std.http; + +const max_input_bytes = 1 << 30; +const max_body_bytes = 64 << 20; + +/// Release assets, in upload order. The names carry extensions because Gitea's +/// `[attachment] ALLOWED_TYPES` is extension-based (milestone-14 ruling 13). +const asset_suffixes = [_][]const u8{ + "SHA256SUMS.txt", + "SHA256SUMS.txt.asc", + "IMAGE-DIGEST.txt", +}; + +const Platform = struct { + docker: []const u8, + triple: []const u8, +}; + +const platforms = [_]Platform{ + .{ .docker = "linux/amd64", .triple = "x86_64-linux-musl" }, + .{ .docker = "linux/arm64", .triple = "aarch64-linux-musl" }, +}; + +/// The members of the image that must equal the tarball's copies. Distributing +/// the image is distribution, so the licence files are compared too (ruling 3). +const image_members = [_][]const u8{ "nxdns", "LICENSE", "THIRD-PARTY-NOTICES" }; + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +const Ctx = struct { + arena: Allocator, + gpa: Allocator, + io: Io, + env: *std.process.Environ.Map, + out: *Io.Writer, + failures: usize = 0, + + fn pass(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) void { + ctx.out.print("release: PASS " ++ check ++ ": " ++ template ++ "\n", args) catch {}; + ctx.out.flush() catch {}; + } + + fn note(ctx: *Ctx, comptime template: []const u8, args: anytype) void { + ctx.out.print("release: " ++ template ++ "\n", args) catch {}; + ctx.out.flush() catch {}; + } + + /// Records a failure and keeps going. Only the guard phases use this; a + /// phase that mutates the registry or the release record stops at the first + /// problem, because continuing would compound it. + fn soft(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) void { + ctx.failures += 1; + ctx.out.print("release: FAIL " ++ check ++ ": " ++ template ++ "\n", args) catch {}; + ctx.out.flush() catch {}; + } + + /// Names the check and exits. Deferred scrubbing does not run through + /// `std.process.exit`, so every caller holding secret material unwinds + /// through `error.CheckFailed` instead of calling this. + fn fatal(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) noreturn { + ctx.out.print("release: FAIL " ++ check ++ ": " ++ template ++ "\n", args) catch {}; + ctx.out.flush() catch {}; + std.process.exit(1); + } + + fn get(ctx: *Ctx, name: []const u8) []const u8 { + return ctx.env.get(name) orelse ""; + } + + fn require(ctx: *Ctx, name: []const u8) []const u8 { + const value = ctx.get(name); + if (value.len == 0) ctx.fatal("environment", "{s} is empty or unset", .{name}); + return value; + } + + fn fmt(ctx: *Ctx, comptime template: []const u8, args: anytype) []const u8 { + return std.fmt.allocPrint(ctx.arena, template, args) catch @panic("OOM"); + } +}; + +/// A phase that already reported why it failed. +const CheckFailed = error.CheckFailed; + +pub fn main(init: std.process.Init) !u8 { + const arena = init.arena.allocator(); + const argv = try init.minimal.args.toSlice(arena); + + var out_buffer: [8192]u8 = undefined; + var out = Io.File.stdout().writerStreaming(init.io, &out_buffer); + + var ctx: Ctx = .{ + .arena = arena, + .gpa = init.gpa, + .io = init.io, + .env = init.environ_map, + .out = &out.interface, + }; + + if (argv.len < 2) std.process.fatal("usage: release ; see tools/release.zig", .{}); + const command = argv[1]; + + const result = dispatch(&ctx, command); + ctx.out.flush() catch {}; + result catch |err| switch (err) { + error.CheckFailed => return 1, + else => return err, + }; + return if (ctx.failures == 0) 0 else 1; +} + +fn dispatch(ctx: *Ctx, command: []const u8) !void { + if (std.mem.eql(u8, command, "guard-tag")) return guardTag(ctx); + if (std.mem.eql(u8, command, "guard-ancestry")) return guardAncestry(ctx); + if (std.mem.eql(u8, command, "guard-releases")) return guardReleases(ctx); + if (std.mem.eql(u8, command, "resolve")) return resolve(ctx); + if (std.mem.eql(u8, command, "changelog")) return changelog(ctx); + if (std.mem.eql(u8, command, "image")) return image(ctx); + if (std.mem.eql(u8, command, "verify-image-binaries")) return verifyImageBinaries(ctx); + if (std.mem.eql(u8, command, "sign")) return sign(ctx); + if (std.mem.eql(u8, command, "draft")) return draft(ctx); + if (std.mem.eql(u8, command, "latest")) return latest(ctx); + if (std.mem.eql(u8, command, "publish")) return publish(ctx); + if (std.mem.eql(u8, command, "scrub")) return scrub(ctx); + std.process.fatal("unknown subcommand '{s}'; see tools/release.zig", .{command}); +} + +// --------------------------------------------------------------------------- +// Pure helpers. Everything below this line that can be tested without a network, +// a keyring or a docker daemon is tested at the foot of this file. +// --------------------------------------------------------------------------- + +const Semver = struct { + major: u32, + minor: u32, + patch: u32, + + fn order(a: Semver, b: Semver) std.math.Order { + if (a.major != b.major) return std.math.order(a.major, b.major); + if (a.minor != b.minor) return std.math.order(a.minor, b.minor); + return std.math.order(a.patch, b.patch); + } +}; + +/// `MAJOR.MINOR.PATCH`, decimal, no pre-release suffix and no leading zeroes +/// beyond a bare `0`. Field-by-field integer comparison is the point: `sort -V` +/// happened to get `0.0.10` above `0.0.9` right, and a lexical fallback would +/// not have. +fn parseSemver(text: []const u8) ?Semver { + var it = std.mem.splitScalar(u8, text, '.'); + var fields: [3]u32 = undefined; + for (&fields) |*field| { + const part = it.next() orelse return null; + if (part.len == 0 or part.len > 9) return null; + if (part.len > 1 and part[0] == '0') return null; + for (part) |c| if (c < '0' or c > '9') return null; + field.* = std.fmt.parseInt(u32, part, 10) catch return null; + } + if (it.next() != null) return null; + return .{ .major = fields[0], .minor = fields[1], .patch = fields[2] }; +} + +/// `vMAJOR.MINOR.PATCH` and nothing else. Releases carry no pre-release suffix. +fn parseTag(tag: []const u8) ?Semver { + if (!std.mem.startsWith(u8, tag, "v")) return null; + return parseSemver(tag[1..]); +} + +fn isFingerprint(text: []const u8) bool { + if (text.len != 40) return false; + for (text) |c| { + const ok = (c >= '0' and c <= '9') or (c >= 'A' and c <= 'F'); + if (!ok) return false; + } + return true; +} + +fn isDigest(text: []const u8) bool { + if (!std.mem.startsWith(u8, text, "sha256:")) return false; + const hex = text["sha256:".len..]; + if (hex.len != 64) return false; + for (hex) |c| { + const ok = (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f'); + if (!ok) return false; + } + return true; +} + +/// The primary certificate fingerprint of a `--raw` / `--status-fd` VALIDSIG +/// line: its LAST field. The line is +/// +/// VALIDSIG +/// +/// +/// +/// prefixed with `[GNUPG:] `, so a complete line has 12 whitespace-separated +/// fields. Reproduced with a throwaway keyring on 2026-08-07 (gpg 2.4.9, primary +/// plus an added signing subkey, `git tag -s`): NF is 12, field 3 is the subkey +/// and field 12 is the primary. +fn validsigPrimary(status: []const u8) ?[]const u8 { + return validsigField(status, .primary); +} + +/// The fingerprint of the key that made the signature: field 3. +fn validsigSigner(status: []const u8) ?[]const u8 { + return validsigField(status, .signer); +} + +fn validsigField(status: []const u8, which: enum { signer, primary }) ?[]const u8 { + var lines = std.mem.splitScalar(u8, status, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trimEnd(u8, raw, "\r"); + var fields = std.mem.tokenizeAny(u8, line, " \t"); + var found: [16][]const u8 = undefined; + var count: usize = 0; + while (fields.next()) |field| { + if (count < found.len) found[count] = field; + count += 1; + } + if (count < 12 or count > found.len) continue; + if (!std.mem.eql(u8, found[1], "VALIDSIG")) continue; + return switch (which) { + .signer => found[2], + .primary => found[count - 1], + }; + } + return null; +} + +const Challenge = struct { + realm: []const u8 = "", + service: []const u8 = "", + scope: []const u8 = "", +}; + +/// A `WWW-Authenticate: Bearer realm="…",service="…",scope="…"` challenge. Basic +/// auth is what Gitea accepts directly; a standards-compliant registry in front +/// of it answers 401 with this instead, and the probe follows whichever shape it +/// meets rather than assuming one. +fn parseChallenge(header: []const u8) Challenge { + var challenge: Challenge = .{}; + challenge.realm = challengeParam(header, "realm") orelse ""; + challenge.service = challengeParam(header, "service") orelse ""; + challenge.scope = challengeParam(header, "scope") orelse ""; + return challenge; +} + +fn challengeParam(header: []const u8, name: []const u8) ?[]const u8 { + var index: usize = 0; + while (std.mem.indexOfPos(u8, header, index, name)) |at| { + index = at + name.len; + // A parameter name starts at the beginning or after a delimiter, so + // `service` never matches inside `myservice`. + if (at > 0) { + const before = header[at - 1]; + if (before != ' ' and before != ',' and before != '\t') continue; + } + var rest = header[index..]; + rest = std.mem.trimStart(u8, rest, " \t"); + if (rest.len == 0 or rest[0] != '=') continue; + rest = std.mem.trimStart(u8, rest[1..], " \t"); + if (rest.len == 0 or rest[0] != '"') continue; + const end = std.mem.indexOfScalar(u8, rest[1..], '"') orelse return null; + return rest[1 .. 1 + end]; + } + return null; +} + +/// The `## [VERSION]` section of a Keep a Changelog file, without its heading. +/// It stops at the next section heading and at the link-reference block the +/// format puts at the foot of the file — those definitions belong to the +/// document, not to the release notes. +fn changelogSection(source: []const u8, version: []const u8) ?[]const u8 { + var start: ?usize = null; + var offset: usize = 0; + var lines = std.mem.splitScalar(u8, source, '\n'); + while (lines.next()) |line| { + const next_offset = offset + line.len + 1; + defer offset = next_offset; + + if (start == null) { + if (isVersionHeading(line, version)) start = @min(next_offset, source.len); + continue; + } + if (std.mem.startsWith(u8, line, "## ") or isLinkReference(line)) { + return source[start.?..@min(offset, source.len)]; + } + } + if (start) |from| return source[from..]; + return null; +} + +fn isVersionHeading(line: []const u8, version: []const u8) bool { + if (!std.mem.startsWith(u8, line, "## [")) return false; + const rest = line["## [".len..]; + if (!std.mem.startsWith(u8, rest, version)) return false; + const after = rest[version.len..]; + return std.mem.startsWith(u8, after, "]"); +} + +/// A Keep a Changelog link definition: `[anything]: url`. +fn isLinkReference(line: []const u8) bool { + if (!std.mem.startsWith(u8, line, "[")) return false; + const close = std.mem.indexOfScalar(u8, line[1..], ']') orelse return false; + if (close == 0) return false; + return std.mem.startsWith(u8, line[1 + close ..], "]: "); +} + +fn isBlank(text: []const u8) bool { + return std.mem.trim(u8, text, " \t\r\n").len == 0; +} + +const SumsLine = struct { + hex: []const u8, + name: []const u8, +}; + +/// `sha256sum` text mode: 64 lowercase hex digits, two spaces, the name. The two +/// spaces are what `sha256sum -c` expects on the operator's machine. +fn parseSumsLine(line: []const u8) ?SumsLine { + if (line.len < 64 + 2 + 1) return null; + const hex = line[0..64]; + for (hex) |c| { + const ok = (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f'); + if (!ok) return null; + } + if (!std.mem.eql(u8, line[64..66], " ")) return null; + return .{ .hex = hex, .name = line[66..] }; +} + +fn sha256Hex(bytes: []const u8) [64]u8 { + var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{}); + return std.fmt.bytesToHex(digest, .lower); +} + +/// gpg's `--with-colons` output, field 15 of a `sec` record: `#` when the +/// primary secret is a stub and `+` when the real key is present. The runner +/// must only ever hold the subkey (ruling 8), so anything but a stub is a leak. +fn primarySecretLeak(colons: []const u8) ?[]const u8 { + var lines = std.mem.splitScalar(u8, colons, '\n'); + while (lines.next()) |line| { + var fields = std.mem.splitScalar(u8, std.mem.trimEnd(u8, line, "\r"), ':'); + var values: [20][]const u8 = @splat(""); + var count: usize = 0; + while (fields.next()) |field| : (count += 1) { + if (count < values.len) values[count] = field; + } + if (count < 15) continue; + if (!std.mem.eql(u8, values[0], "sec")) continue; + if (std.mem.eql(u8, values[14], "#")) continue; + return values[4]; + } + return null; +} + +/// Every `fpr` record's fingerprint (field 10), in listing order, so the first +/// is the certificate's primary. +fn colonFingerprints(arena: Allocator, colons: []const u8) []const []const u8 { + var list: std.ArrayList([]const u8) = .empty; + var lines = std.mem.splitScalar(u8, colons, '\n'); + while (lines.next()) |line| { + var fields = std.mem.splitScalar(u8, std.mem.trimEnd(u8, line, "\r"), ':'); + var values: [20][]const u8 = @splat(""); + var count: usize = 0; + while (fields.next()) |field| : (count += 1) { + if (count < values.len) values[count] = field; + } + if (count < 10) continue; + if (!std.mem.eql(u8, values[0], "fpr")) continue; + list.append(arena, values[9]) catch @panic("OOM"); + } + return list.items; +} + +fn containsString(haystack: []const []const u8, needle: []const u8) bool { + for (haystack) |item| if (std.mem.eql(u8, item, needle)) return true; + return false; +} + +/// The highest `vMAJOR.MINOR.PATCH` among published releases. A payload that is +/// not a JSON array must never reach this: see `releasesArray`. +fn highestPublished(current: ?[]const u8, tag: []const u8) ?[]const u8 { + const candidate = parseTag(tag) orelse return current; + const highest = current orelse return tag; + const known = parseTag(highest) orelse return tag; + return if (candidate.order(known) == .gt) tag else highest; +} + +/// A 200 carrying a JSON *object* — an error body from the API or from something +/// in front of it — must not read as "no releases". That is the one wrong answer +/// with consequences: it moves `:latest` backwards. Deviation 12 records the +/// live bug this replaces. +fn releasesArray(value: std.json.Value) ?[]std.json.Value { + return switch (value) { + .array => |array| array.items, + else => null, + }; +} + +/// Whitespace is stripped before decoding because a repository secret pasted +/// from `base64` output carries line breaks. The base64 wrapper exists at all +/// because a multiline armored key escapes the runner's log masker, which masks +/// per line (deviation 24). +fn decodeBase64(arena: Allocator, source: []const u8) ![]u8 { + var packed_buffer: std.ArrayList(u8) = .empty; + for (source) |c| { + if (c == ' ' or c == '\n' or c == '\r' or c == '\t') continue; + try packed_buffer.append(arena, c); + } + const decoder = std.base64.standard.Decoder; + const size = try decoder.calcSizeForSlice(packed_buffer.items); + const out = try arena.alloc(u8, size); + try decoder.decode(out, packed_buffer.items); + return out; +} + +fn encodeBase64(arena: Allocator, source: []const u8) []const u8 { + const encoder = std.base64.standard.Encoder; + const out = arena.alloc(u8, encoder.calcSize(source.len)) catch @panic("OOM"); + return encoder.encode(out, source); +} + +/// The registry host and the image repository path, derived from the server URL +/// exactly as the workflow used to derive them: strip the scheme, keep the +/// authority, and lowercase the repository because OCI names are lowercase. +fn registryHost(url: []const u8) []const u8 { + var rest = url; + if (std.mem.indexOf(u8, rest, "://")) |at| rest = rest[at + 3 ..]; + if (std.mem.indexOfScalar(u8, rest, '/')) |at| rest = rest[0..at]; + return rest; +} + +fn lowercase(arena: Allocator, text: []const u8) []const u8 { + const out = arena.alloc(u8, text.len) catch @panic("OOM"); + for (text, out) |c, *slot| slot.* = std.ascii.toLower(c); + return out; +} + +/// Every `org.opencontainers.image.version` label in a `imagetools inspect +/// --format '{{json .Image}}'` subtree, in document order. The label lives under +/// a `Labels` object whose depth depends on the manifest shape, so the walk is +/// recursive rather than a fixed path. +fn collectVersionLabels(arena: Allocator, value: std.json.Value, out: *std.ArrayList([]const u8)) void { + switch (value) { + .object => |object| { + if (object.get("Labels")) |labels| { + if (labels == .object) { + if (labels.object.get("org.opencontainers.image.version")) |version| { + if (version == .string) out.append(arena, version.string) catch @panic("OOM"); + } + } + } + var it = object.iterator(); + while (it.next()) |entry| collectVersionLabels(arena, entry.value_ptr.*, out); + }, + .array => |array| for (array.items) |item| collectVersionLabels(arena, item, out), + else => {}, + } +} + +/// True when `imagetools inspect` failed because the tag is absent, as opposed +/// to failing for a reason that must not be read as "first release". +fn saysAbsent(text: []const u8) bool { + const needles = [_][]const u8{ + "not found", + "manifest unknown", + "MANIFEST_UNKNOWN", + "NAME_UNKNOWN", + "no such manifest", + }; + for (needles) |needle| { + if (std.ascii.indexOfIgnoreCase(text, needle) != null) return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// Process and file plumbing +// --------------------------------------------------------------------------- + +const Run = struct { + code: u8, + stdout: []const u8, + stderr: []const u8, + + fn ok(run: Run) bool { + return run.code == 0; + } + + /// stdout and stderr together, which is what a gpg status stream needs: + /// `git verify-tag --raw` writes the status lines to stderr. + fn combined(run: Run, arena: Allocator) []const u8 { + return std.mem.concat(arena, u8, &.{ run.stdout, run.stderr }) catch @panic("OOM"); + } + + fn trimmedStdout(run: Run) []const u8 { + return std.mem.trim(u8, run.stdout, " \t\r\n"); + } +}; + +const RunOptions = struct { + /// Extra environment for the child only. `GNUPGHOME` and `DOCKER_CONFIG` + /// travel this way so no sibling process inherits them. + env: []const [2][]const u8 = &.{}, + cwd: ?[]const u8 = null, + stdin: ?[]const u8 = null, +}; + +fn runCommand(ctx: *Ctx, argv: []const []const u8, options: RunOptions) !Run { + var child_env: ?std.process.Environ.Map = null; + defer if (child_env) |*map| map.deinit(); + if (options.env.len != 0) { + var map = try ctx.env.clone(ctx.gpa); + for (options.env) |pair| try map.put(pair[0], pair[1]); + child_env = map; + } + + const cwd: std.process.Child.Cwd = if (options.cwd) |path| .{ .path = path } else .inherit; + + if (options.stdin) |payload| { + // stdout and stderr are inherited rather than piped: writing a payload + // and then draining two pipes from one thread can deadlock, and the + // commands that take stdin here (`docker login`) say nothing worth + // capturing. + var child = try std.process.spawn(ctx.io, .{ + .argv = argv, + .cwd = cwd, + .environ_map = if (child_env) |*map| map else null, + .stdin = .pipe, + .stdout = .inherit, + .stderr = .inherit, + }); + errdefer child.kill(ctx.io); + var stdin = child.stdin.?; + try stdin.writeStreamingAll(ctx.io, payload); + stdin.close(ctx.io); + child.stdin = null; + const term = try child.wait(ctx.io); + return .{ .code = termCode(term), .stdout = "", .stderr = "" }; + } + + const result = try std.process.run(ctx.gpa, ctx.io, .{ + .argv = argv, + .cwd = cwd, + .environ_map = if (child_env) |*map| map else null, + .stdout_limit = .limited(max_input_bytes), + .stderr_limit = .limited(max_input_bytes), + }); + defer ctx.gpa.free(result.stdout); + defer ctx.gpa.free(result.stderr); + + return .{ + .code = termCode(result.term), + .stdout = try ctx.arena.dupe(u8, result.stdout), + .stderr = try ctx.arena.dupe(u8, result.stderr), + }; +} + +fn termCode(term: std.process.Child.Term) u8 { + return switch (term) { + .exited => |code| code, + else => 255, + }; +} + +/// Runs a command and reports its output before failing. Used wherever a +/// non-zero exit is a release failure rather than information. +fn mustRun(ctx: *Ctx, comptime check: []const u8, argv: []const []const u8, options: RunOptions) ![]const u8 { + const run = try runCommand(ctx, argv, options); + if (!run.ok()) { + ctx.soft(check, "`{s}` exited {d}: {s}", .{ + argv[0], run.code, std.mem.trimEnd(u8, run.combined(ctx.arena), "\n"), + }); + return CheckFailed; + } + return run.stdout; +} + +fn readFile(ctx: *Ctx, path: []const u8) ![]const u8 { + return Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes)); +} + +fn writeFileMode(ctx: *Ctx, path: []const u8, bytes: []const u8, mode: std.posix.mode_t) !void { + var handle = try Io.Dir.cwd().createFile(ctx.io, path, .{}); + defer handle.close(ctx.io); + try handle.writeStreamingAll(ctx.io, bytes); + // After the write, not through the creation mode, which `open(2)` masks + // with the process umask. + try handle.setPermissions(ctx.io, .fromMode(mode)); +} + +/// `$GITHUB_ENV` and `$GITHUB_OUTPUT` are append-only files the runner reads +/// after the step. There is no append mode on `Io.Dir`, and both files are small. +fn appendLine(ctx: *Ctx, env_name: []const u8, line: []const u8) !void { + const path = ctx.get(env_name); + if (path.len == 0) { + ctx.note("{s} is unset; not recording `{s}`", .{ env_name, line }); + return; + } + const existing = Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes)) catch ""; + const separator: []const u8 = if (existing.len == 0 or existing[existing.len - 1] == '\n') "" else "\n"; + const merged = try std.mem.concat(ctx.arena, u8, &.{ existing, separator, line, "\n" }); + try writeFileMode(ctx, path, merged, 0o644); +} + +fn runnerTemp(ctx: *Ctx) []const u8 { + const temp = ctx.get("RUNNER_TEMP"); + return if (temp.len != 0) temp else "/tmp"; +} + +/// A fresh directory under `RUNNER_TEMP`, named so `scrub` can find it. The name +/// is claimed by an exclusive `makeDir` rather than by a random suffix: a +/// collision is a retry, not a silent share. +fn makeTempDir(ctx: *Ctx, prefix: []const u8) ![]const u8 { + const base = runnerTemp(ctx); + var attempt: usize = 0; + while (attempt < 4096) : (attempt += 1) { + const path = ctx.fmt("{s}/{s}.{s}-{d}", .{ + base, prefix, ctx.get("GITHUB_RUN_ID"), attempt, + }); + Io.Dir.cwd().createDirPath(ctx.io, path) catch |err| switch (err) { + error.PathAlreadyExists => continue, + else => return err, + }; + var dir = try Io.Dir.cwd().openDir(ctx.io, path, .{ .iterate = true }); + defer dir.close(ctx.io); + try dir.setPermissions(ctx.io, .fromMode(0o700)); + return path; + } + ctx.fatal("temp-dir", "cannot create a {s}.* directory under {s}", .{ prefix, base }); +} + +// --------------------------------------------------------------------------- +// HTTP +// --------------------------------------------------------------------------- + +const Response = struct { + status: u16, + body: []const u8, + + fn ok(response: Response) bool { + return response.status >= 200 and response.status < 300; + } + + fn json(response: Response, ctx: *Ctx) ?std.json.Value { + return std.json.parseFromSliceLeaky(std.json.Value, ctx.arena, response.body, .{}) catch null; + } +}; + +const Request = struct { + method: http.Method, + url: []const u8, + headers: []const http.Header = &.{}, + payload: ?[]const u8 = null, + content_type: ?[]const u8 = null, +}; + +fn httpSend(ctx: *Ctx, request: Request) !Response { + var client: http.Client = .{ .allocator = ctx.gpa, .io = ctx.io }; + defer client.deinit(); + + var body: Io.Writer.Allocating = .init(ctx.arena); + const result = client.fetch(.{ + .location = .{ .url = request.url }, + .method = request.method, + .payload = request.payload, + .extra_headers = request.headers, + .headers = if (request.content_type) |content_type| + .{ .content_type = .{ .override = content_type } } + else + .{}, + .response_writer = &body.writer, + .redirect_behavior = .unhandled, + }) catch |err| { + ctx.soft("http", "{t} {s} failed: {t}", .{ request.method, request.url, err }); + return CheckFailed; + }; + return .{ .status = @intFromEnum(result.status), .body = body.written() }; +} + +/// A `HEAD` whose *response headers* are the answer, which `fetch` cannot +/// return. Used only by the registry probe. +const HeadResponse = struct { + status: u16, + digest: []const u8 = "", + challenge: []const u8 = "", +}; + +fn httpHead(ctx: *Ctx, url: []const u8, headers: []const http.Header) !HeadResponse { + var client: http.Client = .{ .allocator = ctx.gpa, .io = ctx.io }; + defer client.deinit(); + + const uri = std.Uri.parse(url) catch |err| { + ctx.soft("registry-probe", "'{s}' is not a URL: {t}", .{ url, err }); + return CheckFailed; + }; + var request = client.request(.HEAD, uri, .{ + .extra_headers = headers, + .redirect_behavior = .unhandled, + .keep_alive = false, + }) catch |err| { + ctx.soft("registry-probe", "HEAD {s} failed: {t}", .{ url, err }); + return CheckFailed; + }; + defer request.deinit(); + + request.sendBodiless() catch |err| { + ctx.soft("registry-probe", "HEAD {s} failed: {t}", .{ url, err }); + return CheckFailed; + }; + var redirect_buffer: [8192]u8 = undefined; + var response = request.receiveHead(&redirect_buffer) catch |err| { + ctx.soft("registry-probe", "HEAD {s} returned no usable head: {t}", .{ url, err }); + return CheckFailed; + }; + + var out: HeadResponse = .{ .status = @intFromEnum(response.head.status) }; + var it = response.head.iterateHeaders(); + while (it.next()) |header| { + if (std.ascii.eqlIgnoreCase(header.name, "docker-content-digest")) { + out.digest = try ctx.arena.dupe(u8, std.mem.trim(u8, header.value, " \t\r")); + } else if (std.ascii.eqlIgnoreCase(header.name, "www-authenticate")) { + out.challenge = try ctx.arena.dupe(u8, header.value); + } + } + return out; +} + +fn basicAuth(ctx: *Ctx, user: []const u8, secret: []const u8) []const u8 { + return ctx.fmt("Basic {s}", .{encodeBase64(ctx.arena, ctx.fmt("{s}:{s}", .{ user, secret }))}); +} + +// --------------------------------------------------------------------------- +// Gitea API +// --------------------------------------------------------------------------- + +const Api = struct { + ctx: *Ctx, + base: []const u8, + repository: []const u8, + token: []const u8, + + fn init(ctx: *Ctx) Api { + const explicit = ctx.get("GITHUB_API_URL"); + const base = if (explicit.len != 0) + std.mem.trimEnd(u8, explicit, "/") + else + ctx.fmt("{s}/api/v1", .{std.mem.trimEnd(u8, ctx.require("GITHUB_SERVER_URL"), "/")}); + return .{ + .ctx = ctx, + .base = base, + .repository = ctx.require("GITHUB_REPOSITORY"), + .token = ctx.require("GITEA_TOKEN"), + }; + } + + fn url(api: Api, comptime template: []const u8, args: anytype) []const u8 { + return api.ctx.fmt("{s}/repos/{s}" ++ template, .{ api.base, api.repository } ++ args); + } + + fn headers(api: Api) []const http.Header { + const list = api.ctx.arena.alloc(http.Header, 2) catch @panic("OOM"); + list[0] = .{ .name = "Authorization", .value = api.ctx.fmt("token {s}", .{api.token}) }; + list[1] = .{ .name = "Accept", .value = "application/json" }; + return list; + } + + fn send(api: Api, method: http.Method, url_text: []const u8, payload: ?[]const u8) !Response { + return httpSend(api.ctx, .{ + .method = method, + .url = url_text, + .headers = api.headers(), + .payload = payload, + .content_type = if (payload != null) "application/json" else null, + }); + } + + /// The highest published `vX.Y.Z`, paginated. Both a 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. + fn highestPublishedRelease(api: Api) !?[]const u8 { + var highest: ?[]const u8 = null; + var page: usize = 1; + while (page <= 20) : (page += 1) { + const response = try api.send(.GET, api.url("/releases?limit=50&page={d}", .{page}), null); + if (response.status != 200) { + api.ctx.soft("releases-list", "listing releases answered {d}: {s}", .{ + response.status, response.body, + }); + return CheckFailed; + } + const value = response.json(api.ctx) orelse { + api.ctx.soft("releases-list", "the releases endpoint returned 200 with unparseable JSON: {s}", .{response.body}); + return CheckFailed; + }; + const items = releasesArray(value) orelse { + api.ctx.soft("releases-list", "the releases endpoint returned 200 with a non-array payload: {s}", .{response.body}); + return CheckFailed; + }; + if (items.len == 0) break; + for (items) |item| { + if (item != .object) continue; + const draft_value = item.object.get("draft") orelse continue; + if (draft_value != .bool or draft_value.bool) continue; + const tag_value = item.object.get("tag_name") orelse continue; + if (tag_value != .string) continue; + highest = highestPublished(highest, tag_value.string); + } + } + return highest; + } + + /// Ruling 9: a re-run clears a leftover draft and repeats; a published + /// release for this tag is terminal, because publication is the last + /// irreversible act and so means every earlier step already succeeded. + fn clearDraft(api: Api, tag: []const u8, comptime published_message: []const u8) !void { + const response = try api.send(.GET, api.url("/releases/tags/{s}", .{tag}), null); + switch (response.status) { + 404 => { + api.ctx.note("no existing release for {s}", .{tag}); + return; + }, + 200 => {}, + else => { + api.ctx.soft("existing-release", "status {d} looking up {s}: {s}", .{ + response.status, tag, response.body, + }); + return CheckFailed; + }, + } + + const value = response.json(api.ctx) orelse { + api.ctx.soft("existing-release", "unparseable release payload: {s}", .{response.body}); + return CheckFailed; + }; + if (value != .object) { + api.ctx.soft("existing-release", "the release lookup returned a non-object payload: {s}", .{response.body}); + return CheckFailed; + } + const is_draft = switch (value.object.get("draft") orelse std.json.Value{ .null = {} }) { + .bool => |flag| flag, + else => { + api.ctx.soft("existing-release", "the release lookup carries no `draft` field: {s}", .{response.body}); + return CheckFailed; + }, + }; + if (!is_draft) { + api.ctx.soft("existing-release", published_message, .{tag}); + return CheckFailed; + } + const id = jsonInteger(value, "id") orelse { + api.ctx.soft("existing-release", "the draft release carries no numeric `id`: {s}", .{response.body}); + return CheckFailed; + }; + api.ctx.note("deleting leftover draft release {d}", .{id}); + const deleted = try api.send(.DELETE, api.url("/releases/{d}", .{id}), null); + if (deleted.status != 200 and deleted.status != 204) { + api.ctx.soft("existing-release", "deleting draft {d} answered {d}: {s}", .{ + id, deleted.status, deleted.body, + }); + return CheckFailed; + } + } +}; + +// --------------------------------------------------------------------------- +// GNUPGHOME +// --------------------------------------------------------------------------- + +const Gnupg = struct { + ctx: *Ctx, + home: []const u8, + passphrase_file: []const u8, + + /// Creates the home and imports the subkey export. 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. + fn open(ctx: *Ctx) !Gnupg { + const home = try makeTempDir(ctx, "gnupg"); + var gnupg: Gnupg = .{ + .ctx = ctx, + .home = home, + .passphrase_file = ctx.fmt("{s}/passphrase", .{home}), + }; + errdefer gnupg.close(); + + const encoded = ctx.get("RELEASE_GPG_SUBKEY"); + if (encoded.len == 0) { + ctx.soft("signing-key", "the RELEASE_GPG_SUBKEY secret is empty; see manual prerequisite 1 (ruling 13)", .{}); + return CheckFailed; + } + const armored = decodeBase64(ctx.arena, encoded) catch { + ctx.soft("signing-key", "RELEASE_GPG_SUBKEY is not valid base64; store `base64 -w0` of the armored export", .{}); + return CheckFailed; + }; + const key_path = ctx.fmt("{s}/subkey.asc", .{home}); + try writeFileMode(ctx, key_path, armored, 0o600); + _ = try mustRun(ctx, "signing-key", &.{ "gpg", "--batch", "--quiet", "--import", key_path }, .{ + .env = gnupg.env(), + }); + + const passphrase = ctx.get("RELEASE_GPG_PASSPHRASE"); + if (passphrase.len == 0) { + ctx.soft("signing-key", "the RELEASE_GPG_PASSPHRASE secret is empty; see manual prerequisite 1 (ruling 13)", .{}); + return CheckFailed; + } + try writeFileMode(ctx, gnupg.passphrase_file, passphrase, 0o600); + return gnupg; + } + + fn env(gnupg: Gnupg) []const [2][]const u8 { + const list = gnupg.ctx.arena.alloc([2][]const u8, 1) catch @panic("OOM"); + list[0] = .{ "GNUPGHOME", gnupg.home }; + return list; + } + + fn gpg(gnupg: Gnupg, argv: []const []const u8) !Run { + return runCommand(gnupg.ctx, argv, .{ .env = gnupg.env() }); + } + + /// Every exit path, including a failing check. The agent is killed in its + /// own home: a bare `gpgconf --kill` kills the runner's default agent and + /// leaves this home's agent running with the key cached and unlocked. + fn close(gnupg: *Gnupg) void { + _ = runCommand(gnupg.ctx, &.{ "gpgconf", "--kill", "gpg-agent" }, .{ .env = gnupg.env() }) catch {}; + Io.Dir.cwd().deleteTree(gnupg.ctx.io, gnupg.home) catch |err| { + gnupg.ctx.note("could not delete the temporary GNUPGHOME: {t}", .{err}); + }; + } + + /// Ruling 8's two structural assertions: no primary secret key came with the + /// export, and the pinned subkey did. + fn assertSubkeyOnly(gnupg: Gnupg, subkey_fpr: []const u8, primary_fpr: []const u8) !void { + const ctx = gnupg.ctx; + const listing = try mustRun(ctx, "signing-key", &.{ "gpg", "--list-secret-keys", "--with-colons" }, .{ + .env = gnupg.env(), + }); + if (primarySecretLeak(listing)) |key_id| { + ctx.soft("signing-key", "the imported material contains a primary secret key ({s}); export with --export-secret-subkeys", .{key_id}); + return CheckFailed; + } + const fingerprints = colonFingerprints(ctx.arena, listing); + if (primary_fpr.len != 0) { + const first = if (fingerprints.len != 0) fingerprints[0] else ""; + if (!std.mem.eql(u8, first, primary_fpr)) { + ctx.soft("signing-key", "the imported certificate is {s}, expected {s}", .{ first, primary_fpr }); + return CheckFailed; + } + } + if (!containsString(fingerprints, subkey_fpr)) { + ctx.soft("signing-key", "the export carries no secret key {s}; a public-only export verifies the tag but cannot sign SHA256SUMS (ruling 8)", .{subkey_fpr}); + return CheckFailed; + } + ctx.pass("signing-key", "the export carries the subkey {s} and no primary secret key", .{subkey_fpr}); + } + + /// `--local-user !` so gpg cannot fall back to another key, batch and + /// loopback pinentry so a missing pinentry cannot hang the runner. + fn detachSign(gnupg: Gnupg, subkey_fpr: []const u8, target: []const u8, signature: []const u8) !void { + const ctx = gnupg.ctx; + const run = try gnupg.gpg(&.{ + "gpg", "--batch", + "--yes", "--quiet", + "--pinentry-mode", "loopback", + "--passphrase-file", gnupg.passphrase_file, + "--local-user", ctx.fmt("{s}!", .{subkey_fpr}), + "--armor", "--detach-sign", + "--output", signature, + target, + }); + if (!run.ok()) { + ctx.soft("signature", "signing with {s} failed: {s}", .{ + subkey_fpr, std.mem.trimEnd(u8, run.combined(ctx.arena), "\n"), + }); + ctx.note("the usual cause is a wrong RELEASE_GPG_PASSPHRASE (ruling 13)", .{}); + return CheckFailed; + } + } + + /// A signature this program produced is verified before it is trusted, and + /// the signer is checked against the pin. Field 3 here, not the last field: + /// this asserts which key made the signature. + fn verifySignature(gnupg: Gnupg, subkey_fpr: []const u8, signature: []const u8, target: []const u8) !void { + const ctx = gnupg.ctx; + const run = try gnupg.gpg(&.{ "gpg", "--batch", "--status-fd", "1", "--verify", signature, target }); + const status = run.combined(ctx.arena); + if (!run.ok()) { + ctx.soft("signature", "the signature this job produced does not verify: {s}", .{ + std.mem.trimEnd(u8, status, "\n"), + }); + return CheckFailed; + } + const signer = validsigSigner(status) orelse { + ctx.soft("signature", "the verification emitted no VALIDSIG line: {s}", .{ + std.mem.trimEnd(u8, status, "\n"), + }); + return CheckFailed; + }; + if (!std.mem.eql(u8, signer, subkey_fpr)) { + ctx.soft("signature", "signed by {s}, expected {s}", .{ signer, subkey_fpr }); + return CheckFailed; + } + } +}; + +// --------------------------------------------------------------------------- +// docker +// --------------------------------------------------------------------------- + +/// A private `DOCKER_CONFIG` per phase, so the registry credential never lands +/// in the runner's shared config and is removed with the directory. +const Docker = struct { + ctx: *Ctx, + config: []const u8, + registry: []const u8, + + fn login(ctx: *Ctx, registry: []const u8) !Docker { + const user = registryUser(ctx); + const token = ctx.get("REGISTRY_TOKEN"); + if (token.len == 0) { + // The built-in GITEA_TOKEN cannot publish to the package registry — + // that is what this personal access token exists for (ruling 8). + ctx.soft("registry-login", "the REGISTRY_TOKEN secret is empty; see manual prerequisite 2 (ruling 13)", .{}); + return CheckFailed; + } + const config = try makeTempDir(ctx, "dockercfg"); + var docker: Docker = .{ .ctx = ctx, .config = config, .registry = registry }; + errdefer docker.close(); + + const attempt = try runCommand(ctx, &.{ + "docker", "login", registry, "--username", user, "--password-stdin", + }, .{ .env = docker.env(), .stdin = token }); + if (!attempt.ok()) { + ctx.soft("registry-login", "docker login to {s} exited {d}", .{ registry, attempt.code }); + return CheckFailed; + } + return docker; + } + + fn env(docker: Docker) []const [2][]const u8 { + const list = docker.ctx.arena.alloc([2][]const u8, 1) catch @panic("OOM"); + list[0] = .{ "DOCKER_CONFIG", docker.config }; + return list; + } + + fn run(docker: Docker, argv: []const []const u8) !Run { + return runCommand(docker.ctx, argv, .{ .env = docker.env() }); + } + + fn close(docker: *Docker) void { + _ = runCommand(docker.ctx, &.{ "docker", "logout", docker.registry }, .{ .env = docker.env() }) catch {}; + Io.Dir.cwd().deleteTree(docker.ctx.io, docker.config) catch {}; + } +}; + +fn registryUser(ctx: *Ctx) []const u8 { + const explicit = ctx.get("REGISTRY_USER"); + if (explicit.len != 0) return explicit; + return ctx.require("GITHUB_REPOSITORY_OWNER"); +} + +// --------------------------------------------------------------------------- +// git +// --------------------------------------------------------------------------- + +/// `actions/checkout` on a tag ref fetches the *commit* SHA into +/// `refs/tags/`, silently replacing the annotated tag object with a +/// lightweight tag. Without this refetch every signed tag reads as unannotated. +/// `--force` because that wrong local ref already exists, and the credential is +/// supplied inline because the checkout ran with `persist-credentials: false` +/// (deviation 23). +fn refetchTag(ctx: *Ctx, tag: []const u8) !void { + const refspec = ctx.fmt("refs/tags/{s}:refs/tags/{s}", .{ tag, tag }); + const token = ctx.get("GITEA_TOKEN"); + const run = if (token.len == 0) + try runCommand(ctx, &.{ "git", "fetch", "--force", "--no-tags", "origin", refspec }, .{}) + else run: { + // `oauth2:` is Gitea's basic-auth shape for a token. The header + // goes through `-c`, so the secret never appears in a remote URL that + // git would echo into its own error messages. + const header = ctx.fmt("http.extraheader=Authorization: {s}", .{basicAuth(ctx, "oauth2", token)}); + break :run try runCommand(ctx, &.{ + "git", "-c", header, "fetch", "--force", "--no-tags", "origin", refspec, + }, .{}); + }; + if (!run.ok()) { + ctx.soft("tag-refetch", "refetching {s} exited {d}: {s}", .{ + tag, run.code, std.mem.trimEnd(u8, run.stderr, "\n"), + }); + ctx.note("the annotated tag object must come from origin; checkout replaced it with a lightweight tag", .{}); + return CheckFailed; + } + const kind = try runCommand(ctx, &.{ "git", "cat-file", "-t", ctx.fmt("refs/tags/{s}", .{tag}) }, .{}); + if (!kind.ok() or !std.mem.eql(u8, kind.trimmedStdout(), "tag")) { + ctx.soft("tag-annotated", "'{s}' is not an annotated tag, so it carries no signature", .{tag}); + return CheckFailed; + } +} + +fn requireTag(ctx: *Ctx) []const u8 { + const explicit = ctx.get("TAG"); + const tag = if (explicit.len != 0) explicit else ctx.require("GITHUB_REF_NAME"); + if (parseTag(tag) == null) { + ctx.fatal("tag-format", "refusing '{s}': releases are vMAJOR.MINOR.PATCH only, with no pre-release suffix", .{tag}); + } + return tag; +} + +// --------------------------------------------------------------------------- +// Subcommands +// --------------------------------------------------------------------------- + +/// Ruling 7 step 3, plus the proof that the artifact-signing material works. +fn guardTag(ctx: *Ctx) !void { + const tag = requireTag(ctx); + ctx.pass("tag-format", "{s}", .{tag}); + + const tag_fpr = ctx.require("TAG_SIGNING_FPR"); + const subkey_fpr = ctx.require("RELEASE_SIGNING_FPR"); + for ([_][2][]const u8{ .{ "TAG_SIGNING_FPR", tag_fpr }, .{ "RELEASE_SIGNING_FPR", subkey_fpr } }) |pin| { + if (!isFingerprint(pin[1])) { + ctx.fatal("pinned-fingerprint", "{s} is not 40 uppercase hex characters: '{s}'; paste the fingerprint from manual prerequisite 1 into release.yml", .{ pin[0], pin[1] }); + } + } + + try refetchTag(ctx, tag); + ctx.pass("tag-annotated", "{s} is an annotated tag object", .{tag}); + + var gnupg = try Gnupg.open(ctx); + defer gnupg.close(); + + const status = try verifyTag(ctx, gnupg, tag); + const primary = validsigPrimary(status) orelse { + ctx.soft("tag-signature", "git verify-tag emitted no VALIDSIG line carrying a primary-key fingerprint", .{}); + return CheckFailed; + }; + if (!isFingerprint(primary)) { + ctx.soft("tag-signature", "the VALIDSIG primary field is not a fingerprint: '{s}'", .{primary}); + return CheckFailed; + } + if (!std.mem.eql(u8, primary, tag_fpr)) { + ctx.soft("tag-signature", "tag signed under certificate {s}, expected {s}", .{ primary, tag_fpr }); + return CheckFailed; + } + ctx.pass("tag-signature", "{s} is signed under the pinned certificate {s}", .{ tag, tag_fpr }); + + try gnupg.assertSubkeyOnly(subkey_fpr, tag_fpr); + + // 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 + // phase uses, and verify the result. + const probe = ctx.fmt("{s}/probe", .{gnupg.home}); + try writeFileMode(ctx, probe, "nxdns release key probe\n", 0o600); + const probe_signature = ctx.fmt("{s}.asc", .{probe}); + try gnupg.detachSign(subkey_fpr, probe, probe_signature); + try gnupg.verifySignature(subkey_fpr, probe_signature, probe); + ctx.pass("signing-probe", "the subkey {s} signs and its passphrase is correct", .{subkey_fpr}); +} + +fn verifyTag(ctx: *Ctx, gnupg: Gnupg, tag: []const u8) ![]const u8 { + // Ownertrust is set so gpg does not merely warn about an untrusted key; the + // fingerprint comparison below is what actually decides the outcome. + const ownertrust = ctx.fmt("{s}/ownertrust", .{gnupg.home}); + try writeFileMode(ctx, ownertrust, ctx.fmt("{s}:6:\n", .{ctx.require("TAG_SIGNING_FPR")}), 0o600); + _ = try gnupg.gpg(&.{ "gpg", "--batch", "--quiet", "--import-ownertrust", ownertrust }); + + const run = try runCommand(ctx, &.{ "git", "verify-tag", "--raw", tag }, .{ .env = gnupg.env() }); + const status = run.combined(ctx.arena); + if (!run.ok()) { + ctx.soft("tag-signature", "git verify-tag failed for {s}: {s}", .{ + tag, std.mem.trimEnd(u8, status, "\n"), + }); + return CheckFailed; + } + return status; +} + +/// Ruling 7 step 4. +fn guardAncestry(ctx: *Ctx) !void { + const tag = requireTag(ctx); + const commit = try mustRun(ctx, "tag-ancestry", &.{ + "git", "rev-parse", ctx.fmt("refs/tags/{s}^{{commit}}", .{tag}), + }, .{}); + const tag_commit = std.mem.trim(u8, commit, " \t\r\n"); + + const candidates = [_][]const u8{ "refs/remotes/origin/master", "refs/heads/master" }; + var master: []const u8 = ""; + for (candidates) |ref| { + const run = try runCommand(ctx, &.{ "git", "rev-parse", "--verify", "--quiet", ref }, .{}); + if (run.ok()) { + master = ref; + break; + } + } + if (master.len == 0) { + ctx.soft("tag-ancestry", "no master ref in this clone; the checkout must fetch full history", .{}); + return CheckFailed; + } + + const run = try runCommand(ctx, &.{ "git", "merge-base", "--is-ancestor", tag_commit, master }, .{}); + if (!run.ok()) { + ctx.soft("tag-ancestry", "{s} ({s}) is not an ancestor of {s}", .{ tag, tag_commit, master }); + return CheckFailed; + } + ctx.pass("tag-ancestry", "{s} is an ancestor of {s}", .{ tag, master }); +} + +/// Ruling 7 steps 5 and 6. +fn guardReleases(ctx: *Ctx) !void { + const tag = requireTag(ctx); + const api = Api.init(ctx); + + try api.clearDraft(tag, "{s} already has a published release; it will not be touched (ruling 9)"); + + const highest = try api.highestPublishedRelease(); + if (highest) |previous| { + const new = parseTag(tag).?; + const known = parseTag(previous).?; + if (new.order(known) != .gt) { + ctx.soft("version-increases", "{s} does not exceed the highest published release {s}", .{ tag, previous }); + return CheckFailed; + } + ctx.pass("version-increases", "{s} exceeds the highest published release {s}", .{ tag, previous }); + } else { + ctx.pass("version-increases", "no published release yet; this is the first", .{}); + } + + try appendLine(ctx, "GITHUB_OUTPUT", ctx.fmt("previous_tag={s}", .{highest orelse ""})); +} + +/// 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. +fn resolve(ctx: *Ctx) !void { + const tag = requireTag(ctx); + try refetchTag(ctx, tag); + + const version = tag[1..]; + const commit = try mustRun(ctx, "resolve", &.{ + "git", "rev-parse", ctx.fmt("refs/tags/{s}^{{commit}}", .{tag}), + }, .{}); + const tag_commit = std.mem.trim(u8, commit, " \t\r\n"); + + const tagger = try mustRun(ctx, "resolve", &.{ + "git", "for-each-ref", "--format=%(taggerdate:unix)", ctx.fmt("refs/tags/{s}", .{tag}), + }, .{}); + const epoch_text = std.mem.trim(u8, tagger, " \t\r\n"); + const epoch = std.fmt.parseInt(i64, epoch_text, 10) catch { + ctx.soft("resolve", "{s} has no tagger date; it is not an annotated tag", .{tag}); + return CheckFailed; + }; + + const server = std.mem.trimEnd(u8, ctx.require("GITHUB_SERVER_URL"), "/"); + const registry = registryHost(server); + const repository = lowercase(ctx.arena, ctx.require("GITHUB_REPOSITORY")); + const image_name = ctx.fmt("{s}/{s}", .{ registry, repository }); + + const api_base = api: { + const explicit = ctx.get("GITHUB_API_URL"); + if (explicit.len != 0) break :api std.mem.trimEnd(u8, explicit, "/"); + break :api ctx.fmt("{s}/api/v1", .{server}); + }; + const workspace = ctx.get("GITHUB_WORKSPACE"); + const dist = if (workspace.len != 0) ctx.fmt("{s}/zig-out/dist", .{workspace}) else "zig-out/dist"; + + const created = ctx.fmt("{f}", .{formatEpoch(epoch)}); + + const lines = [_][]const u8{ + ctx.fmt("TAG={s}", .{tag}), + ctx.fmt("VERSION={s}", .{version}), + ctx.fmt("TAG_COMMIT={s}", .{tag_commit}), + ctx.fmt("SOURCE_DATE_EPOCH={d}", .{epoch}), + ctx.fmt("CREATED={s}", .{created}), + ctx.fmt("REGISTRY={s}", .{registry}), + ctx.fmt("IMAGE={s}", .{image_name}), + ctx.fmt("API={s}", .{api_base}), + ctx.fmt("DIST={s}", .{dist}), + }; + for (lines) |line| try appendLine(ctx, "GITHUB_ENV", line); + + ctx.pass("resolve", "releasing {s} from {s} as {s}:{s}", .{ version, tag_commit, image_name, version }); +} + +/// `date -u -d @ +%Y-%m-%dT%H:%M:%SZ`, which the OCI `created` label +/// wants. `std.Io.Clock` is not involved: the value comes from the tag. +fn formatEpoch(epoch: i64) EpochFormatter { + return .{ .epoch = epoch }; +} + +const EpochFormatter = struct { + epoch: i64, + + pub fn format(self: EpochFormatter, writer: *Io.Writer) Io.Writer.Error!void { + const seconds: u64 = @intCast(@max(self.epoch, 0)); + const day_seconds = std.time.epoch.EpochSeconds{ .secs = seconds }; + const day = day_seconds.getEpochDay(); + const time = day_seconds.getDaySeconds(); + const year_day = day.calculateYearDay(); + const month_day = year_day.calculateMonthDay(); + try writer.print("{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{ + year_day.year, + month_day.month.numeric(), + month_day.day_index + 1, + time.getHoursIntoDay(), + time.getMinutesIntoHour(), + time.getSecondsIntoMinute(), + }); + } +}; + +/// Ruling 7 step 9. Extracted and validated before anything is pushed anywhere, +/// so a missing changelog section costs nothing but the run. +fn changelog(ctx: *Ctx) !void { + const version = ctx.require("VERSION"); + const source = readFile(ctx, "CHANGELOG.md") catch { + ctx.soft("changelog", "CHANGELOG.md is missing; the release body is its section for this version (ruling 10)", .{}); + return CheckFailed; + }; + const section = changelogSection(source, version) orelse { + ctx.soft("changelog", "CHANGELOG.md has no '## [{s}]' section; write it before tagging (ruling 10)", .{version}); + return CheckFailed; + }; + if (isBlank(section)) { + ctx.soft("changelog", "the '## [{s}]' section of CHANGELOG.md is empty (ruling 10)", .{version}); + return CheckFailed; + } + const out = ctx.fmt("{s}/changelog-section.md", .{runnerTemp(ctx)}); + try writeFileMode(ctx, out, section, 0o644); + ctx.pass("changelog", "{d} bytes for {s}", .{ section.len, version }); + ctx.out.print("{s}\n", .{std.mem.trimEnd(u8, section, "\n")}) catch {}; +} + +/// Ruling 7 step 10 and the probe-adopt rule of ruling 9. +fn image(ctx: *Ctx) !void { + const version = ctx.require("VERSION"); + const image_name = ctx.require("IMAGE"); + const registry = ctx.require("REGISTRY"); + const dist = ctx.require("DIST"); + + var docker = try Docker.login(ctx, registry); + defer docker.close(); + + const repo_path = std.mem.trimStart(u8, image_name[@min(registry.len, image_name.len)..], "/"); + const probe = try probeManifest(ctx, repo_path, version); + + var digest: []const u8 = ""; + switch (probe.status) { + 404 => ctx.note("{s}:{s} does not exist yet", .{ image_name, version }), + 200 => { + if (!isDigest(probe.digest)) { + ctx.soft("registry-probe", "{s}:{s} exists but the registry sent no usable Docker-Content-Digest: '{s}'", .{ + image_name, version, probe.digest, + }); + return CheckFailed; + } + digest = probe.digest; + // Nothing is built and nothing is pushed. Every assertion below runs + // against the image that is already there, and the binary-identity + // phase compares it with the tarballs this run just built — which is + // what "the same release" actually means. + ctx.note("adopting the pushed image at {s}; this re-run will not rebuild or overwrite it (ruling 9)", .{digest}); + }, + else => { + ctx.soft("registry-probe", "could not determine whether {s}:{s} exists (HTTP {d})", .{ + image_name, version, probe.status, + }); + ctx.note("refusing to push: an unreadable registry cannot be checked for immutability (ruling 9)", .{}); + return CheckFailed; + }, + } + + if (digest.len == 0) digest = try buildAndPush(ctx, docker, image_name, version); + + // The tag must resolve to the digest this phase settled on. + const resolved_run = try docker.run(&.{ + "docker", "buildx", "imagetools", "inspect", + ctx.fmt("{s}:{s}", .{ image_name, version }), "--format", "{{.Manifest.Digest}}", + }); + if (!resolved_run.ok()) { + ctx.soft("image-digest", "imagetools inspect {s}:{s} exited {d}: {s}", .{ + image_name, version, resolved_run.code, std.mem.trimEnd(u8, resolved_run.combined(ctx.arena), "\n"), + }); + return CheckFailed; + } + const resolved = resolved_run.trimmedStdout(); + if (!std.mem.eql(u8, resolved, digest)) { + ctx.soft("image-digest", "{s}:{s} resolves to {s}, not {s}", .{ image_name, version, resolved, digest }); + return CheckFailed; + } + ctx.pass("image-digest", "{s}:{s} resolves to {s}", .{ image_name, version, digest }); + + try assertPlatforms(ctx, docker, image_name, digest); + try assertVersionLabels(ctx, docker, image_name, digest, version); + + Io.Dir.cwd().createDirPath(ctx.io, dist) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + const digest_path = ctx.fmt("{s}/IMAGE-DIGEST.txt", .{dist}); + const line = ctx.fmt("{s}:{s}@{s}\n", .{ image_name, version, digest }); + try writeFileMode(ctx, digest_path, line, 0o644); + ctx.pass("image-digest-file", "{s}", .{std.mem.trimEnd(u8, line, "\n")}); +} + +fn probeManifest(ctx: *Ctx, repo_path: []const u8, reference: []const u8) !HeadResponse { + const server = std.mem.trimEnd(u8, ctx.require("GITHUB_SERVER_URL"), "/"); + const url = ctx.fmt("{s}/v2/{s}/manifests/{s}", .{ server, repo_path, reference }); + const 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"; + + const user = registryUser(ctx); + const token = ctx.require("REGISTRY_TOKEN"); + const basic = [_]http.Header{ + .{ .name = "Authorization", .value = basicAuth(ctx, user, token) }, + .{ .name = "Accept", .value = accept }, + }; + + const first = try httpHead(ctx, url, &basic); + if (first.status != 401) return first; + + const challenge = parseChallenge(first.challenge); + if (challenge.realm.len == 0) { + ctx.soft("registry-probe", "the registry answered 401 with no bearer realm", .{}); + return CheckFailed; + } + const scope = if (challenge.scope.len != 0) + challenge.scope + else + ctx.fmt("repository:{s}:pull", .{repo_path}); + const token_url = ctx.fmt("{s}?service={s}&scope={s}", .{ + challenge.realm, + urlEncode(ctx.arena, challenge.service), + urlEncode(ctx.arena, scope), + }); + const token_response = try httpSend(ctx, .{ + .method = .GET, + .url = token_url, + .headers = &.{.{ .name = "Authorization", .value = basicAuth(ctx, user, token) }}, + }); + const bearer = bearerToken(ctx, token_response) orelse { + ctx.soft("registry-probe", "the registry token endpoint returned no token (HTTP {d})", .{token_response.status}); + return CheckFailed; + }; + const authorized = [_]http.Header{ + .{ .name = "Authorization", .value = ctx.fmt("Bearer {s}", .{bearer}) }, + .{ .name = "Accept", .value = accept }, + }; + return httpHead(ctx, url, &authorized); +} + +fn bearerToken(ctx: *Ctx, response: Response) ?[]const u8 { + const value = response.json(ctx) orelse return null; + if (value != .object) return null; + for ([_][]const u8{ "token", "access_token" }) |key| { + const found = value.object.get(key) orelse continue; + if (found == .string and found.string.len != 0) return found.string; + } + return null; +} + +fn urlEncode(arena: Allocator, text: []const u8) []const u8 { + var out: std.ArrayList(u8) = .empty; + for (text) |c| { + const unreserved = std.ascii.isAlphanumeric(c) or c == '-' or c == '.' or c == '_' or c == '~' or + c == '/' or c == ':'; + if (unreserved) { + out.append(arena, c) catch @panic("OOM"); + } else { + out.print(arena, "%{X:0>2}", .{c}) catch @panic("OOM"); + } + } + return out.items; +} + +/// `--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). +fn buildAndPush(ctx: *Ctx, docker: Docker, image_name: []const u8, version: []const u8) ![]const u8 { + const builder = ctx.fmt("nxdns-release-{s}", .{ctx.get("GITHUB_RUN_ID")}); + defer _ = docker.run(&.{ "docker", "buildx", "rm", builder }) catch {}; + + _ = try mustRunDocker(ctx, docker, "image-build", &.{ + "docker", "buildx", "create", "--name", builder, "--driver", "docker-container", "--bootstrap", + }); + + const metadata = ctx.fmt("{s}/buildx-metadata.json", .{runnerTemp(ctx)}); + _ = try mustRunDocker(ctx, docker, "image-build", &.{ + "docker", "buildx", + "build", "--builder", + builder, "--file", + "deploy/docker/Dockerfile", "--platform", + "linux/amd64,linux/arm64", "--provenance=false", + "--sbom=false", "--build-arg", + ctx.fmt("SOURCE_DATE_EPOCH={s}", .{ctx.require("SOURCE_DATE_EPOCH")}), "--build-arg", + ctx.fmt("VERSION={s}", .{version}), "--build-arg", + ctx.fmt("REVISION={s}", .{ctx.require("TAG_COMMIT")}), "--build-arg", + ctx.fmt("CREATED={s}", .{ctx.require("CREATED")}), "--tag", + ctx.fmt("{s}:{s}", .{ image_name, version }), "--metadata-file", + metadata, "--push", + ".", + }); + + const raw = readFile(ctx, metadata) catch { + ctx.soft("image-build", "buildx wrote no metadata file at {s}", .{metadata}); + return CheckFailed; + }; + const value = std.json.parseFromSliceLeaky(std.json.Value, ctx.arena, raw, .{}) catch { + ctx.soft("image-build", "the buildx metadata file is not JSON: {s}", .{raw}); + return CheckFailed; + }; + const digest = digest: { + if (value == .object) { + if (value.object.get("containerimage.digest")) |found| { + if (found == .string) break :digest found.string; + } + } + break :digest ""; + }; + if (!isDigest(digest)) { + ctx.soft("image-build", "buildx reported no usable index digest: '{s}'", .{digest}); + return CheckFailed; + } + return digest; +} + +fn mustRunDocker(ctx: *Ctx, docker: Docker, comptime check: []const u8, argv: []const []const u8) ![]const u8 { + const run = try docker.run(argv); + if (!run.ok()) { + ctx.soft(check, "`{s} {s}` exited {d}: {s}", .{ + argv[0], argv[1], run.code, std.mem.trimEnd(u8, run.combined(ctx.arena), "\n"), + }); + return CheckFailed; + } + return run.stdout; +} + +fn assertPlatforms(ctx: *Ctx, docker: Docker, image_name: []const u8, digest: []const u8) !void { + const raw = try mustRunDocker(ctx, docker, "image-platforms", &.{ + "docker", "buildx", "imagetools", "inspect", ctx.fmt("{s}@{s}", .{ image_name, digest }), "--raw", + }); + const value = std.json.parseFromSliceLeaky(std.json.Value, ctx.arena, raw, .{}) catch { + ctx.soft("image-platforms", "the manifest index is not JSON: {s}", .{raw}); + return CheckFailed; + }; + const manifests = manifests: { + if (value == .object) { + if (value.object.get("manifests")) |found| { + if (found == .array) break :manifests found.array.items; + } + } + ctx.soft("image-platforms", "the pushed manifest carries no `manifests` array; it is not a multi-platform index", .{}); + return CheckFailed; + }; + + var seen: std.ArrayList([]const u8) = .empty; + for (manifests) |entry| { + if (entry != .object) continue; + const platform = entry.object.get("platform") orelse continue; + if (platform != .object) continue; + const os = platform.object.get("os"); + const arch = platform.object.get("architecture"); + const os_text = if (os != null and os.? == .string) os.?.string else "?"; + const arch_text = if (arch != null and arch.? == .string) arch.?.string else "?"; + try seen.append(ctx.arena, ctx.fmt("{s}/{s}", .{ os_text, arch_text })); + } + + var ok = seen.items.len == platforms.len and manifests.len == platforms.len; + if (ok) { + for (platforms) |wanted| { + if (!containsString(seen.items, wanted.docker)) ok = false; + } + } + if (!ok) { + ctx.soft("image-platforms", "{d} manifest(s) for platforms {s}; expected exactly linux/amd64 and linux/arm64", .{ + manifests.len, std.mem.join(ctx.arena, ",", seen.items) catch @panic("OOM"), + }); + return CheckFailed; + } + ctx.pass("image-platforms", "{d} manifests: {s}", .{ + manifests.len, std.mem.join(ctx.arena, ",", seen.items) catch @panic("OOM"), + }); +} + +/// The OCI labels come from the build args, so asserting them 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, so the assertion +/// is per platform: exactly two entries, each carrying exactly one version +/// label, each equal to the version. An earlier form accepted "at least one" +/// over the flattened list, which passed when only one of the two configs had +/// the label while claiming it had checked every platform. +fn assertVersionLabels(ctx: *Ctx, docker: Docker, image_name: []const u8, digest: []const u8, version: []const u8) !void { + const raw = try mustRunDocker(ctx, docker, "image-labels", &.{ + "docker", "buildx", + "imagetools", "inspect", + ctx.fmt("{s}@{s}", .{ image_name, digest }), "--format", + "{{json .Image}}", + }); + const value = std.json.parseFromSliceLeaky(std.json.Value, ctx.arena, raw, .{}) catch { + ctx.soft("image-labels", "the image config is not JSON: {s}", .{raw}); + return CheckFailed; + }; + if (value != .object or value.object.count() != platforms.len) { + ctx.soft("image-labels", "expected one image config per platform ({d}); check the ARG names deploy/docker/Dockerfile consumes: VERSION, REVISION, CREATED", .{platforms.len}); + return CheckFailed; + } + + var it = value.object.iterator(); + while (it.next()) |entry| { + var labels: std.ArrayList([]const u8) = .empty; + collectVersionLabels(ctx.arena, entry.value_ptr.*, &labels); + if (labels.items.len != 1 or !std.mem.eql(u8, labels.items[0], version)) { + ctx.soft("image-labels", "org.opencontainers.image.version is not {s} on {s} ({d} label(s) found)", .{ + version, entry.key_ptr.*, labels.items.len, + }); + return CheckFailed; + } + } + ctx.pass("image-labels", "org.opencontainers.image.version is {s} on both platforms", .{version}); +} + +/// Ruling 6 and an acceptance criterion: the binary inside each image is +/// byte-identical to the binary in the matching tarball, on both platforms, and +/// against the image that was actually pushed rather than a local rebuild. +/// +/// No qemu and 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 a x86_64 host (docker +/// 29.6.2) 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 the staging directory: 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. +fn verifyImageBinaries(ctx: *Ctx) !void { + const version = ctx.require("VERSION"); + const image_name = ctx.require("IMAGE"); + const registry = ctx.require("REGISTRY"); + const dist = ctx.require("DIST"); + + const digest = try readImageDigest(ctx, dist); + + var docker = try Docker.login(ctx, registry); + defer docker.close(); + + const work = ctx.fmt("{s}/image-check", .{runnerTemp(ctx)}); + Io.Dir.cwd().deleteTree(ctx.io, work) catch {}; + try Io.Dir.cwd().createDirPath(ctx.io, ctx.fmt("{s}/tarball", .{work})); + try Io.Dir.cwd().createDirPath(ctx.io, ctx.fmt("{s}/image", .{work})); + + const before = ctx.failures; + for (platforms) |platform| { + const name = ctx.fmt("nxdns-{s}-{s}", .{ version, platform.triple }); + _ = try mustRun(ctx, "image-contents", &.{ + "tar", "-xzf", ctx.fmt("{s}/{s}.tar.gz", .{ dist, name }), "-C", ctx.fmt("{s}/tarball", .{work}), + }, .{}); + + const reference = ctx.fmt("{s}@{s}", .{ image_name, digest }); + _ = try mustRunDocker(ctx, docker, "image-contents", &.{ + "docker", "pull", "--platform", platform.docker, reference, + }); + const created = try mustRunDocker(ctx, docker, "image-contents", &.{ + "docker", "create", "--platform", platform.docker, reference, + }); + const cid = std.mem.trim(u8, created, " \t\r\n"); + defer _ = docker.run(&.{ "docker", "rm", "-f", cid }) catch {}; + + const out = ctx.fmt("{s}/image/{s}", .{ work, platform.triple }); + try Io.Dir.cwd().createDirPath(ctx.io, out); + for (image_members) |member| { + _ = try mustRunDocker(ctx, docker, "image-contents", &.{ + "docker", "cp", ctx.fmt("{s}:/{s}", .{ cid, member }), ctx.fmt("{s}/{s}", .{ out, member }), + }); + const want = sha256Hex(try readFile(ctx, ctx.fmt("{s}/tarball/{s}/{s}", .{ work, name, member }))); + const got = sha256Hex(try readFile(ctx, ctx.fmt("{s}/{s}", .{ out, member }))); + if (std.mem.eql(u8, &want, &got)) { + ctx.pass("image-contents", "{s}: /{s} matches the tarball ({s})", .{ platform.triple, member, &got }); + } else { + ctx.soft("image-contents", "{s}: /{s} differs: image {s}, tarball {s}", .{ + platform.triple, member, &got, &want, + }); + } + } + } + + if (ctx.failures != before) { + ctx.note("the pushed image does not carry the artifacts this release ships", .{}); + ctx.note("nothing has been published; abandon this tag and ship the next patch (ruling 9)", .{}); + return CheckFailed; + } +} + +fn readImageDigest(ctx: *Ctx, dist: []const u8) ![]const u8 { + const text = readFile(ctx, ctx.fmt("{s}/IMAGE-DIGEST.txt", .{dist})) catch { + ctx.soft("image-digest-file", "{s}/IMAGE-DIGEST.txt is missing", .{dist}); + return CheckFailed; + }; + const first = std.mem.sliceTo(std.mem.trim(u8, text, " \t\r\n"), '\n'); + const at = std.mem.indexOfScalar(u8, first, '@') orelse first.len; + const digest = if (at == first.len) "" else first[at + 1 ..]; + if (!isDigest(digest)) { + ctx.soft("image-digest-file", "no usable digest in IMAGE-DIGEST.txt: '{s}'", .{digest}); + return CheckFailed; + } + return digest; +} + +/// Ruling 7 steps 11 and 12. `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 before it is signed. +fn sign(ctx: *Ctx) !void { + const dist = ctx.require("DIST"); + const subkey_fpr = ctx.require("RELEASE_SIGNING_FPR"); + if (!isFingerprint(subkey_fpr)) { + ctx.fatal("pinned-fingerprint", "RELEASE_SIGNING_FPR is not 40 uppercase hex characters: '{s}'", .{subkey_fpr}); + } + + const base = readFile(ctx, ctx.fmt("{s}/SHA256SUMS", .{dist})) catch { + ctx.soft("checksums", "{s}/SHA256SUMS is missing; run `zig build dist` first", .{dist}); + return CheckFailed; + }; + const digest_file = ctx.fmt("{s}/IMAGE-DIGEST.txt", .{dist}); + const digest_bytes = readFile(ctx, digest_file) catch { + ctx.soft("checksums", "{s} is missing; the image phase writes it", .{digest_file}); + return CheckFailed; + }; + + const separator: []const u8 = if (base.len == 0 or base[base.len - 1] == '\n') "" else "\n"; + const assembled = try std.mem.concat(ctx.arena, u8, &.{ + base, separator, &sha256Hex(digest_bytes), " IMAGE-DIGEST.txt\n", + }); + const sums_path = ctx.fmt("{s}/SHA256SUMS.txt", .{dist}); + try writeFileMode(ctx, sums_path, assembled, 0o644); + + try verifySums(ctx, dist, assembled); + + var gnupg = try Gnupg.open(ctx); + defer gnupg.close(); + try gnupg.assertSubkeyOnly(subkey_fpr, ctx.get("TAG_SIGNING_FPR")); + + const signature_path = ctx.fmt("{s}.asc", .{sums_path}); + try gnupg.detachSign(subkey_fpr, sums_path, signature_path); + try gnupg.verifySignature(subkey_fpr, signature_path, sums_path); + ctx.pass("signature", "SHA256SUMS.txt is signed by {s}", .{subkey_fpr}); +} + +/// `sha256sum -c` in Zig: every line names a file next to it, and the file +/// hashes to what the line says. +fn verifySums(ctx: *Ctx, dist: []const u8, text: []const u8) !void { + var seen: usize = 0; + var lines = std.mem.splitScalar(u8, text, '\n'); + const before = ctx.failures; + while (lines.next()) |line| { + if (line.len == 0) continue; + seen += 1; + const parsed = parseSumsLine(line) orelse { + ctx.soft("checksums", "line '{s}' is not in sha256sum format", .{line}); + continue; + }; + const bytes = readFile(ctx, ctx.fmt("{s}/{s}", .{ dist, parsed.name })) catch { + ctx.soft("checksums", "SHA256SUMS.txt names '{s}', which is not in {s}", .{ parsed.name, dist }); + continue; + }; + const actual = sha256Hex(bytes); + if (!std.mem.eql(u8, parsed.hex, &actual)) { + ctx.soft("checksums", "'{s}' hashes to {s}, SHA256SUMS.txt says {s}", .{ parsed.name, &actual, parsed.hex }); + } + } + if (ctx.failures != before) return CheckFailed; + ctx.pass("checksums", "{d} hashes match the files in {s}", .{ seen, dist }); +} + +/// Ruling 7 step 13. Nothing is visible until the final phase: the release is +/// created as a draft, the assets are uploaded, `:latest` is moved, and only +/// then is the draft published. +fn draft(ctx: *Ctx) !void { + const tag = requireTag(ctx); + const version = ctx.require("VERSION"); + const dist = ctx.require("DIST"); + const api = Api.init(ctx); + + const body = try releaseBody(ctx, tag, dist); + + // 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. + try api.clearDraft(tag, "{s} became published while the gates ran; refusing to touch it (ruling 9)"); + + var payload: Io.Writer.Allocating = .init(ctx.arena); + const w = &payload.writer; + try w.writeAll("{\"tag_name\":"); + try std.json.Stringify.encodeJsonString(tag, .{}, w); + try w.writeAll(",\"name\":"); + try std.json.Stringify.encodeJsonString(tag, .{}, w); + try w.writeAll(",\"body\":"); + try std.json.Stringify.encodeJsonString(body, .{}, w); + try w.writeAll(",\"draft\":true,\"prerelease\":false}"); + + const created = try api.send(.POST, api.url("/releases", .{}), payload.written()); + if (!created.ok()) { + ctx.soft("draft-release", "creating the draft release answered {d}: {s}", .{ created.status, created.body }); + return CheckFailed; + } + const value = created.json(ctx) orelse { + ctx.soft("draft-release", "the create response is not JSON: {s}", .{created.body}); + return CheckFailed; + }; + const id = jsonInteger(value, "id") orelse { + ctx.soft("draft-release", "the create response carries no numeric `id`: {s}", .{created.body}); + return CheckFailed; + }; + try appendLine(ctx, "GITHUB_ENV", ctx.fmt("RELEASE_ID={d}", .{id})); + ctx.pass("draft-release", "draft release {d} created for {s}", .{ id, tag }); + + const assets = try assetNames(ctx, version); + for (assets) |asset| { + const bytes = readFile(ctx, ctx.fmt("{s}/{s}", .{ dist, asset })) catch { + ctx.soft("assets", "{s}/{s} is missing", .{ dist, asset }); + return CheckFailed; + }; + const upload = try uploadAsset(ctx, api, id, asset, bytes); + if (!upload.ok()) { + ctx.soft("assets", "uploading {s} answered {d}: {s}", .{ asset, upload.status, upload.body }); + return CheckFailed; + } + ctx.pass("assets", "uploaded {s} ({d} bytes)", .{ asset, bytes.len }); + } + + try assertAssetList(ctx, api, id, assets); +} + +fn assetNames(ctx: *Ctx, version: []const u8) ![]const []const u8 { + var list: std.ArrayList([]const u8) = .empty; + for (platforms) |platform| { + try list.append(ctx.arena, ctx.fmt("nxdns-{s}-{s}.tar.gz", .{ version, platform.triple })); + } + for (asset_suffixes) |name| try list.append(ctx.arena, name); + return list.items; +} + +/// `multipart/form-data` with one `attachment` part, which is what Gitea's +/// attachment endpoint takes. The boundary is asserted absent from the payload +/// rather than assumed absent. +fn uploadAsset(ctx: *Ctx, api: Api, id: i64, name: []const u8, bytes: []const u8) !Response { + const boundary = "nxdnsReleaseAsset7c1f4b0e2a"; + if (std.mem.indexOf(u8, bytes, boundary) != null) { + ctx.fatal("assets", "{s} contains the multipart boundary; nothing was uploaded", .{name}); + } + var payload: Io.Writer.Allocating = .init(ctx.arena); + const w = &payload.writer; + try w.print("--{s}\r\n", .{boundary}); + try w.print("Content-Disposition: form-data; name=\"attachment\"; filename=\"{s}\"\r\n", .{name}); + try w.writeAll("Content-Type: application/octet-stream\r\n\r\n"); + try w.writeAll(bytes); + try w.print("\r\n--{s}--\r\n", .{boundary}); + + return httpSend(ctx, .{ + .method = .POST, + .url = api.url("/releases/{d}/assets?name={s}", .{ id, name }), + .headers = api.headers(), + .payload = payload.written(), + .content_type = "multipart/form-data; boundary=" ++ boundary, + }); +} + +/// The release must carry exactly the assets this program uploaded. A partial +/// upload that answered 201 for each part and still lost one would otherwise +/// publish a release whose SHA256SUMS covers a file nobody can download. +fn assertAssetList(ctx: *Ctx, api: Api, id: i64, expected: []const []const u8) !void { + const response = try api.send(.GET, api.url("/releases/{d}/assets", .{id}), null); + if (!response.ok()) { + ctx.soft("assets", "listing the release assets answered {d}: {s}", .{ response.status, response.body }); + return CheckFailed; + } + const value = response.json(ctx) orelse { + ctx.soft("assets", "the asset list is not JSON: {s}", .{response.body}); + return CheckFailed; + }; + const items = releasesArray(value) orelse { + ctx.soft("assets", "the asset list is not a JSON array: {s}", .{response.body}); + return CheckFailed; + }; + + var found: std.ArrayList([]const u8) = .empty; + for (items) |item| { + if (item != .object) continue; + const name = item.object.get("name") orelse continue; + if (name == .string) try found.append(ctx.arena, name.string); + } + var ok = found.items.len == expected.len; + for (expected) |name| { + if (!containsString(found.items, name)) ok = false; + } + if (!ok) { + ctx.soft("assets", "release {d} carries [{s}], expected [{s}]", .{ + id, + std.mem.join(ctx.arena, ", ", found.items) catch @panic("OOM"), + std.mem.join(ctx.arena, ", ", expected) catch @panic("OOM"), + }); + return CheckFailed; + } + ctx.pass("assets", "release {d} carries exactly the {d} release assets", .{ id, expected.len }); +} + +/// Ruling 10. `PREVIOUS_TAG` is the highest reachable *published* plain release +/// the guard 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. +fn releaseBody(ctx: *Ctx, tag: []const u8, dist: []const u8) ![]const u8 { + const section = readFile(ctx, ctx.fmt("{s}/changelog-section.md", .{runnerTemp(ctx)})) catch { + ctx.soft("draft-release", "the changelog phase wrote no section file", .{}); + return CheckFailed; + }; + + var base: []const u8 = ""; + const previous = ctx.get("PREVIOUS_TAG"); + if (previous.len != 0) { + const check = try runCommand(ctx, &.{ + "git", "rev-parse", "-q", "--verify", ctx.fmt("refs/tags/{s}^{{commit}}", .{previous}), + }, .{}); + if (check.ok()) { + base = previous; + } else { + ctx.note("published release {s} has no tag object in this clone;", .{previous}); + ctx.note("writing the full history and omitting the compare link", .{}); + } + } + + // On the first release the range must be `git log --oneline ` and NOT + // `git log --oneline ..`: 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 "all history". + const range = if (base.len != 0) ctx.fmt("{s}..{s}", .{ base, tag }) else tag; + const log = try mustRun(ctx, "draft-release", &.{ "git", "log", "--oneline", range }, .{}); + + const sums = try readFile(ctx, ctx.fmt("{s}/SHA256SUMS.txt", .{dist})); + const digest_line = try readFile(ctx, ctx.fmt("{s}/IMAGE-DIGEST.txt", .{dist})); + + var body: Io.Writer.Allocating = .init(ctx.arena); + const w = &body.writer; + try w.writeAll(section); + try w.writeAll("\n### Artifacts\n\n```\n"); + try w.writeAll(sums); + try w.writeAll("```\n\n```\n"); + try w.writeAll(digest_line); + try w.writeAll("```\n\n"); + if (base.len != 0) { + const server = std.mem.trimEnd(u8, ctx.require("GITHUB_SERVER_URL"), "/"); + try w.print("[Compare {s}...{s}]({s}/{s}/compare/{s}...{s})\n\n", .{ + base, tag, server, ctx.require("GITHUB_REPOSITORY"), base, tag, + }); + try w.print("
Commits since {s}\n", .{base}); + } else { + try w.print("
All commits up to {s}\n", .{tag}); + } + try w.writeAll("\n```\n"); + try w.writeAll(log); + try w.writeAll("```\n\n
\n"); + return body.written(); +} + +/// Ruling 7 step 14, and the LAST recoverable phase. See the module comment for +/// why it runs before publication. +/// +/// 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, because 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 what 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. The window left is between that read and +/// `imagetools create`, instead of the whole duration of the gates. +fn latest(ctx: *Ctx) !void { + const tag = requireTag(ctx); + const version = ctx.require("VERSION"); + const image_name = ctx.require("IMAGE"); + const registry = ctx.require("REGISTRY"); + const dist = ctx.require("DIST"); + const new = parseTag(tag).?; + + const api = Api.init(ctx); + if (try api.highestPublishedRelease()) |highest| { + const known = parseTag(highest).?; + if (new.order(known) != .gt) { + ctx.soft("latest-monotonic", "{s} no longer exceeds the highest published release {s}; another release finished first, refusing to move :latest backwards", .{ tag, highest }); + return CheckFailed; + } + ctx.pass("latest-monotonic", "{s} still exceeds the highest published release {s}", .{ tag, highest }); + } else { + ctx.pass("latest-monotonic", "still no published release; this is the first", .{}); + } + + const digest = try readImageDigest(ctx, dist); + + var docker = try Docker.login(ctx, registry); + defer docker.close(); + + const latest_ref = ctx.fmt("{s}:latest", .{image_name}); + const inspect = try docker.run(&.{ + "docker", "buildx", "imagetools", "inspect", latest_ref, "--format", "{{json .Image}}", + }); + if (inspect.ok()) { + const value = std.json.parseFromSliceLeaky(std.json.Value, ctx.arena, inspect.stdout, .{}) catch { + ctx.soft("latest-label", "{s} returned an unparseable image config", .{latest_ref}); + return CheckFailed; + }; + var labels: std.ArrayList([]const u8) = .empty; + collectVersionLabels(ctx.arena, value, &labels); + if (labels.items.len == 0) { + ctx.soft("latest-label", "{s} carries no org.opencontainers.image.version label; refusing to move it, its current version cannot be established", .{latest_ref}); + return CheckFailed; + } + const current = labels.items[0]; + const known = parseSemver(current) orelse { + ctx.soft("latest-label", "{s} serves version '{s}', which is not vMAJOR.MINOR.PATCH", .{ latest_ref, current }); + return CheckFailed; + }; + const wanted = parseSemver(version).?; + switch (wanted.order(known)) { + .eq => ctx.note(":latest already serves {s}; re-pointing it at {s} is idempotent", .{ version, digest }), + .lt => { + ctx.soft("latest-label", ":latest serves {s}, which is newer than {s}; another release moved it first, refusing to move :latest backwards", .{ current, version }); + return CheckFailed; + }, + .gt => ctx.note(":latest serves {s}; {s} supersedes it", .{ current, version }), + } + } else if (saysAbsent(inspect.combined(ctx.arena))) { + // 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. + ctx.note("{s} does not exist yet; this is the first release", .{latest_ref}); + } else { + ctx.soft("latest-label", "could not read {s} (exit {d}): {s}", .{ + latest_ref, inspect.code, std.mem.trimEnd(u8, inspect.combined(ctx.arena), "\n"), + }); + return CheckFailed; + } + + _ = try mustRunDocker(ctx, docker, "latest-move", &.{ + "docker", "buildx", "imagetools", "create", "--tag", latest_ref, + ctx.fmt("{s}@{s}", .{ image_name, digest }), + }); + const resolved_run = try mustRunDocker(ctx, docker, "latest-move", &.{ + "docker", "buildx", "imagetools", "inspect", latest_ref, "--format", "{{.Manifest.Digest}}", + }); + const resolved = std.mem.trim(u8, resolved_run, " \t\r\n"); + if (!std.mem.eql(u8, resolved, digest)) { + ctx.soft("latest-move", "{s} resolves to {s}, not {s}", .{ latest_ref, resolved, digest }); + return CheckFailed; + } + ctx.pass("latest-move", "{s} now points at {s}", .{ latest_ref, digest }); +} + +/// Ruling 7 step 15, last, and the only irreversible act. Every phase 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. +fn publish(ctx: *Ctx) !void { + const tag = requireTag(ctx); + const api = Api.init(ctx); + const id_text = ctx.require("RELEASE_ID"); + const id = std.fmt.parseInt(i64, id_text, 10) catch { + ctx.fatal("publish", "RELEASE_ID is not a number: '{s}'", .{id_text}); + }; + + const response = try api.send(.PATCH, api.url("/releases/{d}", .{id}), "{\"draft\":false}"); + if (response.ok()) { + const value = response.json(ctx) orelse { + ctx.soft("publish", "the publish response is not JSON: {s}", .{response.body}); + return CheckFailed; + }; + if (!isPublished(value)) { + ctx.soft("publish", "release {d} is still a draft", .{id}); + return CheckFailed; + } + ctx.pass("publish", "published {s}", .{tag}); + return; + } + + // 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 is the phase that never reported success. Ask what + // the release actually is before concluding anything from the transport. + ctx.note("the publish request answered {d}: {s}", .{ response.status, response.body }); + ctx.note("re-reading release {d} to see whether it took effect", .{id}); + const recheck = try api.send(.GET, api.url("/releases/{d}", .{id}), null); + if (recheck.status == 200) { + if (recheck.json(ctx)) |value| { + if (isPublished(value)) { + ctx.pass("publish", "release {d} is published; the request took effect despite the response", .{id}); + return; + } + } + } + ctx.soft("publish", "release {d} is not published (re-read answered {d}): {s}", .{ + id, recheck.status, recheck.body, + }); + return CheckFailed; +} + +fn jsonInteger(value: std.json.Value, key: []const u8) ?i64 { + if (value != .object) return null; + const found = value.object.get(key) orelse return null; + return switch (found) { + .integer => |number| number, + else => null, + }; +} + +fn isPublished(value: std.json.Value) bool { + if (value != .object) return false; + const draft_value = value.object.get("draft") orelse return false; + return draft_value == .bool and !draft_value.bool; +} + +/// Belt and braces for the `defer`s above: cancellation and a runner that reuses +/// its workspace both land here. Never fails — a scrub that aborts the job it is +/// cleaning up after would hide the real error. +fn scrub(ctx: *Ctx) !void { + const temp = ctx.get("RUNNER_TEMP"); + if (temp.len == 0) return; + + var dir = Io.Dir.cwd().openDir(ctx.io, temp, .{ .iterate = true }) catch return; + defer dir.close(ctx.io); + + var names: std.ArrayList([]const u8) = .empty; + var it = dir.iterate(); + while (it.next(ctx.io) catch null) |entry| { + if (entry.kind != .directory) continue; + const gnupg = std.mem.startsWith(u8, entry.name, "gnupg."); + const dockercfg = std.mem.startsWith(u8, entry.name, "dockercfg."); + if (!gnupg and !dockercfg) continue; + names.append(ctx.arena, ctx.arena.dupe(u8, entry.name) catch continue) catch continue; + } + + for (names.items) |name| { + const path = ctx.fmt("{s}/{s}", .{ temp, name }); + if (std.mem.startsWith(u8, name, "gnupg.")) { + const env = ctx.arena.alloc([2][]const u8, 1) catch continue; + env[0] = .{ "GNUPGHOME", path }; + _ = runCommand(ctx, &.{ "gpgconf", "--kill", "gpg-agent" }, .{ .env = env }) catch {}; + } + Io.Dir.cwd().deleteTree(ctx.io, path) catch {}; + ctx.note("scrubbed {s}", .{path}); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "parseSemver accepts plain releases and rejects everything else" { + try testing.expectEqual(@as(?Semver, .{ .major = 1, .minor = 2, .patch = 3 }), parseSemver("1.2.3")); + try testing.expectEqual(@as(?Semver, .{ .major = 0, .minor = 0, .patch = 0 }), parseSemver("0.0.0")); + try testing.expectEqual(@as(?Semver, null), parseSemver("1.2")); + try testing.expectEqual(@as(?Semver, null), parseSemver("1.2.3.4")); + try testing.expectEqual(@as(?Semver, null), parseSemver("1.2.3-rc1")); + try testing.expectEqual(@as(?Semver, null), parseSemver("1.2.03")); + try testing.expectEqual(@as(?Semver, null), parseSemver("v1.2.3")); + try testing.expectEqual(@as(?Semver, null), parseSemver("")); +} + +test "semver ordering is numeric, so 0.0.10 beats 0.0.9" { + const ten = parseSemver("0.0.10").?; + const nine = parseSemver("0.0.9").?; + try testing.expectEqual(std.math.Order.gt, ten.order(nine)); + try testing.expectEqual(std.math.Order.lt, nine.order(ten)); + try testing.expectEqual(std.math.Order.eq, ten.order(ten)); + try testing.expectEqual(std.math.Order.gt, parseSemver("1.0.0").?.order(parseSemver("0.99.99").?)); +} + +test "parseTag requires the v prefix" { + try testing.expect(parseTag("v0.0.1") != null); + try testing.expect(parseTag("0.0.1") == null); + try testing.expect(parseTag("v0.0.1-rc1") == null); +} + +test "highestPublished keeps the greatest plain version" { + var highest: ?[]const u8 = null; + for ([_][]const u8{ "v0.0.9", "v0.0.10", "nightly", "v0.0.2" }) |tag| { + highest = highestPublished(highest, tag); + } + try testing.expectEqualStrings("v0.0.10", highest.?); +} + +test "VALIDSIG takes the primary from the last field and the signer from field 3" { + const status = + \\[GNUPG:] NEWSIG + \\[GNUPG:] SIG_ID abc 2026-08-07 1754524800 + \\[GNUPG:] VALIDSIG B281CECC877BD36575543F0A4148C60EC18D831D 2026-08-07 1754524800 0 4 0 22 10 00 A2061F6AB24DF2C0E92346FD1509B54946D08A95 + \\[GNUPG:] TRUST_ULTIMATE 0 pgp + ; + try testing.expectEqualStrings("A2061F6AB24DF2C0E92346FD1509B54946D08A95", validsigPrimary(status).?); + try testing.expectEqualStrings("B281CECC877BD36575543F0A4148C60EC18D831D", validsigSigner(status).?); +} + +test "a VALIDSIG line with too few fields carries no primary fingerprint" { + const status = "[GNUPG:] VALIDSIG B281CECC877BD36575543F0A4148C60EC18D831D 2026-08-07 1754524800\n"; + try testing.expect(validsigPrimary(status) == null); + try testing.expect(validsigSigner(status) == null); + try testing.expect(validsigPrimary("[GNUPG:] BADSIG whatever\n") == null); +} + +test "parseChallenge reads realm, service and scope" { + const header = "Bearer realm=\"http://gitea:3000/v2/token\",service=\"container_registry\",scope=\"repository:mokhtar/nxdns:pull\""; + const challenge = parseChallenge(header); + try testing.expectEqualStrings("http://gitea:3000/v2/token", challenge.realm); + try testing.expectEqualStrings("container_registry", challenge.service); + try testing.expectEqualStrings("repository:mokhtar/nxdns:pull", challenge.scope); +} + +test "parseChallenge leaves absent parameters empty" { + const challenge = parseChallenge("Bearer realm=\"http://gitea:3000/v2/token\""); + try testing.expectEqualStrings("http://gitea:3000/v2/token", challenge.realm); + try testing.expectEqualStrings("", challenge.service); + try testing.expectEqualStrings("", challenge.scope); + try testing.expectEqualStrings("", parseChallenge("Basic realm=gitea").realm); +} + +test "changelogSection stops at the next heading" { + const source = + \\# Changelog + \\ + \\## [Unreleased] + \\ + \\- work in progress + \\ + \\## [0.0.2] - 2026-08-08 + \\ + \\### Added + \\ + \\- a thing + \\ + \\## [0.0.1] - 2026-08-01 + \\ + \\- the first release + \\ + ; + const section = changelogSection(source, "0.0.2").?; + try testing.expectEqualStrings("\n### Added\n\n- a thing\n\n", section); +} + +test "changelogSection stops at the link-reference block" { + const source = + \\## [0.0.1] - 2026-08-01 + \\ + \\- the first release + \\ + \\[0.0.1]: http://gitea:3000/mokhtar/nxdns/releases/tag/v0.0.1 + \\ + ; + const section = changelogSection(source, "0.0.1").?; + try testing.expectEqualStrings("\n- the first release\n\n", section); +} + +test "changelogSection reports an absent or blank section" { + const source = "## [0.0.1]\n\n- text\n"; + try testing.expect(changelogSection(source, "0.0.2") == null); + try testing.expect(isBlank(changelogSection("## [0.0.3]\n\n\n## [0.0.2]\n- x\n", "0.0.3").?)); + // A version that is a prefix of another must not match it. + try testing.expect(changelogSection("## [0.0.10]\n\n- x\n", "0.0.1") == null); +} + +test "isLinkReference matches only a definition line" { + try testing.expect(isLinkReference("[0.0.1]: http://example/x")); + try testing.expect(!isLinkReference("[not a definition]")); + try testing.expect(!isLinkReference("- [a link](http://example)")); + try testing.expect(!isLinkReference("[]: http://example")); +} + +test "parseSumsLine takes sha256sum text mode only" { + const line = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 SHA256SUMS.txt"; + const parsed = parseSumsLine(line).?; + try testing.expectEqualStrings("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", parsed.hex); + try testing.expectEqualStrings("SHA256SUMS.txt", parsed.name); + // One space is sha256sum's binary mode, which `-c` reads differently. + try testing.expect(parseSumsLine("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 x") == null); + try testing.expect(parseSumsLine("E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 x") == null); + try testing.expect(parseSumsLine("short x") == null); +} + +test "sha256Hex matches the known empty-input digest" { + try testing.expectEqualStrings( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + &sha256Hex(""), + ); +} + +test "isDigest accepts only sha256 with 64 lowercase hex digits" { + try testing.expect(isDigest("sha256:" ++ "a" ** 64)); + try testing.expect(!isDigest("sha256:" ++ "A" ** 64)); + try testing.expect(!isDigest("sha256:" ++ "a" ** 63)); + try testing.expect(!isDigest("sha512:" ++ "a" ** 64)); + try testing.expect(!isDigest("")); +} + +test "isFingerprint accepts 40 uppercase hex characters only" { + try testing.expect(isFingerprint("A2061F6AB24DF2C0E92346FD1509B54946D08A95")); + try testing.expect(!isFingerprint("a2061f6ab24df2c0e92346fd1509b54946d08a95")); + try testing.expect(!isFingerprint("PASTE_THE_FINGERPRINT_HERE")); + try testing.expect(!isFingerprint("")); +} + +test "releasesArray refuses a 200 that is not an array" { + const arena = testing.allocator; + const array = try std.json.parseFromSlice(std.json.Value, arena, "[{\"tag_name\":\"v1.0.0\"}]", .{}); + defer array.deinit(); + try testing.expect(releasesArray(array.value) != null); + + const object = try std.json.parseFromSlice(std.json.Value, arena, "{\"message\":\"token required\"}", .{}); + defer object.deinit(); + try testing.expect(releasesArray(object.value) == null); +} + +test "primarySecretLeak fires on anything but a stub" { + const stub = "sec:u:255:22:0123456789ABCDEF:1754524800:::u:::scESC:::#:::23::0:\n" ++ + "fpr:::::::::A2061F6AB24DF2C0E92346FD1509B54946D08A95:\n"; + try testing.expect(primarySecretLeak(stub) == null); + + const present = "sec:u:255:22:0123456789ABCDEF:1754524800:::u:::scESC:::+:::23::0:\n"; + try testing.expectEqualStrings("0123456789ABCDEF", primarySecretLeak(present).?); +} + +test "colonFingerprints lists fingerprints in order, primary first" { + var scratch: std.heap.ArenaAllocator = .init(testing.allocator); + defer scratch.deinit(); + const arena = scratch.allocator(); + const colons = + \\sec:u:255:22:0123456789ABCDEF:1754524800:::u:::scESC:::#:::23::0: + \\fpr:::::::::A2061F6AB24DF2C0E92346FD1509B54946D08A95: + \\ssb:u:255:22:FEDCBA9876543210:1754524800::::::s:::+:::23: + \\fpr:::::::::B281CECC877BD36575543F0A4148C60EC18D831D: + \\ + ; + const found = colonFingerprints(arena, colons); + try testing.expectEqual(@as(usize, 2), found.len); + try testing.expectEqualStrings("A2061F6AB24DF2C0E92346FD1509B54946D08A95", found[0]); + try testing.expect(containsString(found, "B281CECC877BD36575543F0A4148C60EC18D831D")); + try testing.expect(!containsString(found, "0000000000000000000000000000000000000000")); +} + +test "base64 round-trips a multiline armored export" { + var scratch: std.heap.ArenaAllocator = .init(testing.allocator); + defer scratch.deinit(); + const arena = scratch.allocator(); + const armored = "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\nlQOYBGabc\n=abcd\n-----END PGP PRIVATE KEY BLOCK-----\n"; + const encoded = encodeBase64(arena, armored); + + // A secret pasted from `base64` without `-w0` arrives wrapped. + var wrapped: std.ArrayList(u8) = .empty; + for (encoded, 0..) |c, index| { + if (index != 0 and index % 16 == 0) try wrapped.append(arena, '\n'); + try wrapped.append(arena, c); + } + const decoded = try decodeBase64(arena, wrapped.items); + try testing.expectEqualStrings(armored, decoded); +} + +test "registryHost strips the scheme and the path" { + try testing.expectEqualStrings("gitea:3000", registryHost("http://gitea:3000")); + try testing.expectEqualStrings("gitea:3000", registryHost("http://gitea:3000/mokhtar/nxdns")); + try testing.expectEqualStrings("git.example.org", registryHost("https://git.example.org/")); +} + +test "collectVersionLabels finds the label at any depth" { + var scratch: std.heap.ArenaAllocator = .init(testing.allocator); + defer scratch.deinit(); + const arena = scratch.allocator(); + const source = + \\{"linux/amd64":{"config":{"Labels":{"org.opencontainers.image.version":"0.0.2"}}}, + \\ "linux/arm64":{"config":{"Labels":{"org.opencontainers.image.version":"0.0.2"}}}} + ; + const parsed = try std.json.parseFromSlice(std.json.Value, arena, source, .{}); + defer parsed.deinit(); + + var labels: std.ArrayList([]const u8) = .empty; + collectVersionLabels(arena, parsed.value, &labels); + try testing.expectEqual(@as(usize, 2), labels.items.len); + try testing.expectEqualStrings("0.0.2", labels.items[0]); + + // A config with no Labels contributes nothing, which is what makes the + // per-platform "exactly one" assertion able to fail. + const bare = try std.json.parseFromSlice(std.json.Value, arena, "{\"config\":{}}", .{}); + defer bare.deinit(); + var none: std.ArrayList([]const u8) = .empty; + collectVersionLabels(arena, bare.value, &none); + try testing.expectEqual(@as(usize, 0), none.items.len); +} + +test "saysAbsent separates an absent tag from an unreadable registry" { + try testing.expect(saysAbsent("ERROR: manifest unknown")); + try testing.expect(saysAbsent("failed to get image: not found")); + try testing.expect(saysAbsent("NAME_UNKNOWN: repository name not known")); + try testing.expect(!saysAbsent("unauthorized: authentication required")); + try testing.expect(!saysAbsent("dial tcp: connection refused")); +} + +test "formatEpoch renders the OCI created timestamp" { + var buffer: [64]u8 = undefined; + const text = try std.fmt.bufPrint(&buffer, "{f}", .{formatEpoch(1754524800)}); + try testing.expectEqualStrings("2025-08-07T00:00:00Z", text); + const zero = try std.fmt.bufPrint(&buffer, "{f}", .{formatEpoch(0)}); + try testing.expectEqualStrings("1970-01-01T00:00:00Z", zero); +} + +test "urlEncode escapes what a query string cannot carry" { + var scratch: std.heap.ArenaAllocator = .init(testing.allocator); + defer scratch.deinit(); + const arena = scratch.allocator(); + try testing.expectEqualStrings("repository:mokhtar/nxdns:pull", urlEncode(arena, "repository:mokhtar/nxdns:pull")); + try testing.expectEqualStrings("a%20b%26c", urlEncode(arena, "a b&c")); +} diff --git a/web/package.json b/web/package.json index 93beb3e..7a19ac5 100644 --- a/web/package.json +++ b/web/package.json @@ -13,7 +13,8 @@ "lint": "oxlint src vite.config.ts", "format": "prettier --write .", "format:check": "prettier --check .", - "test": "vitest run" + "test": "vitest run", + "assert-bundled": "node scripts/assert-bundled-packages.mjs" }, "prettier": { "useTabs": true, diff --git a/web/scripts/assert-bundled-packages.mjs b/web/scripts/assert-bundled-packages.mjs new file mode 100644 index 0000000..63436f8 --- /dev/null +++ b/web/scripts/assert-bundled-packages.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +// The set of npm packages whose bytes reach web/dist must be exactly the set +// recorded in licenses/dependency-identity.txt (milestone-14 ruling 3). +// +// The shipped build carries no sourcemaps, so this makes a second build with +// them into its own directory: the `sources` list of each chunk names the +// modules that went into it, and the artifact `npm run build` produced stays +// untouched. Runs from web/ as `npm run assert-bundled`, on a laptop exactly as +// on the runner. + +import { execFileSync } from "node:child_process"; +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { bundledPackages, comparePackages, formatDiff, recordedPackages } from "./bundledPackages.mjs"; + +const webRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const outDir = "dist-sourcemap"; +const identityFile = join(webRoot, "..", "licenses", "dependency-identity.txt"); + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exit(1); +} + +function mapFiles(relativeDir) { + const absolute = join(webRoot, relativeDir); + let entries; + try { + entries = readdirSync(absolute, { withFileTypes: true }); + } catch (err) { + fail(`assert-bundled: cannot read ${relativeDir}: ${err.message}`); + } + const found = []; + for (const entry of entries) { + const child = `${relativeDir}/${entry.name}`; + if (entry.isDirectory()) { + found.push(...mapFiles(child)); + } else if (entry.isFile() && entry.name.endsWith(".map")) { + found.push(child); + } + } + return found.sort(); +} + +// The binary npm ci installed, never `npx`: npx silently downloads a package it +// cannot find locally, so a wrong working directory would turn a licence check +// into an unpinned fetch from the network. +try { + execFileSync( + join(webRoot, "node_modules", ".bin", "vite"), + ["build", "--sourcemap", "--outDir", outDir, "--emptyOutDir"], + { + cwd: webRoot, + stdio: ["ignore", "ignore", "inherit"], + }, + ); +} catch (err) { + fail(`assert-bundled: the sourcemap build failed: ${err.message}`); +} + +const maps = mapFiles(outDir); +if (maps.length === 0) fail("assert-bundled: the sourcemap build produced no .map files; this check cannot run blind"); + +const sourceLists = maps.map((path) => { + const raw = readFileSync(join(webRoot, path), "utf8"); + let parsed; + try { + parsed = JSON.parse(raw); + } catch (err) { + fail(`assert-bundled: ${path} is not JSON: ${err.message}`); + } + return Array.isArray(parsed.sources) ? parsed.sources : []; +}); + +const bundled = bundledPackages(sourceLists); + +let identity; +try { + identity = readFileSync(identityFile, "utf8"); +} catch (err) { + fail(`assert-bundled: cannot read licenses/dependency-identity.txt: ${err.message}`); +} + +const recorded = recordedPackages(identity); +if (recorded === null) { + fail("assert-bundled: licenses/dependency-identity.txt has no '[npm packages bundled into web/dist]' section"); +} +if (recorded.length === 0) { + fail("assert-bundled: the '[npm packages bundled into web/dist]' section is empty"); +} + +const { added, removed } = comparePackages(recorded, bundled); +if (added.length !== 0 || removed.length !== 0) { + process.stderr.write(`${formatDiff(recorded, bundled)}\n\n`); + fail( + [ + "the set of npm packages in web/dist has changed (-recorded +current).", + "Work out what the change means for licenses/inventory.zon first, then record", + "the new list in that section of licenses/dependency-identity.txt.", + ].join("\n"), + ); +} + +process.stdout.write(`web/dist bundles exactly the ${bundled.length} recorded packages:\n`); +for (const name of bundled) process.stdout.write(`${name}\n`); diff --git a/web/scripts/bundledPackages.mjs b/web/scripts/bundledPackages.mjs new file mode 100644 index 0000000..321207f --- /dev/null +++ b/web/scripts/bundledPackages.mjs @@ -0,0 +1,77 @@ +// The decisions behind `npm run assert-bundled`, kept separate from the script +// that does the I/O so they can be unit-tested (milestone-14 deviation 24). +// +// The licence inventory has to cover every package whose bytes ship, and the +// lockfile does not answer that question: it lists what could be reached, not +// what rollup kept. Several packages of the non-dev closure are recorded as +// tree-shaken away, and if application code starts importing one of them, no +// lockfile, no version and no dependency set changes — only the bundle does. So +// the bundle is what this reads. + +const sectionHeading = "[npm packages bundled into web/dist]"; + +// A sourcemap `sources` entry for a dependency ends in +// `node_modules//` or `node_modules/@//`. Only +// the last `node_modules/` matters: a nested dependency's path carries two. +export function packageFromSource(source) { + const marker = "node_modules/"; + const at = source.lastIndexOf(marker); + if (at === -1) return null; + const rest = source.slice(at + marker.length); + const parts = rest.split("/"); + if (parts.length === 0 || parts[0] === "") return null; + if (parts[0].startsWith("@")) { + if (parts.length < 2 || parts[1] === "") return null; + return `${parts[0]}/${parts[1]}`; + } + return parts[0]; +} + +/// The sorted, deduplicated package set of a list of sourcemap `sources` arrays. +export function bundledPackages(sourceLists) { + const found = new Set(); + for (const sources of sourceLists) { + for (const source of sources) { + const name = packageFromSource(source); + if (name !== null) found.add(name); + } + } + return [...found].sort(); +} + +/// The recorded section of `licenses/dependency-identity.txt`: every non-blank +/// line after the heading, up to the next `[section]`. +export function recordedPackages(text) { + const recorded = new Set(); + let grabbing = false; + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (!grabbing) { + if (line === sectionHeading) grabbing = true; + continue; + } + if (line.startsWith("[")) break; + if (line !== "") recorded.add(line); + } + return grabbing ? [...recorded].sort() : null; +} + +/// What changed, in the two directions that mean different things: a package +/// that started shipping needs a licence decision, and one that stopped needs +/// the record corrected. +export function comparePackages(recorded, bundled) { + const inBundle = new Set(bundled); + const inRecord = new Set(recorded); + return { + added: bundled.filter((name) => !inRecord.has(name)), + removed: recorded.filter((name) => !inBundle.has(name)), + }; +} + +export function formatDiff(recorded, bundled) { + const { added, removed } = comparePackages(recorded, bundled); + const lines = []; + for (const name of removed) lines.push(`-${name}`); + for (const name of added) lines.push(`+${name}`); + return lines.join("\n"); +} diff --git a/web/scripts/bundledPackages.test.mjs b/web/scripts/bundledPackages.test.mjs new file mode 100644 index 0000000..4d9cb06 --- /dev/null +++ b/web/scripts/bundledPackages.test.mjs @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { + bundledPackages, + comparePackages, + formatDiff, + packageFromSource, + recordedPackages, +} from "./bundledPackages.mjs"; + +describe("packageFromSource", () => { + it("reads a plain package name", () => { + expect(packageFromSource("../../node_modules/react-dom/client.js")).toBe("react-dom"); + }); + + it("keeps the scope of a scoped package", () => { + expect(packageFromSource("../../node_modules/@tanstack/react-query/build/index.js")).toBe( + "@tanstack/react-query", + ); + }); + + it("takes the last node_modules, so a nested dependency is named correctly", () => { + expect(packageFromSource("node_modules/vite/node_modules/@scope/inner/x.js")).toBe("@scope/inner"); + }); + + it("ignores application sources", () => { + expect(packageFromSource("src/lib/api.ts")).toBeNull(); + expect(packageFromSource("../src/main.tsx")).toBeNull(); + }); +}); + +describe("bundledPackages", () => { + it("sorts and deduplicates across every map", () => { + const packages = bundledPackages([ + ["node_modules/react/index.js", "src/main.tsx", "node_modules/react/jsx-runtime.js"], + ["node_modules/@tanstack/react-router/x.js", "node_modules/react/index.js"], + ]); + expect(packages).toEqual(["@tanstack/react-router", "react"]); + }); + + it("returns an empty set when nothing came from node_modules", () => { + expect(bundledPackages([["src/main.tsx"]])).toEqual([]); + }); +}); + +describe("recordedPackages", () => { + const identity = [ + "[some earlier section]", + "ignored", + "", + "[npm packages bundled into web/dist]", + "react", + "@tanstack/react-query", + "", + "react-dom", + "", + "[a later section]", + "not-a-package", + ].join("\n"); + + it("reads only its own section, sorted and deduplicated", () => { + expect(recordedPackages(identity)).toEqual(["@tanstack/react-query", "react", "react-dom"]); + }); + + it("distinguishes a missing section from an empty one", () => { + expect(recordedPackages("[other]\nx\n")).toBeNull(); + expect(recordedPackages("[npm packages bundled into web/dist]\n\n[next]\n")).toEqual([]); + }); +}); + +describe("comparePackages", () => { + it("reports both directions", () => { + const { added, removed } = comparePackages(["a", "b"], ["b", "c"]); + expect(added).toEqual(["c"]); + expect(removed).toEqual(["a"]); + }); + + it("reports nothing when the sets match", () => { + expect(comparePackages(["a", "b"], ["a", "b"])).toEqual({ added: [], removed: [] }); + expect(formatDiff(["a"], ["a"])).toBe(""); + }); + + it("formats a diff the way the failure prints it", () => { + expect(formatDiff(["a", "b"], ["b", "c"])).toBe("-a\n+c"); + }); +});