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. The # release therefore commits to the extension-carrying names: # # nxdns--x86_64-linux-musl.tar.gz # nxdns--aarch64-linux-musl.tar.gz # SHA256SUMS.txt # SHA256SUMS.txt.asc # IMAGE-DIGEST.txt # # `.gz` and `.txt` are in Gitea's default ALLOWED_TYPES; `.asc` is added by # manual prerequisite 3. `zig build dist` still writes `SHA256SUMS` on disk — # 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 # `asset_suffixes` in tools/release.zig and update # docs/how-to/verify-a-release.md to match. Nothing else changes. # --------------------------------------------------------------------------- # # Every action below is pinned to a full commit SHA (ruling 7): this job holds # the signing subkey and the registry token, so a moved tag on someone else's # server must not be able to run code here. ci.yml and gates.yml may keep # moving tags; they hold no secrets. on: push: tags: ["v*"] # The guard's "this version exceeds the highest published release" check runs # before the gates, so on its own it proves nothing about which run reaches the # registry last: two tags pushed close together could interleave and leave # `:latest` on the older one. The group is deliberately NOT ref-scoped — # serialising two *different* tags is the whole point — and never cancels, so a # release that already pushed an image is allowed to finish. The `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 env: # The public registry host, which names the image. Inside the cluster # GITHUB_SERVER_URL is http://gitea:3000: docker refuses to log in to a # plain-http registry, and an image named gitea:3000/... is unpullable from # anywhere that matters. The manifest probe still uses the internal URL — # same registry, cheaper route. Found by dry-run attempt 4. REGISTRY_HOST: "git.mial.net" ZIG_VERSION: "0.16.0" # Exact patch, not a floating "24" (ruling 12). NODE_VERSION: "24.19.0" # The author's commit- and tag-signing key. `git verify-tag` alone proves # only that *some* key in the keyring signed the tag, so the signature's # fingerprint is compared against this pin (ruling 7, step 3). 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: "019D00DF8417EBFDA5471E5EF7319CC024FB5A96" jobs: # Steps 1-6 of ruling 7. Everything here is cheap and refuses a bad tag # before the gates spend a runner on it. guard: runs-on: ubuntu-24.04 timeout-minutes: 15 outputs: previous_tag: ${{ steps.releases.outputs.previous_tag }} steps: # 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 # signing step, which runs *after* the registry push. A placeholder # fingerprint therefore burned the immutable version tag (ruling 9) on # the way to failing. The late checks are still there — they guard the # material actually loaded into GNUPGHOME — but this is the one that # fails closed. # # A secret's *value* cannot be validated here without using it; presence # is what is checkable, and an empty secret is the failure that actually # happens (an unset repository secret expands to the empty string). - name: Validate the release secrets and pinned fingerprints env: RELEASE_GPG_SUBKEY: ${{ secrets.RELEASE_GPG_SUBKEY }} RELEASE_GPG_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} run: | set -euo pipefail rc=0 check_fpr() { if printf '%s\n' "$2" | grep -Eq '^[0-9A-F]{40}$'; then echo "$1 is a well-formed fingerprint" return 0 fi echo "$1 is not 40 uppercase hex characters: '$2'" echo " paste the fingerprint from manual prerequisite 1 (ruling 13) into release.yml" return 1 } check_secret() { if [ -n "$2" ]; then echo "$1 is set" return 0 fi echo "the $1 secret is empty or unset (ruling 13)" return 1 } check_fpr TAG_SIGNING_FPR "$TAG_SIGNING_FPR" || rc=1 check_fpr RELEASE_SIGNING_FPR "$RELEASE_SIGNING_FPR" || rc=1 check_secret RELEASE_GPG_SUBKEY "$RELEASE_GPG_SUBKEY" || rc=1 check_secret RELEASE_GPG_PASSPHRASE "$RELEASE_GPG_PASSPHRASE" || rc=1 check_secret REGISTRY_TOKEN "$REGISTRY_TOKEN" || rc=1 check_secret GITEA_TOKEN "$GITEA_TOKEN" || rc=1 if [ "$rc" -ne 0 ]; then echo "refusing to start: nothing has been built, pushed or published" exit 1 fi # fetch-depth: 0 plus tags. The default shallow clone has no # origin/master to test ancestry against, no previous tag to compare # from, and no tag object to verify. `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: fetch-depth: 0 fetch-tags: true persist-credentials: false - name: Set up Zig uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: ${{ env.ZIG_VERSION }} # zig 0.16.0's package fetcher creates tmp/.zip inside the global # cache without creating tmp/ first (src/Package/Fetch.zig:1499), and # setup-zig's restored cache never contains tmp/. Without this, every # dependency fetch dies with "failed to create temporary zip file: # FileNotFound" before any network I/O. - name: Create the fetch temp dir zig assumes run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp" - name: 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: node-version: ${{ env.NODE_VERSION }} cache: npm cache-dependency-path: web/package-lock.json - name: Build the web UI working-directory: web run: | npm ci npm run build # Step 8. - name: Build the release artifacts run: > zig build dist -Dversion-string="$VERSION" -Dgit-commit="$TAG_COMMIT" -Dweb-dist=web/dist -Doptimize=ReleaseSafe - name: Verify the release artifacts 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. - name: Extract the changelog section for this version run: ./zig-out/bin/release changelog # 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: ./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, 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: ./zig-out/bin/release verify-image-binaries # 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: ./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: ./zig-out/bin/release draft # 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: ./zig-out/bin/release latest # 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: ./zig-out/bin/release publish # 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: ./zig-out/bin/release scrub || true