@rightkit/release 0.6.28 → 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 +37 -44
- package/build-helpers-command.mjs +54 -35
- package/cli/right-release.mjs +6 -0
- package/deterministic-scans.mjs +2 -1
- package/package.json +1 -1
- package/publication-gate.mjs +1 -1
- package/publish-local-npm.mjs +104 -0
- package/rightkit-versions.json +1 -1
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,31 +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
|
-
// With `strace -y`, a directory fd prints its real path (`AT_FDCWD</abs/cwd>`, `5</abs/dir>`): a relative path resolves against that,
|
|
173
|
-
// never against a guessed folder (rustc writes `full.rmeta` relative to its own working directory).
|
|
174
|
-
const dirOf = new Map([...line.matchAll(/(?:AT_FDCWD|\d+)<([^>]+)>,\s*"((?:[^"\\]|\\.)*)"/g)].map((m) => [m[2], m[1]]));
|
|
175
|
-
const resolveIt = (p) => (path.posix.isAbsolute(p) ? p : path.posix.resolve(dirOf.get(p) ?? cwd, p));
|
|
176
|
-
let paths = [];
|
|
177
|
-
const open = OPEN.exec(line);
|
|
178
|
-
if (open) { if (/O_WRONLY|O_RDWR|O_CREAT|O_TRUNC|O_APPEND/.test(open[3])) paths = [open[2]]; }
|
|
179
|
-
else if (MUTATING.test(line)) paths = [...line.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]);
|
|
180
|
-
for (const p of paths) if (p) out.add(resolveIt(p));
|
|
181
|
-
}
|
|
182
|
-
return [...out];
|
|
183
|
-
}
|
|
184
|
-
|
|
185
162
|
/** The repository's committed list (`.rightkit/build-helper-trace.json`, same schema): reviewed passes every host and CI reads. */
|
|
186
163
|
export const repoListPath = (workspaceRoot) => path.join(workspaceRoot, ".rightkit", "build-helper-trace.json");
|
|
187
164
|
|
|
@@ -270,13 +247,6 @@ export function parseEsloggerWrites(text, { rootPid }) {
|
|
|
270
247
|
return [...out];
|
|
271
248
|
}
|
|
272
249
|
|
|
273
|
-
/** The strace -ff output files (`<prefix>.<pid>`, one per process: no call is ever split across lines). */
|
|
274
|
-
export function readStraceFiles(prefix, { fsApi = fs } = {}) {
|
|
275
|
-
const dir = path.dirname(prefix); const base = path.basename(prefix);
|
|
276
|
-
return fsApi.readdirSync(dir).filter((n) => n.startsWith(`${base}.`)).map((n) => fsApi.readFileSync(path.join(dir, n), "utf8")).join("\n");
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
|
|
280
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). */
|
|
281
251
|
export function forEachRecord(file, sep, fn, { fsApi = fs } = {}) {
|
|
282
252
|
const fd = fsApi.openSync(file, "r"); const buf = Buffer.alloc(8 << 20); let rest = "";
|
|
@@ -329,22 +299,45 @@ export function etwWritesByRoot(file, roots, devices = {}) {
|
|
|
329
299
|
const id = (ev) => Number(/<EventID>(\d+)<\/EventID>/.exec(ev)?.[1]);
|
|
330
300
|
const pidOf = (ev) => Number(/ProcessID="(\d+)"/.exec(ev)?.[1]);
|
|
331
301
|
const provider = (ev) => /Provider Name="([^"]+)"/.exec(ev)?.[1] ?? "";
|
|
332
|
-
|
|
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();
|
|
333
306
|
forEachRecord(file, "</Event>", (ev) => {
|
|
334
|
-
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);
|
|
335
310
|
});
|
|
336
|
-
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();
|
|
337
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"));
|
|
338
318
|
const names = new Map();
|
|
339
319
|
forEachRecord(file, "</Event>", (ev) => {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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)); };
|
|
345
338
|
if (n === 12) { const disposition = (Number(field(ev, "CreateOptions") ?? 0) >>> 24) & 0xff; if (disposition !== 1) add(field(ev, "FileName")); }
|
|
346
339
|
else if (n === 30) add(field(ev, "FileName"));
|
|
347
|
-
else if (n === 16) add(names.get(
|
|
340
|
+
else if (n === 16) add(names.get(field(ev, "FileKey")));
|
|
348
341
|
else if (n === 26 || n === 27) add(field(ev, "FilePath") ?? field(ev, "FileName"));
|
|
349
342
|
});
|
|
350
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.
|
|
@@ -119,20 +130,15 @@ function mainWith(argv, { env, platform, run, log, err }) {
|
|
|
119
130
|
const [sub, ...rest] = argv;
|
|
120
131
|
const at = rest.indexOf("--manifest-path");
|
|
121
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; }
|
|
122
134
|
const listFile = hostListPath(resolveManagedRoots({ env, platform }).dataRoot, platform);
|
|
123
135
|
let inv;
|
|
124
|
-
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; }
|
|
125
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
|
|
126
138
|
// passes, and a host traces only what neither holds.
|
|
127
139
|
const repoFile = repoListPath(inv.metadata.workspace_root);
|
|
128
140
|
const list = mergeLists(readHostList(listFile), readHostList(repoFile));
|
|
129
141
|
const verdict = checkBuildHelpers(inv.units, list);
|
|
130
|
-
if (sub === "check" && !verdict.ok && rest.includes("--trace-missing") && platform === "linux" && verdict.untraced.length) {
|
|
131
|
-
log(`right-release build-helpers: ${verdict.untraced.length} untraced unit(s) on this Linux host; tracing them now (strace -ff)`);
|
|
132
|
-
const traced = mainWith(["trace", ...(manifestPath ? ["--manifest-path", manifestPath] : [])], { env, platform, run, log, err });
|
|
133
|
-
if (traced !== 0) return traced;
|
|
134
|
-
return mainWith(["check", ...(manifestPath ? ["--manifest-path", manifestPath] : [])], { env, platform, run, log, err });
|
|
135
|
-
}
|
|
136
142
|
if (sub === "check") {
|
|
137
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; }
|
|
138
144
|
for (const line of buildHelperRefusal(verdict)) err(line);
|
|
@@ -152,12 +158,20 @@ function mainWith(argv, { env, platform, run, log, err }) {
|
|
|
152
158
|
const recordRepo = rest.includes("--record-repo");
|
|
153
159
|
const cargoHome = env.CARGO_HOME ?? path.join(os.homedir(), ".cargo");
|
|
154
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
|
|
155
|
-
// target, and is judged only on the writes of that tree (eslogger and ETW events name their process
|
|
156
|
-
//
|
|
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.
|
|
157
163
|
const jobsAt = rest.indexOf("--jobs");
|
|
158
164
|
const jobs = Math.max(1, Number.parseInt(jobsAt >= 0 ? rest[jobsAt + 1] : "4", 10) || 4);
|
|
159
165
|
const perBuildJobs = String(Math.max(1, Math.floor(os.cpus().length / jobs)));
|
|
160
|
-
|
|
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)`);
|
|
161
175
|
log(`right-release build-helpers trace: ${pending.length} unit(s), ${jobs} at a time`);
|
|
162
176
|
if (!pending.length) return 0;
|
|
163
177
|
const prepared = pending.map((unit) => {
|
|
@@ -173,18 +187,17 @@ function mainWith(argv, { env, platform, run, log, err }) {
|
|
|
173
187
|
fs.writeFileSync(path.join(probe, "src", "lib.rs"), "");
|
|
174
188
|
try { fs.copyFileSync(path.join(inv.metadata.workspace_root, "Cargo.lock"), path.join(probe, "Cargo.lock")); } catch { /* the probe resolves on its own */ }
|
|
175
189
|
// Release profile, like every other build (GATE-CONTRACT GC-A15): no dev or debug build anywhere, a throwaway probe included.
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
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 };
|
|
184
197
|
});
|
|
185
198
|
const sessionDir = fs.mkdtempSync(path.join(os.tmpdir(), "rk-bht-session-"));
|
|
186
199
|
const traceFile = path.join(sessionDir, "trace.txt");
|
|
187
|
-
// One tracer session for the whole run (macOS eslogger, Windows ETW)
|
|
200
|
+
// One tracer session for the whole run (macOS eslogger, Windows ETW).
|
|
188
201
|
let stopTracer = () => {};
|
|
189
202
|
let etl = null;
|
|
190
203
|
if (platform === "darwin") {
|
|
@@ -247,15 +260,21 @@ function mainWith(argv, { env, platform, run, log, err }) {
|
|
|
247
260
|
if (exported.status !== 0) { err("right-release build-helpers trace: tracerpt could not export the ETW trace"); return 2; }
|
|
248
261
|
try { recorded = fs.statSync(xml).size > 0; if (recorded) byRoot = etwWritesByRoot(xml, roots, devices); } catch { /* no output */ }
|
|
249
262
|
}
|
|
250
|
-
if (
|
|
263
|
+
if (!recorded && roots.size) { err(`right-release build-helpers trace: the ${platform === "darwin" ? "eslogger" : "ETW"} session recorded nothing; nothing recorded`); return 2; }
|
|
251
264
|
prepared.forEach((b, k) => {
|
|
252
265
|
const r = results[k] ?? {};
|
|
253
|
-
if (
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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) ?? [];
|
|
257
276
|
const allowed = platform === "win32" ? [b.scratch, cargoHome, os.tmpdir(), env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, "Temp") : null]
|
|
258
|
-
: [b.scratch, cargoHome, os.tmpdir(), "/private/var/folders", "/dev"
|
|
277
|
+
: [b.scratch, cargoHome, os.tmpdir(), "/private/var/folders", "/dev"];
|
|
259
278
|
// Allowed folders by their real path: a linked CARGO_HOME (~/.cargo -> /Volumes/D/...) or temp (/var -> /private/var) is reported by
|
|
260
279
|
// the tracer under the real path, so comparing the link's path would fail every crate.
|
|
261
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
|
@@ -1253,7 +1253,8 @@ export function planScans({
|
|
|
1253
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).
|
|
1254
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),
|
|
1255
1255
|
// like metadata, deny and audit: an npm-only unit traces nothing.
|
|
1256
|
-
|
|
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" });
|
|
1257
1258
|
// Locked package listings are observations in RustRight (#1216). They run through the selected executor
|
|
1258
1259
|
// from the checked Cargo inputs, just like metadata; an executor refusal remains a failed scan.
|
|
1259
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/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",
|