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-<version>-x86_64-linux-musl.tar.gz
#     nxdns-<version>-aarch64-linux-musl.tar.gz
#     SHA256SUMS.txt
#     SHA256SUMS.txt.asc
#     IMAGE-DIGEST.txt
#
# `.gz` and `.txt` are in Gitea's default ALLOWED_TYPES; `.asc` is added by
# manual prerequisite 3. `zig build dist` still writes `SHA256SUMS` on disk —
# 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/<hex>.zip inside the global
      # cache without creating tmp/ first (src/Package/Fetch.zig:1499), and
      # setup-zig's restored cache never contains tmp/. Without this, every
      # dependency fetch dies with "failed to create temporary zip file:
      # FileNotFound" before any network I/O.
      - name: Create the fetch temp dir zig assumes
        run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"

      - name: 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
