8 Commits
Author SHA1 Message Date
mokhtar 5c89acf337 milestone 14: verify-a-release walkthrough run against v0.0.1, acceptance closed
Gates / test (push) Successful in 1m19s
Gates / package (push) Successful in 5m10s
CI / gates (push) Successful in 14m28s
Gates / test-aarch64 (push) Successful in 4m55s
Gates / frontend (push) Successful in 42s
Gates / container (push) Successful in 2m20s
2026-08-09 01:35:43 +02:00
mokhtar 3c2d0d41f0 changelog: 0.0.1 releases today
Release / guard (push) Successful in 19s
Gates / test (push) Successful in 1m13s
Gates / frontend (push) Successful in 48s
Gates / test-aarch64 (push) Successful in 4m26s
Gates / container (push) Successful in 2m22s
Gates / package (push) Successful in 3m51s
CI / gates (push) Successful in 12m40s
Gates / test (push) Successful in 1m13s
Gates / test-aarch64 (push) Successful in 4m29s
Gates / frontend (push) Successful in 48s
Gates / package (push) Successful in 37s
Gates / container (push) Successful in 37s
Release / gates (push) Successful in 7m44s
Release / publish (push) Successful in 4m4s
2026-08-09 01:01:55 +02:00
mokhtar 31a6f0c5e5 changelog and spec: dry run complete, restore 0.0.1, record deviations 25 and 26
Gates / test (push) Successful in 1m18s
Gates / test-aarch64 (push) Successful in 5m4s
Gates / frontend (push) Successful in 49s
Gates / package (push) Successful in 5m24s
Gates / container (push) Successful in 2m18s
CI / gates (push) Successful in 14m54s
2026-08-08 21:43:36 +02:00
mokhtar 51d8281abb release: name the image by the public registry host, not the internal server url
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
2026-08-08 21:14:37 +02:00
mokhtar 266dde7396 release: move publication orchestration into tools/release.zig, pin rotated subkey
Gates / package (push) Successful in 8m51s
Release / guard (push) Successful in 1m33s
Gates / test-aarch64 (push) Successful in 4m24s
Gates / frontend (push) Successful in 44s
Gates / package (push) Successful in 33s
Gates / container (push) Successful in 35s
Release / gates (push) Successful in 8m24s
Gates / test-aarch64 (push) Successful in 7m56s
Gates / frontend (push) Successful in 1m31s
Gates / test (push) Failing after 16m8s
Gates / container (push) Successful in 5m11s
CI / gates (push) Failing after 30m3s
Gates / test (push) Successful in 2m7s
Release / publish (push) Failing after 10m20s
2026-08-08 20:22:04 +02:00
mokhtar 32cd9b8e3e release: refetch the annotated tag object that checkout replaces
Gates / test (push) Successful in 1m16s
Gates / test-aarch64 (push) Successful in 4m33s
Gates / frontend (push) Successful in 48s
Gates / package (push) Successful in 3m50s
Gates / container (push) Successful in 2m22s
CI / gates (push) Successful in 12m52s
Release / guard (push) Failing after 7s
Release / gates (push) Skipped
Release / publish (push) Skipped
2026-08-08 16:03:04 +02:00
mokhtar e14a29c5de changelog: temporary 0.0.0 section for the release dry run
Gates / test (push) Successful in 1m23s
Gates / test-aarch64 (push) Successful in 5m6s
Gates / package (push) Successful in 3m46s
CI / gates (push) Successful in 13m23s
Gates / frontend (push) Successful in 44s
Gates / container (push) Successful in 2m22s
Release / guard (push) Failing after 6s
Release / gates (push) Skipped
Release / publish (push) Skipped
2026-08-08 15:35:40 +02:00
mokhtar da4441b24a release: pin the artifact-signing subkey fingerprint
Gates / test (push) Successful in 1m18s
Gates / test-aarch64 (push) Successful in 4m31s
Gates / frontend (push) Successful in 45s
Gates / package (push) Successful in 3m52s
Gates / container (push) Successful in 2m17s
CI / gates (push) Successful in 12m44s
2026-08-08 14:44:07 +02:00
11 changed files with 3184 additions and 1312 deletions
+4 -49
View File
@@ -123,57 +123,12 @@ jobs:
# The licence inventory has to cover every package whose bytes ship, and
# the lockfile does not answer that question: it lists what could be
# reached, not what rollup kept. Four packages of the non-dev closure are
# recorded as tree-shaken away, and if application code starts importing
# one of them, no lockfile, no version and no dependency set changes —
# only the bundle does. So the bundle is what this reads.
#
# A second build with sourcemaps, because the shipped build has none: the
# `sources` list of each chunk names the packages whose modules went into
# it. The output goes to its own directory so the artifact npm run build
# produced is the one that gets embedded, untouched.
# reached, not what rollup kept. The bundle is what this reads. The logic
# lives in web/scripts/, unit-tested by `npm test`, so it runs on a laptop
# exactly as it runs here (milestone-14 deviation 24).
- name: Assert the packages bundled into web/dist are the recorded ones
working-directory: web
run: |
set -euo pipefail
# The binary npm ci installed, never `npx`: npx silently downloads a
# package it cannot find locally, so a wrong working directory would
# turn a licence check into an unpinned fetch from the network.
./node_modules/.bin/vite build --sourcemap --outDir dist-sourcemap --emptyOutDir >/dev/null
maps=$(find dist-sourcemap -name '*.map' -type f | LC_ALL=C sort)
if [ -z "$maps" ]; then
echo "the sourcemap build produced no .map files; this check cannot run blind"
exit 1
fi
# shellcheck disable=SC2086
bundled=$(jq -r '.sources[]' $maps \
| grep 'node_modules/' \
| sed 's|.*node_modules/||' \
| awk -F/ '{ if ($1 ~ /^@/) print $1"/"$2; else print $1 }' \
| LC_ALL=C sort -u)
recorded=$(awk '
/^\[npm packages bundled into web\/dist\]$/ { grab = 1; next }
grab && /^\[/ { exit }
grab && NF { print }
' ../licenses/dependency-identity.txt | LC_ALL=C sort -u)
if [ -z "$recorded" ]; then
echo "licenses/dependency-identity.txt has no '[npm packages bundled into web/dist]' section"
exit 1
fi
if ! diff -u <(printf '%s\n' "$recorded") <(printf '%s\n' "$bundled"); then
echo
echo "the set of npm packages in web/dist has changed (-recorded +current)."
echo "Work out what the change means for licenses/inventory.zon first, then record"
echo "the new list in that section of licenses/dependency-identity.txt."
exit 1
fi
echo "web/dist bundles exactly the recorded packages:"
printf '%s\n' "$bundled"
run: npm run assert-bundled
package:
runs-on: ubuntu-24.04
+173 -1138
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -10,7 +10,7 @@ subject rarely does.
## [Unreleased]
## [0.0.1] - 2026-08-07
## [0.0.1] - 2026-08-09
First release. Everything below is new.
+21
View File
@@ -218,6 +218,27 @@ pub fn build(b: *std.Build) void {
b.step("test-aarch64", "Run the test suite for aarch64-linux-musl (use -fqemu)")
.dependOn(&aarch64_run.step);
// The release publication tool (milestone-14 deviation 24). It is a host
// tool like `dist_stage` and `verify_dist`, and it is installed rather than
// run from the build graph: the workflow invokes it once per phase with the
// secrets in its environment, and a Run step would have to carry them.
const release_tool = hostTool(b, "release");
b.step("release-tool", "Install the release publication tool into zig-out/bin")
.dependOn(&b.addInstallArtifact(release_tool, .{}).step);
// Its pure decisions — semver ordering, VALIDSIG field selection, changelog
// extraction, the releases-payload shape guard — are the reason it exists,
// so they run in the same `zig build test` as everything else.
const release_tests = b.addTest(.{
.name = "release-tool",
.root_module = b.createModule(.{
.root_source_file = b.path("tools/release.zig"),
.target = b.graph.host,
.optimize = optimize,
}),
});
test_step.dependOn(&b.addRunArtifact(release_tests).step);
addDist(b, options, web_assets, .{
.version = version_option,
.version_string = version_string,
+60 -106
View File
@@ -8,41 +8,12 @@ Do this before you run the binary, not after. The whole point of the checksum
file is that it is signed, so a tampered mirror cannot hand you a matching
tarball and a matching checksum at the same time.
> Verification: no nxdns release exists yet. The repository has no tags, no
> release page and no pushed image, so nothing on this page could be run against
> a real release asset and no command here was pointed at
> `git.mial.net/mokhtar/nxdns` with any expectation of success. Substitutes were
> used, and every block says which one applies to it.
>
> The URL shapes were probed against `gitea.com`, a public instance of the same
> Gitea series running `1.27.0+dev-652-g0571722545`, using `gitea/tea`, which
> does have releases. `git.mial.net` reports `1.27.1`, and its
> `/mokhtar/nxdns/releases/latest` answers 404 — no release to redirect to. On
> `gitea/tea`, `releases/latest` answered 303 to the tag page of `v0.15.1`;
> `releases/download/v0.15.1/checksums.txt` and
> `releases/download/latest/checksums.txt` both answered 303 to the same stored
> object and delivered the same 1,842-byte file under `-L`;
> `releases/latest/download/checksums.txt` — GitHub's spelling — answered 404.
>
> The `gpg --verify` and `sha256sum -c` blocks were run on this host against
> stand-in files: two random-byte files named like the release tarballs, an
> `IMAGE-DIGEST.txt` holding one image reference, and a `SHA256SUMS.txt`
> computed over the three, signed by a **throwaway demonstration key generated
> for this page**. That key has the shape the real one will have — an ed25519
> primary key plus a separate ed25519 signing subkey, with the signature made by
> the subkey — so the `gpg --verify` output on this page has the two-fingerprint
> structure a subkey-signed release produces. The fingerprints printed in those
> transcripts are the throwaway key's, they are not the project's, and they will
> not match anything you download. The only edit to that run's output is the
> version in every filename, which became `<version>`.
>
> The container blocks were not run against nxdns — there is no published image.
> The two `docker buildx imagetools inspect --format` shapes were run here
> against `alpine:3.22` on Docker Hub, the base this project's builder stage
> pins; the digest form printed
> `sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce` and
> the platform form printed a list. The `docker create`/`docker cp` comparison
> was run against an image built from this checkout rather than a pulled one.
> Verification: every command on this page was run on 2026-08-09 against the
> published `v0.0.1` release, from a clean directory, with a clean `GNUPGHOME`
> holding only the key fetched from keys.openpgp.org. Every transcript below is
> that run's output. Where a block shows a failure — a `BAD signature`, a
> `FAILED` hash — the failure was produced deliberately by tampering with a
> copy of the real file, and the surrounding text says how.
## What a release contains
@@ -94,11 +65,8 @@ want for the placeholder:
VERSION=<version>
```
> Not verified against nxdns: there is no release to redirect to, so the first
> block prints an empty line here and every URL built from it is a 404. The
> exact two-command form was run against `gitea.com/gitea/tea`, a public
> repository on Gitea `1.27.0+dev` that does have releases, and printed
> `0.15.1`.
> Verified: the two-command form, run against this repository, printed `0.0.1`
> with `v0.0.1` published.
Pin the version in anything you script or automate. `latest` is convenient for
a person at a terminal and a liability in a machine that upgrades itself.
@@ -136,11 +104,9 @@ That is the Gitea spelling, and it is not GitHub's. `releases/latest/download/`
position, as `releases/download/latest/`. The tarball filenames contain the
version, so this alias never saves you from knowing it for those two.
> Not verified against nxdns: no release, so every URL above is a 404 today.
> Both URL forms, including the 404 for GitHub's spelling, were exercised
> against `gitea.com/gitea/tea` on Gitea `1.27.0+dev`; the versioned path and
> the `latest` alias each answered 303 to the same stored object and delivered
> the same 1,842-byte `checksums.txt` when the redirect was followed.
> Verified against `v0.0.1`: all five assets downloaded through the versioned
> path, `SHA256SUMS.txt` downloaded again through the `latest` alias and hashed
> identical, and GitHub's spelling answered 404.
## 3. Check the signature over `SHA256SUMS.txt`
@@ -157,11 +123,9 @@ curl -fsSL https://keys.openpgp.org/vks/v1/by-fingerprint/A2061F6AB24DF2C0E92346
gpg --import
```
> Not verified: the key is not published yet. Run on this host, that URL
> returned 404, and so did the `by-email` lookup for the same address. The
> endpoint itself is live: the same `by-fingerprint` path returned 200 for an
> unrelated key that is on keys.openpgp.org. Until this key is published there,
> get it from a source you can check some other way.
> Verified: the key is published, and that exact `curl | gpg --import` reported
> `key 1509B54946D08A95: public key "Mokhtar Mial (pc) <mokhtar@mial.net>"
> imported` into a clean `GNUPGHOME`.
Then verify:
@@ -170,22 +134,21 @@ gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt
```
```
gpg: Signature made Fri 07 Aug 2026 10:11:12 PM CEST
gpg: using EDDSA key 9D1EA241DAEA89E09381A21BDC27E8A3D53C32D6
gpg: Good signature from "nxdns release signing (throwaway demonstration key) <demo@example.invalid>" [unknown]
gpg: Signature made Sun 09 Aug 2026 01:26:42 AM CEST
gpg: using EDDSA key 019D00DF8417EBFDA5471E5EF7319CC024FB5A96
gpg: Good signature from "Mokhtar Mial (pc) <mokhtar@mial.net>" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!
gpg: There is no indication that the signature belongs to the owner.
Primary key fingerprint: 6643 13AA F527 DDAE 1C1E 516C A36F F8DA 4E6C 1C07
Subkey fingerprint: 9D1E A241 DAEA 89E0 9381 A21B DC27 E8A3 D53C 32D6
Primary key fingerprint: A206 1F6A B24D F2C0 E923 46FD 1509 B549 46D0 8A95
Subkey fingerprint: 019D 00DF 8417 EBFD A547 1E5E F731 9CC0 24FB 5A96
```
**Those two fingerprints and that user id belong to a throwaway key generated
to produce this transcript.** They are not the project's, and what you see will
carry the project's uid and the fingerprint in this page instead. The
*structure* is what to read: three lines, not one. `using EDDSA key` and
The *structure* is what to read: three lines, not one. `using EDDSA key` and
`Subkey fingerprint` name the signing subkey that actually made the signature;
`Primary key fingerprint` names the certificate it hangs off, and that is the
one published above.
one published above. The subkey fingerprint can change — a signing subkey is
revoked and replaced on its own — but the primary fingerprint is the
project's identity and stays.
Exit status 0, and `Good signature`. That warning is normal and is not a
failure: it says you have not told GnuPG you believe the key belongs to the
@@ -210,22 +173,18 @@ including one an attacker talked you into importing.
A tampered `SHA256SUMS.txt` looks like this, and exits 1:
```
gpg: Signature made Fri 07 Aug 2026 10:11:12 PM CEST
gpg: using EDDSA key 9D1EA241DAEA89E09381A21BDC27E8A3D53C32D6
gpg: BAD signature from "nxdns release signing (throwaway demonstration key) <demo@example.invalid>" [unknown]
gpg: Signature made Sun 09 Aug 2026 01:26:42 AM CEST
gpg: using EDDSA key 019D00DF8417EBFDA5471E5EF7319CC024FB5A96
gpg: BAD signature from "Mokhtar Mial (pc) <mokhtar@mial.net>" [unknown]
```
> Verified on this host. A throwaway ed25519 primary key was generated into a
> temporary `GNUPGHOME`, an ed25519 **signing subkey** was added to it, and the
> stand-in `SHA256SUMS.txt` was signed with `--local-user <subkey-fingerprint>!`
> the same construction the release workflow uses — so the transcripts above
> are what a subkey-signed release actually prints, rather than what a key
> signing with its primary would. The verification ran from a second
> `GNUPGHOME` holding only that key's public half, which is why the `[unknown]`
> trust marker and the warning are there rather than being written in by hand.
> The second transcript is the same command after one newline was appended to
> `SHA256SUMS.txt`. The `sed`/`tr` pipeline was run against that same output and
> printed `664313AAF527DDAE1C1E516CA36FF8DA4E6C1C07`, the throwaway primary.
> Verified against `v0.0.1`, from a clean `GNUPGHOME` holding only the imported
> public key — which is why the `[unknown]` trust marker and the warning are
> there rather than being written in by hand. The good-signature transcript is
> the real release's; the `BAD signature` transcript is the same command
> against a copy of `SHA256SUMS.txt` with one newline appended, and it exited
> 1. The `sed`/`tr` pipeline printed
> `A2061F6AB24DF2C0E92346FD1509B54946D08A95`, matching the fingerprint above.
## 4. Check the hashes
@@ -263,12 +222,11 @@ Check the signature before the hashes, not after. An attacker who can replace
the tarball can replace `SHA256SUMS.txt` next to it; the signature is the only
thing in the set they cannot forge.
> Verified on this host against the stand-in files: all three transcripts are
> real `sha256sum` output over two random-byte files named like the release
> tarballs plus an `IMAGE-DIGEST.txt` holding one image reference, with one
> tarball deleted for the first two blocks and one byte appended to the other
> for the third. Only the version in the filenames was replaced with
> `<version>`.
> Verified against `v0.0.1`: with both tarballs present, `sha256sum -c` printed
> three `OK` lines. The three transcripts above are the same command over
> copies of the real assets, with the aarch64 tarball absent for the first two
> and one byte appended to the x86_64 tarball for the third. Only the version
> in the filenames was replaced with `<version>`.
## 5. Look inside before extracting
@@ -294,7 +252,11 @@ tar -xzf nxdns-$VERSION-x86_64-linux-musl.tar.gz
Zig version. The version has to match the tag you downloaded, and the commit
has to match the commit the tag points at.
> Not verified on this host: there is no release tarball to list or extract.
> Verified against `v0.0.1`: both tarballs listed exactly the one directory and
> six files with the stated modes, no symlinks and no absolute or `..` paths,
> and the extracted binary printed `nxdns 0.0.1
> (3c2d0d41f04570038e805b759da4541e198eae17)` — the commit `v0.0.1` points at —
> then `zig 0.16.0`.
## 6. Verify the container image
@@ -343,23 +305,13 @@ docker rm nxdns-verify
sha256sum ./nxdns-from-image ./nxdns-$VERSION-x86_64-linux-musl/nxdns
```
> Not verified against nxdns: no image is published, so no command here was run
> against `git.mial.net/mokhtar/nxdns`. The two
> `docker buildx imagetools inspect --format` shapes were run on this host
> against `alpine:3.22` on Docker Hub — the digest form printed
> `sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce`,
> which is the digest this project's builder stage pins, and the platform form
> printed `linux/amd64 unknown/unknown linux/arm unknown/unknown ...`. That
> `unknown/unknown` is exactly what the paragraph above says nxdns's own index
> must not contain: Alpine's index carries attestation entries, and nxdns's
> build turns them off. Nothing was checked about how nxdns's index will
> actually look.
>
> The `docker create` / `docker cp` / `sha256sum` comparison at the end was run
> here against an image built from this checkout rather than a pulled one, and
> the two hashes matched: the binary copied out of the image and
> `zig-out/dist/stage/nxdns-<version>-x86_64-linux-musl/nxdns` were the same
> file.
> Verified against `v0.0.1`: the digest in `IMAGE-DIGEST.txt` and the digest
> the `:0.0.1` tag resolves to were the same string
> (`sha256:f2945fbf6c1e16509f0e33e3d62da9a9cd7dc706718d333ce4edf95c80dbb00e`,
> and `:latest` resolved to it too), the platform form printed exactly
> `linux/amd64 linux/arm64` with no attestation entries, `docker pull` of the
> pinned reference succeeded, and the binary copied out of that pulled image
> hashed identical to the `nxdns` in the x86_64 tarball.
## What the signature proves, and what it does not
@@ -431,14 +383,16 @@ release used. The Zig version is the second line of `nxdns version`, and both
it and the Node version are pinned to exact patch releases at the top of
`.gitea/workflows/gates.yml`, which is the workflow the release runs.
> Partly verified on this host. `zig build dist` and `sha256sum` on its output
> were run to completion, with the version read out of `build.zig.zon`: `dist`
> exited 0 and wrote the two tarballs, `SHA256SUMS` and the staged payloads
> described above. `zig build verify-dist` was run on the result too and exited
> 0. What could not be run is everything that needs a release: the clone, the
> checkout and `git verify-tag` need a tag that does not exist, and there is no
> published `SHA256SUMS.txt` to compare a local build against, so the comparison
> this section is about has never been performed.
> Verified against `v0.0.1`, and the result is the caveat above in action. The
> whole recipe ran from a fresh clone: `git verify-tag v0.0.1` printed
> `Good signature` under the same signing subkey as the release, and
> `zig build dist` produced both tarballs. The hashes did **not** match the
> published `SHA256SUMS.txt` — the binaries themselves already differ. The Zig
> version matched the pin exactly; the Node version did not (24.14.1 against
> the pinned 24.19.0) and the build path differed, two of the ordinary causes
> listed above. That is a measurement of what an unpinned rebuild gives you,
> not evidence of tampering: the signature, checksum and image checks earlier
> on this page all passed against the same release.
## If a check fails
+110 -15
View File
@@ -580,12 +580,87 @@ was reproduced before it was fixed.
would have turned a licence check into an unpinned fetch. Reproduced: it
fetched `vite@8.2.0` over the pinned `8.1.5`.
23. **`actions/checkout` destroys the annotated tag object.** Found by the first
live dry run, not by review: on a tag ref, checkout fetches the *commit* SHA
into `refs/tags/<tag>`, so the signed tag reads as lightweight and the guard
refuses it as unannotated. Both jobs that read the tag object — signature
verification in the guard, the tagger date in the publish job — now force-
refetch `refs/tags/$TAG` from origin first. The same run also proved the
fail-closed secret guard for real: the first dry-run attempt ran with no
secrets configured (they were on the wrong repository) and stopped in the
guard with nothing built or pushed.
24. **Publication orchestration moved out of workflow shell into
`tools/release.zig`.** Ruling 5 already moved the packaging asserts out of
CI shell for one reason — "checks that only exist inside a workflow file are
the brittleness this exists to remove" — and the release job was the larger
half of the same problem, left in place. Three live failures came out of it,
and each was found by executing the workflow, which is the most expensive
place to find anything: `actions/checkout` replacing the annotated tag object
(deviation 23), the refetch that fixed it having no credentials because
`persist-credentials` is off, and the multiline armored subkey escaping the
runner's log masker, which masks per line.
Twelve subcommands, one per step group: `guard-tag`, `guard-ancestry`,
`guard-releases`, `resolve`, `changelog`, `image`,
`verify-image-binaries`, `sign`, `draft`, `latest`, `publish`, `scrub`. Every
behaviour recorded in deviations 10 to 15 and 23 is carried over unchanged —
probe-adopt, the VALIDSIG last field, the subkey-only import and signing
probe, the array-shape guard on the releases payload, the `:latest` label
read, the publish re-read, the per-home `gpgconf --kill`, the tag refetch.
What is new is that the semver ordering, VALIDSIG field selection, challenge
parsing, changelog extraction, checksum-line parsing, colon-format parsing
and payload-shape guard are 25 unit tests in `zig build test` rather than
shell that only ever runs on a tag push. `release.yml` keeps the triggers,
the concurrency group, the job graph, the SHA pins, the two pinned
fingerprints and the fail-closed secret presence check — which stays as
shell, deliberately, so that it runs before the tool is even compiled.
The same reasoning applies to the `jq` pipeline of the bundled-package gate,
which moved to `web/scripts/bundledPackages.mjs` with its own vitest
coverage and an `npm run assert-bundled` entry point.
**Secret contract change:** `RELEASE_GPG_SUBKEY` keeps its name but now
holds `base64 -w0` of the armored
`--export-secret-subkeys` output rather than the armored text. Manual
prerequisite 1 and 3 change accordingly. The tool decodes it in memory and
writes it to a mode-600 file inside the temporary `GNUPGHOME`. A single-line
secret is one the masker can actually mask.
25. **The first signing subkey was leaked into a job log and rotated.** Dry-run
attempt 3 failed inside the credential-less refetch, and the runner printed
the failing step's env block; the multiline armored `RELEASE_GPG_SUBKEY`
escaped the per-line masker while the single-line passphrase was masked.
Exposure: the passphrase-protected secret subkey only — the passphrase and
the primary key were never on the runner. Response: both runs that ever saw
the secret were deleted (verified 404 via the API and absent from
`actions_log` on disk), subkey `B281CECC…` was revoked with the primary,
and its replacement `019D00DF…` is the pinned `RELEASE_SIGNING_FPR`. The
base64 contract in deviation 24 is the preventive half of this record.
26. **The image is named by the public registry host, never the server URL.**
Attempt 4 reached the registry and failed at `docker login gitea:3000`:
inside the cluster `GITHUB_SERVER_URL` is `http://gitea:3000`, docker
refuses plain-http registries, and an image named `gitea:3000/…` would be
unpullable from anywhere that matters — a wrong name that would have been
written into the released `IMAGE-DIGEST.txt`. `release.yml` now pins
`REGISTRY_HOST: git.mial.net`; the tool uses it for docker and image
naming, and keeps the internal URL for the manifest probe (same registry,
no TLS dependency in the tool). The old shell had the identical latent bug;
no run ever reached it.
### Not verified, and why
- **No workflow has ever executed.** `release.yml` and `gates.yml` were validated
by YAML parse and `bash -n`, plus two steps lifted out and run directly: the
registry probe against a fake registry (five response shapes) and the whole
bundled-package check against the real `web/` build, proven able to fail.
- **The workflows' validation history.** Before any live run, `release.yml` and
`gates.yml` were validated by YAML parse and `bash -n`, plus two steps lifted
out and run directly: the registry probe against a fake registry (five
response shapes) and the bundled-package check against the real `web/` build,
proven able to fail. The live dry run then superseded this: attempt 5
published `v0.0.0` end to end — guard, gates, image push to both platforms,
binary-identity assertion, signing, draft, `:latest`, publication — and the
assets verified from a clean directory (checksums OK, signature good under
the rotated subkey). The throwaway release, tag and registry versions were
deleted afterwards.
Everything else that talks to the registry or the Gitea API — `buildx build
--push`, `imagetools`, draft creation, asset upload, publication, the
adopt-an-existing-tag path — is unexercised.
@@ -620,24 +695,44 @@ was reproduced before it was fixed.
- [x] 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
- [x] 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. Verified for the native amd64
image only; the arm64 half needs a runner with buildx.
- [ ] `gates.yml` runs from both `ci.yml` and `release.yml`; `ci.yml` triggers
on `master`; `origin/main` is gone. The first two are in the files; no
workflow has run and `origin/main` still exists (manual prerequisite).
byte-identical to the tarball binaries. The v0.0.1 run built and pushed
both platforms on the runner; the published index lists exactly
`linux/amd64 linux/arm64`, and `release verify-image-binaries` compared
both binaries against the tarballs before publication.
- [x] `gates.yml` runs from both `ci.yml` and `release.yml`; `ci.yml` triggers
on `master`; `origin/main` is gone. Proven live: pushes to `master` run
the gates through `ci.yml`, and release runs 484-493 ran them through
`release.yml`.
- [x] `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:
removing an inventory entry, staling a dependency version, staling the Zig
version, changing the base image digest, editing a pinned licence text and
dropping a package from the recorded bundle each produce a named failure.
- [ ] 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.
- [x] A dry run of `release.yml` completes with publication disabled. Done with
a disposable published tag instead: publication cannot be disabled without
forking the flow it is supposed to prove, so `v0.0.0` ran the real path
end to end — five assets, verifying checksums and signature, a
multi-architecture image — and was then deleted (release, git tag, both
registry versions). Five attempts; the failures and their fixes are
deviations 23-26.
- [x] `v0.0.1` is published: five assets, a verifying signature, and an image at
`git.mial.net/mokhtar/nxdns:0.0.1` and `:latest`. Run 493, all jobs green
on the first attempt after the dry-run fixes.
- [x] `docs/how-to/verify-a-release.md` was followed end to end against the
published release, from a clean directory, on this host, with a clean
`GNUPGHOME` holding only the key fetched from keys.openpgp.org. Every
command on the page passed: the `releases/latest` redirect printed
`0.0.1`, both tarball downloads and the `latest` alias worked (and
GitHub's spelling answered 404 as documented), the signature verified
with matching primary and subkey fingerprints, `sha256sum -c` said OK for
all three files, the tarball layout and modes matched, `nxdns version`
printed the tag's commit, the tag digest equalled `IMAGE-DIGEST.txt`, the
platform list was exactly `linux/amd64 linux/arm64`, and the binary
copied out of the pulled-by-digest image hashed identical to the tarball
binary.
- [x] No `zig build cross` or source-only-distribution text remains on any
**active** surface: `build.zig`, the workflows, `deploy/`, `README.md` and
`docs/`. Historical milestone specs and `TECH_DEBT.md` keep their text —
+2541
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -13,7 +13,8 @@
"lint": "oxlint src vite.config.ts",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run"
"test": "vitest run",
"assert-bundled": "node scripts/assert-bundled-packages.mjs"
},
"prettier": {
"useTabs": true,
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env node
// The set of npm packages whose bytes reach web/dist must be exactly the set
// recorded in licenses/dependency-identity.txt (milestone-14 ruling 3).
//
// The shipped build carries no sourcemaps, so this makes a second build with
// them into its own directory: the `sources` list of each chunk names the
// modules that went into it, and the artifact `npm run build` produced stays
// untouched. Runs from web/ as `npm run assert-bundled`, on a laptop exactly as
// on the runner.
import { execFileSync } from "node:child_process";
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { bundledPackages, comparePackages, formatDiff, recordedPackages } from "./bundledPackages.mjs";
const webRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const outDir = "dist-sourcemap";
const identityFile = join(webRoot, "..", "licenses", "dependency-identity.txt");
function fail(message) {
process.stderr.write(`${message}\n`);
process.exit(1);
}
function mapFiles(relativeDir) {
const absolute = join(webRoot, relativeDir);
let entries;
try {
entries = readdirSync(absolute, { withFileTypes: true });
} catch (err) {
fail(`assert-bundled: cannot read ${relativeDir}: ${err.message}`);
}
const found = [];
for (const entry of entries) {
const child = `${relativeDir}/${entry.name}`;
if (entry.isDirectory()) {
found.push(...mapFiles(child));
} else if (entry.isFile() && entry.name.endsWith(".map")) {
found.push(child);
}
}
return found.sort();
}
// The binary npm ci installed, never `npx`: npx silently downloads a package it
// cannot find locally, so a wrong working directory would turn a licence check
// into an unpinned fetch from the network.
try {
execFileSync(
join(webRoot, "node_modules", ".bin", "vite"),
["build", "--sourcemap", "--outDir", outDir, "--emptyOutDir"],
{
cwd: webRoot,
stdio: ["ignore", "ignore", "inherit"],
},
);
} catch (err) {
fail(`assert-bundled: the sourcemap build failed: ${err.message}`);
}
const maps = mapFiles(outDir);
if (maps.length === 0) fail("assert-bundled: the sourcemap build produced no .map files; this check cannot run blind");
const sourceLists = maps.map((path) => {
const raw = readFileSync(join(webRoot, path), "utf8");
let parsed;
try {
parsed = JSON.parse(raw);
} catch (err) {
fail(`assert-bundled: ${path} is not JSON: ${err.message}`);
}
return Array.isArray(parsed.sources) ? parsed.sources : [];
});
const bundled = bundledPackages(sourceLists);
let identity;
try {
identity = readFileSync(identityFile, "utf8");
} catch (err) {
fail(`assert-bundled: cannot read licenses/dependency-identity.txt: ${err.message}`);
}
const recorded = recordedPackages(identity);
if (recorded === null) {
fail("assert-bundled: licenses/dependency-identity.txt has no '[npm packages bundled into web/dist]' section");
}
if (recorded.length === 0) {
fail("assert-bundled: the '[npm packages bundled into web/dist]' section is empty");
}
const { added, removed } = comparePackages(recorded, bundled);
if (added.length !== 0 || removed.length !== 0) {
process.stderr.write(`${formatDiff(recorded, bundled)}\n\n`);
fail(
[
"the set of npm packages in web/dist has changed (-recorded +current).",
"Work out what the change means for licenses/inventory.zon first, then record",
"the new list in that section of licenses/dependency-identity.txt.",
].join("\n"),
);
}
process.stdout.write(`web/dist bundles exactly the ${bundled.length} recorded packages:\n`);
for (const name of bundled) process.stdout.write(`${name}\n`);
+77
View File
@@ -0,0 +1,77 @@
// The decisions behind `npm run assert-bundled`, kept separate from the script
// that does the I/O so they can be unit-tested (milestone-14 deviation 24).
//
// The licence inventory has to cover every package whose bytes ship, and the
// lockfile does not answer that question: it lists what could be reached, not
// what rollup kept. Several packages of the non-dev closure are recorded as
// tree-shaken away, and if application code starts importing one of them, no
// lockfile, no version and no dependency set changes — only the bundle does. So
// the bundle is what this reads.
const sectionHeading = "[npm packages bundled into web/dist]";
// A sourcemap `sources` entry for a dependency ends in
// `node_modules/<name>/<file>` or `node_modules/@<scope>/<name>/<file>`. Only
// the last `node_modules/` matters: a nested dependency's path carries two.
export function packageFromSource(source) {
const marker = "node_modules/";
const at = source.lastIndexOf(marker);
if (at === -1) return null;
const rest = source.slice(at + marker.length);
const parts = rest.split("/");
if (parts.length === 0 || parts[0] === "") return null;
if (parts[0].startsWith("@")) {
if (parts.length < 2 || parts[1] === "") return null;
return `${parts[0]}/${parts[1]}`;
}
return parts[0];
}
/// The sorted, deduplicated package set of a list of sourcemap `sources` arrays.
export function bundledPackages(sourceLists) {
const found = new Set();
for (const sources of sourceLists) {
for (const source of sources) {
const name = packageFromSource(source);
if (name !== null) found.add(name);
}
}
return [...found].sort();
}
/// The recorded section of `licenses/dependency-identity.txt`: every non-blank
/// line after the heading, up to the next `[section]`.
export function recordedPackages(text) {
const recorded = new Set();
let grabbing = false;
for (const raw of text.split("\n")) {
const line = raw.trim();
if (!grabbing) {
if (line === sectionHeading) grabbing = true;
continue;
}
if (line.startsWith("[")) break;
if (line !== "") recorded.add(line);
}
return grabbing ? [...recorded].sort() : null;
}
/// What changed, in the two directions that mean different things: a package
/// that started shipping needs a licence decision, and one that stopped needs
/// the record corrected.
export function comparePackages(recorded, bundled) {
const inBundle = new Set(bundled);
const inRecord = new Set(recorded);
return {
added: bundled.filter((name) => !inRecord.has(name)),
removed: recorded.filter((name) => !inBundle.has(name)),
};
}
export function formatDiff(recorded, bundled) {
const { added, removed } = comparePackages(recorded, bundled);
const lines = [];
for (const name of removed) lines.push(`-${name}`);
for (const name of added) lines.push(`+${name}`);
return lines.join("\n");
}
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import {
bundledPackages,
comparePackages,
formatDiff,
packageFromSource,
recordedPackages,
} from "./bundledPackages.mjs";
describe("packageFromSource", () => {
it("reads a plain package name", () => {
expect(packageFromSource("../../node_modules/react-dom/client.js")).toBe("react-dom");
});
it("keeps the scope of a scoped package", () => {
expect(packageFromSource("../../node_modules/@tanstack/react-query/build/index.js")).toBe(
"@tanstack/react-query",
);
});
it("takes the last node_modules, so a nested dependency is named correctly", () => {
expect(packageFromSource("node_modules/vite/node_modules/@scope/inner/x.js")).toBe("@scope/inner");
});
it("ignores application sources", () => {
expect(packageFromSource("src/lib/api.ts")).toBeNull();
expect(packageFromSource("../src/main.tsx")).toBeNull();
});
});
describe("bundledPackages", () => {
it("sorts and deduplicates across every map", () => {
const packages = bundledPackages([
["node_modules/react/index.js", "src/main.tsx", "node_modules/react/jsx-runtime.js"],
["node_modules/@tanstack/react-router/x.js", "node_modules/react/index.js"],
]);
expect(packages).toEqual(["@tanstack/react-router", "react"]);
});
it("returns an empty set when nothing came from node_modules", () => {
expect(bundledPackages([["src/main.tsx"]])).toEqual([]);
});
});
describe("recordedPackages", () => {
const identity = [
"[some earlier section]",
"ignored",
"",
"[npm packages bundled into web/dist]",
"react",
"@tanstack/react-query",
"",
"react-dom",
"",
"[a later section]",
"not-a-package",
].join("\n");
it("reads only its own section, sorted and deduplicated", () => {
expect(recordedPackages(identity)).toEqual(["@tanstack/react-query", "react", "react-dom"]);
});
it("distinguishes a missing section from an empty one", () => {
expect(recordedPackages("[other]\nx\n")).toBeNull();
expect(recordedPackages("[npm packages bundled into web/dist]\n\n[next]\n")).toEqual([]);
});
});
describe("comparePackages", () => {
it("reports both directions", () => {
const { added, removed } = comparePackages(["a", "b"], ["b", "c"]);
expect(added).toEqual(["c"]);
expect(removed).toEqual(["a"]);
});
it("reports nothing when the sets match", () => {
expect(comparePackages(["a", "b"], ["a", "b"])).toEqual({ added: [], removed: [] });
expect(formatDiff(["a"], ["a"])).toBe("");
});
it("formats a diff the way the failure prints it", () => {
expect(formatDiff(["a", "b"], ["b", "c"])).toBe("-a\n+c");
});
});