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
+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");
});
});