Files
nxdns/.gitea/workflows/release.yml
T
mokhtar 51d8281abb
Gates / frontend (push) Successful in 45s
Gates / package (push) Successful in 3m52s
Gates / container (push) Successful in 2m18s
CI / gates (push) Successful in 12m35s
Gates / test-aarch64 (push) Successful in 4m26s
Gates / frontend (push) Successful in 47s
Release / guard (push) Successful in 1m16s
Gates / test (push) Successful in 1m9s
Gates / package (push) Successful in 31s
Gates / container (push) Successful in 34s
Release / gates (push) Successful in 7m29s
Release / publish (push) Successful in 5m15s
Gates / test (push) Successful in 1m11s
Gates / test-aarch64 (push) Successful in 4m28s
release: name the image by the public registry host, not the internal server url
2026-08-08 21:14:37 +02:00

369 lines
16 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"
# 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