@rightkit/release 0.6.27 → 0.6.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build-helper-trace.mjs +39 -42
- package/build-helpers-command.mjs +85 -38
- package/cli/right-release.mjs +6 -0
- package/deterministic-scans.mjs +5 -2
- package/package.json +1 -1
- package/pipeline-receipts.mjs +302 -302
- package/publication-gate.mjs +1 -1
- package/publish-local-npm.mjs +104 -0
- package/rightkit-versions.json +1 -1
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +0 -6
package/build-helper-trace.mjs
CHANGED
|
@@ -75,12 +75,12 @@ export function readHostList(file, { readFile = (f) => fs.readFileSync(f, "utf8"
|
|
|
75
75
|
|
|
76
76
|
/** Record one trace verdict in the list (atomic write). */
|
|
77
77
|
/** Tracers whose verdicts count: each follows the build's whole process tree. fs_usage (name-filtered) verdicts recorded before do not count. */
|
|
78
|
-
export const TRUSTED_TRACERS = Object.freeze(["eslogger", "
|
|
79
|
-
export const TRACER_OF = Object.freeze({ darwin: "eslogger",
|
|
78
|
+
export const TRUSTED_TRACERS = Object.freeze(["eslogger", "etw"]);
|
|
79
|
+
export const TRACER_OF = Object.freeze({ darwin: "eslogger", win32: "etw" });
|
|
80
80
|
|
|
81
81
|
export function recordTrace(file, unit, verdict, { now = () => new Date().toISOString(), fsApi = fs, tracer = TRACER_OF[process.platform] } = {}) {
|
|
82
82
|
const list = readHostList(file, { readFile: (f) => fsApi.readFileSync(f, "utf8"), exists: (f) => fsApi.existsSync(f) });
|
|
83
|
-
list.units[unit.key] = { crate: unit.crate, version: unit.version, checksum: unit.checksum, features: unit.features, target: unit.target, toolchain: unit.toolchain, role: unit.role, verdict: verdict.ok ? "pass" : "fail", outside: verdict.outside ?? [], tracer, tracedAt: now() };
|
|
83
|
+
list.units[unit.key] = { crate: unit.crate, version: unit.version, checksum: unit.checksum, features: unit.features, target: unit.target, toolchain: unit.toolchain, role: unit.role, verdict: verdict.ok ? "pass" : verdict.unbuildable ? "unbuildable" : "fail", outside: verdict.outside ?? [], tracer, tracedAt: now() };
|
|
84
84
|
fsApi.mkdirSync(path.dirname(file), { recursive: true });
|
|
85
85
|
const tmp = `${file}.tmp-${process.pid}`;
|
|
86
86
|
fsApi.writeFileSync(tmp, `${JSON.stringify(list, null, 2)}\n`);
|
|
@@ -105,6 +105,8 @@ export function checkBuildHelpers(units, list) {
|
|
|
105
105
|
for (const u of units) {
|
|
106
106
|
const entry = byKey.get(u.key);
|
|
107
107
|
if (!entry || !u.checksum || !TRUSTED_TRACERS.includes(entry.tracer)) untraced.push(u);
|
|
108
|
+
// Unbuildable on this host for its target: the gate builds nothing that runs it here, so it is neither refused nor traced again.
|
|
109
|
+
else if (entry.verdict === "unbuildable") continue;
|
|
108
110
|
else if (entry.verdict !== "pass") failed.push({ unit: u, outside: entry.outside ?? [] });
|
|
109
111
|
}
|
|
110
112
|
return { ok: untraced.length === 0 && failed.length === 0, untraced, failed };
|
|
@@ -157,27 +159,6 @@ export function parseFsUsageWrites(text) {
|
|
|
157
159
|
return [...out];
|
|
158
160
|
}
|
|
159
161
|
|
|
160
|
-
/**
|
|
161
|
-
* The written paths of Linux `strace -f -e trace=<file calls>` output: successful calls that create, write, rename, link or remove a file.
|
|
162
|
-
* An open counts when its flags include O_WRONLY, O_RDWR, O_CREAT, O_TRUNC or O_APPEND; every other listed call mutates by definition.
|
|
163
|
-
* A failed call (`= -1`) wrote nothing. Every quoted path of a mutating call is taken (rename names both); a relative path resolves
|
|
164
|
-
* against `cwd` (the traced cargo's directory; a build script that changes directory is resolved approximately, a recorded limit).
|
|
165
|
-
*/
|
|
166
|
-
export function parseStraceWrites(text, { cwd = "/" } = {}) {
|
|
167
|
-
const OPEN = /\b(open|openat|openat2)\((?:[^"]*?)"((?:[^"\\]|\\.)*)"[^)]*?\b(O_[A-Z_|]+)/;
|
|
168
|
-
const MUTATING = /\b(creat|rename|renameat|renameat2|unlink|unlinkat|mkdir|mkdirat|rmdir|link|linkat|symlink|symlinkat|truncate|chmod|fchmodat|chown|fchownat|lchown|utimensat|utimes)\(/;
|
|
169
|
-
const out = new Set();
|
|
170
|
-
for (const line of String(text).split(/\r?\n/)) {
|
|
171
|
-
if (/=\s*-1\s/.test(line) || /<unfinished \.\.\.>/.test(line)) continue;
|
|
172
|
-
let paths = [];
|
|
173
|
-
const open = OPEN.exec(line);
|
|
174
|
-
if (open) { if (/O_WRONLY|O_RDWR|O_CREAT|O_TRUNC|O_APPEND/.test(open[3])) paths = [open[2]]; }
|
|
175
|
-
else if (MUTATING.test(line)) paths = [...line.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]);
|
|
176
|
-
for (const p of paths) if (p) out.add(path.posix.isAbsolute(p) ? p : path.posix.resolve(cwd, p));
|
|
177
|
-
}
|
|
178
|
-
return [...out];
|
|
179
|
-
}
|
|
180
|
-
|
|
181
162
|
/** The repository's committed list (`.rightkit/build-helper-trace.json`, same schema): reviewed passes every host and CI reads. */
|
|
182
163
|
export const repoListPath = (workspaceRoot) => path.join(workspaceRoot, ".rightkit", "build-helper-trace.json");
|
|
183
164
|
|
|
@@ -210,7 +191,7 @@ export function parseEtwWrites(xml, { rootPid, devices = {} }) {
|
|
|
210
191
|
if (tree.has(parent)) tree.add(child);
|
|
211
192
|
}
|
|
212
193
|
}
|
|
213
|
-
const toDos = (p) => { if (!p) return null; for (const [dev, letter] of Object.entries(devices)) if (p.toLowerCase().startsWith(dev.toLowerCase())) return letter + p.slice(dev.length); return p; };
|
|
194
|
+
const toDos = (p) => { if (!p) return null; for (const [dev, letter] of Object.entries(devices)) if (p.toLowerCase().startsWith(`${dev.toLowerCase()}\\`)) return letter + p.slice(dev.length); return p; };
|
|
214
195
|
const names = new Map(); const out = new Set();
|
|
215
196
|
for (const ev of events) {
|
|
216
197
|
if (provider(ev) !== "Microsoft-Windows-Kernel-File" || !tree.has(pidOf(ev))) continue;
|
|
@@ -266,13 +247,6 @@ export function parseEsloggerWrites(text, { rootPid }) {
|
|
|
266
247
|
return [...out];
|
|
267
248
|
}
|
|
268
249
|
|
|
269
|
-
/** The strace -ff output files (`<prefix>.<pid>`, one per process: no call is ever split across lines). */
|
|
270
|
-
export function readStraceFiles(prefix, { fsApi = fs } = {}) {
|
|
271
|
-
const dir = path.dirname(prefix); const base = path.basename(prefix);
|
|
272
|
-
return fsApi.readdirSync(dir).filter((n) => n.startsWith(`${base}.`)).map((n) => fsApi.readFileSync(path.join(dir, n), "utf8")).join("\n");
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
|
|
276
250
|
/** Calls `fn(record)` for every record of `file` separated by `sep`, reading 8 MiB at a time (a whole-run trace can pass Node's string limit). */
|
|
277
251
|
export function forEachRecord(file, sep, fn, { fsApi = fs } = {}) {
|
|
278
252
|
const fd = fsApi.openSync(file, "r"); const buf = Buffer.alloc(8 << 20); let rest = "";
|
|
@@ -325,22 +299,45 @@ export function etwWritesByRoot(file, roots, devices = {}) {
|
|
|
325
299
|
const id = (ev) => Number(/<EventID>(\d+)<\/EventID>/.exec(ev)?.[1]);
|
|
326
300
|
const pidOf = (ev) => Number(/ProcessID="(\d+)"/.exec(ev)?.[1]);
|
|
327
301
|
const provider = (ev) => /Provider Name="([^"]+)"/.exec(ev)?.[1] ?? "";
|
|
328
|
-
|
|
302
|
+
// Windows reuses a process id within seconds, so a pid belongs to a build only for one lifetime: from its ProcessStart (1) to its
|
|
303
|
+
// ProcessStop (2), in recording order. A build's root is the start of a root pid whose parent is the build runner: the parent most of
|
|
304
|
+
// the root pids were started by (a pid-only match took PowerShell, Pulse and sccache processes that later reused a build's pid).
|
|
305
|
+
const parentsOfRoots = new Map();
|
|
329
306
|
forEachRecord(file, "</Event>", (ev) => {
|
|
330
|
-
if (provider(ev)
|
|
307
|
+
if (provider(ev) !== "Microsoft-Windows-Kernel-Process" || id(ev) !== 1) return;
|
|
308
|
+
const pid = Number(field(ev, "ProcessID")); if (!roots.has(pid)) return;
|
|
309
|
+
const ppid = Number(field(ev, "ParentProcessID")); parentsOfRoots.set(ppid, (parentsOfRoots.get(ppid) ?? 0) + 1);
|
|
331
310
|
});
|
|
332
|
-
const
|
|
333
|
-
const
|
|
311
|
+
const runner = [...parentsOfRoots].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
312
|
+
const out = new Map([...roots].map((r) => [r, new Set()]));
|
|
313
|
+
const live = new Map();
|
|
314
|
+
const toDos = (p) => { if (!p) return null; for (const [dev, letter] of Object.entries(devices)) if (p.toLowerCase().startsWith(`${dev.toLowerCase()}\\`)) return letter + p.slice(dev.length); return p; };
|
|
315
|
+
// FileKey -> path for files open on the host. Every kept string is a copy: a regex match is a slice of its 8 MiB read chunk and keeps the
|
|
316
|
+
// whole chunk alive (holding names that way ran the Dell out of heap).
|
|
317
|
+
const own = (s) => (s == null ? s : Buffer.from(s, "utf8").toString("utf8"));
|
|
334
318
|
const names = new Map();
|
|
335
319
|
forEachRecord(file, "</Event>", (ev) => {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
320
|
+
const prov = provider(ev); const n = id(ev);
|
|
321
|
+
if (prov === "Microsoft-Windows-Kernel-Process") {
|
|
322
|
+
const pid = Number(field(ev, "ProcessID"));
|
|
323
|
+
if (n === 1) {
|
|
324
|
+
const ppid = Number(field(ev, "ParentProcessID"));
|
|
325
|
+
if (roots.has(pid) && ppid === runner) live.set(pid, pid);
|
|
326
|
+
else if (live.has(ppid)) live.set(pid, live.get(ppid));
|
|
327
|
+
else live.delete(pid);
|
|
328
|
+
} else if (n === 2) live.delete(pid);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (prov !== "Microsoft-Windows-Kernel-File") return;
|
|
332
|
+
// A write names its file by FileKey, which NameCreate (10) maps to the path until NameDelete (11), from whatever process logs them. The
|
|
333
|
+
// session records no close, and FileObject addresses are reused, so mapping a write by FileObject named files the build only read.
|
|
334
|
+
if (n === 10) { names.set(own(field(ev, "FileKey")), own(field(ev, "FileName"))); return; }
|
|
335
|
+
if (n === 11) { names.delete(field(ev, "FileKey")); return; }
|
|
336
|
+
const root = live.get(pidOf(ev)); if (root === undefined) return;
|
|
337
|
+
const add = (p) => { const d = toDos(p); if (d) out.get(root).add(own(d)); };
|
|
341
338
|
if (n === 12) { const disposition = (Number(field(ev, "CreateOptions") ?? 0) >>> 24) & 0xff; if (disposition !== 1) add(field(ev, "FileName")); }
|
|
342
339
|
else if (n === 30) add(field(ev, "FileName"));
|
|
343
|
-
else if (n === 16) add(names.get(
|
|
340
|
+
else if (n === 16) add(names.get(field(ev, "FileKey")));
|
|
344
341
|
else if (n === 26 || n === 27) add(field(ev, "FilePath") ?? field(ev, "FileName"));
|
|
345
342
|
});
|
|
346
343
|
return new Map([...out].map(([r, s]) => [r, [...s]]));
|
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// `right-release build-helpers check [--manifest-path <Cargo.toml>]` (Class A, rightkit#1450) and
|
|
3
|
-
// `right-release build-helpers trace [--manifest-path <Cargo.toml>]` (the one-time trace: eslogger on macOS (sudo + Full Disk Access),
|
|
3
|
+
// `right-release build-helpers trace [--manifest-path <Cargo.toml>]` (the one-time trace: eslogger on macOS (sudo + Full Disk Access), ETW on Windows (Administrator)).
|
|
4
|
+
// Only broker hosts (Mac, Dell) vet build helpers (Adrian, 2026-11-03: "linux is the wrong target and it has to be only where we have brokers"):
|
|
5
|
+
// a Linux host or CI runner is a throwaway container, so check and trace do nothing there.
|
|
4
6
|
//
|
|
5
7
|
// check: reads the workspace's resolved graph (`cargo metadata --format-version 1 --locked`, a non-compiling call run directly: GATE-CONTRACT
|
|
6
8
|
// GC-A11), its Cargo.lock, the host triple and toolchain (`rustc -vV`), and the host list, and refuses (exit 1) when any build-helper unit is
|
|
7
9
|
// untraced or traced as writing outside its build directories, naming each.
|
|
8
10
|
// trace: for each untraced unit, builds the workspace's dependency alone (`cargo build -p <crate>@<version> --locked`) under the host's tracer, into a
|
|
9
11
|
// scratch target, judges every write against OUT_DIR / target / CARGO_HOME / temp, and records the verdict in the host list.
|
|
10
|
-
//
|
|
12
|
+
// Units are listed per target the host compiles: its own and the two cross-check targets (GC-A14), each from `cargo metadata --filter-platform`.
|
|
13
|
+
// A unit that does not build on this host for its target is recorded once as unbuildable and skipped (Adrian, 2026-11-03: "if it's not compiling
|
|
14
|
+
// on the OS, and doesn't generate output ... why not skip it?"): the gate's own scan cannot build it there either.
|
|
11
15
|
import { spawn, spawnSync } from "node:child_process";
|
|
12
16
|
import fs from "node:fs";
|
|
13
17
|
import os from "node:os";
|
|
14
18
|
import path from "node:path";
|
|
15
19
|
import { fileURLToPath } from "node:url";
|
|
16
|
-
import { buildHelperRefusal, buildHelperUnits, checkBuildHelpers, hostListPath, esloggerWritesByRoot, etwWritesByRoot, judgeTrace, mergeLists,
|
|
20
|
+
import { buildHelperRefusal, buildHelperUnits, checkBuildHelpers, hostListPath, esloggerWritesByRoot, etwWritesByRoot, judgeTrace, mergeLists, readHostList, recordTrace, repoListPath } from "./build-helper-trace.mjs";
|
|
21
|
+
import { crossCompileEnv, crossTargetsFor } from "./cross-targets.mjs";
|
|
17
22
|
import { resolveManagedRoots } from "./managed-roots.mjs";
|
|
18
23
|
import { directCargoPath } from "./managed-cargo.mjs";
|
|
19
24
|
|
|
@@ -43,13 +48,19 @@ const start = (i) => new Promise((resolve) => {
|
|
|
43
48
|
const worker = async () => { while (next < jobs.length) { const i = next++; await start(i); } };
|
|
44
49
|
Promise.all(Array.from({ length: Math.min(limit, jobs.length) }, worker)).then(() => fs.writeFileSync(process.argv[2], JSON.stringify(results)));`;
|
|
45
50
|
|
|
46
|
-
export function inventory({ manifestPath, run = spawnSync, readFile = (f) => fs.readFileSync(f, "utf8") }) {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
51
|
+
export function inventory({ manifestPath, run = spawnSync, readFile = (f) => fs.readFileSync(f, "utf8"), platform = process.platform }) {
|
|
52
|
+
const host = hostFacts(run);
|
|
53
|
+
// The host's own target plus the two targets every crate is cross-checked for: each target's resolved graph and features.
|
|
54
|
+
const targets = [host.target, ...crossTargetsFor({}, platform)];
|
|
55
|
+
let metadata = null; const units = [];
|
|
56
|
+
for (const target of targets) {
|
|
57
|
+
const args = ["metadata", "--format-version", "1", "--locked", "--filter-platform", target, ...(manifestPath ? ["--manifest-path", manifestPath] : [])];
|
|
58
|
+
const r = run("cargo", args, { encoding: "utf8", maxBuffer: 1 << 28 });
|
|
59
|
+
if (r.error || r.status !== 0) throw new Error(`cargo ${args.join(" ")} failed (${r.error?.message ?? `exit ${r.status}`}): ${String(r.stderr ?? "").trim().split(/\r?\n/).slice(-2).join(" | ")}`);
|
|
60
|
+
const m = JSON.parse(r.stdout); metadata ??= m;
|
|
61
|
+
units.push(...buildHelperUnits(m, readFile(path.join(m.workspace_root, "Cargo.lock")), { target, toolchain: host.toolchain }));
|
|
62
|
+
}
|
|
63
|
+
return { metadata, units, host: host.target };
|
|
53
64
|
}
|
|
54
65
|
|
|
55
66
|
// Every Cargo workspace (a directory holding Cargo.lock) under `root`, skipping build outputs, dependencies and vendored copies.
|
|
@@ -63,7 +74,8 @@ export function cargoWorkspaces(root, { depth = 4, fsApi = fs } = {}) {
|
|
|
63
74
|
};
|
|
64
75
|
walk(root, depth);
|
|
65
76
|
// Only repositories the gate builds: the workspace's repository root (nearest .git) carries a .rightkit directory.
|
|
66
|
-
|
|
77
|
+
// A gate snapshot of a commit (<managed root>/snapshots/<key>) has .rightkit but no .git: the first ancestor with either decides.
|
|
78
|
+
const gated = (dir) => { for (let d = dir; ; d = path.dirname(d)) { if (fsApi.existsSync(path.join(d, ".rightkit"))) return true; if (fsApi.existsSync(path.join(d, ".git"))) return false; if (path.dirname(d) === d) return false; } };
|
|
67
79
|
return found.filter(gated).sort();
|
|
68
80
|
}
|
|
69
81
|
|
|
@@ -118,20 +130,15 @@ function mainWith(argv, { env, platform, run, log, err }) {
|
|
|
118
130
|
const [sub, ...rest] = argv;
|
|
119
131
|
const at = rest.indexOf("--manifest-path");
|
|
120
132
|
const manifestPath = at >= 0 ? rest[at + 1] : undefined;
|
|
133
|
+
if (platform !== "darwin" && platform !== "win32") { log(`right-release build-helpers ${sub}: nothing to do on ${platform}; only broker hosts (Mac, Dell) vet build helpers`); return 0; }
|
|
121
134
|
const listFile = hostListPath(resolveManagedRoots({ env, platform }).dataRoot, platform);
|
|
122
135
|
let inv;
|
|
123
|
-
try { inv = inventory({ manifestPath, run }); } catch (e) { err(`right-release build-helpers: ${e.message}`); return 2; }
|
|
136
|
+
try { inv = inventory({ manifestPath, run, platform }); } catch (e) { err(`right-release build-helpers: ${e.message}`); return 2; }
|
|
124
137
|
// The host's own list and the repository's committed list (.rightkit/build-helper-trace.json): CI and every host read the same reviewed
|
|
125
138
|
// passes, and a host traces only what neither holds.
|
|
126
139
|
const repoFile = repoListPath(inv.metadata.workspace_root);
|
|
127
140
|
const list = mergeLists(readHostList(listFile), readHostList(repoFile));
|
|
128
141
|
const verdict = checkBuildHelpers(inv.units, list);
|
|
129
|
-
if (sub === "check" && !verdict.ok && rest.includes("--trace-missing") && platform === "linux" && verdict.untraced.length) {
|
|
130
|
-
log(`right-release build-helpers: ${verdict.untraced.length} untraced unit(s) on this Linux host; tracing them now (strace -ff)`);
|
|
131
|
-
const traced = mainWith(["trace", ...(manifestPath ? ["--manifest-path", manifestPath] : [])], { env, platform, run, log, err });
|
|
132
|
-
if (traced !== 0) return traced;
|
|
133
|
-
return mainWith(["check", ...(manifestPath ? ["--manifest-path", manifestPath] : [])], { env, platform, run, log, err });
|
|
134
|
-
}
|
|
135
142
|
if (sub === "check") {
|
|
136
143
|
if (verdict.ok) { log(`right-release build-helpers: ${inv.units.length} build-helper unit(s), every one traced on this host (${listFile})`); return 0; }
|
|
137
144
|
for (const line of buildHelperRefusal(verdict)) err(line);
|
|
@@ -143,18 +150,28 @@ function mainWith(argv, { env, platform, run, log, err }) {
|
|
|
143
150
|
// ETW kernel sessions need an elevated shell; kernel paths are mapped back to drive letters once (QueryDosDevice).
|
|
144
151
|
const admin = run("net", ["session"], { encoding: "utf8" });
|
|
145
152
|
if (admin.status !== 0) { err("right-release build-helpers trace: run from an elevated (Administrator) shell; ETW kernel tracing needs it"); return 2; }
|
|
146
|
-
const map = run("powershell", ["-NoProfile", "-Command", "$s='[DllImport(
|
|
153
|
+
const map = run("powershell", ["-NoProfile", "-Command", "$s='[DllImport(\"kernel32.dll\")] public static extern uint QueryDosDevice(string d, System.Text.StringBuilder b, uint m);'; $k=Add-Type -MemberDefinition $s -Name K -Namespace W -PassThru; foreach($d in [IO.DriveInfo]::GetDrives()){ $l=$d.Name.Substring(0,2); $b=New-Object Text.StringBuilder 512; [void]$k::QueryDosDevice($l,$b,512); \"$l=$b\" }"], { encoding: "utf8" });
|
|
147
154
|
for (const line of String(map.stdout ?? "").split(/\r?\n/)) { const m = /^([A-Z]:)=(\\Device\\\S+)$/.exec(line.trim()); if (m) devices[m[2]] = m[1]; }
|
|
155
|
+
// Without the map every kernel path would be judged outside the allowed folders: stop instead of recording false verdicts.
|
|
156
|
+
if (!Object.keys(devices).length) { err(`right-release build-helpers trace: could not map kernel device paths to drive letters (QueryDosDevice): ${String(map.stderr ?? "").trim().split(/\r?\n/)[0] ?? ""}`); return 2; }
|
|
148
157
|
}
|
|
149
158
|
const recordRepo = rest.includes("--record-repo");
|
|
150
159
|
const cargoHome = env.CARGO_HOME ?? path.join(os.homedir(), ".cargo");
|
|
151
160
|
// Parallel: `--jobs N` builds at once (default 4). Each build is its own process tree, rooted at its own cargo, in its own scratch
|
|
152
|
-
// target, and is judged only on the writes of that tree (eslogger and ETW events name their process
|
|
153
|
-
//
|
|
161
|
+
// target, and is judged only on the writes of that tree (eslogger and ETW events name their process). The cores are shared out so the
|
|
162
|
+
// builds do not fight over them.
|
|
154
163
|
const jobsAt = rest.indexOf("--jobs");
|
|
155
164
|
const jobs = Math.max(1, Number.parseInt(jobsAt >= 0 ? rest[jobsAt + 1] : "4", 10) || 4);
|
|
156
165
|
const perBuildJobs = String(Math.max(1, Math.floor(os.cpus().length / jobs)));
|
|
157
|
-
|
|
166
|
+
// A cross target builds with the provisioned zig C toolchain; without it those units wait (never recorded as unbuildable).
|
|
167
|
+
const crossEnv = new Map(); const waiting = [];
|
|
168
|
+
const pending = [...verdict.untraced, ...verdict.failed.map((f) => f.unit)].filter((unit) => {
|
|
169
|
+
if (unit.target === inv.host) return true;
|
|
170
|
+
if (!crossEnv.has(unit.target)) crossEnv.set(unit.target, crossCompileEnv(unit.target, undefined, platform) ?? null);
|
|
171
|
+
if (crossEnv.get(unit.target)) return true;
|
|
172
|
+
waiting.push(unit); return false;
|
|
173
|
+
});
|
|
174
|
+
if (waiting.length) err(`right-release build-helpers trace: ${waiting.length} cross-target unit(s) not traced: the zig C toolchain is not provisioned (scripts/ci/provision-zig.mjs)`);
|
|
158
175
|
log(`right-release build-helpers trace: ${pending.length} unit(s), ${jobs} at a time`);
|
|
159
176
|
if (!pending.length) return 0;
|
|
160
177
|
const prepared = pending.map((unit) => {
|
|
@@ -170,25 +187,49 @@ function mainWith(argv, { env, platform, run, log, err }) {
|
|
|
170
187
|
fs.writeFileSync(path.join(probe, "src", "lib.rs"), "");
|
|
171
188
|
try { fs.copyFileSync(path.join(inv.metadata.workspace_root, "Cargo.lock"), path.join(probe, "Cargo.lock")); } catch { /* the probe resolves on its own */ }
|
|
172
189
|
// Release profile, like every other build (GATE-CONTRACT GC-A15): no dev or debug build anywhere, a throwaway probe included.
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
return { unit, scratch, target, straceOut, job };
|
|
190
|
+
// Built for the unit's own target, as the gate builds it: the host's, or a cross-check target with --target and zig.
|
|
191
|
+
const cross = unit.target !== inv.host;
|
|
192
|
+
const buildArgs = ["build", "--profile", "release", "--manifest-path", path.join(probe, "Cargo.toml"), "--target-dir", target, ...(cross ? ["--target", unit.target] : [])];
|
|
193
|
+
const buildEnv = { ...env, ...(cross ? crossEnv.get(unit.target) : {}), CARGO_TARGET_DIR: target, CARGO_BUILD_JOBS: perBuildJobs };
|
|
194
|
+
const job = { cmd: "cargo", args: buildArgs, env: buildEnv };
|
|
195
|
+
job.label = `${unit.crate} ${unit.version}${cross ? ` (${unit.target})` : ""}`;
|
|
196
|
+
return { unit, scratch, target, job };
|
|
181
197
|
});
|
|
182
198
|
const sessionDir = fs.mkdtempSync(path.join(os.tmpdir(), "rk-bht-session-"));
|
|
183
199
|
const traceFile = path.join(sessionDir, "trace.txt");
|
|
184
|
-
// One tracer session for the whole run (macOS eslogger, Windows ETW)
|
|
200
|
+
// One tracer session for the whole run (macOS eslogger, Windows ETW).
|
|
185
201
|
let stopTracer = () => {};
|
|
186
202
|
let etl = null;
|
|
187
203
|
if (platform === "darwin") {
|
|
188
204
|
const tracer = run("sh", ["-c", `sudo -n eslogger fork exec create rename unlink link truncate open close clone copyfile exchangedata > '${traceFile}' 2>'${traceFile}.err' & echo $!`], { encoding: "utf8" });
|
|
189
205
|
const tracerPid = String(tracer.stdout ?? "").trim();
|
|
190
206
|
run("sleep", ["2"]);
|
|
191
|
-
|
|
207
|
+
// Drain before stopping: eslogger writes a block-buffered file and Endpoint Security delivers late, so the last builds' events were
|
|
208
|
+
// still in flight when a fixed one-second wait ended (every "build was not observed" fail on the Mac was a build that finished last
|
|
209
|
+
// in its session). Marker files are created in the session folder, one a second, until the first appears in the recording: every
|
|
210
|
+
// event before it is then on disk. Two minutes without it leaves the verdicts to judgeTrace, which fails a build it did not see.
|
|
211
|
+
const drain = () => {
|
|
212
|
+
const tag = `rk-bht-drain-${process.pid}-`;
|
|
213
|
+
let from = 0; try { from = fs.statSync(traceFile).size; } catch { /* nothing yet */ }
|
|
214
|
+
for (let i = 0; i < 120; i += 1) {
|
|
215
|
+
fs.writeFileSync(path.join(sessionDir, `${tag}${i}`), "");
|
|
216
|
+
run("sleep", ["1"]);
|
|
217
|
+
let size = 0; try { size = fs.statSync(traceFile).size; } catch { continue; }
|
|
218
|
+
if (size <= from) continue;
|
|
219
|
+
const fd = fs.openSync(traceFile, "r");
|
|
220
|
+
try {
|
|
221
|
+
const chunk = Buffer.alloc(8 << 20);
|
|
222
|
+
for (let at = from; at < size; at += chunk.length - tag.length) {
|
|
223
|
+
const n = fs.readSync(fd, chunk, 0, chunk.length, at);
|
|
224
|
+
if (chunk.subarray(0, n).includes(tag)) return true;
|
|
225
|
+
}
|
|
226
|
+
} finally { fs.closeSync(fd); }
|
|
227
|
+
from = Math.max(from, size - tag.length);
|
|
228
|
+
}
|
|
229
|
+
err("right-release build-helpers trace: eslogger did not record the drain marker within 2 minutes; builds it did not see are judged unobserved");
|
|
230
|
+
return false;
|
|
231
|
+
};
|
|
232
|
+
stopTracer = () => { drain(); if (tracerPid) { run("sudo", ["-n", "pkill", "-INT", "-P", tracerPid]); run("kill", [tracerPid]); run("sleep", ["1"]); } };
|
|
192
233
|
} else if (platform === "win32") {
|
|
193
234
|
// One ETW session with both kernel providers, each with an explicit level (0x5): a separate process-only session without a level
|
|
194
235
|
// recorded nothing on the Dell (proc.etl 0 MB), so no write could be tied to its build. Tested on the Dell: process starts (with
|
|
@@ -219,15 +260,21 @@ function mainWith(argv, { env, platform, run, log, err }) {
|
|
|
219
260
|
if (exported.status !== 0) { err("right-release build-helpers trace: tracerpt could not export the ETW trace"); return 2; }
|
|
220
261
|
try { recorded = fs.statSync(xml).size > 0; if (recorded) byRoot = etwWritesByRoot(xml, roots, devices); } catch { /* no output */ }
|
|
221
262
|
}
|
|
222
|
-
if (
|
|
263
|
+
if (!recorded && roots.size) { err(`right-release build-helpers trace: the ${platform === "darwin" ? "eslogger" : "ETW"} session recorded nothing; nothing recorded`); return 2; }
|
|
223
264
|
prepared.forEach((b, k) => {
|
|
224
265
|
const r = results[k] ?? {};
|
|
225
|
-
if (
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
266
|
+
if (!Number.isInteger(r.status)) { err(`right-release build-helpers trace: ${b.unit.crate} ${b.unit.version} did not finish (${r.error ?? "no result"}); not recorded`); return; }
|
|
267
|
+
if (r.status !== 0) {
|
|
268
|
+
// It does not build here for this target, so nothing this host's gate runs builds it either: recorded once, skipped from then on.
|
|
269
|
+
const reason = String(r.error ?? `exit ${r.status}`);
|
|
270
|
+
recordTrace(listFile, b.unit, { ok: false, unbuildable: true, outside: [reason] });
|
|
271
|
+
if (recordRepo) recordTrace(repoFile, b.unit, { ok: false, unbuildable: true, outside: [reason] });
|
|
272
|
+
log(`skip ${b.unit.crate} ${b.unit.version} (${b.unit.target}): does not build on this host (${reason.slice(0, 160)})`);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const writes = byRoot.get(r.pid) ?? [];
|
|
229
276
|
const allowed = platform === "win32" ? [b.scratch, cargoHome, os.tmpdir(), env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, "Temp") : null]
|
|
230
|
-
: [b.scratch, cargoHome, os.tmpdir(), "/private/var/folders", "/dev"
|
|
277
|
+
: [b.scratch, cargoHome, os.tmpdir(), "/private/var/folders", "/dev"];
|
|
231
278
|
// Allowed folders by their real path: a linked CARGO_HOME (~/.cargo -> /Volumes/D/...) or temp (/var -> /private/var) is reported by
|
|
232
279
|
// the tracer under the real path, so comparing the link's path would fail every crate.
|
|
233
280
|
const real = (d) => { try { return fs.realpathSync.native(d); } catch { return d; } };
|
package/cli/right-release.mjs
CHANGED
|
@@ -181,6 +181,9 @@ if (first === "--version" || first === "-v") {
|
|
|
181
181
|
// Local qualification (default): both native hosts qualify the crates through the RustRight broker and a candidate is cut.
|
|
182
182
|
// It uploads nothing; the approval commit and publish-reviewed.yml publish the pinned bytes (docs/CI.md "Local qualification").
|
|
183
183
|
run("local-qualify.mjs", args.slice(1));
|
|
184
|
+
} else if (first === "publish-local") {
|
|
185
|
+
// The local route (rightkit#1471): Class A on the exact pushed commit, then the Keychain token publishes that commit's bytes.
|
|
186
|
+
run("publish-local-npm.mjs", args.slice(1));
|
|
184
187
|
} else if (first === "publish") {
|
|
185
188
|
const rest = args.slice(1);
|
|
186
189
|
// RightKit crates and @rightkit packages reach a registry from CI only: publish.yml qualification, the approval
|
|
@@ -352,6 +355,9 @@ Commands:
|
|
|
352
355
|
release Compatibility alias for build
|
|
353
356
|
publish Compatibility alias for upload
|
|
354
357
|
check-release-dependencies [--platform mac|win|linux | --all-targets] Check app-forbidden release crates
|
|
358
|
+
publish-local npm --package @rightkit/<name> --commit <sha>
|
|
359
|
+
Mac: Class A on the pushed commit, pack it, publish with the
|
|
360
|
+
Keychain token (npm-rightkit-publish), verify the registry shasum
|
|
355
361
|
publish npm --package @rightkit/<name> [--dry-run] Gate preflight for npm publish
|
|
356
362
|
publish npm --package @rightkit/<name> --publish --tarball <gated.tgz>
|
|
357
363
|
Publish the gated tarball with the Keychain token
|
package/deterministic-scans.mjs
CHANGED
|
@@ -1071,7 +1071,9 @@ export function planScans({
|
|
|
1071
1071
|
const wsOf = (n) => (rustMap ? rustMap.get(n).cargoDir : cargoWs);
|
|
1072
1072
|
const crateOf = (n) => (rustMap?.get(n)?.crate ?? n);
|
|
1073
1073
|
const featuresOf = (n) => crateFeatures?.[n] ?? crateFeatures?.[crateOf(n)] ?? {};
|
|
1074
|
-
|
|
1074
|
+
// With no Rust unit in play the default workspace counts only when it has a Cargo.toml: a root rust-toolchain.toml (or other Rust config)
|
|
1075
|
+
// in a crate-per-workspace repository must not turn the repository root into a phantom workspace whose cargo scans have no manifest.
|
|
1076
|
+
const workspaces = rustMap && rustMap.size ? [...new Set([...rustMap.values()].map((u) => u.cargoDir))].sort() : (exists(path.join(contentRoot, cargoWs === "." ? "Cargo.toml" : `${cargoWs}/Cargo.toml`)) ? [cargoWs] : []);
|
|
1075
1077
|
const lockOf = (ws) => (rustLocks?.[ws] instanceof Map ? rustLocks[ws] : ws === cargoWs && rustLock instanceof Map ? rustLock : null);
|
|
1076
1078
|
const rustDir = (n) => (rustMap ? rustMap.get(n).dir : `crates/${n}`);
|
|
1077
1079
|
const npmDir = (n) => (npmMap ? npmMap.get(n).dir : `packages/${n}`);
|
|
@@ -1251,7 +1253,8 @@ export function planScans({
|
|
|
1251
1253
|
// inside its build directories; an untraced or out-of-bounds unit refuses Class A, naming it. Runs the gate's own command, directly (GC-A11).
|
|
1252
1254
|
// It runs in its workspace (cwd), so a unit's scope keeps it only for the Cargo workspaces of its own closure (app-closure CARGO_WORKSPACE_SCAN),
|
|
1253
1255
|
// like metadata, deny and audit: an npm-only unit traces nothing.
|
|
1254
|
-
|
|
1256
|
+
// Build helpers are vetted only on broker hosts (Mac, Dell); a Linux host or CI runner is a throwaway container (Adrian, 2026-11-03).
|
|
1257
|
+
if (platform === "darwin" || platform === "win32") for (const ws of touchedWs) add({ id: `build-helpers${wsIdSuffix(ws)}`, class: A, tool: "node", args: [BUILD_HELPERS_COMMAND, "check", "--manifest-path", "Cargo.toml"], ...(ws !== "." ? { cwd: ws } : {}), root: "content" });
|
|
1255
1258
|
// Locked package listings are observations in RustRight (#1216). They run through the selected executor
|
|
1256
1259
|
// from the checked Cargo inputs, just like metadata; an executor refusal remains a failed scan.
|
|
1257
1260
|
for (const name of crateNames.filter((n) => rustMap?.get(n).publish !== false)) add({ id: rustScanId.packageList(name), class: A, ...cargoReadIn(wsOf(name), "package", "-p", crateOf(name), "--list", "--allow-dirty", "--locked") });
|
package/package.json
CHANGED
package/pipeline-receipts.mjs
CHANGED
|
@@ -1,302 +1,302 @@
|
|
|
1
|
-
// Pipeline stage receipts and their verifier (architecture-v3 sections 4-5, "Receipts").
|
|
2
|
-
//
|
|
3
|
-
// A receipt records a fact about one stage of one request. It never authorizes execution alone: the
|
|
4
|
-
// broker (RustRight) signs receipts and issues short-lived capabilities; this module only checks that
|
|
5
|
-
// a chain of receipts is well formed, linked, fresh and complete. Signature checking is an injected
|
|
6
|
-
// interface (`verifySignature`); this module does no cryptography beyond SHA-256 hashing and invents none.
|
|
7
|
-
//
|
|
8
|
-
// Chain: ADMISSION -> SCAN_A -> ROUTE -> SCAN_B -> BUILD -> SIGN -> QUALIFIED -> PUBLISH_INTENT -> PUBLISHED.
|
|
9
|
-
// Each stage requires exactly its predecessor; `parents` carries that predecessor's receipt hash.
|
|
10
|
-
import crypto from "node:crypto";
|
|
11
|
-
import { canonicalJson, digestScanSet, scanClassOf } from "./deterministic-scans.mjs";
|
|
12
|
-
|
|
13
|
-
export const RECEIPT_SCHEMA = "rightkit.pipeline-receipt/1";
|
|
14
|
-
export const STAGES = Object.freeze(["ADMISSION", "SCAN_A", "ROUTE", "SCAN_B", "BUILD", "SIGN", "QUALIFIED", "PUBLISH_INTENT", "PUBLISHED"]);
|
|
15
|
-
export const OPERATIONS = Object.freeze(["dev", "build", "qualify", "sign", "publish"]);
|
|
16
|
-
/** The last stage each operation may reach. */
|
|
17
|
-
export const OPERATION_LAST_STAGE = Object.freeze({ dev: "BUILD", build: "BUILD", sign: "SIGN", qualify: "QUALIFIED", publish: "PUBLISHED" });
|
|
18
|
-
/** Each stage requires only these predecessors. */
|
|
19
|
-
export const PREREQUISITES = Object.freeze({
|
|
20
|
-
ADMISSION: [], SCAN_A: ["ADMISSION"], ROUTE: ["SCAN_A"], SCAN_B: ["ROUTE"], BUILD: ["SCAN_B"],
|
|
21
|
-
SIGN: ["BUILD"], QUALIFIED: ["SIGN"], PUBLISH_INTENT: ["QUALIFIED"], PUBLISHED: ["PUBLISH_INTENT"],
|
|
22
|
-
});
|
|
23
|
-
const RELEASE_ID_STAGES = new Set(["SIGN", "QUALIFIED", "PUBLISH_INTENT", "PUBLISHED"]);
|
|
24
|
-
/** Stages only a publishable request may reach: signing and everything after it. A dev or non-publishable chain stops at BUILD. */
|
|
25
|
-
const PUBLISHABLE_ONLY_STAGES = new Set(RELEASE_ID_STAGES);
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* What ADMISSION's evidence allows for `stage`, as [{ code, message }]. ADMISSION is the sole authority:
|
|
29
|
-
* a missing or malformed ADMISSION authorizes nothing, and `publishable` must be exactly true to reach SIGN or later.
|
|
30
|
-
*/
|
|
31
|
-
function admissionProblems(admission, stage) {
|
|
32
|
-
const out = [];
|
|
33
|
-
if (!admission) return [{ code: "STAGE_NOT_AUTHORIZED", message: `no valid ADMISSION evidence authorizes ${stage}` }];
|
|
34
|
-
if (!Array.isArray(admission.authorizedStages) || !admission.authorizedStages.includes(stage)) out.push({ code: "STAGE_NOT_AUTHORIZED", message: `ADMISSION does not authorize ${stage}` });
|
|
35
|
-
if (admission.publishable !== true && PUBLISHABLE_ONLY_STAGES.has(stage)) out.push({ code: "NOT_PUBLISHABLE", message: `ADMISSION does not mark this request publishable:true; it cannot reach ${stage}` });
|
|
36
|
-
return out;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const ENVELOPE_STRINGS = ["receiptId", "jobId", "requestId", "repositoryId", "commit", "inputDigest", "configDigest", "policyDigest", "stagePlanDigest", "runtimeDigest", "executor", "issuedAt", "expiresAt", "keyId", "signature"];
|
|
40
|
-
const HEX64 = /^[0-9a-f]{64}$/;
|
|
41
|
-
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
|
42
|
-
|
|
43
|
-
/** Domain-separated bytes a signature covers: receipt type bound in, signature field excluded. */
|
|
44
|
-
export function signingBytes(receipt) {
|
|
45
|
-
const { signature, ...rest } = receipt;
|
|
46
|
-
return Buffer.from(`${RECEIPT_SCHEMA}\0${receipt.type}\0${canonicalJson(rest)}`, "utf8");
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** The hash a child receipt records in `parents`: covers the whole receipt including its signature. */
|
|
50
|
-
export function receiptHash(receipt) {
|
|
51
|
-
return sha256(Buffer.concat([Buffer.from(`${RECEIPT_SCHEMA}.hash\0`, "utf8"), Buffer.from(canonicalJson(receipt), "utf8")]));
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** In-memory request ledger for tests and single-process brokers: records which job owns a requestId. */
|
|
55
|
-
export function createMemoryLedger() {
|
|
56
|
-
const owners = new Map(); const consumed = new Set();
|
|
57
|
-
return {
|
|
58
|
-
requestOwner: (requestId) => owners.get(requestId),
|
|
59
|
-
isConsumed: (receiptId) => consumed.has(receiptId),
|
|
60
|
-
record(chain) { for (const r of chain) { if (!owners.has(r.requestId)) owners.set(r.requestId, r.jobId); consumed.add(r.receiptId); } },
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
65
|
-
const nonEmptyString = (v) => typeof v === "string" && v.trim() !== "";
|
|
66
|
-
const isHex = (v) => typeof v === "string" && HEX64.test(v);
|
|
67
|
-
const nonEmptyArray = (v) => Array.isArray(v) && v.length > 0;
|
|
68
|
-
|
|
69
|
-
/** Stage-specific evidence checks. Each returns a list of problem messages. */
|
|
70
|
-
const STAGE_EVIDENCE = {
|
|
71
|
-
ADMISSION(e) {
|
|
72
|
-
const p = [];
|
|
73
|
-
for (const f of ["inputManifestDigest", "dependencyManifestDigest", "toolchainManifestDigest"]) if (!isHex(e[f])) p.push(`${f} must be a sha256 hex digest`);
|
|
74
|
-
if (!nonEmptyArray(e.authorizedStages) || e.authorizedStages.some((s) => !STAGES.includes(s))) p.push("authorizedStages must list known stages");
|
|
75
|
-
if (typeof e.publishable !== "boolean") p.push("publishable must be a boolean");
|
|
76
|
-
return p;
|
|
77
|
-
},
|
|
78
|
-
SCAN_A: (e, ctx) => scanEvidence(e, "A", ctx),
|
|
79
|
-
ROUTE(e, ctx) {
|
|
80
|
-
const p = [];
|
|
81
|
-
const d = e.decision;
|
|
82
|
-
if (!isObj(d)) return ["decision is required"];
|
|
83
|
-
if (!["local", "ci"].includes(d.executor)) p.push("decision.executor must be local or ci");
|
|
84
|
-
if (!["public", "private"].includes(d.visibility)) p.push("decision.visibility must be public or private (unknown visibility is rejected)");
|
|
85
|
-
if (d.visibility === "private" && d.executor !== "local") p.push("a private repository never routes to CI");
|
|
86
|
-
if (typeof d.localGrant !== "boolean") p.push("decision.localGrant must be a boolean");
|
|
87
|
-
if (d.executor === "local" && !nonEmptyString(String(e.leaseFence ?? ""))) p.push("a local route needs a leaseFence");
|
|
88
|
-
if (d.executor === "ci" && !isObj(e.dispatchBinding)) p.push("a CI route needs a dispatchBinding");
|
|
89
|
-
if (!nonEmptyString(d.policyVersion)) p.push("decision.policyVersion is required");
|
|
90
|
-
return p;
|
|
91
|
-
},
|
|
92
|
-
SCAN_B: (e, ctx) => scanEvidence(e, "B", ctx),
|
|
93
|
-
BUILD(e) {
|
|
94
|
-
const p = [];
|
|
95
|
-
if (!isHex(e.artifactManifestDigest)) p.push("artifactManifestDigest must be a sha256 hex digest");
|
|
96
|
-
if (!nonEmptyArray(e.artifacts) || e.artifacts.some((a) => !nonEmptyString(a?.path) || !isHex(a?.sha256))) p.push("artifacts must list path and sha256 for every output");
|
|
97
|
-
for (const f of ["generationEvidenceDigest", "provenanceDigest"]) if (!isHex(e[f])) p.push(`${f} must be a sha256 hex digest`);
|
|
98
|
-
if (!nonEmptyArray(e.targetMatrix)) p.push("targetMatrix must list the built targets");
|
|
99
|
-
return p;
|
|
100
|
-
},
|
|
101
|
-
SIGN(e, ctx) {
|
|
102
|
-
const p = [];
|
|
103
|
-
if (!["SIGN", "SEAL"].includes(e.mode)) p.push("mode must be SIGN or SEAL");
|
|
104
|
-
for (const f of ["inputManifestDigest", "outputManifestDigest", "signingEvidenceDigest"]) if (!isHex(e[f])) p.push(`${f} must be a sha256 hex digest`);
|
|
105
|
-
if (!nonEmptyString(e.signingProfile)) p.push("signingProfile is required");
|
|
106
|
-
const build = ctx.byStage.BUILD;
|
|
107
|
-
if (build && e.inputManifestDigest !== build.evidence?.artifactManifestDigest) p.push("inputManifestDigest does not equal the BUILD artifactManifestDigest");
|
|
108
|
-
return p;
|
|
109
|
-
},
|
|
110
|
-
QUALIFIED(e, ctx) {
|
|
111
|
-
const p = [];
|
|
112
|
-
if (!isHex(e.artifactManifestDigest)) p.push("artifactManifestDigest must be a sha256 hex digest");
|
|
113
|
-
if (!nonEmptyArray(e.testMatrix) || e.testMatrix.some((t) => !nonEmptyString(t?.name) || t?.status !== "pass")) p.push("testMatrix must be non-empty and every row must pass");
|
|
114
|
-
const sign = ctx.byStage.SIGN;
|
|
115
|
-
if (sign && e.artifactManifestDigest !== sign.evidence?.outputManifestDigest) p.push("artifactManifestDigest does not equal the SIGN outputManifestDigest (qualification covers the final signed bytes)");
|
|
116
|
-
return p;
|
|
117
|
-
},
|
|
118
|
-
PUBLISH_INTENT(e, ctx) {
|
|
119
|
-
const p = [];
|
|
120
|
-
if (!isHex(e.releaseManifestDigest)) p.push("releaseManifestDigest must be a sha256 hex digest");
|
|
121
|
-
if (!nonEmptyArray(e.destinations) || e.destinations.some((d) => !nonEmptyString(d?.id) || !nonEmptyString(d?.identity) || !nonEmptyString(d?.version) || !isHex(d?.expectedDigest) || !nonEmptyString(d?.idempotencyKey))) {
|
|
122
|
-
p.push("destinations must name id, identity, version, expectedDigest and idempotencyKey");
|
|
123
|
-
}
|
|
124
|
-
const q = ctx.byStage.QUALIFIED;
|
|
125
|
-
if (q && e.releaseManifestDigest !== q.evidence?.artifactManifestDigest) p.push("releaseManifestDigest does not equal the QUALIFIED artifactManifestDigest");
|
|
126
|
-
return p;
|
|
127
|
-
},
|
|
128
|
-
PUBLISHED(e, ctx) {
|
|
129
|
-
const p = [];
|
|
130
|
-
const intent = ctx.byStage.PUBLISH_INTENT;
|
|
131
|
-
if (!nonEmptyArray(e.entries)) return ["entries must list every destination outcome"];
|
|
132
|
-
for (const d of intent?.evidence?.destinations ?? []) {
|
|
133
|
-
const entry = e.entries.find((x) => x?.destinationId === d.id);
|
|
134
|
-
if (!entry) p.push(`no entry for destination ${d.id}`);
|
|
135
|
-
else if (entry.outcome !== "verified") p.push(`destination ${d.id} is ${entry.outcome}, not verified`);
|
|
136
|
-
else if (entry.remoteDigest !== d.expectedDigest) p.push(`destination ${d.id} readback digest differs from the intent`);
|
|
137
|
-
}
|
|
138
|
-
return p;
|
|
139
|
-
},
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
function scanEvidence(e, cls, ctx) {
|
|
143
|
-
const p = [];
|
|
144
|
-
const other = cls === "A" ? "B" : "A";
|
|
145
|
-
if (e.scanClass !== cls) p.push(`scanClass must be ${cls}`);
|
|
146
|
-
if (!nonEmptyArray(e.scans)) return [...p, "scans must list every required scan result"];
|
|
147
|
-
if (cls === "A" && !isHex(e.advisorySnapshotDigest)) p.push("advisorySnapshotDigest must be a sha256 hex digest");
|
|
148
|
-
if (cls === "A" && !nonEmptyString(e.advisorySnapshotTime)) p.push("advisorySnapshotTime is required");
|
|
149
|
-
for (const s of e.scans) {
|
|
150
|
-
const derived = scanClassOf(s?.id);
|
|
151
|
-
if (s?.class === other || derived === other) p.push(`${cls === "A" ? "SCAN_A claims" : "SCAN_B carries"} class ${other} scan ${s?.id}`);
|
|
152
|
-
else if (s?.class !== cls) p.push(`scan ${s?.id} has class ${s?.class}, expected ${cls}`);
|
|
153
|
-
if (s?.status !== "pass") p.push(`scan ${s?.id} is ${s?.status}, not pass`);
|
|
154
|
-
}
|
|
155
|
-
const ids = new Set(e.scans.map((s) => s?.id));
|
|
156
|
-
const required = ctx.requiredScans?.[cls];
|
|
157
|
-
const declared = Array.isArray(e.requiredScanIds) ? e.requiredScanIds : null;
|
|
158
|
-
if (!declared) p.push("requiredScanIds is required");
|
|
159
|
-
// The required set comes only from the injected policy matrix. A receipt's own declaration is never trusted:
|
|
160
|
-
// without the policy, verification fails closed.
|
|
161
|
-
if (!Array.isArray(required) || required.some((id) => typeof id !== "string")) {
|
|
162
|
-
p.push(`trusted required scan set for class ${cls} is not supplied by policy; verification fails closed`);
|
|
163
|
-
return p;
|
|
164
|
-
}
|
|
165
|
-
for (const id of required) if (!ids.has(id)) p.push(`required class ${cls} scan ${id} is missing`);
|
|
166
|
-
if (declared && canonicalJson([...required].sort()) !== canonicalJson([...declared].sort())) p.push(`requiredScanIds differ from the trusted class ${cls} set`);
|
|
167
|
-
if (e.scanSetDigest !== digestScanSet(e.scans)) p.push("scanSetDigest does not match the listed scans");
|
|
168
|
-
if (cls === "B" && !nonEmptyArray(required)) p.push("SCAN_B must require at least one class B scan");
|
|
169
|
-
return p;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Verify a chain of receipts (ADMISSION first, ending at the stage being authorized).
|
|
174
|
-
*
|
|
175
|
-
* options:
|
|
176
|
-
* verifySignature({ receipt, keyId, signingBytes, signature }) -> boolean REQUIRED (the broker supplies keys)
|
|
177
|
-
* now ISO string or Date, default current time
|
|
178
|
-
* expected { requestId, operation, inputDigest, stage, repositoryId, commit, jobId, releaseId, targetSet } (each optional but checked when given)
|
|
179
|
-
* requiredScans { A: [ids], B: [ids] } trusted required sets from the policy matrix: REQUIRED; absence rejects (NO_SCAN_POLICY)
|
|
180
|
-
* ledger { requestOwner(requestId), isConsumed(receiptId) } REQUIRED: without it verification fails (NO_LEDGER)
|
|
181
|
-
* keyAllowed (keyId, receipt) -> boolean, for revocations
|
|
182
|
-
* Returns { ok, problems: [{ code, stage, message }], head }. Never throws on a bad receipt.
|
|
183
|
-
*/
|
|
184
|
-
export function verifyReceiptChain(chain, options = {}) {
|
|
185
|
-
const problems = [];
|
|
186
|
-
const bad = (code, stage, message) => problems.push({ code, stage: stage ?? null, message });
|
|
187
|
-
if (typeof options.verifySignature !== "function") return { ok: false, problems: [{ code: "NO_SIGNATURE_VERIFIER", stage: null, message: "a signature verifier must be supplied by the broker" }], head: null };
|
|
188
|
-
if (!Array.isArray(chain) || chain.length === 0) return { ok: false, problems: [{ code: "EMPTY_CHAIN", stage: null, message: "no receipts" }], head: null };
|
|
189
|
-
const now = new Date(options.now ?? Date.now()).getTime();
|
|
190
|
-
const expected = options.expected ?? {};
|
|
191
|
-
const byStage = {};
|
|
192
|
-
let previous = null;
|
|
193
|
-
// Replay protection is mandatory: without a ledger the chain cannot be proven fresh, so it fails closed.
|
|
194
|
-
if (typeof options.ledger?.requestOwner !== "function" || typeof options.ledger?.isConsumed !== "function") {
|
|
195
|
-
bad("NO_LEDGER", null, "a request ledger with requestOwner and isConsumed must be supplied; replay cannot be ruled out without it");
|
|
196
|
-
}
|
|
197
|
-
// One release id for the whole chain: the ADMISSION one when it has it, else the first release-stage one.
|
|
198
|
-
const releaseRef = chain.find((r) => isObj(r) && nonEmptyString(r.releaseId))?.releaseId;
|
|
199
|
-
// ADMISSION fixes which stages may run and whether the request may publish at all.
|
|
200
|
-
const admission = isObj(chain[0]) && chain[0].type === "ADMISSION" && isObj(chain[0].evidence) ? chain[0].evidence : null;
|
|
201
|
-
|
|
202
|
-
chain.forEach((receipt, index) => {
|
|
203
|
-
const stage = isObj(receipt) ? receipt.type : null;
|
|
204
|
-
if (!isObj(receipt)) { bad("MALFORMED", null, `receipt ${index} is not an object`); return; }
|
|
205
|
-
if (receipt.schema !== RECEIPT_SCHEMA) bad("SCHEMA", stage, `schema must be ${RECEIPT_SCHEMA}`);
|
|
206
|
-
if (!STAGES.includes(stage)) { bad("UNKNOWN_STAGE", stage, `unknown receipt type ${JSON.stringify(stage)}`); return; }
|
|
207
|
-
const wantStage = STAGES[index];
|
|
208
|
-
if (stage !== wantStage) bad("WRONG_STAGE", stage, `position ${index} requires ${wantStage}, got ${stage}`);
|
|
209
|
-
for (const f of ENVELOPE_STRINGS) if (!nonEmptyString(receipt[f])) bad("ENVELOPE", stage, `${f} is required`);
|
|
210
|
-
if (!Number.isInteger(receipt.attempt) || receipt.attempt < 1) bad("ENVELOPE", stage, "attempt must be an integer >= 1");
|
|
211
|
-
if (!Array.isArray(receipt.targetSet)) bad("ENVELOPE", stage, "targetSet must be an array");
|
|
212
|
-
if (!OPERATIONS.includes(receipt.operation)) bad("WRONG_OPERATION", stage, `unknown operation ${JSON.stringify(receipt.operation)}`);
|
|
213
|
-
if (RELEASE_ID_STAGES.has(stage) && !nonEmptyString(receipt.releaseId)) bad("ENVELOPE", stage, "releaseId is required");
|
|
214
|
-
if (!isObj(receipt.evidence)) bad("EVIDENCE", stage, "evidence is required");
|
|
215
|
-
|
|
216
|
-
// The operation bounds how far a request may go.
|
|
217
|
-
const last = OPERATION_LAST_STAGE[receipt.operation];
|
|
218
|
-
if (last && STAGES.indexOf(stage) > STAGES.indexOf(last)) bad("WRONG_OPERATION", stage, `operation ${receipt.operation} does not authorize ${stage}`);
|
|
219
|
-
|
|
220
|
-
// ADMISSION bounds the chain: only its authorized stages, and nothing from SIGN on unless publishable:true.
|
|
221
|
-
for (const x of admissionProblems(admission, stage)) bad(x.code, stage, x.message);
|
|
222
|
-
|
|
223
|
-
// Binding to the caller's expectations.
|
|
224
|
-
if (expected.requestId !== undefined && receipt.requestId !== expected.requestId) bad("WRONG_REQUEST_ID", stage, "requestId differs from the expected request");
|
|
225
|
-
if (expected.operation !== undefined && receipt.operation !== expected.operation) bad("WRONG_OPERATION", stage, `operation ${receipt.operation} differs from expected ${expected.operation}`);
|
|
226
|
-
if (expected.inputDigest !== undefined && receipt.inputDigest !== expected.inputDigest) bad("WRONG_INPUT_DIGEST", stage, "inputDigest differs from the expected input");
|
|
227
|
-
for (const f of ["repositoryId", "commit", "jobId", "releaseId"]) if (expected[f] !== undefined && receipt[f] !== expected[f]) bad("BINDING", stage, `${f} differs from the expected value`);
|
|
228
|
-
if (expected.targetSet !== undefined && canonicalJson(receipt.targetSet ?? null) !== canonicalJson(expected.targetSet)) bad("BINDING", stage, "targetSet differs from the expected value");
|
|
229
|
-
if (releaseRef !== undefined && receipt.releaseId !== undefined && receipt.releaseId !== releaseRef) bad("BINDING", stage, "releaseId differs from the chain's release");
|
|
230
|
-
|
|
231
|
-
// Consistency across the chain.
|
|
232
|
-
const first = chain[0];
|
|
233
|
-
if (index > 0 && isObj(first)) {
|
|
234
|
-
for (const f of ["requestId", "jobId", "repositoryId", "commit", "inputDigest", "configDigest", "policyDigest", "stagePlanDigest", "operation", "targetSet"]) {
|
|
235
|
-
if (f === "targetSet" ? canonicalJson(receipt[f] ?? null) !== canonicalJson(first[f] ?? null) : receipt[f] !== first[f]) bad(f === "requestId" ? "WRONG_REQUEST_ID" : f === "inputDigest" ? "WRONG_INPUT_DIGEST" : f === "operation" ? "WRONG_OPERATION" : "BINDING", stage, `${f} differs from the ADMISSION receipt`);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Freshness.
|
|
240
|
-
const issued = Date.parse(receipt.issuedAt); const expires = Date.parse(receipt.expiresAt);
|
|
241
|
-
if (Number.isNaN(issued) || Number.isNaN(expires)) bad("ENVELOPE", stage, "issuedAt and expiresAt must be ISO timestamps");
|
|
242
|
-
else {
|
|
243
|
-
if (expires <= issued) bad("ENVELOPE", stage, "expiresAt must be after issuedAt");
|
|
244
|
-
if (expires <= now) bad("EXPIRED", stage, `expired at ${receipt.expiresAt}`);
|
|
245
|
-
if (issued > now + 60_000) bad("ENVELOPE", stage, "issuedAt is in the future");
|
|
246
|
-
if (previous && issued < Date.parse(previous.issuedAt)) bad("ORDER", stage, "issued before its parent receipt");
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Hash chain.
|
|
250
|
-
const wantParents = previous ? [receiptHash(previous)] : [];
|
|
251
|
-
if (!Array.isArray(receipt.parents) || canonicalJson(receipt.parents) !== canonicalJson(wantParents)) bad("BROKEN_CHAIN", stage, previous ? `parents must be exactly [hash of ${previous.type}]` : "ADMISSION has no parents");
|
|
252
|
-
|
|
253
|
-
// Replay (the ledger is required; see NO_LEDGER above).
|
|
254
|
-
if (typeof options.ledger?.requestOwner === "function" && typeof options.ledger?.isConsumed === "function") {
|
|
255
|
-
const owner = options.ledger.requestOwner(receipt.requestId);
|
|
256
|
-
if (owner !== undefined && owner !== receipt.jobId) bad("REPLAYED_REQUEST_ID", stage, "requestId already belongs to another job");
|
|
257
|
-
if (options.ledger.isConsumed(receipt.receiptId)) bad("REPLAYED_RECEIPT", stage, "receipt already consumed");
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Signature (injected).
|
|
261
|
-
if (options.keyAllowed && !options.keyAllowed(receipt.keyId, receipt)) bad("KEY_REVOKED", stage, `key ${receipt.keyId} is not allowed for this receipt`);
|
|
262
|
-
let signatureOk = false;
|
|
263
|
-
try { signatureOk = options.verifySignature({ receipt, keyId: receipt.keyId, signingBytes: signingBytes(receipt), signature: receipt.signature }) === true; } catch { signatureOk = false; }
|
|
264
|
-
if (!signatureOk) bad("BAD_SIGNATURE", stage, "signature did not verify");
|
|
265
|
-
|
|
266
|
-
byStage[stage] = receipt;
|
|
267
|
-
// Stage evidence last: it can look at the earlier stages collected so far.
|
|
268
|
-
if (isObj(receipt.evidence) && STAGE_EVIDENCE[stage]) {
|
|
269
|
-
for (const message of STAGE_EVIDENCE[stage](receipt.evidence, { byStage, requiredScans: options.requiredScans })) {
|
|
270
|
-
const code = /trusted required scan set/.test(message) ? "NO_SCAN_POLICY" : /missing|claims|carries/.test(message) && (stage === "SCAN_A" || stage === "SCAN_B") ? (/claims|carries/.test(message) ? "SCAN_CLASS_MIX" : "SCAN_INCOMPLETE") : "EVIDENCE";
|
|
271
|
-
bad(code, stage, message);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
previous = receipt;
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
const head = chain[chain.length - 1];
|
|
278
|
-
if (expected.stage !== undefined) {
|
|
279
|
-
if (!STAGES.includes(expected.stage)) bad("WRONG_STAGE", null, `unknown expected stage ${expected.stage}`);
|
|
280
|
-
else if (head?.type !== expected.stage) bad("WRONG_STAGE", head?.type, `chain ends at ${head?.type}, expected ${expected.stage}`);
|
|
281
|
-
}
|
|
282
|
-
return { ok: problems.length === 0, problems, head: isObj(head) ? { type: head.type, hash: receiptHash(head) } : null };
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Verify that a new stage may start: the chain so far must verify and end at the stage `stage` requires.
|
|
287
|
-
* Returns the same result shape; `authorizes` is the stage the chain authorizes next (or null).
|
|
288
|
-
*/
|
|
289
|
-
export function authorizeNextStage(chain, stage, options = {}) {
|
|
290
|
-
const need = PREREQUISITES[stage];
|
|
291
|
-
if (!need) return { ok: false, problems: [{ code: "UNKNOWN_STAGE", stage, message: `unknown stage ${stage}` }], head: null, authorizes: null };
|
|
292
|
-
if (need.length === 0) return { ok: !chain?.length, problems: chain?.length ? [{ code: "WRONG_STAGE", stage, message: "ADMISSION starts a chain" }] : [], head: null, authorizes: chain?.length ? null : stage };
|
|
293
|
-
const result = verifyReceiptChain(chain, { ...options, expected: { ...(options.expected ?? {}), stage: need[0] } });
|
|
294
|
-
// The requested stage itself must sit inside the operation's boundary and ADMISSION's authorized stages.
|
|
295
|
-
const first = Array.isArray(chain) ? chain[0] : null;
|
|
296
|
-
const last = OPERATION_LAST_STAGE[first?.operation];
|
|
297
|
-
if (!last || STAGES.indexOf(stage) > STAGES.indexOf(last)) result.problems.push({ code: "WRONG_OPERATION", stage, message: `operation ${first?.operation} does not authorize ${stage}` });
|
|
298
|
-
const admission = first?.type === "ADMISSION" && isObj(first.evidence) ? first.evidence : null;
|
|
299
|
-
for (const x of admissionProblems(admission, stage)) result.problems.push({ code: x.code, stage, message: x.message });
|
|
300
|
-
result.ok = result.problems.length === 0;
|
|
301
|
-
return { ...result, authorizes: result.ok ? stage : null };
|
|
302
|
-
}
|
|
1
|
+
// Pipeline stage receipts and their verifier (architecture-v3 sections 4-5, "Receipts").
|
|
2
|
+
//
|
|
3
|
+
// A receipt records a fact about one stage of one request. It never authorizes execution alone: the
|
|
4
|
+
// broker (RustRight) signs receipts and issues short-lived capabilities; this module only checks that
|
|
5
|
+
// a chain of receipts is well formed, linked, fresh and complete. Signature checking is an injected
|
|
6
|
+
// interface (`verifySignature`); this module does no cryptography beyond SHA-256 hashing and invents none.
|
|
7
|
+
//
|
|
8
|
+
// Chain: ADMISSION -> SCAN_A -> ROUTE -> SCAN_B -> BUILD -> SIGN -> QUALIFIED -> PUBLISH_INTENT -> PUBLISHED.
|
|
9
|
+
// Each stage requires exactly its predecessor; `parents` carries that predecessor's receipt hash.
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
import { canonicalJson, digestScanSet, scanClassOf } from "./deterministic-scans.mjs";
|
|
12
|
+
|
|
13
|
+
export const RECEIPT_SCHEMA = "rightkit.pipeline-receipt/1";
|
|
14
|
+
export const STAGES = Object.freeze(["ADMISSION", "SCAN_A", "ROUTE", "SCAN_B", "BUILD", "SIGN", "QUALIFIED", "PUBLISH_INTENT", "PUBLISHED"]);
|
|
15
|
+
export const OPERATIONS = Object.freeze(["dev", "build", "qualify", "sign", "publish"]);
|
|
16
|
+
/** The last stage each operation may reach. */
|
|
17
|
+
export const OPERATION_LAST_STAGE = Object.freeze({ dev: "BUILD", build: "BUILD", sign: "SIGN", qualify: "QUALIFIED", publish: "PUBLISHED" });
|
|
18
|
+
/** Each stage requires only these predecessors. */
|
|
19
|
+
export const PREREQUISITES = Object.freeze({
|
|
20
|
+
ADMISSION: [], SCAN_A: ["ADMISSION"], ROUTE: ["SCAN_A"], SCAN_B: ["ROUTE"], BUILD: ["SCAN_B"],
|
|
21
|
+
SIGN: ["BUILD"], QUALIFIED: ["SIGN"], PUBLISH_INTENT: ["QUALIFIED"], PUBLISHED: ["PUBLISH_INTENT"],
|
|
22
|
+
});
|
|
23
|
+
const RELEASE_ID_STAGES = new Set(["SIGN", "QUALIFIED", "PUBLISH_INTENT", "PUBLISHED"]);
|
|
24
|
+
/** Stages only a publishable request may reach: signing and everything after it. A dev or non-publishable chain stops at BUILD. */
|
|
25
|
+
const PUBLISHABLE_ONLY_STAGES = new Set(RELEASE_ID_STAGES);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What ADMISSION's evidence allows for `stage`, as [{ code, message }]. ADMISSION is the sole authority:
|
|
29
|
+
* a missing or malformed ADMISSION authorizes nothing, and `publishable` must be exactly true to reach SIGN or later.
|
|
30
|
+
*/
|
|
31
|
+
function admissionProblems(admission, stage) {
|
|
32
|
+
const out = [];
|
|
33
|
+
if (!admission) return [{ code: "STAGE_NOT_AUTHORIZED", message: `no valid ADMISSION evidence authorizes ${stage}` }];
|
|
34
|
+
if (!Array.isArray(admission.authorizedStages) || !admission.authorizedStages.includes(stage)) out.push({ code: "STAGE_NOT_AUTHORIZED", message: `ADMISSION does not authorize ${stage}` });
|
|
35
|
+
if (admission.publishable !== true && PUBLISHABLE_ONLY_STAGES.has(stage)) out.push({ code: "NOT_PUBLISHABLE", message: `ADMISSION does not mark this request publishable:true; it cannot reach ${stage}` });
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ENVELOPE_STRINGS = ["receiptId", "jobId", "requestId", "repositoryId", "commit", "inputDigest", "configDigest", "policyDigest", "stagePlanDigest", "runtimeDigest", "executor", "issuedAt", "expiresAt", "keyId", "signature"];
|
|
40
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
41
|
+
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
|
42
|
+
|
|
43
|
+
/** Domain-separated bytes a signature covers: receipt type bound in, signature field excluded. */
|
|
44
|
+
export function signingBytes(receipt) {
|
|
45
|
+
const { signature, ...rest } = receipt;
|
|
46
|
+
return Buffer.from(`${RECEIPT_SCHEMA}\0${receipt.type}\0${canonicalJson(rest)}`, "utf8");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The hash a child receipt records in `parents`: covers the whole receipt including its signature. */
|
|
50
|
+
export function receiptHash(receipt) {
|
|
51
|
+
return sha256(Buffer.concat([Buffer.from(`${RECEIPT_SCHEMA}.hash\0`, "utf8"), Buffer.from(canonicalJson(receipt), "utf8")]));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** In-memory request ledger for tests and single-process brokers: records which job owns a requestId. */
|
|
55
|
+
export function createMemoryLedger() {
|
|
56
|
+
const owners = new Map(); const consumed = new Set();
|
|
57
|
+
return {
|
|
58
|
+
requestOwner: (requestId) => owners.get(requestId),
|
|
59
|
+
isConsumed: (receiptId) => consumed.has(receiptId),
|
|
60
|
+
record(chain) { for (const r of chain) { if (!owners.has(r.requestId)) owners.set(r.requestId, r.jobId); consumed.add(r.receiptId); } },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
65
|
+
const nonEmptyString = (v) => typeof v === "string" && v.trim() !== "";
|
|
66
|
+
const isHex = (v) => typeof v === "string" && HEX64.test(v);
|
|
67
|
+
const nonEmptyArray = (v) => Array.isArray(v) && v.length > 0;
|
|
68
|
+
|
|
69
|
+
/** Stage-specific evidence checks. Each returns a list of problem messages. */
|
|
70
|
+
const STAGE_EVIDENCE = {
|
|
71
|
+
ADMISSION(e) {
|
|
72
|
+
const p = [];
|
|
73
|
+
for (const f of ["inputManifestDigest", "dependencyManifestDigest", "toolchainManifestDigest"]) if (!isHex(e[f])) p.push(`${f} must be a sha256 hex digest`);
|
|
74
|
+
if (!nonEmptyArray(e.authorizedStages) || e.authorizedStages.some((s) => !STAGES.includes(s))) p.push("authorizedStages must list known stages");
|
|
75
|
+
if (typeof e.publishable !== "boolean") p.push("publishable must be a boolean");
|
|
76
|
+
return p;
|
|
77
|
+
},
|
|
78
|
+
SCAN_A: (e, ctx) => scanEvidence(e, "A", ctx),
|
|
79
|
+
ROUTE(e, ctx) {
|
|
80
|
+
const p = [];
|
|
81
|
+
const d = e.decision;
|
|
82
|
+
if (!isObj(d)) return ["decision is required"];
|
|
83
|
+
if (!["local", "ci"].includes(d.executor)) p.push("decision.executor must be local or ci");
|
|
84
|
+
if (!["public", "private"].includes(d.visibility)) p.push("decision.visibility must be public or private (unknown visibility is rejected)");
|
|
85
|
+
if (d.visibility === "private" && d.executor !== "local") p.push("a private repository never routes to CI");
|
|
86
|
+
if (typeof d.localGrant !== "boolean") p.push("decision.localGrant must be a boolean");
|
|
87
|
+
if (d.executor === "local" && !nonEmptyString(String(e.leaseFence ?? ""))) p.push("a local route needs a leaseFence");
|
|
88
|
+
if (d.executor === "ci" && !isObj(e.dispatchBinding)) p.push("a CI route needs a dispatchBinding");
|
|
89
|
+
if (!nonEmptyString(d.policyVersion)) p.push("decision.policyVersion is required");
|
|
90
|
+
return p;
|
|
91
|
+
},
|
|
92
|
+
SCAN_B: (e, ctx) => scanEvidence(e, "B", ctx),
|
|
93
|
+
BUILD(e) {
|
|
94
|
+
const p = [];
|
|
95
|
+
if (!isHex(e.artifactManifestDigest)) p.push("artifactManifestDigest must be a sha256 hex digest");
|
|
96
|
+
if (!nonEmptyArray(e.artifacts) || e.artifacts.some((a) => !nonEmptyString(a?.path) || !isHex(a?.sha256))) p.push("artifacts must list path and sha256 for every output");
|
|
97
|
+
for (const f of ["generationEvidenceDigest", "provenanceDigest"]) if (!isHex(e[f])) p.push(`${f} must be a sha256 hex digest`);
|
|
98
|
+
if (!nonEmptyArray(e.targetMatrix)) p.push("targetMatrix must list the built targets");
|
|
99
|
+
return p;
|
|
100
|
+
},
|
|
101
|
+
SIGN(e, ctx) {
|
|
102
|
+
const p = [];
|
|
103
|
+
if (!["SIGN", "SEAL"].includes(e.mode)) p.push("mode must be SIGN or SEAL");
|
|
104
|
+
for (const f of ["inputManifestDigest", "outputManifestDigest", "signingEvidenceDigest"]) if (!isHex(e[f])) p.push(`${f} must be a sha256 hex digest`);
|
|
105
|
+
if (!nonEmptyString(e.signingProfile)) p.push("signingProfile is required");
|
|
106
|
+
const build = ctx.byStage.BUILD;
|
|
107
|
+
if (build && e.inputManifestDigest !== build.evidence?.artifactManifestDigest) p.push("inputManifestDigest does not equal the BUILD artifactManifestDigest");
|
|
108
|
+
return p;
|
|
109
|
+
},
|
|
110
|
+
QUALIFIED(e, ctx) {
|
|
111
|
+
const p = [];
|
|
112
|
+
if (!isHex(e.artifactManifestDigest)) p.push("artifactManifestDigest must be a sha256 hex digest");
|
|
113
|
+
if (!nonEmptyArray(e.testMatrix) || e.testMatrix.some((t) => !nonEmptyString(t?.name) || t?.status !== "pass")) p.push("testMatrix must be non-empty and every row must pass");
|
|
114
|
+
const sign = ctx.byStage.SIGN;
|
|
115
|
+
if (sign && e.artifactManifestDigest !== sign.evidence?.outputManifestDigest) p.push("artifactManifestDigest does not equal the SIGN outputManifestDigest (qualification covers the final signed bytes)");
|
|
116
|
+
return p;
|
|
117
|
+
},
|
|
118
|
+
PUBLISH_INTENT(e, ctx) {
|
|
119
|
+
const p = [];
|
|
120
|
+
if (!isHex(e.releaseManifestDigest)) p.push("releaseManifestDigest must be a sha256 hex digest");
|
|
121
|
+
if (!nonEmptyArray(e.destinations) || e.destinations.some((d) => !nonEmptyString(d?.id) || !nonEmptyString(d?.identity) || !nonEmptyString(d?.version) || !isHex(d?.expectedDigest) || !nonEmptyString(d?.idempotencyKey))) {
|
|
122
|
+
p.push("destinations must name id, identity, version, expectedDigest and idempotencyKey");
|
|
123
|
+
}
|
|
124
|
+
const q = ctx.byStage.QUALIFIED;
|
|
125
|
+
if (q && e.releaseManifestDigest !== q.evidence?.artifactManifestDigest) p.push("releaseManifestDigest does not equal the QUALIFIED artifactManifestDigest");
|
|
126
|
+
return p;
|
|
127
|
+
},
|
|
128
|
+
PUBLISHED(e, ctx) {
|
|
129
|
+
const p = [];
|
|
130
|
+
const intent = ctx.byStage.PUBLISH_INTENT;
|
|
131
|
+
if (!nonEmptyArray(e.entries)) return ["entries must list every destination outcome"];
|
|
132
|
+
for (const d of intent?.evidence?.destinations ?? []) {
|
|
133
|
+
const entry = e.entries.find((x) => x?.destinationId === d.id);
|
|
134
|
+
if (!entry) p.push(`no entry for destination ${d.id}`);
|
|
135
|
+
else if (entry.outcome !== "verified") p.push(`destination ${d.id} is ${entry.outcome}, not verified`);
|
|
136
|
+
else if (entry.remoteDigest !== d.expectedDigest) p.push(`destination ${d.id} readback digest differs from the intent`);
|
|
137
|
+
}
|
|
138
|
+
return p;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
function scanEvidence(e, cls, ctx) {
|
|
143
|
+
const p = [];
|
|
144
|
+
const other = cls === "A" ? "B" : "A";
|
|
145
|
+
if (e.scanClass !== cls) p.push(`scanClass must be ${cls}`);
|
|
146
|
+
if (!nonEmptyArray(e.scans)) return [...p, "scans must list every required scan result"];
|
|
147
|
+
if (cls === "A" && !isHex(e.advisorySnapshotDigest)) p.push("advisorySnapshotDigest must be a sha256 hex digest");
|
|
148
|
+
if (cls === "A" && !nonEmptyString(e.advisorySnapshotTime)) p.push("advisorySnapshotTime is required");
|
|
149
|
+
for (const s of e.scans) {
|
|
150
|
+
const derived = scanClassOf(s?.id);
|
|
151
|
+
if (s?.class === other || derived === other) p.push(`${cls === "A" ? "SCAN_A claims" : "SCAN_B carries"} class ${other} scan ${s?.id}`);
|
|
152
|
+
else if (s?.class !== cls) p.push(`scan ${s?.id} has class ${s?.class}, expected ${cls}`);
|
|
153
|
+
if (s?.status !== "pass") p.push(`scan ${s?.id} is ${s?.status}, not pass`);
|
|
154
|
+
}
|
|
155
|
+
const ids = new Set(e.scans.map((s) => s?.id));
|
|
156
|
+
const required = ctx.requiredScans?.[cls];
|
|
157
|
+
const declared = Array.isArray(e.requiredScanIds) ? e.requiredScanIds : null;
|
|
158
|
+
if (!declared) p.push("requiredScanIds is required");
|
|
159
|
+
// The required set comes only from the injected policy matrix. A receipt's own declaration is never trusted:
|
|
160
|
+
// without the policy, verification fails closed.
|
|
161
|
+
if (!Array.isArray(required) || required.some((id) => typeof id !== "string")) {
|
|
162
|
+
p.push(`trusted required scan set for class ${cls} is not supplied by policy; verification fails closed`);
|
|
163
|
+
return p;
|
|
164
|
+
}
|
|
165
|
+
for (const id of required) if (!ids.has(id)) p.push(`required class ${cls} scan ${id} is missing`);
|
|
166
|
+
if (declared && canonicalJson([...required].sort()) !== canonicalJson([...declared].sort())) p.push(`requiredScanIds differ from the trusted class ${cls} set`);
|
|
167
|
+
if (e.scanSetDigest !== digestScanSet(e.scans)) p.push("scanSetDigest does not match the listed scans");
|
|
168
|
+
if (cls === "B" && !nonEmptyArray(required)) p.push("SCAN_B must require at least one class B scan");
|
|
169
|
+
return p;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Verify a chain of receipts (ADMISSION first, ending at the stage being authorized).
|
|
174
|
+
*
|
|
175
|
+
* options:
|
|
176
|
+
* verifySignature({ receipt, keyId, signingBytes, signature }) -> boolean REQUIRED (the broker supplies keys)
|
|
177
|
+
* now ISO string or Date, default current time
|
|
178
|
+
* expected { requestId, operation, inputDigest, stage, repositoryId, commit, jobId, releaseId, targetSet } (each optional but checked when given)
|
|
179
|
+
* requiredScans { A: [ids], B: [ids] } trusted required sets from the policy matrix: REQUIRED; absence rejects (NO_SCAN_POLICY)
|
|
180
|
+
* ledger { requestOwner(requestId), isConsumed(receiptId) } REQUIRED: without it verification fails (NO_LEDGER)
|
|
181
|
+
* keyAllowed (keyId, receipt) -> boolean, for revocations
|
|
182
|
+
* Returns { ok, problems: [{ code, stage, message }], head }. Never throws on a bad receipt.
|
|
183
|
+
*/
|
|
184
|
+
export function verifyReceiptChain(chain, options = {}) {
|
|
185
|
+
const problems = [];
|
|
186
|
+
const bad = (code, stage, message) => problems.push({ code, stage: stage ?? null, message });
|
|
187
|
+
if (typeof options.verifySignature !== "function") return { ok: false, problems: [{ code: "NO_SIGNATURE_VERIFIER", stage: null, message: "a signature verifier must be supplied by the broker" }], head: null };
|
|
188
|
+
if (!Array.isArray(chain) || chain.length === 0) return { ok: false, problems: [{ code: "EMPTY_CHAIN", stage: null, message: "no receipts" }], head: null };
|
|
189
|
+
const now = new Date(options.now ?? Date.now()).getTime();
|
|
190
|
+
const expected = options.expected ?? {};
|
|
191
|
+
const byStage = {};
|
|
192
|
+
let previous = null;
|
|
193
|
+
// Replay protection is mandatory: without a ledger the chain cannot be proven fresh, so it fails closed.
|
|
194
|
+
if (typeof options.ledger?.requestOwner !== "function" || typeof options.ledger?.isConsumed !== "function") {
|
|
195
|
+
bad("NO_LEDGER", null, "a request ledger with requestOwner and isConsumed must be supplied; replay cannot be ruled out without it");
|
|
196
|
+
}
|
|
197
|
+
// One release id for the whole chain: the ADMISSION one when it has it, else the first release-stage one.
|
|
198
|
+
const releaseRef = chain.find((r) => isObj(r) && nonEmptyString(r.releaseId))?.releaseId;
|
|
199
|
+
// ADMISSION fixes which stages may run and whether the request may publish at all.
|
|
200
|
+
const admission = isObj(chain[0]) && chain[0].type === "ADMISSION" && isObj(chain[0].evidence) ? chain[0].evidence : null;
|
|
201
|
+
|
|
202
|
+
chain.forEach((receipt, index) => {
|
|
203
|
+
const stage = isObj(receipt) ? receipt.type : null;
|
|
204
|
+
if (!isObj(receipt)) { bad("MALFORMED", null, `receipt ${index} is not an object`); return; }
|
|
205
|
+
if (receipt.schema !== RECEIPT_SCHEMA) bad("SCHEMA", stage, `schema must be ${RECEIPT_SCHEMA}`);
|
|
206
|
+
if (!STAGES.includes(stage)) { bad("UNKNOWN_STAGE", stage, `unknown receipt type ${JSON.stringify(stage)}`); return; }
|
|
207
|
+
const wantStage = STAGES[index];
|
|
208
|
+
if (stage !== wantStage) bad("WRONG_STAGE", stage, `position ${index} requires ${wantStage}, got ${stage}`);
|
|
209
|
+
for (const f of ENVELOPE_STRINGS) if (!nonEmptyString(receipt[f])) bad("ENVELOPE", stage, `${f} is required`);
|
|
210
|
+
if (!Number.isInteger(receipt.attempt) || receipt.attempt < 1) bad("ENVELOPE", stage, "attempt must be an integer >= 1");
|
|
211
|
+
if (!Array.isArray(receipt.targetSet)) bad("ENVELOPE", stage, "targetSet must be an array");
|
|
212
|
+
if (!OPERATIONS.includes(receipt.operation)) bad("WRONG_OPERATION", stage, `unknown operation ${JSON.stringify(receipt.operation)}`);
|
|
213
|
+
if (RELEASE_ID_STAGES.has(stage) && !nonEmptyString(receipt.releaseId)) bad("ENVELOPE", stage, "releaseId is required");
|
|
214
|
+
if (!isObj(receipt.evidence)) bad("EVIDENCE", stage, "evidence is required");
|
|
215
|
+
|
|
216
|
+
// The operation bounds how far a request may go.
|
|
217
|
+
const last = OPERATION_LAST_STAGE[receipt.operation];
|
|
218
|
+
if (last && STAGES.indexOf(stage) > STAGES.indexOf(last)) bad("WRONG_OPERATION", stage, `operation ${receipt.operation} does not authorize ${stage}`);
|
|
219
|
+
|
|
220
|
+
// ADMISSION bounds the chain: only its authorized stages, and nothing from SIGN on unless publishable:true.
|
|
221
|
+
for (const x of admissionProblems(admission, stage)) bad(x.code, stage, x.message);
|
|
222
|
+
|
|
223
|
+
// Binding to the caller's expectations.
|
|
224
|
+
if (expected.requestId !== undefined && receipt.requestId !== expected.requestId) bad("WRONG_REQUEST_ID", stage, "requestId differs from the expected request");
|
|
225
|
+
if (expected.operation !== undefined && receipt.operation !== expected.operation) bad("WRONG_OPERATION", stage, `operation ${receipt.operation} differs from expected ${expected.operation}`);
|
|
226
|
+
if (expected.inputDigest !== undefined && receipt.inputDigest !== expected.inputDigest) bad("WRONG_INPUT_DIGEST", stage, "inputDigest differs from the expected input");
|
|
227
|
+
for (const f of ["repositoryId", "commit", "jobId", "releaseId"]) if (expected[f] !== undefined && receipt[f] !== expected[f]) bad("BINDING", stage, `${f} differs from the expected value`);
|
|
228
|
+
if (expected.targetSet !== undefined && canonicalJson(receipt.targetSet ?? null) !== canonicalJson(expected.targetSet)) bad("BINDING", stage, "targetSet differs from the expected value");
|
|
229
|
+
if (releaseRef !== undefined && receipt.releaseId !== undefined && receipt.releaseId !== releaseRef) bad("BINDING", stage, "releaseId differs from the chain's release");
|
|
230
|
+
|
|
231
|
+
// Consistency across the chain.
|
|
232
|
+
const first = chain[0];
|
|
233
|
+
if (index > 0 && isObj(first)) {
|
|
234
|
+
for (const f of ["requestId", "jobId", "repositoryId", "commit", "inputDigest", "configDigest", "policyDigest", "stagePlanDigest", "operation", "targetSet"]) {
|
|
235
|
+
if (f === "targetSet" ? canonicalJson(receipt[f] ?? null) !== canonicalJson(first[f] ?? null) : receipt[f] !== first[f]) bad(f === "requestId" ? "WRONG_REQUEST_ID" : f === "inputDigest" ? "WRONG_INPUT_DIGEST" : f === "operation" ? "WRONG_OPERATION" : "BINDING", stage, `${f} differs from the ADMISSION receipt`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Freshness.
|
|
240
|
+
const issued = Date.parse(receipt.issuedAt); const expires = Date.parse(receipt.expiresAt);
|
|
241
|
+
if (Number.isNaN(issued) || Number.isNaN(expires)) bad("ENVELOPE", stage, "issuedAt and expiresAt must be ISO timestamps");
|
|
242
|
+
else {
|
|
243
|
+
if (expires <= issued) bad("ENVELOPE", stage, "expiresAt must be after issuedAt");
|
|
244
|
+
if (expires <= now) bad("EXPIRED", stage, `expired at ${receipt.expiresAt}`);
|
|
245
|
+
if (issued > now + 60_000) bad("ENVELOPE", stage, "issuedAt is in the future");
|
|
246
|
+
if (previous && issued < Date.parse(previous.issuedAt)) bad("ORDER", stage, "issued before its parent receipt");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Hash chain.
|
|
250
|
+
const wantParents = previous ? [receiptHash(previous)] : [];
|
|
251
|
+
if (!Array.isArray(receipt.parents) || canonicalJson(receipt.parents) !== canonicalJson(wantParents)) bad("BROKEN_CHAIN", stage, previous ? `parents must be exactly [hash of ${previous.type}]` : "ADMISSION has no parents");
|
|
252
|
+
|
|
253
|
+
// Replay (the ledger is required; see NO_LEDGER above).
|
|
254
|
+
if (typeof options.ledger?.requestOwner === "function" && typeof options.ledger?.isConsumed === "function") {
|
|
255
|
+
const owner = options.ledger.requestOwner(receipt.requestId);
|
|
256
|
+
if (owner !== undefined && owner !== receipt.jobId) bad("REPLAYED_REQUEST_ID", stage, "requestId already belongs to another job");
|
|
257
|
+
if (options.ledger.isConsumed(receipt.receiptId)) bad("REPLAYED_RECEIPT", stage, "receipt already consumed");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Signature (injected).
|
|
261
|
+
if (options.keyAllowed && !options.keyAllowed(receipt.keyId, receipt)) bad("KEY_REVOKED", stage, `key ${receipt.keyId} is not allowed for this receipt`);
|
|
262
|
+
let signatureOk = false;
|
|
263
|
+
try { signatureOk = options.verifySignature({ receipt, keyId: receipt.keyId, signingBytes: signingBytes(receipt), signature: receipt.signature }) === true; } catch { signatureOk = false; }
|
|
264
|
+
if (!signatureOk) bad("BAD_SIGNATURE", stage, "signature did not verify");
|
|
265
|
+
|
|
266
|
+
byStage[stage] = receipt;
|
|
267
|
+
// Stage evidence last: it can look at the earlier stages collected so far.
|
|
268
|
+
if (isObj(receipt.evidence) && STAGE_EVIDENCE[stage]) {
|
|
269
|
+
for (const message of STAGE_EVIDENCE[stage](receipt.evidence, { byStage, requiredScans: options.requiredScans })) {
|
|
270
|
+
const code = /trusted required scan set/.test(message) ? "NO_SCAN_POLICY" : /missing|claims|carries/.test(message) && (stage === "SCAN_A" || stage === "SCAN_B") ? (/claims|carries/.test(message) ? "SCAN_CLASS_MIX" : "SCAN_INCOMPLETE") : "EVIDENCE";
|
|
271
|
+
bad(code, stage, message);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
previous = receipt;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const head = chain[chain.length - 1];
|
|
278
|
+
if (expected.stage !== undefined) {
|
|
279
|
+
if (!STAGES.includes(expected.stage)) bad("WRONG_STAGE", null, `unknown expected stage ${expected.stage}`);
|
|
280
|
+
else if (head?.type !== expected.stage) bad("WRONG_STAGE", head?.type, `chain ends at ${head?.type}, expected ${expected.stage}`);
|
|
281
|
+
}
|
|
282
|
+
return { ok: problems.length === 0, problems, head: isObj(head) ? { type: head.type, hash: receiptHash(head) } : null };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Verify that a new stage may start: the chain so far must verify and end at the stage `stage` requires.
|
|
287
|
+
* Returns the same result shape; `authorizes` is the stage the chain authorizes next (or null).
|
|
288
|
+
*/
|
|
289
|
+
export function authorizeNextStage(chain, stage, options = {}) {
|
|
290
|
+
const need = PREREQUISITES[stage];
|
|
291
|
+
if (!need) return { ok: false, problems: [{ code: "UNKNOWN_STAGE", stage, message: `unknown stage ${stage}` }], head: null, authorizes: null };
|
|
292
|
+
if (need.length === 0) return { ok: !chain?.length, problems: chain?.length ? [{ code: "WRONG_STAGE", stage, message: "ADMISSION starts a chain" }] : [], head: null, authorizes: chain?.length ? null : stage };
|
|
293
|
+
const result = verifyReceiptChain(chain, { ...options, expected: { ...(options.expected ?? {}), stage: need[0] } });
|
|
294
|
+
// The requested stage itself must sit inside the operation's boundary and ADMISSION's authorized stages.
|
|
295
|
+
const first = Array.isArray(chain) ? chain[0] : null;
|
|
296
|
+
const last = OPERATION_LAST_STAGE[first?.operation];
|
|
297
|
+
if (!last || STAGES.indexOf(stage) > STAGES.indexOf(last)) result.problems.push({ code: "WRONG_OPERATION", stage, message: `operation ${first?.operation} does not authorize ${stage}` });
|
|
298
|
+
const admission = first?.type === "ADMISSION" && isObj(first.evidence) ? first.evidence : null;
|
|
299
|
+
for (const x of admissionProblems(admission, stage)) result.problems.push({ code: x.code, stage, message: x.message });
|
|
300
|
+
result.ok = result.problems.length === 0;
|
|
301
|
+
return { ...result, authorizes: result.ok ? stage : null };
|
|
302
|
+
}
|
package/publication-gate.mjs
CHANGED
|
@@ -370,7 +370,7 @@ export function verifyNpmRegistryBytes({ name, version, sha1 }, deps = {}) {
|
|
|
370
370
|
}
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
-
function readKeychainToken(run) {
|
|
373
|
+
export function readKeychainToken(run = spawnSync) {
|
|
374
374
|
if (process.platform !== "darwin") throw new Error(`npm publish token lookup is implemented for the macOS Keychain (${NPM_KEYCHAIN_SERVICE}) only`);
|
|
375
375
|
const result = run("security", ["find-generic-password", "-s", NPM_KEYCHAIN_SERVICE, "-w"], { encoding: "utf8", windowsHide: true });
|
|
376
376
|
const token = (result.stdout ?? "").trim();
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `right-release publish-local npm --package @rightkit/<name> --commit <sha>`: the local npm route (Adrian, 2026-11-03, rightkit#1471:
|
|
3
|
+
// "i want you to publish it from local. the npm and crates are both local. we will do a provenance skip fuck your ci. feel free to push
|
|
4
|
+
// but no ci"). Mac only: the token is the granular @rightkit token with 2FA bypass in the Keychain item npm-rightkit-publish, read inside
|
|
5
|
+
// this process, never printed, never on a command line, never left on disk.
|
|
6
|
+
//
|
|
7
|
+
// It publishes exactly the bytes of one commit, and only after:
|
|
8
|
+
// 1. the commit is on origin/main (pushed; [skip ci] is fine);
|
|
9
|
+
// 2. the gate's Class A for the package passes on that commit (`rightkit-gate --to check --unit npm:<name> --commit <sha>`, this
|
|
10
|
+
// package's own gate, so a release publishes through the gate it ships);
|
|
11
|
+
// 3. the version is not on npm yet.
|
|
12
|
+
// The package folder is exported from the commit (git archive) and packed there; npm provenance is off (rightkit#1472). After publishing,
|
|
13
|
+
// the registry's shasum must equal the packed tarball's, or the command fails.
|
|
14
|
+
import crypto from "node:crypto";
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { NPM_KEYCHAIN_SERVICE, readKeychainToken } from "./publication-gate.mjs";
|
|
21
|
+
|
|
22
|
+
const REGISTRY = "https://registry.npmjs.org/";
|
|
23
|
+
const GATE = fileURLToPath(new URL("./bin/rightkit-gate.mjs", import.meta.url));
|
|
24
|
+
|
|
25
|
+
function fail(message) { throw new Error(message); }
|
|
26
|
+
|
|
27
|
+
export function parseArgs(argv) {
|
|
28
|
+
const out = { kind: argv[0], pkg: null, commit: null, repo: process.cwd() };
|
|
29
|
+
for (let i = 1; i < argv.length; i += 1) {
|
|
30
|
+
if (argv[i] === "--package") out.pkg = argv[++i];
|
|
31
|
+
else if (argv[i] === "--commit") out.commit = argv[++i];
|
|
32
|
+
else if (argv[i] === "--repo") out.repo = path.resolve(argv[++i]);
|
|
33
|
+
else fail(`unknown option ${argv[i]}`);
|
|
34
|
+
}
|
|
35
|
+
if (out.kind !== "npm") fail("publish-local supports npm; crates publish with publish-local cargo once it lands");
|
|
36
|
+
if (!/^@rightkit\/[a-z0-9-]+$/.test(out.pkg ?? "")) fail("--package @rightkit/<name> is required");
|
|
37
|
+
if (!/^[0-9a-f]{7,40}$/.test(out.commit ?? "")) fail("--commit <sha> is required");
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function publishLocalNpm(argv, { run = spawnSync, log = console.log, platform = process.platform, readToken = () => readKeychainToken(run) } = {}) {
|
|
42
|
+
const o = parseArgs(argv);
|
|
43
|
+
if (platform !== "darwin") fail(`publish-local npm runs on the Mac (the token is the Keychain item ${NPM_KEYCHAIN_SERVICE}); this host is ${platform}`);
|
|
44
|
+
const git = (args) => {
|
|
45
|
+
const r = run("git", args, { cwd: o.repo, encoding: "utf8", maxBuffer: 1 << 26 });
|
|
46
|
+
if (r.status !== 0) fail(`git ${args.join(" ")} failed: ${String(r.stderr ?? "").trim()}`);
|
|
47
|
+
return String(r.stdout ?? "").trim();
|
|
48
|
+
};
|
|
49
|
+
const commit = git(["rev-parse", "--verify", `${o.commit}^{commit}`]);
|
|
50
|
+
git(["fetch", "-q", "origin", "main"]);
|
|
51
|
+
if (run("git", ["merge-base", "--is-ancestor", commit, "origin/main"], { cwd: o.repo }).status !== 0) fail(`${commit} is not on origin/main: push it first ([skip ci])`);
|
|
52
|
+
const dir = `packages/${o.pkg.slice("@rightkit/".length)}`;
|
|
53
|
+
const manifest = JSON.parse(git(["show", `${commit}:${dir}/package.json`]));
|
|
54
|
+
if (manifest.name !== o.pkg) fail(`${dir}/package.json at ${commit} names ${manifest.name}, not ${o.pkg}`);
|
|
55
|
+
const version = manifest.version;
|
|
56
|
+
|
|
57
|
+
const already = run("npm", ["view", `${o.pkg}@${version}`, "version", "--prefer-online", "--registry", REGISTRY], { encoding: "utf8" });
|
|
58
|
+
if (String(already.stdout ?? "").trim() === version) fail(`${o.pkg}@${version} is already on npm; bump the version`);
|
|
59
|
+
|
|
60
|
+
log(`publish-local: Class A for npm:${o.pkg} on ${commit.slice(0, 12)}`);
|
|
61
|
+
const classA = run(process.execPath, [GATE, "--to", "check", "--unit", `npm:${o.pkg}`, "--commit", commit], { cwd: o.repo, stdio: ["ignore", "inherit", "inherit"] });
|
|
62
|
+
if (classA.status !== 0) fail(`Class A failed for npm:${o.pkg} on ${commit} (exit ${classA.status}); nothing published`);
|
|
63
|
+
|
|
64
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), "rk-publish-local-"));
|
|
65
|
+
try {
|
|
66
|
+
const archive = run("git", ["archive", "--format=tar", commit, dir], { cwd: o.repo, maxBuffer: 1 << 30 });
|
|
67
|
+
if (archive.status !== 0) fail(`git archive ${commit} ${dir} failed`);
|
|
68
|
+
const tarIn = run("tar", ["-x", "-C", work], { input: archive.stdout });
|
|
69
|
+
if (tarIn.status !== 0) fail("unpacking the commit's package folder failed");
|
|
70
|
+
const pkgDir = path.join(work, dir);
|
|
71
|
+
const packed = run("pnpm", ["pack", "--pack-destination", work], { cwd: pkgDir, encoding: "utf8" });
|
|
72
|
+
if (packed.status !== 0) fail(`pnpm pack failed: ${String(packed.stderr ?? packed.stdout ?? "").trim().split(/\r?\n/).slice(-3).join(" | ")}`);
|
|
73
|
+
const tgz = fs.readdirSync(work).find((f) => f.endsWith(".tgz"));
|
|
74
|
+
if (!tgz) fail("pnpm pack wrote no tarball");
|
|
75
|
+
const bytes = fs.readFileSync(path.join(work, tgz));
|
|
76
|
+
const sha1 = crypto.createHash("sha1").update(bytes).digest("hex");
|
|
77
|
+
log(`publish-local: packed ${tgz} sha1 ${sha1}`);
|
|
78
|
+
|
|
79
|
+
const token = readToken();
|
|
80
|
+
const userconfig = path.join(work, "npmrc");
|
|
81
|
+
fs.writeFileSync(userconfig, "//registry.npmjs.org/:_authToken=${RIGHTKIT_NPM_TOKEN}\n", { mode: 0o600 });
|
|
82
|
+
const redact = (text) => String(text ?? "").split(token).join("<redacted>");
|
|
83
|
+
const pub = run("npm", ["publish", path.join(work, tgz), "--access", "public", "--provenance=false", "--registry", REGISTRY, "--userconfig", userconfig],
|
|
84
|
+
{ encoding: "utf8", env: { ...process.env, RIGHTKIT_NPM_TOKEN: token } });
|
|
85
|
+
if (pub.status !== 0) fail(`npm publish failed:\n${redact(`${pub.stdout ?? ""}${pub.stderr ?? ""}`).trim()}`);
|
|
86
|
+
|
|
87
|
+
// The registry may take a moment to serve a new version.
|
|
88
|
+
let shasum = "";
|
|
89
|
+
for (let i = 0; i < 20 && !shasum; i += 1) {
|
|
90
|
+
const view = run("npm", ["view", `${o.pkg}@${version}`, "dist.shasum", "--prefer-online", "--registry", REGISTRY], { encoding: "utf8" });
|
|
91
|
+
shasum = String(view.stdout ?? "").trim();
|
|
92
|
+
if (!shasum) run("sleep", ["15"]);
|
|
93
|
+
}
|
|
94
|
+
if (shasum !== sha1) fail(`registry shasum ${shasum || "(not served yet)"} != packed ${sha1} for ${o.pkg}@${version}`);
|
|
95
|
+
log(`publish-local: ${o.pkg}@${version} published from ${commit.slice(0, 12)}; registry shasum ${shasum} matches`);
|
|
96
|
+
return { name: o.pkg, version, commit, shasum };
|
|
97
|
+
} finally {
|
|
98
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
103
|
+
try { publishLocalNpm(process.argv.slice(2)); } catch (e) { console.error(`right-release publish-local: ${e.message}`); process.exit(1); }
|
|
104
|
+
}
|
package/rightkit-versions.json
CHANGED
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"@rightkit/qa": "0.6.2",
|
|
50
50
|
"@rightkit/qa-chrome": "0.1.12",
|
|
51
51
|
"@rightkit/react-shell": "0.7.2",
|
|
52
|
-
"@rightkit/release": "0.6.
|
|
52
|
+
"@rightkit/release": "0.6.29",
|
|
53
53
|
"@rightkit/settings-ui": "0.7.0",
|
|
54
54
|
"@rightkit/social-ui": "0.1.0",
|
|
55
55
|
"@rightkit/system-ui": "0.1.0",
|
package/bin/darwin/rightkit-swap
DELETED
|
Binary file
|