7 Commits
Author SHA1 Message Date
mokhtar 31d8baad15 changelog: 0.0.1 releases today
Release / guard (push) Successful in 1m30s
Release / gates (push) Successful in 14m44s
Gates / test (push) Successful in 1m19s
Gates / test-aarch64 (push) Successful in 4m59s
Gates / frontend (push) Successful in 48s
Gates / package (push) Successful in 5m13s
Gates / container (push) Successful in 2m23s
Release / publish (push) Successful in 53s
2026-08-09 01:01:55 +02:00
mokhtar 3c38e18027 changelog and spec: dry run complete, restore 0.0.1, record deviations 25 and 26 2026-08-08 21:43:36 +02:00
mokhtar 1a82b0491a release: name the image by the public registry host, not the internal server url 2026-08-08 21:14:37 +02:00
mokhtar d7f63b9f6e release: move publication orchestration into tools/release.zig, pin rotated subkey 2026-08-08 20:22:04 +02:00
mokhtar fbd70e6a70 release: refetch the annotated tag object that checkout replaces 2026-08-08 16:03:04 +02:00
mokhtar 95939fedf0 changelog: temporary 0.0.0 section for the release dry run 2026-08-08 15:35:40 +02:00
mokhtar 75038b61a6 release: pin the artifact-signing subkey fingerprint 2026-08-08 14:44:07 +02:00
10 changed files with 3100 additions and 1196 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
+175 -1140
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,
+86 -5
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.
@@ -633,7 +708,13 @@ was reproduced before it was fixed.
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.
- [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.
- [ ] `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
+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");
});
});