milestone 14 spec: build, package and publish releases

This commit is contained in:
2026-08-05 21:40:16 +02:00
parent 16c9de2414
commit 1ff727feb8
+430
View File
@@ -0,0 +1,430 @@
# Milestone 14: build, package and publish releases
Goal: turn a signed git tag into a published, verifiable release — two static
musl tarballs and one multi-architecture container image on the self-hosted
Gitea at `git.mial.net`, with a licence, third-party notices, a changelog, a
detached signature, and documentation that leads with download-and-verify.
First tag: `v0.0.1`.
## Rulings (binding)
### 1. PLAN.md is amended first
`PLAN.md:41` lists "Prebuilt binaries / published Docker images / project
website" under §2.2 *Out of Scope (permanent scope decisions, not deferrals)*.
`AGENTS.md` makes PLAN the source of truth, so this milestone is invalid until
that line is amended. The orchestrator edits PLAN before any session starts:
- §2.2: remove prebuilt binaries and published container images. **The project
website stays out of scope.**
- Add a §on publication: tag-triggered releases, the artifact set, the signing
model, and the two deferrals in ruling 12.
- `PLAN.md:571`: drop "build date" from the `nxdns version` line. The version
string and the git commit identify a build exactly, and a date is one more
input that a reproducible build would have to pin. `src/version.zig` is
already correct; PLAN was wrong.
- `PLAN.md:638`: state exact byte limits — 15,728,640 with embedded assets,
10,485,760 without — resolving milestone-13 discrepancy 9 in favour of what
CI already asserts.
### 2. The version lives in the tag, and in exactly one other place
The tag is authoritative. `v0.0.1` means `-Dversion-string=0.0.1`;
`-Dgit-commit` is the tag's peeled commit.
`build.zig.zon:3` holds `.version`, which Zig requires and nothing reads. It is
bumped in the commit before each tag, and `verify-dist` fails when it disagrees
with the version under build. Two strings, one assert, no third copy anywhere.
Tags are **never moved**. A tag that produced a bad release is abandoned; the
fix ships as the next patch version. Only `vMAJOR.MINOR.PATCH` is accepted —
the release workflow rejects any tag carrying a pre-release suffix.
### 3. Licence and third-party notices
`LICENSE` holds the English text of EUPL-1.2 verbatim, with
`Copyright (c) 2026 Mokhtar Mial`. `README.md` gains a short notice naming the
licence and the SPDX identifier `EUPL-1.2`. **No per-file SPDX headers.**
`THIRD-PARTY-NOTICES` is assembled by `zig build dist` from a committed,
reviewed inventory under `licenses/`. It is not scraped from the dependency
tree at build time: a generated notices file that nobody reads rots silently
into a false statement.
The inventory must cover everything the shipped artifacts actually contain, not
the direct dependency list:
- **musl libc** (MIT) — statically linked into every binary.
- **Zig standard library and compiler-rt** (MIT) — likewise.
- **SQLite 3.53.4** — public domain, no obligation, listed for completeness.
- **Mbed TLS 3.6.7** — with an explicit line recording that it is taken under
the Apache-2.0 option of its dual `Apache-2.0 OR GPL-2.0-or-later` licence,
followed by the **full Apache-2.0 text**. Naming the choice is good practice;
shipping the text is the actual obligation.
- **Project Everest and p256-m** — compiled in at `build.zig:394` even though
the stock config leaves both drivers disabled.
- **The web bundle's runtime closure** — the transitive set, not the four direct
entries in `web/package.json`. Tailwind is a devDependency whose generated CSS
ships, so "production dependencies" understates it.
A CI guard records the identity of the dependency sets (`build.zig.zon`
dependencies, and the npm closure) and fails when either changes without a
matching change under `licenses/`. It detects drift; it does not derive the
inventory.
The **container image carries `/LICENSE` and `/THIRD-PARTY-NOTICES` too**.
Distributing the image is distribution, and the obligations do not live in the
tarball.
### 4. `zig build dist` replaces `zig build cross`
The `cross` step is deleted. `dist` is the single command that produces
everything releasable, and it runs on a laptop exactly as it runs on the runner.
Inputs: `-Dversion-string` (required — no default), `-Dgit-commit`,
`-Dweb-dist`, `-Doptimize=ReleaseSafe`.
**`dist` fails when `-Dweb-dist` resolves to `web/dist-placeholder`.** The
default at `build.zig:24` is the placeholder, so a release built without the
flag would silently ship a placeholder admin page. There is no override flag;
an escape hatch here is a foot-gun with a safety label on it.
Per triple (`x86_64-linux-musl`, `aarch64-linux-musl`), `dist` builds
ReleaseSafe with `.linkage = .static` and `.strip = true`. Stripping uses
`std.Build.Module.strip` (`-fstrip`, `Module.zig:545`), which removes the
`objcopy` and `binutils-aarch64-linux-gnu` dependency from the runner.
It stages one directory per triple, `nxdns-<version>-<triple>/`, holding:
| File | Mode |
|---|---|
| `nxdns` | 0755 |
| `nxdns.service` | 0644 |
| `nxdns.conf` | 0644 |
| `LICENSE` | 0644 |
| `THIRD-PARTY-NOTICES` | 0644 |
| `INSTALL.md` | 0644 |
`deploy/systemd/sysusers.conf` is renamed to `nxdns.conf` — the name it is
installed under at `/usr/lib/sysusers.d/nxdns.conf`. A file that changes name
during install is a step an operator can get wrong.
Archiving runs as two `b.addSystemCommand` steps, never one:
```
tar --format=gnu --sort=name --mtime=@0 --owner=0 --group=0 \
--numeric-owner -cf <name>.tar <dir>
gzip -n -9 <name>.tar
```
`b.addSystemCommand` executes argv directly and does not interpret `|`, and a
shell wrapper without `pipefail` would report only `gzip`'s exit status while a
failed `tar` passed silently. `gzip -n` is required because `--mtime=@0`
normalises the tar member times but not the timestamp gzip writes into its own
header. The environment sets `LC_ALL=C` and `TZ=UTC`.
Both steps declare the staging directory as a build input and the archive as an
output, so Zig's cache cannot serve a stale artifact.
`dist` also emits a checksum file covering **only the two tarballs**. It cannot
cover the image: the digest does not exist until buildx has pushed, which
happens later and elsewhere. The release job appends that line (ruling 7).
### 5. `zig build verify-dist` asserts what CI shell asserts today
The 50 lines of shell at `ci.yml:129` and `:168` move into a build step, so a
developer can run the release checks on a laptop. Logic that only runs in CI is
the brittleness this milestone exists to remove.
It operates on the **extracted** archive, not the staging directory:
- ELF header: correct `e_machine` per triple, **no `PT_INTERP`, no `DT_NEEDED`**.
Matching the string `statically linked` from `file(1)` is not a static-linkage
test.
- Stripped binary ≤ 15,728,640 bytes.
- Archive layout: exactly one top-level directory, the exact file allowlist from
ruling 4, expected modes, no symlinks, no path traversal.
- `nxdns version` prints the version under build and the git commit. Native
architecture only — the aarch64 binary needs qemu and is skipped without
`-fqemu`.
- `build.zig.zon` `.version` equals the version under build.
The asset-free budget (10,485,760 bytes) gets **its own build against a
generated empty assets directory**, not against the placeholder. Ruling 4 makes
the placeholder unbuildable, and re-admitting it through a back door for one
size check would defeat the point.
### 6. Container image
`deploy/docker/Dockerfile`:
- Pin the base by digest:
`alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce`.
- Add `--platform=$BUILDPLATFORM` to the builder stage. It only copies files, so
pinning it to the build host means the arm64 image needs no qemu.
- **Delete `apk add --no-cache ca-certificates` (`Dockerfile:15`).** Verified:
the Alpine base already ships `/etc/ssl/certs/ca-certificates.crt` (179,359
bytes) from `ca-certificates-bundle`. Removing it removes the only network
fetch in the image build.
- Keep the `mkdir` and `chown 65532``/var/lib/nxdns` must exist with that
ownership so Docker copies it onto a fresh named volume.
- Add `/LICENSE` and `/THIRD-PARTY-NOTICES` (ruling 3).
- OCI labels: `source`, `revision`, `version`, `licenses=EUPL-1.2`, `created`,
`title`, `description`.
`deploy/docker/.dockerignore` is renamed to `deploy/docker/Dockerfile.dockerignore`.
The build context is the repository root, so Docker never reads the current
path, and the whole worktree — `.git`, `node_modules`, `zig-cache` — is being
sent to the daemon today.
buildx runs with `--provenance=false --sbom=false`. Recent buildx adds
provenance attestations by default, which create `unknown/unknown` platform
entries and change the index digest; Gitea's OCI 1.1 support is unverified
(go-gitea#25846) and this is not the milestone to find out. `SOURCE_DATE_EPOCH`
comes from the tag date.
`deploy/docker/compose.yaml` references the published image rather than
building one.
**Verify before publishing:** the binary inside each image is byte-identical to
the binary in the matching tarball.
### 7. Workflows
`.gitea/workflows/gates.yml`, `on: workflow_call`, holds every blocking check:
`test` (with `-Dintegration`), `test-aarch64` (`-fqemu`), `frontend`, `package`
(`dist` + `verify-dist` + the asset-free size check) and `container` (build the
image and run the existing smoke test from `ci.yml:219`). Moving only the three
test jobs would drop the packaging and container checks precisely when they
matter most.
`ci.yml` calls it on `push` and `pull_request` for **`master`**. `master` is
canonical: `origin/main` is deleted, and Gitea's default branch is changed to
match. Today `ci.yml:5` watches `main` while work happens on `master`, so
milestone 13 has never run through CI.
`.gitea/workflows/release.yml`, `on: push: tags: ['v*']`, in this order:
1. Checkout with `fetch-depth: 0` and tags. The default shallow clone breaks
ancestry checks, previous-tag lookup and changelog generation.
2. Reject any tag that is not exactly `vMAJOR.MINOR.PATCH`.
3. `git verify-tag`, requiring an annotated tag and requiring the signature's
fingerprint to equal a fingerprint pinned in the workflow. A bare
`verify-tag` proves only that *some* imported key signed it.
4. Assert the tag's commit is an ancestor of `origin/master`.
5. Assert no published release exists for this tag. Delete any leftover draft.
6. Assert the version is greater than the highest published release version, so
a late-finishing older tag cannot move `latest` backwards.
7. Gates, blocking, via `needs:`.
8. Build the web UI; `zig build dist`; `zig build verify-dist`.
9. Extract the `CHANGELOG.md` section matching the version. **Fail when absent.**
10. buildx build and push **`:<version>` only**. Capture the index digest from
`--metadata-file`, validate it as `sha256:<64 hex>`, and confirm the pushed
tag resolves to that digest with exactly the two intended platforms.
11. Write the digest file, append its checksum line, verify the assembled
checksum file.
12. Sign the checksum file (ruling 8). Verify the signature locally before
uploading it.
13. Create the release as a **draft**; upload the assets.
14. Publish the draft.
15. **Move `:latest` last.**
Every action in `release.yml` is pinned to a full commit SHA.
`actions/checkout@v4` and `mlugg/setup-zig@v2` are mutable tags on another
party's server, and a compromise upstream would run on the runner holding the
signing subkey and the registry token. `ci.yml` may keep moving tags; it holds
no secrets.
### 8. Signing
Automated on the runner, with a dedicated GPG **signing subkey** of the author's
existing commit-signing key. A leaked subkey is revoked on its own; the
identity, the commit signature history and everyone's existing trust survive.
Implementation requirements: a temporary `GNUPGHOME`; assert the imported
material contains no primary secret key; `--local-user <subkey-fingerprint>!`
so GPG cannot fall back to another key; batch and loopback pinentry; verify the
produced signature before upload; scrub `GNUPGHOME` and `DOCKER_CONFIG` and kill
the agent on every exit path. The registry token is passed by
`--password-stdin` into a temporary `DOCKER_CONFIG`, and `persist-credentials`
is off.
Secrets: `RELEASE_GPG_SUBKEY`, `RELEASE_GPG_PASSPHRASE`, `REGISTRY_TOKEN`. The
built-in `GITEA_TOKEN` cannot publish to the package registry, which is why the
third exists; it can still create the release and upload assets.
**Stated honestly, in `docs/how-to/verify-a-release.md`:** the signature proves
the artifact came from this pipeline and reached the operator unaltered. It does
not prove the binary matches the source, because the machine that built it also
held the key. Ruling 12 defers the control that closes that gap. Do not write a
stronger claim than that.
### 9. Failure and recovery are specified, not improvised
- The draft is the unit of work. Nothing is visible until step 14.
- A re-run deletes an existing **draft** and repeats. It refuses to touch a
**published** release.
- The registry version tag is immutable. A re-run accepts an existing tag whose
digest matches exactly what it just built, and refuses a differing one.
- `:latest` moves last, so a failure between the image push and publication
leaves the version tag pushed and `latest` untouched. That is recoverable by
re-running.
- If a tag is burned — the pipeline itself is broken and the fix is on `master`
the release is abandoned and reissued as the next patch version. This is the
terminal path, and it must be written down before `v0.0.1`, not discovered
during it.
### 10. Release notes
`CHANGELOG.md` at the repository root, Keep a Changelog format, with an
`## [Unreleased]` section maintained as work happens. The release body is that
section, plus a generated appendix: `git log --oneline` since the previous tag
inside a collapsed `<details>`, a compare link, the tarball hashes and the image
digest.
First release: no previous tag exists, `git describe` fails, and the compare
link is omitted. Later releases select the highest reachable **published** plain
release, not merely the nearest git tag — an abandoned tag from ruling 9 must
not become the comparison base.
The `v0.0.1` section is hand-written.
### 11. Documentation
`install-with-systemd.md`, `install-with-docker.md` and `upgrade.md` lead with
download-and-verify; the existing build-from-source steps move to a later
section of the same page. New `docs/how-to/verify-a-release.md` gives the
verification commands and the rebuild recipe.
**Milestone 13 ruling 3 applies unchanged**: every command block in `tutorial/`
and `how-to/` is executed on this host by the session that writes it, or marked
in-page as unverified with the reason.
Two rot hazards the drift test cannot see. `src/docs_drift_test.zig` guards only
`reference/{api,configuration,cli}.md`; the how-to pages are unguarded:
- The transcripts hardcode `nxdns 0.1.0-dev` in four pages. After `v0.0.1` they
are wrong and nothing fails. Use a version-neutral placeholder, and add a
guard that rejects a stale literal release version in the docs.
- A download URL with the version in the path goes stale at `v0.0.2`. Use a
`latest` download form if Gitea provides one, or a placeholder the reader
substitutes. Probe first (ruling 13); do not guess.
Sweep the whole repository for surfaces the rewrite would otherwise miss:
`README.md`, `Dockerfile` comments, `compose.yaml`, `explanation/performance-and-testing.md`,
and any remaining `zig build cross` or source-only-distribution text.
### 12. Deferred, deliberately
Recorded in PLAN so they are decisions rather than oversights:
- **The reproducibility gate** — build twice in two directory paths and assert
identical hashes. Deferred until `v0.0.1` proves the pipeline. Until it exists,
no document may describe the build as reproducible. Do the cheap parts now
regardless: pin Node to an exact patch in `ci.yml` and `web/package.json`
(both float at `24` today), `gzip -n`, `LC_ALL=C`, `TZ=UTC`.
- **cosign signatures on the image** — Gitea Actions has no OIDC identity token,
so keyless signing is impossible and only a key-based signature is available.
Whether Gitea's registry accepts a cosign signature manifest is unverified.
Spike it before committing to it.
### 13. Manual prerequisites, done before the first tag
None of these are code, and all of them block `v0.0.1`:
1. Create the GPG signing subkey, export it with `--export-secret-subkeys`,
publish the public key to `keys.openpgp.org`, and store the armored subkey
and its passphrase as secrets.
2. Create the registry personal access token with package write scope.
3. `app.ini`: add `.asc` and `.sig` to `[attachment] ALLOWED_TYPES`, and raise
`MAX_FILES` from 5. Verified against the live instance: `max_size` is 100 MB
(ample), `max_files` is 5 (this release has exactly five assets, no
headroom), and `.asc` is absent, so the signature is rejected today.
4. **Probe: can an extensionless asset be uploaded?** `ALLOWED_TYPES` is
extension-based, and `SHA256SUMS` and `IMAGE-DIGEST` have none. If the API
rejects them, they become `SHA256SUMS.txt`, `SHA256SUMS.txt.asc` and
`IMAGE-DIGEST.txt`. Resolve by trying it against a scratch repository, not by
assuming.
5. **Probe: does the runner support `docker buildx` with the `docker-container`
driver?** The current `docker` job only proves plain `docker build`.
6. Push milestone 13 to `master`, set Gitea's default branch to `master`, then
delete `origin/main` last.
7. Merge the licence, changelog and `build.zig.zon` version commit, and let it
pass on `master`, before creating the tag.
8. Dry-run the whole release workflow with publication disabled.
## Sessions
S1S5 run in parallel. S1 and S2 share one interface, fixed here so neither
blocks: S2 owns everything under `licenses/` and the licence texts; S1 owns the
build step that assembles them into `THIRD-PARTY-NOTICES`. The contract is
`zig build dist -Dversion-string=X -Dgit-commit=Y -Dweb-dist=web/dist` writing
to `zig-out/dist/`.
### Session S1: build system
Owns `build.zig`. Rulings 2, 4, 5. Deletes `cross`, adds `dist` and
`verify-dist`, moves the CI shell asserts into the build graph.
### Session S2: licence, notices, changelog
Owns `LICENSE`, `licenses/`, `CHANGELOG.md`, `README.md` (notice and links).
Ruling 3, ruling 10. Compiles the inventory by auditing what the artifacts
actually contain, and writes the CI drift guard for the dependency sets.
### Session S3: container
Owns `deploy/docker/*`, `deploy/systemd/*` (the `sysusers.conf` rename).
Ruling 6.
### Session S4: workflows
Owns `.gitea/workflows/*`. Rulings 7, 8, 9. Must not edit `build.zig` — it
consumes the step contract above.
### Session S5: documentation
Owns `docs/**`. Ruling 11, and milestone 13 ruling 3.
### Orchestrator
`PLAN.md` (ruling 1), this spec, deletion of `origin/main`, the manual
prerequisites in ruling 13, and the `v0.0.1` tag.
## Acceptance (milestone complete)
- [ ] `PLAN.md` §2.2 amended; the build-date line and the byte limits corrected.
- [ ] `zig build dist -Dversion-string=0.0.1 -Dweb-dist=web/dist` produces two
tarballs and a checksum file, and fails without `-Dweb-dist`.
- [ ] `zig build verify-dist` passes, and was proven able to fail: an oversized
binary, a dynamically linked binary, a mismatched `build.zig.zon` version
and a wrong archive mode each produce a named failure.
- [ ] Two runs of `zig build dist` on the same commit produce byte-identical
tarballs **in the same directory**. (Cross-directory reproducibility is
ruling 12 and is not claimed here.)
- [ ] The image builds for both platforms with no qemu, carries `/LICENSE` and
`/THIRD-PARTY-NOTICES` and the OCI labels, and its binaries are
byte-identical to the tarball binaries.
- [ ] `gates.yml` runs from both `ci.yml` and `release.yml`; `ci.yml` triggers
on `master`; `origin/main` is gone.
- [ ] `THIRD-PARTY-NOTICES` covers musl, the Zig runtime, SQLite, Mbed TLS with
its Apache-2.0 selection line and full text, Everest, p256-m and the web
runtime closure. The dependency drift guard was proven able to fail.
- [ ] A dry run of `release.yml` completes with publication disabled.
- [ ] `v0.0.1` is published: five assets, a verifying signature, and an image at
`git.mial.net/mokhtar/nxdns:0.0.1` and `:latest`.
- [ ] `docs/how-to/verify-a-release.md` was followed end to end against the
published release, from a clean directory, on this host.
- [ ] No `zig build cross` or source-only-distribution text remains anywhere.
## Anti-requirements
- No GoReleaser, no nfpm, no `.deb` or `.rpm`, no Homebrew, no AUR.
- No cosign, no SBOM, no SLSA provenance, no in-toto attestations.
- No `:edge` image, no rolling dev build, no pre-release tags, no floating
`0.0` or `0` image tags.
- No third architecture, no non-Linux target, no glibc build.
- No project website, no documentation site generator.
- No changelog generation from commit messages, and no Conventional Commits.
- No release automation that cannot be run from a laptop.