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

This commit is contained in:
2026-08-08 20:22:04 +02:00
parent 32cd9b8e3e
commit 266dde7396
9 changed files with 3042 additions and 1202 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
+168 -1152
View File
File diff suppressed because it is too large Load Diff
+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,
+47
View File
@@ -580,6 +580,53 @@ 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` becomes
`RELEASE_GPG_SUBKEY` and 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.
### Not verified, and why
- **No workflow has ever executed.** `release.yml` and `gates.yml` were validated
+2530
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");
});
});