@rightkit/release 0.6.28 → 0.6.30

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.
@@ -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", "strace-ff", "etw"]);
79
- export const TRACER_OF = Object.freeze({ darwin: "eslogger", linux: "strace-ff", win32: "etw" });
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 = "";
@@ -324,27 +294,57 @@ export function esloggerWritesByRoot(file, roots) {
324
294
  }
325
295
 
326
296
  /** tracerpt XML from `file`, streamed by `</Event>`: Map(root pid -> written paths) for every pid in `roots`, kernel paths mapped by `devices`. */
327
- export function etwWritesByRoot(file, roots, devices = {}) {
297
+ export function etwWritesByRoot(file, roots, devices = {}, { toolchainDirs = [] } = {}) {
328
298
  const field = (ev, name) => new RegExp(`<Data Name="${name}">([^<]*)</Data>`).exec(ev)?.[1];
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
- const parent = new Map();
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) === "Microsoft-Windows-Kernel-Process" && id(ev) === 1) parent.set(Number(field(ev, "ProcessID")), Number(field(ev, "ParentProcessID")));
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 rootOf = rootFinder(parent, roots); const out = new Map([...roots].map((r) => [r, new Set()]));
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
- if (provider(ev) !== "Microsoft-Windows-Kernel-File") return;
341
- const n = id(ev); const fileObject = field(ev, "FileObject");
342
- if (n === 12) names.set(fileObject, field(ev, "FileName"));
343
- const root = rootOf(pidOf(ev)); if (root === null) return;
344
- const add = (p) => { const d = toDos(p); if (d) out.get(root).add(d); };
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
+ // The MSVC linker starts its telemetry helper (vctip.exe, a .NET program) after a link: it writes the user profile and .NET's
326
+ // caches, belongs to Microsoft's toolchain rather than to any crate, and is left out of the build's tree with everything it starts.
327
+ // Only Microsoft's own copy, by path prefix (Program Files\Microsoft Visual Studio): a copy a build script places elsewhere stays in
328
+ // the tree, and placing one under Program Files is itself an outside write the trace fails.
329
+ const imagePath = String(toDos(field(ev, "ImageName")) ?? "").toLowerCase();
330
+ const msvcOwned = imagePath.endsWith("\\vctip.exe") && toolchainDirs.some((d) => imagePath.startsWith(`${String(d).toLowerCase().replace(/[\\/]+$/, "")}\\`));
331
+ if (msvcOwned) live.delete(pid);
332
+ else if (roots.has(pid) && ppid === runner) live.set(pid, pid);
333
+ else if (live.has(ppid)) live.set(pid, live.get(ppid));
334
+ else live.delete(pid);
335
+ } else if (n === 2) live.delete(pid);
336
+ return;
337
+ }
338
+ if (prov !== "Microsoft-Windows-Kernel-File") return;
339
+ // A write names its file by FileKey, which NameCreate (10) maps to the path until NameDelete (11), from whatever process logs them. The
340
+ // session records no close, and FileObject addresses are reused, so mapping a write by FileObject named files the build only read.
341
+ if (n === 10) { names.set(own(field(ev, "FileKey")), own(field(ev, "FileName"))); return; }
342
+ if (n === 11) { names.delete(field(ev, "FileKey")); return; }
343
+ const root = live.get(pidOf(ev)); if (root === undefined) return;
344
+ const add = (p) => { const d = toDos(p); if (d) out.get(root).add(own(d)); };
345
345
  if (n === 12) { const disposition = (Number(field(ev, "CreateOptions") ?? 0) >>> 24) & 0xff; if (disposition !== 1) add(field(ev, "FileName")); }
346
346
  else if (n === 30) add(field(ev, "FileName"));
347
- else if (n === 16) add(names.get(fileObject));
347
+ else if (n === 16) add(names.get(field(ev, "FileKey")));
348
348
  else if (n === 26 || n === 27) add(field(ev, "FilePath") ?? field(ev, "FileName"));
349
349
  });
350
350
  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), strace -ff on Linux, ETW on Windows (Administrator)).
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
- // check --trace-missing (Linux only, where strace needs no admin; CI and Linux hosts): untraced units are traced first, then judged.
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, parseStraceWrites, readStraceFiles, readHostList, recordTrace, repoListPath } from "./build-helper-trace.mjs";
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 args = ["metadata", "--format-version", "1", "--locked", ...(manifestPath ? ["--manifest-path", manifestPath] : [])];
48
- const r = run("cargo", args, { encoding: "utf8", maxBuffer: 1 << 28 });
49
- 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(" | ")}`);
50
- const metadata = JSON.parse(r.stdout);
51
- const lockText = readFile(path.join(metadata.workspace_root, "Cargo.lock"));
52
- return { metadata, units: buildHelperUnits(metadata, lockText, hostFacts(run)) };
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.
@@ -73,6 +84,8 @@ export function cargoWorkspaces(root, { depth = 4, fsApi = fs } = {}) {
73
84
  // in the workspace's own committed list (--record-repo). Default root: /Volumes/D/claude (Mac), D:\claude (Windows), the current
74
85
  // directory elsewhere.
75
86
  function traceAll(argv, { env, platform, log, err }) {
87
+ // A unit is judged once per run: a verdict recorded in one workspace (pass, fail or unbuildable) is never rebuilt in the next.
88
+ const judged = new Set();
76
89
  const roots = argv.flatMap((a, i) => (a === "--root" && argv[i + 1] ? [path.resolve(argv[i + 1])] : []));
77
90
  if (!roots.length) roots.push(platform === "darwin" ? "/Volumes/D/claude" : platform === "win32" ? "D:\\claude" : process.cwd());
78
91
  let keepAlive = null;
@@ -92,7 +105,7 @@ function traceAll(argv, { env, platform, log, err }) {
92
105
  const jobsAt = argv.indexOf("--jobs");
93
106
  // The host list is what Class A reads on this host; a repository's committed list is written only on request (--record-repo), so
94
107
  // tracing never edits a checkout another owner holds.
95
- const code = main(["trace", "--manifest-path", path.join(ws, "Cargo.toml"), ...(argv.includes("--record-repo") ? ["--record-repo"] : []), ...(jobsAt >= 0 ? ["--jobs", argv[jobsAt + 1]] : [])], { env, platform, log, err });
108
+ const code = main(["trace", "--manifest-path", path.join(ws, "Cargo.toml"), ...(argv.includes("--record-repo") ? ["--record-repo"] : []), ...(jobsAt >= 0 ? ["--jobs", argv[jobsAt + 1]] : [])], { env, platform, log, err, judged });
96
109
  if (code !== 0) err(`right-release build-helpers trace-all: ${ws} ended with ${code}`);
97
110
  worst = Math.max(worst, code);
98
111
  }
@@ -101,7 +114,7 @@ function traceAll(argv, { env, platform, log, err }) {
101
114
  return worst;
102
115
  }
103
116
 
104
- export function main(argv = process.argv.slice(2), { env = process.env, platform = process.platform, run: runRaw = null, log = console.log, err = console.error } = {}) {
117
+ export function main(argv = process.argv.slice(2), { env = process.env, platform = process.platform, run: runRaw = null, log = console.log, err = console.error, judged = new Set() } = {}) {
105
118
  if (argv[0] === "trace-all") return traceAll(argv.slice(1), { env, platform, log, err });
106
119
  // GATE-CONTRACT GC-A11: the check's cargo and rustc calls compile nothing and run directly with the real toolchain (RustRight shims off PATH),
107
120
  // writing to a scratch target, never through the broker.
@@ -112,27 +125,22 @@ export function main(argv = process.argv.slice(2), { env = process.env, platform
112
125
  // The parallel trace builds are spawned by the batch runner with the job's own env: that env carries the direct PATH too, so no build
113
126
  // ever goes through the RustRight shim (which refuses a build that redirects its output folder).
114
127
  const directEnv = { ...env, [pathKey]: directPath };
115
- try { return mainWith(argv, { env: directEnv, platform, run, log, err }); } finally { fs.rmSync(scratchTarget, { recursive: true, force: true }); }
128
+ try { return mainWith(argv, { env: directEnv, platform, run, log, err, judged }); } finally { fs.rmSync(scratchTarget, { recursive: true, force: true }); }
116
129
  }
117
130
 
118
- function mainWith(argv, { env, platform, run, log, err }) {
131
+ function mainWith(argv, { env, platform, run, log, err, judged = new Set(), confirm = null }) {
119
132
  const [sub, ...rest] = argv;
120
133
  const at = rest.indexOf("--manifest-path");
121
134
  const manifestPath = at >= 0 ? rest[at + 1] : undefined;
135
+ 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
136
  const listFile = hostListPath(resolveManagedRoots({ env, platform }).dataRoot, platform);
123
137
  let inv;
124
- try { inv = inventory({ manifestPath, run }); } catch (e) { err(`right-release build-helpers: ${e.message}`); return 2; }
138
+ try { inv = inventory({ manifestPath, run, platform }); } catch (e) { err(`right-release build-helpers: ${e.message}`); return 2; }
125
139
  // 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
140
  // passes, and a host traces only what neither holds.
127
141
  const repoFile = repoListPath(inv.metadata.workspace_root);
128
142
  const list = mergeLists(readHostList(listFile), readHostList(repoFile));
129
143
  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
144
  if (sub === "check") {
137
145
  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
146
  for (const line of buildHelperRefusal(verdict)) err(line);
@@ -152,13 +160,40 @@ function mainWith(argv, { env, platform, run, log, err }) {
152
160
  const recordRepo = rest.includes("--record-repo");
153
161
  const cargoHome = env.CARGO_HOME ?? path.join(os.homedir(), ".cargo");
154
162
  // 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; strace -ff writes one file per
156
- // process of its own run). The cores are shared out so the builds do not fight over them.
163
+ // 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
164
+ // builds do not fight over them.
157
165
  const jobsAt = rest.indexOf("--jobs");
158
- const jobs = Math.max(1, Number.parseInt(jobsAt >= 0 ? rest[jobsAt + 1] : "4", 10) || 4);
166
+ // A confirming pass builds one unit at a time, so nothing else of this run shares the machine with it.
167
+ const jobs = confirm ? 1 : Math.max(1, Number.parseInt(jobsAt >= 0 ? rest[jobsAt + 1] : "4", 10) || 4);
159
168
  const perBuildJobs = String(Math.max(1, Math.floor(os.cpus().length / jobs)));
160
- const pending = [...verdict.untraced, ...verdict.failed.map((f) => f.unit)];
161
- log(`right-release build-helpers trace: ${pending.length} unit(s), ${jobs} at a time`);
169
+ // A cross target builds with the provisioned zig C toolchain; without it those units wait (never recorded as unbuildable).
170
+ // The toolchain is proved first: a one-line C file compiled through the target's CC wrapper. A wrapper that cannot run (on the Dell a
171
+ // Node junction an elevated shell refused to traverse) failed every C crate, and 0.6.29 recorded those as unbuildable.
172
+ const workingCrossEnv = (target) => {
173
+ const cc = crossCompileEnv(target, undefined, platform);
174
+ if (!cc) return null;
175
+ const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), "rk-bht-cc-"));
176
+ try {
177
+ fs.writeFileSync(path.join(probeDir, "probe.c"), "int rk_probe(void) { return 0; }\n");
178
+ const zigCache = { ZIG_GLOBAL_CACHE_DIR: path.join(probeDir, "zig-global"), ZIG_LOCAL_CACHE_DIR: path.join(probeDir, "zig-local") };
179
+ // A .cmd wrapper runs through the shell on Windows, which joins arguments unquoted: every path is quoted there.
180
+ const q = (s) => (platform === "win32" ? `"${s}"` : s);
181
+ const r = run(q(cc[`CC_${target.replaceAll("-", "_")}`]), ["-c", q(path.join(probeDir, "probe.c")), "-o", q(path.join(probeDir, "probe.o"))], { encoding: "utf8", env: { ...env, ...zigCache }, shell: platform === "win32" });
182
+ if (r.status === 0 && fs.existsSync(path.join(probeDir, "probe.o"))) return cc;
183
+ err(`right-release build-helpers trace: the ${target} C compiler wrapper does not work (${String(r.error?.message ?? r.stderr ?? r.stdout ?? `exit ${r.status}`).trim().split(/\r?\n/)[0]}); its units are not traced (rerun scripts/ci/provision-zig.mjs)`);
184
+ return null;
185
+ } finally { fs.rmSync(probeDir, { recursive: true, force: true }); }
186
+ };
187
+ const crossEnv = new Map(); const waiting = [];
188
+ const pending = [...verdict.untraced, ...verdict.failed.map((f) => f.unit)].filter((unit) => (confirm ? confirm.has(unit.key) : !judged.has(unit.key))).filter((unit) => {
189
+ if (unit.target === inv.host) return true;
190
+ if (!crossEnv.has(unit.target)) crossEnv.set(unit.target, workingCrossEnv(unit.target));
191
+ if (crossEnv.get(unit.target)) return true;
192
+ waiting.push(unit); return false;
193
+ });
194
+ if (waiting.length) err(`right-release build-helpers trace: ${waiting.length} cross-target unit(s) not traced: the zig C toolchain is not provisioned or does not work (scripts/ci/provision-zig.mjs)`);
195
+ log(`right-release build-helpers trace: ${pending.length} unit(s)${confirm ? " to confirm" : ""}, ${jobs} at a time`);
196
+ const firstFails = new Map();
162
197
  if (!pending.length) return 0;
163
198
  const prepared = pending.map((unit) => {
164
199
  const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "rk-bht-"));
@@ -173,18 +208,20 @@ function mainWith(argv, { env, platform, run, log, err }) {
173
208
  fs.writeFileSync(path.join(probe, "src", "lib.rs"), "");
174
209
  try { fs.copyFileSync(path.join(inv.metadata.workspace_root, "Cargo.lock"), path.join(probe, "Cargo.lock")); } catch { /* the probe resolves on its own */ }
175
210
  // Release profile, like every other build (GATE-CONTRACT GC-A15): no dev or debug build anywhere, a throwaway probe included.
176
- const buildArgs = ["build", "--profile", "release", "--manifest-path", path.join(probe, "Cargo.toml"), "--target-dir", target];
177
- const buildEnv = { ...env, CARGO_TARGET_DIR: target, CARGO_BUILD_JOBS: perBuildJobs };
178
- const straceOut = path.join(scratch, "strace");
179
- const job = platform === "linux"
180
- ? { cmd: "strace", args: ["-ff", "-qq", "-y", "-o", straceOut, "-e", "trace=open,openat,openat2,creat,rename,renameat,renameat2,unlink,unlinkat,mkdir,mkdirat,rmdir,link,linkat,symlink,symlinkat,truncate,chmod,fchmodat,chown,fchownat,lchown,utimensat", "cargo", ...buildArgs], env: buildEnv }
181
- : { cmd: "cargo", args: buildArgs, env: buildEnv };
182
- job.label = `${unit.crate} ${unit.version}`;
183
- return { unit, scratch, target, straceOut, job };
211
+ // Built for the unit's own target, as the gate builds it: the host's, or a cross-check target with --target and zig.
212
+ const cross = unit.target !== inv.host;
213
+ const buildArgs = ["build", "--profile", "release", "--manifest-path", path.join(probe, "Cargo.toml"), "--target-dir", target, ...(cross ? ["--target", unit.target] : [])];
214
+ // zig (the cross C compiler) keeps its caches in this build's scratch folder: its default global cache is a host folder outside the
215
+ // allowed roots, and every crate with C code was failed for zig's own cache writes (ring, rustls, blake3, libsqlite3-sys on 0.6.29).
216
+ const zigCache = cross ? { ZIG_GLOBAL_CACHE_DIR: path.join(scratch, "zig-global"), ZIG_LOCAL_CACHE_DIR: path.join(scratch, "zig-local") } : {};
217
+ const buildEnv = { ...env, ...(cross ? crossEnv.get(unit.target) : {}), ...zigCache, CARGO_TARGET_DIR: target, CARGO_BUILD_JOBS: perBuildJobs };
218
+ const job = { cmd: "cargo", args: buildArgs, env: buildEnv };
219
+ job.label = `${unit.crate} ${unit.version}${cross ? ` (${unit.target})` : ""}`;
220
+ return { unit, scratch, target, job };
184
221
  });
185
222
  const sessionDir = fs.mkdtempSync(path.join(os.tmpdir(), "rk-bht-session-"));
186
223
  const traceFile = path.join(sessionDir, "trace.txt");
187
- // One tracer session for the whole run (macOS eslogger, Windows ETW); Linux traces per build through strace.
224
+ // One tracer session for the whole run (macOS eslogger, Windows ETW).
188
225
  let stopTracer = () => {};
189
226
  let etl = null;
190
227
  if (platform === "darwin") {
@@ -245,29 +282,61 @@ function mainWith(argv, { env, platform, run, log, err }) {
245
282
  const xml = path.join(sessionDir, "trace.xml");
246
283
  const exported = run("tracerpt", [etl.file, "-o", xml, "-of", "XML", "-y"], { encoding: "utf8" });
247
284
  if (exported.status !== 0) { err("right-release build-helpers trace: tracerpt could not export the ETW trace"); return 2; }
248
- try { recorded = fs.statSync(xml).size > 0; if (recorded) byRoot = etwWritesByRoot(xml, roots, devices); } catch { /* no output */ }
285
+ try { recorded = fs.statSync(xml).size > 0; if (recorded) byRoot = etwWritesByRoot(xml, roots, devices, { toolchainDirs: [env.ProgramFiles, env["ProgramFiles(x86)"]].filter(Boolean).map((d) => path.win32.join(d, "Microsoft Visual Studio")) }); } catch { /* no output */ }
249
286
  }
250
- if (platform !== "linux" && !recorded && roots.size) { err(`right-release build-helpers trace: the ${platform === "darwin" ? "eslogger" : "ETW"} session recorded nothing; nothing recorded`); return 2; }
287
+ if (!recorded && roots.size) { err(`right-release build-helpers trace: the ${platform === "darwin" ? "eslogger" : "ETW"} session recorded nothing; nothing recorded`); return 2; }
251
288
  prepared.forEach((b, k) => {
252
289
  const r = results[k] ?? {};
253
- if (r.status !== 0 || !Number.isInteger(r.pid)) { err(`right-release build-helpers trace: ${b.unit.crate} ${b.unit.version} did not build (${r.error ?? `exit ${r.status}`}); not recorded`); return; }
254
- let writes;
255
- if (platform === "linux") { let text = ""; try { text = readStraceFiles(b.straceOut); } catch { /* none */ } writes = parseStraceWrites(text, { cwd: path.dirname(path.resolve(manifestPath ?? "Cargo.toml")) }); }
256
- else writes = byRoot.get(r.pid) ?? [];
290
+ 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; }
291
+ if (r.status !== 0 && confirm) {
292
+ // It built in the first pass, so a failed rebuild is a passing problem (memory, a lock), not "unbuildable": the first FAIL stands.
293
+ const before = [...confirm.get(b.unit.key)];
294
+ judged.add(b.unit.key);
295
+ recordTrace(listFile, b.unit, { ok: false, outside: before });
296
+ if (recordRepo) recordTrace(repoFile, b.unit, { ok: false, outside: before });
297
+ log(`FAIL ${b.unit.crate} ${b.unit.version} (${b.unit.target}): the rebuild did not build; the first trace stands: wrote ${before.slice(0, 5).join(", ")}`);
298
+ return;
299
+ }
300
+ if (r.status !== 0) {
301
+ // It does not build here for this target, so nothing this host's gate runs builds it either: recorded once, skipped from then on.
302
+ const reason = String(r.error ?? `exit ${r.status}`);
303
+ judged.add(b.unit.key);
304
+ recordTrace(listFile, b.unit, { ok: false, unbuildable: true, outside: [reason] });
305
+ if (recordRepo) recordTrace(repoFile, b.unit, { ok: false, unbuildable: true, outside: [reason] });
306
+ log(`skip ${b.unit.crate} ${b.unit.version} (${b.unit.target}): does not build on this host (${reason.slice(0, 160)})`);
307
+ return;
308
+ }
309
+ const writes = byRoot.get(r.pid) ?? [];
257
310
  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", "/proc/self"];
311
+ : [b.scratch, cargoHome, os.tmpdir(), "/private/var/folders", "/dev"];
259
312
  // Allowed folders by their real path: a linked CARGO_HOME (~/.cargo -> /Volumes/D/...) or temp (/var -> /private/var) is reported by
260
313
  // the tracer under the real path, so comparing the link's path would fail every crate.
261
314
  const real = (d) => { try { return fs.realpathSync.native(d); } catch { return d; } };
262
315
  const roots = allowed.filter(Boolean).flatMap((d) => [d, real(d)]);
263
- const judged = judgeTrace(writes, { allowed: roots, platform, mustSee: real(b.target) });
264
- recordTrace(listFile, b.unit, judged);
316
+ let verdictOf = judgeTrace(writes, { allowed: roots, platform, mustSee: real(b.target) });
317
+ // A FAIL must repeat (Dell, 2026-11-03: Windows' recorder pinned other programs' writes on a build now and then). The first FAIL is
318
+ // held and the unit is rebuilt alone; it fails only on the outside paths both builds wrote, or when either build went unobserved.
319
+ // Only Windows' recorder sees the whole machine, so only there can another program's write land on a build: on the Mac (eslogger
320
+ // follows the build's own processes) an outside write fails at once, and the rebuild only retries a build that went unobserved.
321
+ const unseen = (o) => o.startsWith("(the tracer recorded no write");
322
+ if (!verdictOf.ok && !confirm && (platform === "win32" || verdictOf.outside.every(unseen))) { firstFails.set(b.unit.key, new Set(verdictOf.outside)); log(`recheck ${b.unit.crate} ${b.unit.version} (${b.unit.target}): rebuilding it alone`); return; }
323
+ if (!verdictOf.ok && confirm) {
324
+ const before = confirm.get(b.unit.key);
325
+ // An unobserved first build proves nothing either way: then every outside write of the rebuild counts.
326
+ const firstUnseen = [...before].some(unseen);
327
+ const repeated = verdictOf.outside.filter((o) => !unseen(o) && (firstUnseen || before.has(o)));
328
+ const unobserved = verdictOf.outside.some(unseen);
329
+ verdictOf = repeated.length || unobserved ? { ok: false, outside: repeated.length ? repeated : verdictOf.outside } : { ok: true, outside: [] };
330
+ }
331
+ judged.add(b.unit.key);
332
+ recordTrace(listFile, b.unit, verdictOf);
265
333
  // --record-repo also writes the verdict into the repository's committed list, for review and commit by the repository's owner.
266
- if (recordRepo) recordTrace(repoFile, b.unit, judged);
267
- log(`${judged.ok ? "pass" : "FAIL"} ${b.unit.crate} ${b.unit.version}${judged.ok ? "" : `: wrote ${judged.outside.slice(0, 5).join(", ")}`}`);
334
+ if (recordRepo) recordTrace(repoFile, b.unit, verdictOf);
335
+ log(`${verdictOf.ok ? "pass" : "FAIL"} ${b.unit.crate} ${b.unit.version} (${b.unit.target})${verdictOf.ok ? "" : `: wrote ${verdictOf.outside.slice(0, 5).join(", ")}`}`);
268
336
  });
269
337
  for (const b of prepared) fs.rmSync(b.scratch, { recursive: true, force: true });
270
338
  fs.rmSync(sessionDir, { recursive: true, force: true });
339
+ if (firstFails.size) return mainWith(argv, { env, platform, run, log, err, judged, confirm: firstFails });
271
340
  return 0;
272
341
  }
273
342
  err("usage: right-release build-helpers check|trace [--manifest-path <Cargo.toml>] [--record-repo] [--jobs N] | trace-all [--root <dir>]... [--jobs N]");
@@ -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
@@ -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
- for (const ws of touchedWs) add({ id: `build-helpers${wsIdSuffix(ws)}`, class: A, tool: "node", args: [BUILD_HELPERS_COMMAND, "check", "--manifest-path", "Cargo.toml", ...(platform === "linux" ? ["--trace-missing"] : [])], ...(ws !== "." ? { cwd: ws } : {}), root: "content" });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.6.28",
3
+ "version": "0.6.30",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
@@ -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
+ }
@@ -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.28",
52
+ "@rightkit/release": "0.6.30",
53
53
  "@rightkit/settings-ui": "0.7.0",
54
54
  "@rightkit/social-ui": "0.1.0",
55
55
  "@rightkit/system-ui": "0.1.0",
@@ -512,7 +512,7 @@
512
512
  "rightkit-app-settings": "0.3.1",
513
513
  "rightkit-apple-ops": "0.1.1",
514
514
  "rightkit-approvals": "0.1.0",
515
- "rightkit-asr": "0.3.4",
515
+ "rightkit-asr": "0.4.0",
516
516
  "rightkit-audio": "0.3.1",
517
517
  "rightkit-autoedit": "0.1.1",
518
518
  "rightkit-automation": "0.6.4",