flake.nix fetches the release tarballs and carries their SRI hashes in a generated block. The cut tool builds the release locally with the toolchain gates.yml pins, in a normalized nine-variable environment, writes the hashes into flake.nix, and commits it with build.zig.zon as the single bump commit. The package job verifies the pins on the bump commit and the publish job verifies them again on the tag, before anything is uploaded. The tarballs are written by dist_stage (std.tar.Writer, flate gzip) instead of the runner's tar and gzip, and -ffile-prefix-map keeps checkout paths out of the C objects; two checkouts at different absolute paths produce byte-identical archives. nxdns version, /api/version and the admin footer report the version only: the bump commit cannot know its own sha.
393 lines
17 KiB
YAML
393 lines
17 KiB
YAML
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"
|
|
NPM_VERSION: "11.17.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: admin/package-lock.json
|
|
|
|
# The same pinned toolchain and normalized environment as the frontend
|
|
# job in gates.yml: the cut pinned the bundle's bytes into flake.nix
|
|
# before this run existed, and the pin check below compares against them.
|
|
- name: Assert the pinned Node and npm
|
|
run: |
|
|
test "$(node --version)" = "v${NODE_VERSION:?}"
|
|
test "$(npm --version)" = "${NPM_VERSION:?}"
|
|
test ! -e /nonexistent/npmrc-user
|
|
test ! -e /nonexistent/npmrc-global
|
|
|
|
# The same normalized environment the cut builds the bundle in: exactly
|
|
# nine variables, a file mode from the umask, a C locale, UTC and a zero
|
|
# build timestamp. Each of those can move the bytes the release hashes
|
|
# cover, and this job's bundle has to reproduce the one the cut pinned.
|
|
- name: Build the web UI
|
|
working-directory: admin
|
|
run: env -i PATH="$PATH" HOME="$HOME" LC_ALL=C LANG=C TZ=UTC SOURCE_DATE_EPOCH=0 CI=true npm_config_userconfig=/nonexistent/npmrc-user npm_config_globalconfig=/nonexistent/npmrc-global sh -c 'umask 022 && npm ci && npm run build'
|
|
|
|
# Step 8.
|
|
- name: Build the release artifacts
|
|
run: >
|
|
zig build dist
|
|
-Dversion-string="$VERSION"
|
|
-Dadmin-dist=admin/dist
|
|
-Doptimize=ReleaseSafe
|
|
|
|
- name: Verify the release artifacts
|
|
run: >
|
|
zig build verify-dist
|
|
-Dversion-string="$VERSION"
|
|
-Dadmin-dist=admin/dist
|
|
-Doptimize=ReleaseSafe
|
|
|
|
# Unconditional, and before the image push, the draft and every upload:
|
|
# the tag's tree IS the bump commit's tree, so the flake block must pin
|
|
# these exact bytes and name this exact version. A consumer who resolves
|
|
# the tag through the flake gets hashes that were written before this run
|
|
# existed; this is where the claim is proved, while nothing has yet left
|
|
# the runner.
|
|
- name: Verify the flake pins
|
|
run: >
|
|
zig build verify-pins
|
|
-Dversion-string="$VERSION"
|
|
-Dadmin-dist=admin/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
|