@rightkit/release 0.6.8 → 0.6.10
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/README.md +25 -9
- package/admission-files.mjs +1 -1
- package/app-check.mjs +94 -20
- package/assemble-app-command.mjs +151 -0
- package/assembled-app.mjs +445 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/bin/rightkit-gate.mjs +928 -82
- package/build-launch.mjs +88 -0
- package/build-ownership.mjs +1231 -0
- package/build-release.mjs +2 -1
- package/cargo-contract.mjs +3 -1
- package/cargo-target-shim.mjs +166 -0
- package/class-a-launch-guard.mjs +126 -0
- package/cli/right-release.mjs +3 -1
- package/codesign-shim.mjs +11 -1
- package/deterministic-scans.mjs +807 -56
- package/development-signing.mjs +4 -1
- package/development.mjs +110 -20
- package/door-policy.mjs +61 -6
- package/gate-executor.mjs +14 -2
- package/gate-stages.mjs +15 -5
- package/heavy-command.mjs +3 -2
- package/install-app.mjs +11 -2
- package/install-assembled-app.mjs +423 -0
- package/ios-dev.mjs +70 -3
- package/linux-artifacts.mjs +93 -0
- package/local-publish.mjs +161 -26
- package/managed-cargo.mjs +47 -5
- package/managed-root-guard.mjs +34 -0
- package/native-helpers.mjs +355 -0
- package/package-recipe-targets.mjs +52 -7
- package/package.json +4 -3
- package/pipeline-receipts.mjs +302 -302
- package/project-type.mjs +2 -2
- package/provenance-verify.mjs +115 -0
- package/publish-cargo.mjs +8 -6
- package/qa-admission-policy.json +10 -1
- package/recipe-build.mjs +73 -5
- package/release-config.mjs +1 -1
- package/release.mjs +15 -5
- package/rightkit-versions.json +13 -7
- package/snapshot-check.mjs +306 -0
- package/snapshot-git.mjs +166 -0
- package/snapshot-request.mjs +161 -0
- package/snapshot-root-config.mjs +76 -0
- package/snapshot.mjs +856 -0
- package/stage-commands.mjs +60 -0
- package/stage-sidecar.mjs +57 -7
- package/test-command.mjs +17 -4
- package/unit-gate.mjs +259 -0
- package/web-build.mjs +38 -6
package/bin/rightkit-gate.mjs
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
// rightkit-gate [--base <ref>] [--plan] [--no-cache]
|
|
28
28
|
// run the gate (default base origin/main); --plan prints the scans and runs none;
|
|
29
29
|
// --no-cache (or RK_GATE_CACHE=0) reruns every scan
|
|
30
|
-
// rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]
|
|
30
|
+
// rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--rule-tier compile|release] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]
|
|
31
31
|
// the deterministic stage: the app check (../app-check.mjs), then every class A scan
|
|
32
32
|
// (lockfiles, cargo-deny/audit, secrets, licences, manifests, workflow lint, syntax) over the
|
|
33
33
|
// whole tree of the commit, not a diff. Nothing compiles; without a broker class A uses
|
|
@@ -59,18 +59,21 @@
|
|
|
59
59
|
// rust.<crate>.testInputs lists the paths they read, for npm, node and docs scans, and whenever a reference cannot be resolved
|
|
60
60
|
// statically) are all identical. Stored in <git common dir>/rightkit-gate-cache; a hit prints
|
|
61
61
|
// `cached` and is listed in the result's cacheHits. Failures, deferrals and class A scans are never cached.
|
|
62
|
-
// RK_GATE_JOBS (default 4) sets
|
|
63
|
-
//
|
|
62
|
+
// RK_GATE_JOBS (default 4) sets class A scan concurrency; class B runs one scan at a time per checkout.
|
|
63
|
+
// A crate whose ci/gate.config.json entry says `quietTests: true` has its test scans run last,
|
|
64
64
|
// one at a time, under the RustRight quiet lease (`rustright quiet acquire`, RUSTRIGHT_QUIET_LEASE in their environment).
|
|
65
65
|
// SIGINT/SIGTERM stop scheduling, kill every running scan's process tree and exit non-zero listing the scans not run.
|
|
66
66
|
import fs from "node:fs";
|
|
67
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
68
|
+
import { resolveManagedRoots } from "../managed-roots.mjs";
|
|
67
69
|
import os from "node:os";
|
|
68
70
|
import path from "node:path";
|
|
69
71
|
import { fileURLToPath } from "node:url";
|
|
70
72
|
import { BIOME_CONFIG_FILES, ESLINT_CONFIG_FILES, WORKSPACE_RUST_FILE, ancestorConfigChanged, ancestorDirsOf, canonicalJson, defaultSpawn, digestScanSet, fileScanCache, findRustfmt, metadataArgs, planScans, runScans, scanDeferrals, scanFailures } from "../deterministic-scans.mjs";
|
|
71
73
|
import { changedLockNames, parseLock, reach } from "../lock-graph.mjs";
|
|
72
74
|
import { resolveExecutor } from "../gate-executor.mjs";
|
|
73
|
-
import {
|
|
75
|
+
import { directPlatform } from "../managed-cargo.mjs";
|
|
76
|
+
import { bindingOf, endRun, readReceipt, RECEIPT_SCHEMA, receiptKey, sameBinding, sameSource, sealedFor, stagesTo, startRun, STAGES, writeReceipt } from "../gate-stages.mjs";
|
|
74
77
|
import { spawnSync as spawnInherit } from "../background-process.mjs";
|
|
75
78
|
import { readDiskPolicy, scratchRoots } from "../disk-policy.mjs";
|
|
76
79
|
|
|
@@ -83,7 +86,10 @@ const why = (r) => String(r?.error?.message ?? r?.stderr ?? "").trim().split(/\r
|
|
|
83
86
|
|
|
84
87
|
// A green `check --only` view: app rules passed but no class A scan ran. Distinct from 0 so no caller can admit it as a full check.
|
|
85
88
|
export const PARTIAL_EXIT = 3;
|
|
86
|
-
|
|
89
|
+
/** The platforms a door run can package: desktop (signed, sealed, installed QA, publisher upload) and mobile (signed, sealed, store test track). */
|
|
90
|
+
export const PLATFORMS = Object.freeze(["mac", "win", "ios", "android"]);
|
|
91
|
+
export const MOBILE_PLATFORMS = Object.freeze(new Set(["ios", "android"]));
|
|
92
|
+
const USAGE = `usage: rightkit-gate --to check|compile|package|qualify|release [--platform mac|win|ios|android] [--tier patch|update] [--receipt-in <file>] [--receipt-out <file>] | rightkit-gate --to check|compile|package|qualify|release --unit npm:@rightkit/<name>|cargo:rightkit-<name> [--receipt-out <file>] | rightkit-gate --to dev|test [-- <right-release args>] | rightkit-gate stage --platform mac|win [--tier patch|update] | rightkit-gate [--base <ref>] [--plan] [--no-cache] [--pre-push] | rightkit-gate install-hook [--force] | rightkit-gate env-digest | rightkit-gate snapshot [--commit <rev>] [--verify] [--json] | rightkit-gate live-scans --repo <root> | rightkit-gate env-digest [--snapshot <dir> --commit <sha>] | rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--rule-tier compile|release] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>] | rightkit-gate check --commit <sha> --full-a --snapshot <dir> | --version`;
|
|
87
93
|
|
|
88
94
|
// ---- pure helpers (exported for tests) ----------------------------------------------------------------------
|
|
89
95
|
|
|
@@ -252,6 +258,245 @@ export function exportTmpRoot({ env = process.env, platform = process.platform,
|
|
|
252
258
|
return os.tmpdir();
|
|
253
259
|
}
|
|
254
260
|
|
|
261
|
+
/** The files cargo metadata, deny and audit read, in the repository root and in every workspace that has a manifest. */
|
|
262
|
+
export const CARGO_INPUT_NAMES = Object.freeze(["Cargo.toml", "Cargo.lock", "deny.toml", "rust-toolchain.toml", "rust-toolchain", ".cargo"]);
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The Cargo inputs of the live checkout `root` that are not blob-identical to `snapshot`'s (a commit or tree): a changed or deleted
|
|
266
|
+
* tracked input, or any untracked file (ignored ones included, cargo reads them) under those paths. Empty means the broker may scan
|
|
267
|
+
* the primary checkout for exactly the snapshot's result. `manifests` are the snapshot's tracked Cargo.toml paths (posix).
|
|
268
|
+
*/
|
|
269
|
+
export function cargoInputsDiffer({ root, snapshot, manifests, spawn = defaultSpawn }) {
|
|
270
|
+
const git = (...a) => spawn("git", a, { cwd: root });
|
|
271
|
+
// Every manifest directory and every ancestor up to the root: cargo reads a .cargo/config.toml or a rust-toolchain file from any of them.
|
|
272
|
+
const dirs = new Set([""]);
|
|
273
|
+
for (const m of manifests) { const parts = path.posix.dirname(m).split("/").filter((x) => x && x !== "."); for (let i = 1; i <= parts.length; i++) dirs.add(parts.slice(0, i).join("/")); }
|
|
274
|
+
const dirList = [...dirs];
|
|
275
|
+
const paths = dirList.flatMap((d) => CARGO_INPUT_NAMES.map((n) => (d ? `${d}/${n}` : n)));
|
|
276
|
+
// Pathspecs go to git in bounded batches: a repository with many crates (RightKit: ~1000 input paths) overflows Windows' 32767-
|
|
277
|
+
// character command line in one argv (ENAMETOOLONG on the Dell, 2026-10-27; G4 was the same for rustfmt). Each batch's results
|
|
278
|
+
// are merged; a failed batch fails the whole listing closed.
|
|
279
|
+
const batches = pathspecBatches(paths);
|
|
280
|
+
const listedAll = batches.map((b) => git("ls-tree", "-r", "-z", snapshot, "--", ...b));
|
|
281
|
+
const badList = listedAll.find((r) => failed(r));
|
|
282
|
+
if (badList) return [`(cannot list ${snapshot}: ${why(badList)})`];
|
|
283
|
+
const snap = new Map();
|
|
284
|
+
for (const listed of listedAll) for (const entry of text(listed).split("\0").filter(Boolean)) { const tab = entry.indexOf("\t"); const [, , id] = entry.slice(0, tab).split(" "); snap.set(entry.slice(tab + 1), id); }
|
|
285
|
+
const differ = new Set();
|
|
286
|
+
for (const [p, id] of snap) {
|
|
287
|
+
const live = path.join(root, ...p.split("/"));
|
|
288
|
+
let liveId = null;
|
|
289
|
+
// Cargo reads the live bytes, so the live bytes are hashed raw (`--no-filters`: no clean filter, eol or text attribute can make
|
|
290
|
+
// two different byte strings look the same). The one accepted difference is an exact CRLF checkout of an LF blob (D13, Dell
|
|
291
|
+
// 2026-10-27: core.autocrlf), which every TOML/toolchain reader treats as the same newline; anything else differs.
|
|
292
|
+
try { if (fs.statSync(live).isFile()) { const h = git("hash-object", "--no-filters", "--", live); liveId = failed(h) ? null : text(h).trim(); } } catch { liveId = null; }
|
|
293
|
+
if (liveId !== id && !isCrlfCheckoutOf({ git, id, live })) differ.add(p);
|
|
294
|
+
}
|
|
295
|
+
// Untracked under the input paths, ignored files included: cargo reads a .cargo/config.toml or a deny.toml whether or not git tracks it.
|
|
296
|
+
for (const batch of batches) {
|
|
297
|
+
const others = git("ls-files", "-z", "--others", "--", ...batch);
|
|
298
|
+
if (failed(others)) differ.add(`(cannot list untracked Cargo inputs: ${why(others)})`); // fail closed: an unlisted input is an unknown input
|
|
299
|
+
else for (const p of text(others).split("\0").filter(Boolean)) if (!snap.has(p)) differ.add(p);
|
|
300
|
+
// A tracked input the snapshot lacks (added in HEAD after the snapshot, say) is live-only too.
|
|
301
|
+
const tracked = git("ls-files", "-z", "--", ...batch);
|
|
302
|
+
if (failed(tracked)) differ.add(`(cannot list tracked Cargo inputs: ${why(tracked)})`);
|
|
303
|
+
else for (const p of text(tracked).split("\0").filter(Boolean)) if (!snap.has(p)) differ.add(p);
|
|
304
|
+
}
|
|
305
|
+
return [...differ].sort();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** True only when the live file's bytes are exactly blob `id` with every LF written as CRLF (an autocrlf checkout); fails closed. */
|
|
309
|
+
export function isCrlfCheckoutOf({ git, id, live, readFile = fs.readFileSync }) {
|
|
310
|
+
try {
|
|
311
|
+
const blob = git("cat-file", "blob", id);
|
|
312
|
+
if (failed(blob)) return false;
|
|
313
|
+
const committed = text(blob);
|
|
314
|
+
if (committed.includes("\r")) return false;
|
|
315
|
+
return readFile(live, "utf8") === committed.replace(/\n/g, "\r\n");
|
|
316
|
+
} catch { return false; }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Tool caches inside node_modules that nothing installs: test-result caches (vitest), the vite optimizer and bundled-config temp
|
|
321
|
+
* files, transpile and other tool caches (.cache). Some hold code a later step executes, so the environment a verdict carries binds
|
|
322
|
+
* their bytes as they stand when class A ends, and the broker's START recomputes it: a cache edited, added or removed after the
|
|
323
|
+
* verdict (by a dev server, another tool, or by hand) voids the ticket and class A runs again. Nothing is deleted. Inside one run
|
|
324
|
+
* the start/end comparison leaves them out, because the run itself writes them. pnpm's task-run state holds only completion
|
|
325
|
+
* markers pnpm reads, never code, and is not bound.
|
|
326
|
+
*/
|
|
327
|
+
/** Longest the class A environment waits for `pnpm --version`. */
|
|
328
|
+
export const PNPM_PROBE_TIMEOUT_MS = 60_000;
|
|
329
|
+
export const TOOL_CACHE_DIRS = new Set([".vite", ".vite-temp", ".vitest-cache", ".cache"]);
|
|
330
|
+
export const INSTALL_STATE_NOT_COVERED = new Set([".pnpm-task-run-state-v1"]);
|
|
331
|
+
const nodeModulesDirs = (root, files) => [...new Set(files.filter((f) => /(^|\/)package\.json$/.test(f)).map((f) => path.join(path.dirname(path.join(root, f)), "node_modules")))].sort();
|
|
332
|
+
function isLink(p) { try { return fs.lstatSync(p).isSymbolicLink(); } catch { return false; } }
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Members of the root pnpm workspace holding their own pnpm virtual store (node_modules/.pnpm) while the root has one, without
|
|
336
|
+
* being an install root (no pnpm-lock.yaml of their own): leftovers of a standalone install from before the package joined the workspace. Their dependencies resolve to
|
|
337
|
+
* the root store, but the leftover can still be read, and vitest hashes the nearest lockfile it finds (node_modules/.pnpm/
|
|
338
|
+
* lock.yaml) for a module cache it shares across the workspace, so one such package makes concurrent vitest runs clear the
|
|
339
|
+
* shared cache under each other (ENOTEMPTY, fullA-16).
|
|
340
|
+
*/
|
|
341
|
+
export function orphanedPnpmStores({ root, files }) {
|
|
342
|
+
// Only proven orphans: the repository root is a pnpm workspace with its own virtual store, and the package is a member of it by
|
|
343
|
+
// the root pnpm-workspace.yaml globs (negations honoured) and not an install root of its own. A standalone nested project is
|
|
344
|
+
// not judged.
|
|
345
|
+
const ws = path.join(root, "pnpm-workspace.yaml");
|
|
346
|
+
if (!fs.existsSync(ws) || !fs.existsSync(path.join(root, "node_modules", ".pnpm"))) return [];
|
|
347
|
+
const globs = parseWorkspaceGlobs(fs.readFileSync(ws, "utf8"));
|
|
348
|
+
const include = globs.filter((g) => !g.startsWith("!")).map(globToRegExp);
|
|
349
|
+
const exclude = globs.filter((g) => g.startsWith("!")).map((g) => globToRegExp(g.slice(1)));
|
|
350
|
+
return nodeModulesDirs(root, files).filter((nm) => {
|
|
351
|
+
const pkg = path.dirname(nm); const rel = path.relative(root, pkg).split(path.sep).join("/");
|
|
352
|
+
if (!rel || !include.some((re) => re.test(rel)) || exclude.some((re) => re.test(rel))) return false;
|
|
353
|
+
return fs.existsSync(path.join(nm, ".pnpm")) && !fs.existsSync(path.join(pkg, "pnpm-lock.yaml"));
|
|
354
|
+
}).map((nm) => path.relative(root, path.join(nm, ".pnpm")).split(path.sep).join("/"));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Cache files that hold only a tool's own bookkeeping, never code a check executes, at exact paths: vitest's result ordering,
|
|
359
|
+
* node_modules/.vite/vitest/results.json and node_modules/.vite/vitest/<40-hex project hash>/results.json, which vitest reads only
|
|
360
|
+
* with JSON.parse to order test files (vitest 5.0.5 dist/chunks/cli-api.*.js ResultsCache.readFromCache).
|
|
361
|
+
*/
|
|
362
|
+
export const CACHE_NON_CODE = [/(^|\/)node_modules\/\.vite\/vitest\/([0-9a-f]{40}\/)?results\.json$/];
|
|
363
|
+
/** Every file in the workspace's tool caches (TOOL_CACHE_DIRS, not following links) with its content hash, keyed by path. */
|
|
364
|
+
export function toolCacheManifest({ root, files }) {
|
|
365
|
+
const out = new Map();
|
|
366
|
+
const walk = (dir) => {
|
|
367
|
+
let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (error) { if (error?.code === "ENOENT") return; throw new Error(`cannot list the tool cache ${path.relative(root, dir)} (${error?.code ?? error?.message})`); }
|
|
368
|
+
for (const e of entries) {
|
|
369
|
+
const p = path.join(dir, e.name); const rel = path.relative(root, p).split(path.sep).join("/");
|
|
370
|
+
if (e.isDirectory()) walk(p);
|
|
371
|
+
else if (e.isSymbolicLink()) out.set(rel, `link:${fs.readlinkSync(p)}`);
|
|
372
|
+
else if (e.isFile()) { try { out.set(rel, createHash("sha256").update(fs.readFileSync(p)).digest("hex")); } catch (error) { if (error?.code !== "ENOENT") throw new Error(`cannot read the tool cache file ${rel} (${error?.code ?? error?.message})`); } }
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
for (const d of nodeModulesDirs(root, files)) for (const name of TOOL_CACHE_DIRS) walk(path.join(d, name));
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* What a run did to the tool caches: a pre-existing cache file that changed or vanished means the checks may have read bytes that
|
|
380
|
+
* are no longer there (a concurrent writer, an interrupted or partial write), so the verdict cannot name them. Files added while
|
|
381
|
+
* it ran are listed and bound in env, though another process may have written them. Limits: a file changed and restored between
|
|
382
|
+
* the two manifests is not observed, and a file added and read by a check is bound only as it stands at the end. CACHE_NON_CODE
|
|
383
|
+
* files may change.
|
|
384
|
+
*/
|
|
385
|
+
export function toolCacheDrift(before, after) {
|
|
386
|
+
const changed = []; const added = [];
|
|
387
|
+
for (const [p, h] of before) if (!CACHE_NON_CODE.some((re) => re.test(p)) && after.get(p) !== h) changed.push(p);
|
|
388
|
+
for (const p of after.keys()) if (!before.has(p)) added.push(p);
|
|
389
|
+
return { changed: changed.sort(), added: added.sort() };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The installed dependency bytes a class A verdict executes: every node_modules directory of the workspace (the root one holds
|
|
394
|
+
* pnpm's virtual store) walked without following links, plus everything a link there resolves to outside those directories (a
|
|
395
|
+
* workspace package's built and ignored files, a linked package elsewhere on disk), each resolved target hashed once. A real file
|
|
396
|
+
* is its path, exec bit and content hash; a link is its target (a dangling one is bound as dangling). A cycle is cut by the
|
|
397
|
+
* resolved-path set; a target that is the root, an ancestor of it or the filesystem root, any other entry type, an unreadable
|
|
398
|
+
* entry, or more than `maxFiles` files throws. Tool caches (TOOL_CACHE_DIRS) leave no trace. Bytes that change while it hashes are caught by comparing the digest at the start
|
|
399
|
+
* and end of the run and again at START.
|
|
400
|
+
*/
|
|
401
|
+
export function installedDependencyDigest({ root, files, maxFiles = 400000, includeCaches = true }) {
|
|
402
|
+
const h = createHash("sha256");
|
|
403
|
+
const realRoot = fs.realpathSync(root);
|
|
404
|
+
const walkedRoots = nodeModulesDirs(root, files);
|
|
405
|
+
const realWalked = walkedRoots.map((d) => { try { return fs.realpathSync(d); } catch (error) { if (error?.code === "ENOENT") return null; throw error; } }).filter(Boolean);
|
|
406
|
+
const inside = (p, dir) => p === dir || p.startsWith(dir + path.sep);
|
|
407
|
+
const rel = (p) => (inside(p, realRoot) ? path.relative(realRoot, p).split(path.sep).join("/") : p);
|
|
408
|
+
const seen = new Set(); const pending = []; let count = 0;
|
|
409
|
+
const fail = (what, error) => { throw new Error(`cannot read ${what} for the class A environment (${error?.code ?? error?.message ?? error})`); };
|
|
410
|
+
const entry = (p, name, parentIsNodeModules, fromLink) => {
|
|
411
|
+
const st = (() => { try { return fs.lstatSync(p); } catch (error) { return fail(rel(p), error); } })();
|
|
412
|
+
if (st.isSymbolicLink()) {
|
|
413
|
+
let target; let real;
|
|
414
|
+
try { target = fs.readlinkSync(p); } catch (error) { return fail(`the link ${rel(p)}`, error); }
|
|
415
|
+
// A dangling link (pnpm hoists optional packages for other platforms that were never installed) resolves nothing, so it
|
|
416
|
+
// supplies no bytes; it is bound as dangling, and a target that later appears changes the digest.
|
|
417
|
+
try { real = fs.realpathSync(p); } catch (error) { if (error?.code === "ENOENT") { h.update(`${rel(p)}\tdangling\t${target}\n`); return; } return fail(`the link ${rel(p)}`, error); }
|
|
418
|
+
h.update(`${rel(p)}\tlink\t${target}\n`);
|
|
419
|
+
if (!realWalked.some((d) => inside(real, d))) pending.push(real);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
// pnpm's task-run state holds only completion markers, never code, and leaves no trace. Tool caches are bound byte for byte
|
|
423
|
+
// unless `includeCaches` is false (only for the start/end comparison inside one run, which writes them itself).
|
|
424
|
+
if (parentIsNodeModules && !fromLink && INSTALL_STATE_NOT_COVERED.has(name)) return;
|
|
425
|
+
if (parentIsNodeModules && !fromLink && TOOL_CACHE_DIRS.has(name) && !includeCaches) return;
|
|
426
|
+
if (st.isDirectory()) return walk(p, fromLink);
|
|
427
|
+
if (st.isFile()) {
|
|
428
|
+
if (++count > maxFiles) throw new Error(`more than ${maxFiles} installed dependency files; refusing an unbounded class A environment`);
|
|
429
|
+
let bytes; try { bytes = fs.readFileSync(p); } catch (error) { return fail(rel(p), error); }
|
|
430
|
+
h.update(`${rel(p)}\tfile\t${st.mode & 0o111 ? "x" : "-"}\t${createHash("sha256").update(bytes).digest("hex")}\n`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
throw new Error(`unsupported entry ${rel(p)} in the installed dependencies`);
|
|
434
|
+
};
|
|
435
|
+
const walk = (dir, fromLink) => {
|
|
436
|
+
let names; try { names = fs.readdirSync(dir).sort(); } catch (error) { return fail(rel(dir), error); }
|
|
437
|
+
const nm = path.basename(dir) === "node_modules";
|
|
438
|
+
for (const name of names) {
|
|
439
|
+
const p = path.join(dir, name);
|
|
440
|
+
if (fromLink && (name === ".git" || (name === "node_modules" && walkedRoots.includes(p)))) continue;
|
|
441
|
+
entry(p, name, nm, fromLink);
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
for (const d of walkedRoots) {
|
|
445
|
+
h.update(`root\t${rel(d)}\n`);
|
|
446
|
+
try { fs.lstatSync(d); } catch (error) { if (error?.code === "ENOENT" || error?.code === "ENOTDIR") { h.update("absent\n"); continue; } fail(rel(d), error); }
|
|
447
|
+
walk(d, false);
|
|
448
|
+
}
|
|
449
|
+
while (pending.length) {
|
|
450
|
+
const real = pending.shift();
|
|
451
|
+
if (seen.has(real)) continue;
|
|
452
|
+
seen.add(real);
|
|
453
|
+
if (real === path.parse(real).root || inside(realRoot, real)) throw new Error(`a dependency link resolves to ${real}, the repository or one of its ancestors; refusing to bind it`);
|
|
454
|
+
h.update(`target\t${rel(real)}\n`);
|
|
455
|
+
const st = fs.lstatSync(real);
|
|
456
|
+
// A linked package outside the walked node_modules trees (a workspace package, or one elsewhere on disk) is hashed whole:
|
|
457
|
+
// any of its files can be executed by a consumer (an ignored dist/, an implicit index.js, a file its entry imports).
|
|
458
|
+
if (st.isDirectory()) walk(real, true); else entry(real, path.basename(real), false, true);
|
|
459
|
+
}
|
|
460
|
+
return h.digest("hex");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* What a class A verdict depends on outside the checked tree: every file of this gate package (code at any depth, the advisory
|
|
465
|
+
* database, contracts, assets), node and pnpm versions, platform/arch, and each package's installed state
|
|
466
|
+
* (node_modules/.modules.yaml) and the installed dependency bytes (installedDependencyDigest). The broker binds an admission ticket to it and recomputes it when the compile starts
|
|
467
|
+
* (`rightkit-gate env-digest`), so a dependency or tool change while a compile waits invalidates the ticket.
|
|
468
|
+
*/
|
|
469
|
+
export function classAEnvironmentDigest({ root, files, spawn = defaultSpawn, includeCaches = true }) {
|
|
470
|
+
// Only a file that does not exist is "absent"; any other read failure throws: an input whose bytes cannot be read is never
|
|
471
|
+
// identified by a placeholder that would match the same failure later.
|
|
472
|
+
// `optional`: only an install-state file may legitimately not exist; a gate file found by the walk must be readable.
|
|
473
|
+
const fileDigest = (f, { optional = false } = {}) => { try { return createHash("sha256").update(fs.readFileSync(f)).digest("hex"); } catch (error) { if (optional && (error?.code === "ENOENT" || error?.code === "ENOTDIR")) return "absent"; throw new Error(`cannot read ${f} for the class A environment (${error?.code ?? error?.message})`); } };
|
|
474
|
+
const gatePkg = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
475
|
+
const walkGate = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.name === "node_modules" || e.name.startsWith(".") ? [] : e.isDirectory() ? walkGate(path.join(dir, e.name)) : [path.join(dir, e.name)]));
|
|
476
|
+
const gateFiles = walkGate(gatePkg).sort().map((f) => `${path.relative(gatePkg, f)}:${fileDigest(f)}`);
|
|
477
|
+
// Bounded: a pnpm that switches itself to the repository's packageManager version on first use, or waits on the network,
|
|
478
|
+
// must fail the probe (and its process tree) within PNPM_PROBE_TIMEOUT_MS, never hang an admission.
|
|
479
|
+
const pnpmVersion = (() => {
|
|
480
|
+
const r = spawn("pnpm", ["--version"], { cwd: root, boundedMs: PNPM_PROBE_TIMEOUT_MS });
|
|
481
|
+
if (r?.timedOut) throw new Error(`cannot identify pnpm for the class A environment (pnpm --version timed out after ${PNPM_PROBE_TIMEOUT_MS} ms; run it once in ${root} so pnpm can switch to the packageManager version)`);
|
|
482
|
+
if (failed(r) || !text(r).trim()) throw new Error(`cannot identify pnpm for the class A environment (${r?.error ? `could not run pnpm: ${r.error.code ?? r.error.message}` : why(r)})`);
|
|
483
|
+
return text(r).trim();
|
|
484
|
+
})();
|
|
485
|
+
const installState = files.filter((f) => /(^|\/)package\.json$/.test(f)).map((f) => path.join(path.dirname(path.join(root, f)), "node_modules", ".modules.yaml")).map((m) => `${path.relative(root, m)}:${fileDigest(m, { optional: true })}`);
|
|
486
|
+
return createHash("sha256").update([`node ${process.version}`, `pnpm ${pnpmVersion}`, `platform ${process.platform}-${process.arch}`, ...gateFiles, ...installState, `installed ${installedDependencyDigest({ root, files, includeCaches })}`].join("\n")).digest("hex");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Split pathspecs into batches whose joined length stays under `limit` characters (well below Windows' 32767 command line). */
|
|
490
|
+
export function pathspecBatches(paths, limit = 8000) {
|
|
491
|
+
const batches = []; let current = []; let size = 0;
|
|
492
|
+
for (const p of paths) {
|
|
493
|
+
if (current.length && size + p.length + 1 > limit) { batches.push(current); current = []; size = 0; }
|
|
494
|
+
current.push(p); size += p.length + 1;
|
|
495
|
+
}
|
|
496
|
+
if (current.length) batches.push(current);
|
|
497
|
+
return batches;
|
|
498
|
+
}
|
|
499
|
+
|
|
255
500
|
export function discoverRustUnits({ root, spawn, cargoCommand, tracked, readFile, realpath = fs.realpathSync }) {
|
|
256
501
|
const manifests = tracked("Cargo.toml");
|
|
257
502
|
if (!manifests.length) return { present: false, units: [], cargoDir: ".", uncovered: [] };
|
|
@@ -508,7 +753,7 @@ export function lockScope({ changed, cargoDir = ".", unitNames, showBase, readLo
|
|
|
508
753
|
* or the committed hook of every app copied from the template stops being "already current"). `nodePath` and every package are written into
|
|
509
754
|
* a shell script: installHook passes only plain relative paths (hookGatePlan).
|
|
510
755
|
*/
|
|
511
|
-
export function hookText(nodePath, packages = []) {
|
|
756
|
+
export function hookText(nodePath, packages = [], legacy = false) {
|
|
512
757
|
const bins = packages.map((dir) => ` ${dir}/node_modules/.bin/rightkit-gate`).join("");
|
|
513
758
|
const pnpmExec = packages.length ? `pnpm --dir ${packages[0]} exec rightkit-gate` : "pnpm exec rightkit-gate";
|
|
514
759
|
return `#!/bin/sh
|
|
@@ -528,7 +773,13 @@ found=""
|
|
|
528
773
|
for bin in node_modules/.bin/rightkit-gate${bins} */node_modules/.bin/rightkit-gate */*/node_modules/.bin/rightkit-gate */*/*/node_modules/.bin/rightkit-gate; do
|
|
529
774
|
if [ -x "$bin" ]; then found="$bin"; break; fi
|
|
530
775
|
done
|
|
531
|
-
if
|
|
776
|
+
${legacy ? "" : `if command -v rightkit-host-gate >/dev/null 2>&1; then
|
|
777
|
+
echo "rightkit-gate: using host trusted gate" >&2
|
|
778
|
+
rightkit_gate() { rightkit-host-gate "$@"; }
|
|
779
|
+
elif [ -f "$HOME/Library/Application Support/RightKit/build-control/config.json" ] || [ -f /d/.rightkit-managed/config.json ]; then
|
|
780
|
+
echo "rightkit-gate: managed host is missing rightkit-host-gate" >&2
|
|
781
|
+
exit 1
|
|
782
|
+
el`}if [ -n "$found" ]; then
|
|
532
783
|
echo "rightkit-gate: using $found" >&2
|
|
533
784
|
rightkit_gate() { "$found" "$@"; }
|
|
534
785
|
elif command -v pnpm >/dev/null 2>&1; then
|
|
@@ -725,8 +976,8 @@ const plainRelative = (p) => typeof p === "string" && p !== "" && !path.posix.is
|
|
|
725
976
|
* when declared. Without a declaration, a closure source that navigates out of its crate at run time without a literal path
|
|
726
977
|
* (externalInputs `leavesCrate`: workspace root, current_dir, a bare "..", the manifest directory's parent) keys the scan on the
|
|
727
978
|
* whole HEAD tree, since its tests may read anything.
|
|
728
|
-
* - An npm package scan (test:npm, tsc, eslint, biome) keys on the package's workspace closure (
|
|
729
|
-
* transitively, by package name) and the root npm inputs; test:scripts-ci on scripts/, ci/, .github/, packages/release and the
|
|
979
|
+
* - An npm package scan (test:npm, tsc, eslint, biome) keys on the package's workspace closure (every dependency that is a workspace
|
|
980
|
+
* package, whatever its spec, transitively, by package name) and the root npm inputs; test:scripts-ci on scripts/, ci/, .github/, packages/release and the
|
|
730
981
|
* root install graph. Every other class B scan (generated-docs, swift-build) uses the whole tree.
|
|
731
982
|
* - Class A: cargo-deny, cargo-audit, pnpm-audit, pnpm-lockfile and advisory-exceptions are never cached (live answers); rustfmt,
|
|
732
983
|
* node-check, actionlint and the parse scans key on their files' blob ids; cargo-package-list on the crate, the manifest projection
|
|
@@ -764,9 +1015,10 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
|
|
|
764
1015
|
if (!m) { unresolved = true; continue; }
|
|
765
1016
|
for (const section of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
|
|
766
1017
|
for (const [name, spec] of Object.entries(m[section] ?? {})) {
|
|
767
|
-
|
|
1018
|
+
// A workspace package is a member whatever the spec: pnpm links it for a version range too (linkWorkspacePackages), and the
|
|
1019
|
+
// tsc row checks it from its committed source (deterministic-scans.mjs TSC_PROJECT). Only an explicit local spec must resolve.
|
|
768
1020
|
const dep = npmByPkgName.get(`${u.root ?? "."}\0${name}`);
|
|
769
|
-
if (!dep) { unresolved = true; continue; }
|
|
1021
|
+
if (!dep) { if (/^(workspace:|link:|file:)/.test(String(spec))) unresolved = true; continue; }
|
|
770
1022
|
if (!seen.has(dep.name)) { seen.set(dep.name, dep); stack.push(dep); }
|
|
771
1023
|
}
|
|
772
1024
|
}
|
|
@@ -911,13 +1163,18 @@ export const QUIET_LEASE_WAIT_DRAIN_MS = 2 * 60 * 1000;
|
|
|
911
1163
|
* Without `rustright` on PATH, or when the broker refuses, the lane runs without a lease and the gate says so: the tests still run,
|
|
912
1164
|
* their verdict is simply taken on a possibly busy host. `o.spawn` is the gate's synchronous spawner.
|
|
913
1165
|
*/
|
|
914
|
-
export function quietLeaseLane(o, { tag = "rightkit-gate:", reason = "rightkit-gate-quiet-tests", ttlMs = QUIET_LEASE_TTL_MS, waitDrainMs = QUIET_LEASE_WAIT_DRAIN_MS, pid = process.pid } = {}) {
|
|
1166
|
+
export function quietLeaseLane(o, { tag = "rightkit-gate:", reason = "rightkit-gate-quiet-tests", ttlMs: baseTtlMs = QUIET_LEASE_TTL_MS, waitDrainMs = QUIET_LEASE_WAIT_DRAIN_MS, pid = process.pid } = {}) {
|
|
915
1167
|
const which = (cmd) => {
|
|
916
1168
|
const r = o.spawn(o.platform === "win32" ? "where" : "which", [cmd]);
|
|
917
1169
|
return failed(r) ? null : (text(r).split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? null);
|
|
918
1170
|
};
|
|
919
1171
|
return {
|
|
920
|
-
|
|
1172
|
+
// `want.ttlMs` (from the scan runner): the lane's own bound, every quiet scan's timeout plus a margin, so the lease cannot
|
|
1173
|
+
// expire while a quiet suite still runs (each scan is killed at its timeout first).
|
|
1174
|
+
acquire: async (want = {}) => {
|
|
1175
|
+
const ttlMs = Number.isFinite(want.ttlMs) && want.ttlMs > baseTtlMs ? want.ttlMs : baseTtlMs;
|
|
1176
|
+
// A direct (Linux) host never takes a broker lease, whatever is on PATH (managed-cargo directPlatform).
|
|
1177
|
+
if (directPlatform(o.platform ?? process.platform)) { o.out(`${tag} note: direct host (no broker on ${o.platform ?? process.platform}); quiet test scans run without a broker lease`); return null; }
|
|
921
1178
|
if (!which("rustright")) { o.out(`${tag} note: no rustright on PATH; quiet test scans run without a broker lease`); return null; }
|
|
922
1179
|
const started = Date.now();
|
|
923
1180
|
const r = o.spawn("rustright", ["quiet", "acquire", "--ttl-ms", String(ttlMs), "--reason", reason, "--wait-drain-ms", String(waitDrainMs), "--holder-pid", String(pid), "--json"]);
|
|
@@ -1015,7 +1272,7 @@ async function installHook(o, root, force) {
|
|
|
1015
1272
|
const wanted = hookText(nodePath, packages);
|
|
1016
1273
|
const hasHook = o.exists(hookPath);
|
|
1017
1274
|
const current = hasHook ? o.readFile(hookPath).replace(/\r\n/g, "\n") : null;
|
|
1018
|
-
if (hasHook && current !== wanted && !force) {
|
|
1275
|
+
if (hasHook && current !== wanted && current !== hookText(nodePath, packages, true) && !force) {
|
|
1019
1276
|
o.err(`rightkit-gate: ${path.join(".githooks", "pre-push")} already exists and differs; nothing changed. Review it, or run \`rightkit-gate install-hook --force\` to replace it.`);
|
|
1020
1277
|
return 1;
|
|
1021
1278
|
}
|
|
@@ -1112,33 +1369,84 @@ async function installHook(o, root, force) {
|
|
|
1112
1369
|
* the receipt only when every bound input is unchanged; the right-release stages run under a live door run, so they refuse any other
|
|
1113
1370
|
* caller (gate-stages.mjs requireDoorRun).
|
|
1114
1371
|
*/
|
|
1372
|
+
/**
|
|
1373
|
+
* The app a door run is for: the directory the gate was run from when it holds a release config (a nested app such as
|
|
1374
|
+
* apps/coderight-tauri), else the repository root. Every right-release stage runs there, the sealed set is read from there, and the
|
|
1375
|
+
* receipt is keyed to it (receiptKey). Returns { appRoot, appDir } with appDir relative to the repository root, "." at the root.
|
|
1376
|
+
*/
|
|
1377
|
+
export function appRootOf({ root, cwd, exists = fs.existsSync }) {
|
|
1378
|
+
const inside = cwd && (cwd === root || cwd.startsWith(root + path.sep));
|
|
1379
|
+
const hasConfig = (d) => ["right-release.config.json", "right-release.config.mjs"].some((f) => exists(path.join(d, f)));
|
|
1380
|
+
// The nearest directory at or above cwd (below the root) that carries a release config is the app; a deeper cwd (apps/foo/src) is apps/foo.
|
|
1381
|
+
let appRoot = root;
|
|
1382
|
+
for (let d = path.resolve(cwd); d.startsWith(root) && d !== root; d = path.dirname(d)) { if (hasConfig(d)) { appRoot = d; break; } }
|
|
1383
|
+
return { appRoot, appDir: path.relative(root, appRoot).split(path.sep).join("/") || "." };
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1115
1386
|
async function runStages(o, args, root, git, io) {
|
|
1116
1387
|
const option = (name) => { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; };
|
|
1117
1388
|
const tag = "rightkit-gate:";
|
|
1389
|
+
const { appRoot, appDir } = appRootOf({ root, cwd: o.cwd, exists: o.exists });
|
|
1118
1390
|
const to = option("--to");
|
|
1119
|
-
if (to === "dev" || to === "test") return await runWorktreeCompile(o, args, root, git, io, to);
|
|
1120
|
-
|
|
1391
|
+
if (to === "dev" || to === "test") return await runWorktreeCompile(o, args, root, git, io, to, appRoot);
|
|
1392
|
+
let stages = stagesTo(to);
|
|
1121
1393
|
if (!stages) { o.err(`${tag} --to must be one of ${STAGES.join(", ")}`); return 2; }
|
|
1122
1394
|
const platform = option("--platform");
|
|
1123
1395
|
const tier = option("--tier");
|
|
1124
|
-
if (stages.includes("package") && !
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1396
|
+
if (stages.includes("package") && !PLATFORMS.includes(platform)) { o.err(`${tag} --to ${to} needs --platform ${PLATFORMS.join("|")}`); return 2; }
|
|
1397
|
+
// iOS and Android (Adrian, 2026-10-26: every app build goes through the door, class A first): check, compile and package run here as
|
|
1398
|
+
// for desktop; there is no installed-QA lane for them, so the qualify stage does not exist for a mobile platform and is never
|
|
1399
|
+
// recorded as passed. Qualification is the store's test track, after the release stage uploads the sealed build from this host
|
|
1400
|
+
// (TestFlight, Play internal track; right-release upload --apply, no patch/update tier).
|
|
1401
|
+
const mobile = MOBILE_PLATFORMS.has(platform);
|
|
1402
|
+
if (mobile && to === "qualify") { o.err(`${tag} --to qualify has no stage for ${platform}: there is no installed-QA lane for a mobile build; qualification is the store's test track after --to release. Run --to package or --to release.`); return 2; }
|
|
1403
|
+
if (mobile) stages = stages.filter((s) => s !== "qualify");
|
|
1404
|
+
if (stages.includes("release") && !mobile && !["patch", "update"].includes(tier)) { o.err(`${tag} --to release needs --tier patch|update (chosen explicitly, never defaulted)`); return 2; }
|
|
1405
|
+
// Commit-only build (GATE-CONTRACT §1, joint plan step 4c): on a direct host (Linux; not hosted CI, whose runner checkout is the
|
|
1406
|
+
// commit) the door selects a commit (--commit <rev>, default HEAD), takes the host's build ownership, syncs the commit's
|
|
1407
|
+
// gate-owned snapshot once, and every stage below reads that snapshot; the checkout may hold anything. Broker hosts keep the
|
|
1408
|
+
// checkout route until the RustRight snapshot carrier lands (step 4d).
|
|
1409
|
+
if (!o.doorSnapshot && directPlatform(o.platform) && o.env.GITHUB_ACTIONS !== "true") {
|
|
1410
|
+
const snap = await import("../snapshot.mjs");
|
|
1411
|
+
const { acquireBuildOwnership, ownershipFile } = await import("../build-ownership.mjs");
|
|
1412
|
+
let selected;
|
|
1413
|
+
try { selected = snap.resolveCommit(root, option("--commit") ?? "HEAD"); }
|
|
1414
|
+
catch (e) { o.err(`${tag} cannot resolve ${option("--commit") ?? "HEAD"} to a commit: ${e.message}`); return 2; }
|
|
1415
|
+
const snapshotsAt = o.snapshotRoot ?? snap.snapshotRoot({ platform: o.platform, env: o.env });
|
|
1416
|
+
let own;
|
|
1417
|
+
try { own = (o.acquireOwnership ?? acquireBuildOwnership)({ file: ownershipFile({ platform: o.platform, beside: o.snapshotRoot }), stage: `door:${to}`, snapshot: path.join(snapshotsAt, snap.repositoryOf(root).key), commit: selected.commit, platform: o.platform, env: o.env }); }
|
|
1418
|
+
catch (e) { if (e.code !== "busy" && e.code !== "refused") throw e; o.err(`${tag} BLOCKED: ${e.message}; nothing ran${e.code === "refused" ? ", nothing created" : ""}`); return 1; }
|
|
1419
|
+
o.out(`${tag} build ownership ${own.joined ? "joined" : "held"} ${own.handle}${own.reclaimed ? ` (reclaimed from ${own.reclaimed.from}: ${own.reclaimed.proofs.join("; ")})` : ""}`);
|
|
1420
|
+
try {
|
|
1421
|
+
// One sync per door run: a second one would prune what class A installed into the snapshot.
|
|
1422
|
+
let synced;
|
|
1423
|
+
try { synced = (o.syncSnapshot ?? snap.syncSnapshot)({ checkout: root, rev: selected.commit, root: snapshotsAt }); }
|
|
1424
|
+
catch (e) { o.err(`${tag} ${e.code === "error" ? "RK_CHECK_ERROR" : "BLOCKED"}: snapshot ${e.message}; nothing ran`); return e.code === "error" ? 2 : 1; }
|
|
1425
|
+
o.out(`${tag} snapshot ${synced.path} is commit ${synced.commit} tree ${synced.tree} (written=${synced.written} removed=${synced.removed} unchanged=${synced.unchanged})`);
|
|
1426
|
+
return await runStages({ ...o, buildOwnership: own, doorSnapshot: { own, path: synced.path, commit: synced.commit, tree: synced.tree, root: snapshotsAt } }, args, root, git, { ...io, buildOwnership: own });
|
|
1427
|
+
} finally {
|
|
1428
|
+
const r = own.release();
|
|
1429
|
+
if (r?.released === false && !own.joined) o.err(`${tag} build ownership ${own.handle} kept: ${r.why}`);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
const ds = o.doorSnapshot ?? null;
|
|
1433
|
+
const commit = ds ? ds.commit : text(git("rev-parse", "HEAD")).trim();
|
|
1434
|
+
const tree = ds ? ds.tree : text(git("rev-parse", "HEAD^{tree}")).trim();
|
|
1128
1435
|
const common = text(git("rev-parse", "--git-common-dir")).trim();
|
|
1129
1436
|
if (!/^[0-9a-f]{40}$/.test(commit) || !tree || !common) { o.err(`${tag} cannot read HEAD and the git directory`); return 2; }
|
|
1130
1437
|
const commonDir = path.resolve(root, common);
|
|
1131
|
-
|
|
1132
|
-
|
|
1438
|
+
// The snapshot route reads only the commit; the checkout may hold anything (GATE-CONTRACT §1: no dirty-tree condition).
|
|
1439
|
+
const dirty = ds ? null : git("status", "--porcelain", "--untracked-files=no");
|
|
1440
|
+
if (!ds && (failed(dirty) || text(dirty).trim())) { o.err(`${tag} BLOCKED: the checkout differs from HEAD; every stage works on the commit, so commit or stash first`); return 1; }
|
|
1133
1441
|
|
|
1134
1442
|
const which = (cmd) => { const r = o.spawn(o.platform === "win32" ? "where" : "which", [cmd]); return failed(r) ? [] : text(r).split(/\r?\n/).map((l) => l.trim()).filter(Boolean); };
|
|
1135
|
-
const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile });
|
|
1443
|
+
const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile }); applyExecutorEnv(o, executor);
|
|
1136
1444
|
if (executor.kind === "refuse") { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
|
|
1137
1445
|
const version = (cmd, a) => { const r = o.spawn(cmd, a, { cwd: root }); return failed(r) ? null : text(r).trim().split(/\r?\n/)[0]; };
|
|
1138
|
-
const listed = git("ls-tree", "-r", "--name-only", "-z",
|
|
1446
|
+
const listed = git("ls-tree", "-r", "--name-only", "-z", commit);
|
|
1139
1447
|
const lockFiles = {};
|
|
1140
1448
|
for (const f of failed(listed) ? [] : text(listed).split("\0").filter((x) => /(^|\/)(Cargo\.lock|pnpm-lock\.yaml)$/.test(x))) {
|
|
1141
|
-
const shown = git("show",
|
|
1449
|
+
const shown = git("show", `${commit}:${f}`); if (!failed(shown)) lockFiles[f] = text(shown);
|
|
1142
1450
|
}
|
|
1143
1451
|
const binding = bindingOf({
|
|
1144
1452
|
tree, lockFiles, gateFile: fileURLToPath(import.meta.url), executor: executor.kind,
|
|
@@ -1147,7 +1455,7 @@ async function runStages(o, args, root, git, io) {
|
|
|
1147
1455
|
// Release writes to R2. The write credential lives only in the trusted publisher: a GitHub Actions job in the protected
|
|
1148
1456
|
// publication environment, which sets RIGHTKIT_PUBLISHER=github-environment. Hosts and agents never hold it, so the door refuses the
|
|
1149
1457
|
// stage anywhere else rather than fail half way through an upload.
|
|
1150
|
-
if (stages.includes("release") && !(o.env.GITHUB_ACTIONS === "true" && o.env.RIGHTKIT_PUBLISHER === "github-environment")) {
|
|
1458
|
+
if (stages.includes("release") && !mobile && !(o.env.GITHUB_ACTIONS === "true" && o.env.RIGHTKIT_PUBLISHER === "github-environment")) {
|
|
1151
1459
|
o.err(`${tag} BLOCKED: --to release runs only in the trusted publisher (the release workflow's protected publication job). Run --to qualify here, then dispatch the release workflow for ${commit.slice(0, 12)}`);
|
|
1152
1460
|
return 1;
|
|
1153
1461
|
}
|
|
@@ -1164,12 +1472,12 @@ async function runStages(o, args, root, git, io) {
|
|
|
1164
1472
|
}
|
|
1165
1473
|
(o.writeReceipt ?? writeReceipt)({ commonDir, receipt: imported });
|
|
1166
1474
|
}
|
|
1167
|
-
const previous = (o.readReceipt ?? readReceipt)({ commonDir, commit });
|
|
1475
|
+
const previous = (o.readReceipt ?? readReceipt)({ commonDir, commit, app: appDir });
|
|
1168
1476
|
// Same host and inputs: every recorded stage counts. Same source on another host: only the stages bound to sealed bytes (package and
|
|
1169
1477
|
// later) carry over; check and compile run again here.
|
|
1170
1478
|
const receipt = previous && sameBinding(previous.binding, binding) ? previous
|
|
1171
1479
|
: previous && sameSource(previous.binding, binding) ? { ...previous, binding, stages: Object.fromEntries(Object.entries(previous.stages ?? {}).filter(([k]) => k !== "check" && k !== "compile")) }
|
|
1172
|
-
: { schema: RECEIPT_SCHEMA, commit, binding, stages: {} };
|
|
1480
|
+
: { schema: RECEIPT_SCHEMA, commit, app: appDir, binding, stages: {} };
|
|
1173
1481
|
const receiptOut = option("--receipt-out");
|
|
1174
1482
|
const save = () => {
|
|
1175
1483
|
(o.writeReceipt ?? writeReceipt)({ commonDir, receipt });
|
|
@@ -1181,18 +1489,22 @@ async function runStages(o, args, root, git, io) {
|
|
|
1181
1489
|
const run = startRun({ commonDir, commit, tree, stage });
|
|
1182
1490
|
try {
|
|
1183
1491
|
o.out(`${tag} [${stage}] right-release ${cliArgs.join(" ")}`);
|
|
1184
|
-
const r = (o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" })))(process.execPath, [cli, ...cliArgs], { cwd:
|
|
1492
|
+
const r = (o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" })))(process.execPath, [cli, ...cliArgs], { cwd: appRoot, env: { ...o.env, ...run.env } });
|
|
1185
1493
|
return r.status === 0;
|
|
1186
1494
|
} finally { endRun(run); }
|
|
1187
1495
|
};
|
|
1188
1496
|
|
|
1189
|
-
o.out(`${tag} --to ${to} for ${commit.slice(0, 12)}: ${stages.join(" -> ")}; executor ${executor.kind}`);
|
|
1497
|
+
o.out(`${tag} --to ${to} for ${commit.slice(0, 12)}${appDir === "." ? "" : ` (app ${appDir})`}: ${stages.join(" -> ")}; executor ${executor.kind}`);
|
|
1190
1498
|
// A later stage on the sealed bytes: check, compile and package already passed for them (in this receipt) and the bytes on disk are
|
|
1191
1499
|
// the ones they sealed, so they are not run again.
|
|
1192
|
-
const sealedNow = receipt.stages.package?.ok ? (o.sealedFor ?? sealedFor)({ root, commit, platform }) : null;
|
|
1500
|
+
const sealedNow = receipt.stages.package?.ok ? (o.sealedFor ?? sealedFor)({ root: appRoot, commit, platform }) : null;
|
|
1193
1501
|
const sealedIntact = Boolean(sealedNow && sealedNow.id === receipt.stages.package.sealed && sealedNow.digest === receipt.stages.package.manifestSha256);
|
|
1502
|
+
// Every deterministic rule and scan must run before compilation, for every door stage.
|
|
1503
|
+
const earlyTier = "release";
|
|
1504
|
+
const tierAtLeast = (have, need) => have === "release" || need === "compile";
|
|
1194
1505
|
for (const stage of stages) {
|
|
1195
|
-
if (
|
|
1506
|
+
if (stage === "check" && receipt.stages.check?.ok && tierAtLeast(receipt.stages.check.ruleTier ?? "compile", earlyTier)) { o.out(`${tag} [check] already passed (${receipt.stages.check.ruleTier ?? "compile"} tier) for this exact input (commit, tree, lockfiles, gate, toolchain)`); continue; }
|
|
1507
|
+
if (stage === "compile" && receipt.stages.compile?.ok) { o.out(`${tag} [${stage}] already passed for this exact input (commit, tree, lockfiles, gate, toolchain)`); continue; }
|
|
1196
1508
|
if ((stage === "check" || stage === "compile" || stage === "package") && sealedIntact) {
|
|
1197
1509
|
o.out(`${tag} [${stage}] already passed for the sealed set ${sealedNow.id} (manifest ${sealedNow.digest.slice(0, 12)})`); continue;
|
|
1198
1510
|
}
|
|
@@ -1203,41 +1515,77 @@ async function runStages(o, args, root, git, io) {
|
|
|
1203
1515
|
}
|
|
1204
1516
|
// The publisher only uploads. It never checks out a build environment, signs or runs installed QA, so every earlier stage must
|
|
1205
1517
|
// already stand in its receipt for exactly the sealed bytes on disk; anything else is refused, never rebuilt or requalified here.
|
|
1206
|
-
if (to === "release" && stage !== "release") {
|
|
1518
|
+
if (to === "release" && !mobile && stage !== "release") {
|
|
1207
1519
|
o.err(`${tag} BLOCKED: [${stage}] --to release only uploads: the receipt must already record ${stage} for the sealed bytes on disk (${sealedNow ? `manifest ${sealedNow.digest.slice(0, 12)}` : "none found"}); run --to qualify on the host and stage again`);
|
|
1208
1520
|
return 1;
|
|
1209
1521
|
}
|
|
1210
|
-
let ok = false; const at = new Date().toISOString(); let extra = {};
|
|
1211
|
-
if (stage === "check")
|
|
1522
|
+
let ok = false; const at = new Date().toISOString(); let extra = {}; let directReceipt = null;
|
|
1523
|
+
if (stage === "check") {
|
|
1524
|
+
ok = (await (o.runCheck ?? (() => main(["check", "--commit", ds ? commit : "HEAD", "--full-a", ...(ds ? ["--snapshot", ds.path] : []), "--rule-tier", earlyTier], { ...io, cwd: root, ...(ds ? { buildOwnership: ds.own } : {}) })))(earlyTier)) === 0;
|
|
1525
|
+
extra = { ruleTier: earlyTier };
|
|
1526
|
+
}
|
|
1212
1527
|
else if (stage === "compile") {
|
|
1213
1528
|
// A Tauri crate with externalBin needs its sidecars in src-tauri/binaries before it compiles (its build script refuses
|
|
1214
1529
|
// otherwise), so every release config that declares `sidecars` is staged first, through right-release under this door run.
|
|
1215
|
-
const listedConfigs = git("ls-tree", "-r", "--name-only", "-z",
|
|
1216
|
-
const sidecarConfigs = (failed(listedConfigs) ? [] : text(listedConfigs).split("\0").filter((f) => /(^|\/)right-release\.config\.(json|mjs)$/.test(f)))
|
|
1217
|
-
|
|
1530
|
+
const listedConfigs = git("ls-tree", "-r", "--name-only", "-z", commit);
|
|
1531
|
+
const sidecarConfigs = (failed(listedConfigs) ? [] : text(listedConfigs).split("\0").filter((f) => /(^|\/)right-release\.config\.(json|mjs)$/.test(f)).filter((f) => appDir === "." || f.startsWith(`${appDir}/`)))
|
|
1532
|
+
// On the snapshot route the commit's config decides, never the checkout's (which may add, edit or hide one).
|
|
1533
|
+
.filter((f) => { try { return /\bsidecars\b/.test(o.readFile(path.join(ds ? ds.path : root, f))); } catch { return false; } });
|
|
1218
1534
|
ok = true;
|
|
1219
|
-
|
|
1220
|
-
|
|
1535
|
+
// Sidecar staging is right-release's, which still reads the checkout: from a snapshot it waits for the right-release source
|
|
1536
|
+
// split (joint plan step 4c, second part), so a commit with sidecars is refused here rather than built from the checkout.
|
|
1537
|
+
if (ds && sidecarConfigs.length) { o.err(`${tag} BLOCKED: [compile] ${sidecarConfigs.join(", ")} stage sidecars through right-release, which does not build from a commit snapshot yet; nothing compiled`); ok = false; }
|
|
1538
|
+
// right-release runs in appRoot (nested apps), so the repository-relative config is handed over absolute (Astra, second pass).
|
|
1539
|
+
for (const config of ok && !ds ? sidecarConfigs : []) if (!rightRelease(stage, ["stage-sidecar", "--config", path.join(root, config)])) { ok = false; break; }
|
|
1540
|
+
if (ok) ok = (await (o.runCompile ?? (() => main(ds ? ["--all", "--commit", commit, "--snapshot", ds.path] : ["--all"], { ...io, cwd: root, readStdin: () => "", ...(ds ? { buildOwnership: ds.own, onDirectReceipt: (r) => { directReceipt = r; } } : {}) })))()) === 0;
|
|
1541
|
+
if (directReceipt) extra = { ...extra, direct: directReceipt };
|
|
1542
|
+
}
|
|
1543
|
+
else if (stage === "package" && ds) {
|
|
1544
|
+
o.err(`${tag} BLOCKED: [package] right-release builds and seals from the checkout; building from a commit snapshot waits for the right-release source split (joint plan step 4c, second part); nothing was built`);
|
|
1545
|
+
ok = false;
|
|
1221
1546
|
}
|
|
1222
1547
|
else if (stage === "package") {
|
|
1223
|
-
|
|
1224
|
-
|
|
1548
|
+
// Source QA (right-release qa --stage source) exists for mac and win only; a mobile package is the signed build alone.
|
|
1549
|
+
ok = (mobile || rightRelease(stage, ["qa", "--stage", "source", "--platform", platform])) && rightRelease(stage, ["build", "--platform", platform]);
|
|
1550
|
+
const sealed = ok ? (o.sealedFor ?? sealedFor)({ root: appRoot, commit, platform }) : null;
|
|
1225
1551
|
if (ok && !sealed) { o.err(`${tag} [package] the build reported success but sealed nothing for ${commit.slice(0, 8)} on ${platform}`); ok = false; }
|
|
1226
1552
|
if (sealed) extra = { platform, sealed: sealed.id, manifestSha256: sealed.digest };
|
|
1227
1553
|
} else {
|
|
1228
1554
|
const sealed = receipt.stages.package?.ok ? receipt.stages.package : null;
|
|
1229
|
-
const current = sealed ? (o.sealedFor ?? sealedFor)({ root, commit, platform }) : null;
|
|
1555
|
+
const current = sealed ? (o.sealedFor ?? sealedFor)({ root: appRoot, commit, platform }) : null;
|
|
1230
1556
|
if (!sealed || !current || current.id !== sealed.sealed || current.digest !== sealed.manifestSha256) {
|
|
1231
1557
|
o.err(`${tag} BLOCKED: [${stage}] needs the sealed set the package stage produced for this commit, unchanged (sealed manifest digest)`);
|
|
1232
1558
|
ok = false;
|
|
1559
|
+
} else if (stage === "release" && mobile) {
|
|
1560
|
+
// The host uploads the sealed mobile build to the store's test track itself (the credentials live in the host's credential
|
|
1561
|
+
// store); the release tier is owed first, as for desktop qualification.
|
|
1562
|
+
const releaseTier = receipt.stages.check?.ok && receipt.stages.check.ruleTier === "release"
|
|
1563
|
+
? (o.out(`${tag} [release] the release tier already passed at check for this exact input; not run again`), 0)
|
|
1564
|
+
: await (o.runReleaseCheck ?? (() => main(["check", "--commit", ds ? commit : "HEAD", "--full-a", ...(ds ? ["--snapshot", ds.path] : []), "--rule-tier", "release"], { ...io, cwd: root, ...(ds ? { buildOwnership: ds.own } : {}) })))();
|
|
1565
|
+
if (releaseTier !== 0) { o.err(`${tag} [release] the release tier (every rule and scan) is red for ${commit.slice(0, 12)}; nothing was uploaded`); ok = false; }
|
|
1566
|
+
else ok = rightRelease(stage, ["upload", "--platform", platform, "--release", sealed.sealed, "--apply"]);
|
|
1567
|
+
extra = { sealed: sealed.sealed, manifestSha256: sealed.manifestSha256, track: platform === "ios" ? "testflight" : "play-internal", ruleTier: "release" };
|
|
1233
1568
|
} else if (stage === "qualify") {
|
|
1234
|
-
|
|
1235
|
-
|
|
1569
|
+
// Qualification owes the release tier: every rule and scan, the ones held out of compile admission included (app-check.mjs TIERS).
|
|
1570
|
+
const releaseTier = receipt.stages.check?.ok && receipt.stages.check.ruleTier === "release"
|
|
1571
|
+
? (o.out(`${tag} [qualify] the release tier already passed at check for this exact input; not run again`), 0)
|
|
1572
|
+
: await (o.runReleaseCheck ?? (() => main(["check", "--commit", ds ? commit : "HEAD", "--full-a", ...(ds ? ["--snapshot", ds.path] : []), "--rule-tier", "release"], { ...io, cwd: root, ...(ds ? { buildOwnership: ds.own } : {}) })))();
|
|
1573
|
+
if (releaseTier !== 0) { o.err(`${tag} [qualify] the release tier (every rule and scan) is red for ${commit.slice(0, 12)}; nothing was qualified`); ok = false; }
|
|
1574
|
+
else ok = rightRelease(stage, ["qa", "--stage", "installed", "--platform", platform, "--release", sealed.sealed]);
|
|
1575
|
+
extra = { sealed: sealed.sealed, manifestSha256: sealed.manifestSha256, ruleTier: "release" };
|
|
1236
1576
|
} else {
|
|
1237
1577
|
if (!receipt.stages.qualify?.ok || receipt.stages.qualify.manifestSha256 !== sealed.manifestSha256) { o.err(`${tag} BLOCKED: [release] the sealed set was not qualified (installed QA) in this door run's receipt`); ok = false; }
|
|
1238
1578
|
else { ok = rightRelease(stage, ["upload", "--release", sealed.sealed, "--platform", platform, "--tier", tier]); extra = { sealed: sealed.sealed, manifestSha256: sealed.manifestSha256, tier }; }
|
|
1239
1579
|
}
|
|
1240
1580
|
}
|
|
1581
|
+
if (ds) {
|
|
1582
|
+
// The stage covers the commit only if the snapshot is still its tree and the host is still this door's (E6).
|
|
1583
|
+
let intact = ds.own.held();
|
|
1584
|
+
if (intact) { try { (o.verifySnapshot ?? (await import("../snapshot.mjs")).verifySnapshot)({ checkout: root, rev: commit, root: ds.root, dir: ds.path }); } catch (e) { intact = false; o.err(`${tag} RK_CHECK_ERROR: [${stage}] ${e.message}`); } }
|
|
1585
|
+
else o.err(`${tag} RK_CHECK_ERROR: [${stage}] the build ownership ${ds.own.handle} is no longer held`);
|
|
1586
|
+
extra = { ...extra, snapshot: { path: ds.path, tree: ds.tree, intactAfter: intact }, ownership: ds.own.handle };
|
|
1587
|
+
if (!intact) { receipt.stages[stage] = { ok: false, at, ...extra }; save(); o.err(`${tag} --to ${to} stopped at ${stage}: the result covers no known bytes`); return 2; }
|
|
1588
|
+
}
|
|
1241
1589
|
receipt.stages[stage] = { ok, at, ...extra };
|
|
1242
1590
|
save();
|
|
1243
1591
|
if (!ok) { o.err(`${tag} --to ${to} stopped at ${stage}: nothing after it ran`); return 1; }
|
|
@@ -1265,20 +1613,22 @@ function stageForPublisher(o, args, root, git) {
|
|
|
1265
1613
|
const common = text(git("rev-parse", "--git-common-dir")).trim();
|
|
1266
1614
|
if (!/^[0-9a-f]{40}$/.test(commit) || !common) { o.err(`${tag} cannot read HEAD and the git directory`); return 2; }
|
|
1267
1615
|
const commonDir = path.resolve(root, common);
|
|
1268
|
-
const
|
|
1616
|
+
const { appRoot, appDir } = appRootOf({ root, cwd: o.cwd, exists: o.exists });
|
|
1617
|
+
const receipt = (o.readReceipt ?? readReceipt)({ commonDir, commit, app: appDir });
|
|
1269
1618
|
const pack = receipt?.stages?.package; const qual = receipt?.stages?.qualify;
|
|
1270
|
-
const sealed = pack?.ok ? (o.sealedFor ?? sealedFor)({ root, commit, platform }) : null;
|
|
1619
|
+
const sealed = pack?.ok ? (o.sealedFor ?? sealedFor)({ root: appRoot, commit, platform }) : null;
|
|
1271
1620
|
if (!pack?.ok || !qual?.ok || pack.platform !== platform || qual.manifestSha256 !== pack.manifestSha256 || !sealed || sealed.id !== pack.sealed || sealed.digest !== pack.manifestSha256) {
|
|
1272
1621
|
o.err(`${tag} BLOCKED: stage needs this host's receipt for ${commit.slice(0, 12)} with package and qualify passed on ${platform} and the sealed set unchanged; run rightkit-gate --to qualify --platform ${platform} first`);
|
|
1273
1622
|
return 1;
|
|
1274
1623
|
}
|
|
1275
|
-
|
|
1624
|
+
// One draft per commit and app (receiptKey): two apps of one repository staged at one commit never clobber each other's assets.
|
|
1625
|
+
const releaseTag = `rightkit-staging-${receiptKey(commit, appDir)}`;
|
|
1276
1626
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), "rightkit-stage-"));
|
|
1277
1627
|
try {
|
|
1278
1628
|
const receiptFile = path.join(work, `receipt-${platform}.json`);
|
|
1279
1629
|
fs.writeFileSync(receiptFile, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
1280
1630
|
const archive = path.join(work, `sealed-${platform}.tar.gz`);
|
|
1281
|
-
const tar = o.spawn("tar", ["-czf", archive, "-C",
|
|
1631
|
+
const tar = o.spawn("tar", ["-czf", archive, "-C", appRoot, path.join(".right-release", "sealed", sealed.id, platform)]);
|
|
1282
1632
|
if (failed(tar)) { o.err(`${tag} could not pack ${sealed.id}/${platform}`); return 1; }
|
|
1283
1633
|
const gh = (...a) => o.spawn("gh", a, { cwd: root });
|
|
1284
1634
|
if (failed(gh("release", "view", releaseTag))) {
|
|
@@ -1291,7 +1641,6 @@ function stageForPublisher(o, args, root, git) {
|
|
|
1291
1641
|
if (tier) {
|
|
1292
1642
|
// The trusted publisher is RightKit's publish-app workflow: app repositories are private and run no Actions.
|
|
1293
1643
|
const slug = text(gh("repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner")).trim();
|
|
1294
|
-
const appDir = path.relative(root, o.cwd).replaceAll("\\", "/") || ".";
|
|
1295
1644
|
const run = gh("workflow", "run", "publish-app.yml", "-R", PUBLISHER_REPO, "-f", `app=${slug}`, "-f", `app_dir=${appDir}`, "-f", `source_revision=${commit}`, "-f", `tier=${tier}`, "-f", `platforms=${platform}`);
|
|
1296
1645
|
if (!slug || failed(run)) { o.err(`${tag} staged, but could not dispatch ${PUBLISHER_REPO} publish-app.yml: ${text(run).trim()}`); return 1; }
|
|
1297
1646
|
o.out(`${tag} dispatched ${PUBLISHER_REPO} publish-app.yml for ${slug}@${commit.slice(0, 12)} (${platform}, ${tier}); the publisher re-runs class A, verifies the receipt and the bytes, then uploads`);
|
|
@@ -1306,28 +1655,55 @@ function stageForPublisher(o, args, root, git) {
|
|
|
1306
1655
|
* run, the only way it runs at all. The executor is the same as every other stage: the broker where there is one (its own admission
|
|
1307
1656
|
* checks again), plain cargo on an unmanaged host, never around a managed host's broker.
|
|
1308
1657
|
*/
|
|
1309
|
-
async function runWorktreeCompile(o, args, root, git, io, to) {
|
|
1658
|
+
async function runWorktreeCompile(o, args, root, git, io, to, appRoot = root) {
|
|
1310
1659
|
const tag = "rightkit-gate:";
|
|
1311
|
-
const
|
|
1660
|
+
const separator = args.indexOf("--");
|
|
1661
|
+
const passthrough = separator >= 0 ? args.slice(separator + 1) : [];
|
|
1662
|
+
// `--to dev --platform mac` (the documented form) hands the platform to right-release as `--platform mac`; a platform given after
|
|
1663
|
+
// `--` wins (Pulse, 2026-10-27: the flag before `--` was dropped and right-release refused "requires --platform").
|
|
1664
|
+
const gateArgs = separator >= 0 ? args.slice(0, separator) : args;
|
|
1665
|
+
const platformAt = gateArgs.indexOf("--platform");
|
|
1666
|
+
if (platformAt >= 0 && gateArgs[platformAt + 1] && !passthrough.includes("--platform")) passthrough.unshift("--platform", gateArgs[platformAt + 1]);
|
|
1312
1667
|
const commit = text(git("rev-parse", "HEAD")).trim();
|
|
1313
1668
|
const tree = text(git("rev-parse", "HEAD^{tree}")).trim();
|
|
1314
1669
|
const common = text(git("rev-parse", "--git-common-dir")).trim();
|
|
1315
1670
|
if (!/^[0-9a-f]{40}$/.test(commit) || !common) { o.err(`${tag} cannot read HEAD and the git directory`); return 2; }
|
|
1316
1671
|
const which = (cmd) => { const r = o.spawn(o.platform === "win32" ? "where" : "which", [cmd]); return failed(r) ? [] : text(r).split(/\r?\n/).map((l) => l.trim()).filter(Boolean); };
|
|
1317
|
-
const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile });
|
|
1672
|
+
const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile }); applyExecutorEnv(o, executor);
|
|
1318
1673
|
if (executor.kind === "refuse") { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
|
|
1319
1674
|
o.out(`${tag} --to ${to} on the working tree of ${commit.slice(0, 12)}: check (class A) -> right-release ${to}; executor ${executor.kind}`);
|
|
1320
|
-
|
|
1675
|
+
// Full class A on the working tree, the checks that execute repository code included, before right-release compiles anything.
|
|
1676
|
+
const checked = await (o.runCheck ?? (() => main(["check", "--worktree", "--full-a"], { ...io, cwd: root })))();
|
|
1321
1677
|
if (checked !== 0) { o.err(`${tag} --to ${to} stopped at check: nothing compiled`); return 1; }
|
|
1322
1678
|
const commonDir = path.resolve(root, common);
|
|
1323
1679
|
const run = startRun({ commonDir, commit, tree, stage: to });
|
|
1324
1680
|
try {
|
|
1325
1681
|
const cli = fileURLToPath(new URL("../cli/right-release.mjs", import.meta.url));
|
|
1326
|
-
|
|
1682
|
+
// right-release reads its config from its cwd: the resolved app root (appRootOf), not the directory the command was typed in
|
|
1683
|
+
// (apps/foo/src is apps/foo; Astra, third pass).
|
|
1684
|
+
// The door's own RightKit runs every sub-step: a dev target's `right-release web-build|stage-sidecar` steps resolve `right-release`
|
|
1685
|
+
// on PATH, which found the app's node_modules copy (HeardRight on the Dell, 2026-10-27: the app's 0.6.8 guard refused what the
|
|
1686
|
+
// door's 0.6.9 admitted). An installed gate (node_modules/@rightkit/release/bin) has its package's bin shims two levels up.
|
|
1687
|
+
const r = (o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" })))(process.execPath, [cli, to, ...passthrough], { cwd: appRoot, env: { ...o.env, ...run.env, ...doorPath(o) } });
|
|
1327
1688
|
return r.status === 0 ? 0 : 1;
|
|
1328
1689
|
} finally { endRun(run); }
|
|
1329
1690
|
}
|
|
1330
1691
|
|
|
1692
|
+
/** A direct (Linux) executor's environment (PATH without RustRight shim directories) for every child this run starts. */
|
|
1693
|
+
function applyExecutorEnv(o, executor) {
|
|
1694
|
+
if (executor?.env && o.env) Object.assign(o.env, executor.env);
|
|
1695
|
+
if (executor?.note && o.out) o.out(`rightkit-gate: ${executor.note}`);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/** PATH with the door's own `right-release` shims first (`<node_modules>/.bin` beside an installed gate), or nothing for a checkout. */
|
|
1699
|
+
export function doorPath(o, gateFile = o.gateFile ?? fileURLToPath(import.meta.url), platform = o.platform ?? process.platform) {
|
|
1700
|
+
const bin = path.resolve(path.dirname(gateFile), "..", "..", "..", ".bin");
|
|
1701
|
+
const shim = path.join(bin, platform === "win32" ? "right-release.cmd" : "right-release");
|
|
1702
|
+
if (!(o.exists ?? fs.existsSync)(shim)) return {};
|
|
1703
|
+
const key = platform === "win32" ? Object.keys(o.env ?? {}).find((k) => k.toUpperCase() === "PATH") ?? "Path" : "PATH";
|
|
1704
|
+
return { [key]: `${bin}${path.delimiter}${(o.env ?? {})[key] ?? ""}` };
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1331
1707
|
// ---- main ---------------------------------------------------------------------------------------------------
|
|
1332
1708
|
|
|
1333
1709
|
/**
|
|
@@ -1351,14 +1727,67 @@ export async function main(argv, io = {}) {
|
|
|
1351
1727
|
return 0;
|
|
1352
1728
|
}
|
|
1353
1729
|
if (args.includes("--help") || args.includes("-h")) { o.out(USAGE); return 0; }
|
|
1730
|
+
// `snapshot`: sync the selected commit into the gate-owned source directory and verify it is exactly the commit's tree
|
|
1731
|
+
// (../snapshot.mjs; docs/GATE-CONTRACT.md §1). Reads the checkout's commits only, never its working files.
|
|
1732
|
+
// `--verify --env <digest>` also requires the installed dependency environment to still be the digest `env-digest --snapshot` recorded.
|
|
1733
|
+
if (args[0] === "snapshot") {
|
|
1734
|
+
const sc = await import("../snapshot-check.mjs");
|
|
1735
|
+
return (await import("../snapshot.mjs")).snapshotCommand(o, args.slice(1), { environmentDigest: ({ checkout, dir, commit }) => classAEnvironmentDigest({ root: dir, files: sc.commitFiles(checkout, commit, o.spawn).files, spawn: o.spawn }) });
|
|
1736
|
+
}
|
|
1737
|
+
// `live-scans`: re-run only the live remote scans (pnpm-audit) of a checked tree, answering the broker's verdict-reuse refresh
|
|
1738
|
+
// (../snapshot-check.mjs; GATE-CONTRACT §1, E18; rightkit#1257).
|
|
1739
|
+
if (args[0] === "live-scans") return (await import("../snapshot-check.mjs")).liveScansCommand(o, args.slice(1), { discoverPnpmRoots, discoverNpmUnits });
|
|
1354
1740
|
const sub = args[0] && !args[0].startsWith("-") ? args[0] : null;
|
|
1355
|
-
if (sub && sub !== "install-hook" && sub !== "check" && sub !== "stage") { o.err(`rightkit-gate: unknown command ${JSON.stringify(sub)}\n${USAGE}`); return 2; }
|
|
1741
|
+
if (sub && sub !== "install-hook" && sub !== "check" && sub !== "stage" && sub !== "env-digest") { o.err(`rightkit-gate: unknown command ${JSON.stringify(sub)}\n${USAGE}`); return 2; }
|
|
1356
1742
|
// `check` is the deterministic stage (Adrian, 2026-10-25: deterministic always first, one door): the app-check rules, then every
|
|
1357
1743
|
// class A scan (lockfiles, cargo-deny, cargo-audit, secrets, licences, manifests, workflow lint, syntax...) over the whole tree of the
|
|
1358
1744
|
// commit, never a diff. Nothing compiles. The RustRight broker runs exactly this before it admits any compile. `--list`,
|
|
1359
1745
|
// `--print-baseline` and `--only` stay app-check-only views.
|
|
1746
|
+
if ((sub === "check" || (!sub && args.includes("--all"))) && args.includes("--snapshot") && !o.buildOwnership) {
|
|
1747
|
+
// Host-wide build ownership (../build-ownership.mjs; GATE-CONTRACT §1): held from before the first snapshot read through the
|
|
1748
|
+
// end of the check, so no other request can sync the snapshot while class A runs in it. A nested gate whose
|
|
1749
|
+
// RIGHTKIT_BUILD_OWNERSHIP names the current owner joins it. Busy (or liveness unknown) runs nothing.
|
|
1750
|
+
const { acquireBuildOwnership, ownershipFile } = await import("../build-ownership.mjs");
|
|
1751
|
+
let own;
|
|
1752
|
+
try { own = (o.acquireOwnership ?? acquireBuildOwnership)({ file: ownershipFile({ platform: o.platform, beside: o.snapshotRoot }), stage: sub ?? "compile", snapshot: option("--snapshot") ?? null, commit: option("--commit") ?? null, platform: o.platform, env: o.env }); }
|
|
1753
|
+
catch (e) { if (e.code !== "busy" && e.code !== "refused") throw e; o.err(`rightkit-gate: RK_CHECK_ERROR: ${e.message}; nothing ran${e.code === "refused" ? ", nothing created" : ""}`); return 2; }
|
|
1754
|
+
o.out(`rightkit-gate: build ownership ${own.joined ? "joined" : "held"} ${own.handle}${own.reclaimed ? ` (reclaimed from ${own.reclaimed.from}: ${own.reclaimed.proofs.join("; ")})` : ""}`);
|
|
1755
|
+
try { return await main(argv, { ...io, buildOwnership: own }); }
|
|
1756
|
+
finally { const r = own.release(); if (r?.released === false && !own.joined) o.err(`rightkit-gate: build ownership ${own.handle} kept: ${r.why}`); }
|
|
1757
|
+
}
|
|
1360
1758
|
if (sub === "check") {
|
|
1361
|
-
|
|
1759
|
+
// `--snapshot <dir>`: full class A of the gate-owned snapshot of a selected commit (../snapshot-check.mjs). The checkout is only
|
|
1760
|
+
// where the commit comes from: its HEAD and status are never read, and the snapshot must be exactly the commit's tree before and
|
|
1761
|
+
// after the check, or the check is RK_CHECK_ERROR (exit 2).
|
|
1762
|
+
let snapshotDir = null; let snapshotCommit = null; let snapshotCheckout = null; let sc = null;
|
|
1763
|
+
if (args.includes("--snapshot")) {
|
|
1764
|
+
sc = await import("../snapshot-check.mjs");
|
|
1765
|
+
try {
|
|
1766
|
+
const at = option("--repo") ? path.resolve(o.cwd, option("--repo")) : o.cwd;
|
|
1767
|
+
const top0 = o.spawn("git", ["rev-parse", "--show-toplevel"], { cwd: at });
|
|
1768
|
+
if (failed(top0) || !text(top0).trim()) throw new sc.CheckError(`${at} is not a git checkout`);
|
|
1769
|
+
snapshotCheckout = fs.realpathSync.native(path.resolve(text(top0).trim()));
|
|
1770
|
+
snapshotDir = sc.snapshotOption(args, { checkout: snapshotCheckout });
|
|
1771
|
+
snapshotCommit = sc.commitOf(snapshotCheckout, option("--commit"), o.spawn);
|
|
1772
|
+
sc.verifySnapshotDir({ checkout: snapshotCheckout, dir: snapshotDir, ...snapshotCommit, when: "before the check" });
|
|
1773
|
+
o.out(`rightkit-gate: snapshot ${snapshotDir} is commit ${snapshotCommit.commit} tree ${snapshotCommit.tree} (verified before the check)`);
|
|
1774
|
+
} catch (e) { o.err(`rightkit-gate: ${sc.CHECK_ERROR}: ${e.message}`); return 2; }
|
|
1775
|
+
}
|
|
1776
|
+
if (args.filter((arg) => arg === "--rule-tier").length > 1 || (args.includes("--rule-tier") && option("--rule-tier") === undefined)) {
|
|
1777
|
+
o.err("rightkit-gate: check requires exactly one value for --rule-tier"); return 2;
|
|
1778
|
+
}
|
|
1779
|
+
const requestedTier = option("--rule-tier") ?? "compile";
|
|
1780
|
+
if (!["compile", "release"].includes(requestedTier)) { o.err(`rightkit-gate: check --rule-tier must be compile or release, not ${JSON.stringify(requestedTier)}`); return 2; }
|
|
1781
|
+
// Full admission cannot be downgraded by a caller's tier flag. Partial views retain their requested tier.
|
|
1782
|
+
const fullAdmission = args.includes("--full-a") && !args.includes("--list") && !args.includes("--print-baseline") && !args.includes("--only");
|
|
1783
|
+
const stripSnapshot = (list) => { const i = list.indexOf("--snapshot"); return i < 0 ? list : [...list.slice(0, i), ...list.slice(i + 2)]; };
|
|
1784
|
+
const appArgs = stripSnapshot(args.slice(1).filter((a) => a !== "--full-a"));
|
|
1785
|
+
if (fullAdmission) {
|
|
1786
|
+
const tierIndex = appArgs.indexOf("--rule-tier");
|
|
1787
|
+
if (tierIndex >= 0) appArgs.splice(tierIndex, 2);
|
|
1788
|
+
appArgs.push("--rule-tier", "release");
|
|
1789
|
+
}
|
|
1790
|
+
const appCheck = await (o.appCheck ?? (await import("../app-check.mjs")).main)(appArgs, { out: o.out, err: o.err, label: "check", cwd: o.cwd });
|
|
1362
1791
|
if (args.includes("--list") || args.includes("--print-baseline") || args.includes("--only") || o.classA === false) {
|
|
1363
1792
|
// A view of the app rules, never a class A verdict: say so, so no caller can take its exit 0 for a full check.
|
|
1364
1793
|
// A green partial view exits PARTIAL_EXIT (3), never 0, so a caller that admits only exit 0 rejects it by status alone.
|
|
@@ -1366,13 +1795,17 @@ export async function main(argv, io = {}) {
|
|
|
1366
1795
|
o.err("rightkit-gate: PARTIAL: app rules only (--only); no class A scan ran, so this is not a class A result");
|
|
1367
1796
|
return appCheck === 0 ? PARTIAL_EXIT : appCheck;
|
|
1368
1797
|
}
|
|
1798
|
+
if (appCheck !== 0) {
|
|
1799
|
+
o.err("rightkit-gate: BLOCKED: app rules failed; no further scans ran");
|
|
1800
|
+
return appCheck;
|
|
1801
|
+
}
|
|
1369
1802
|
const repoArg = option("--repo");
|
|
1370
1803
|
const checkCwd = repoArg ? path.resolve(o.cwd, repoArg) : o.cwd;
|
|
1371
|
-
|
|
1804
|
+
// Admission uses the full tier; a partial diagnostic check may choose either tier.
|
|
1805
|
+
const ruleTier = fullAdmission ? "release" : requestedTier;
|
|
1806
|
+
const classA = await main(["--class-a", "--rule-tier", ruleTier, ...(args.includes("--full-a") ? ["--full-a"] : []), ...(args.includes("--worktree") ? ["--worktree"] : ["--commit", snapshotCommit?.commit ?? option("--commit") ?? "HEAD"]), ...(snapshotDir ? ["--snapshot", snapshotDir] : [])], { ...io, cwd: checkCwd });
|
|
1372
1807
|
// RightKit checking itself: its own rules (line endings, consumer recipes, npm surface, version copies and the version feed, dispatcher
|
|
1373
|
-
// pins, consumer locks, generated docs, temp paths: scripts/preflight.mjs) and its exports' import safety are class A for the RightKit repository.
|
|
1374
|
-
// repository's own in-tree copy, so a gate checking any other repository (the broker's pinned one included) never runs
|
|
1375
|
-
// that repository's code.
|
|
1808
|
+
// pins, consumer locks, generated docs, temp paths: scripts/preflight.mjs) and its exports' import safety are class A for the RightKit repository.
|
|
1376
1809
|
let selfRules = 0;
|
|
1377
1810
|
const top = o.spawn("git", ["rev-parse", "--show-toplevel"], { cwd: checkCwd });
|
|
1378
1811
|
const root = failed(top) ? null : path.resolve(text(top).trim());
|
|
@@ -1381,7 +1814,30 @@ export async function main(argv, io = {}) {
|
|
|
1381
1814
|
// never after a failure, so a red tree executes nothing of its own.
|
|
1382
1815
|
// Compared as real, long paths (and case-blind on Windows): git prints the long form of an 8.3 short path such as RUNNER~1.
|
|
1383
1816
|
const canon = (p) => { let c; try { c = fs.realpathSync.native(p); } catch { c = path.resolve(p); } return process.platform === "win32" ? c.toLowerCase() : c; };
|
|
1384
|
-
|
|
1817
|
+
// In snapshot mode RightKit's own scripts are the snapshot's (the commit's code), never the live checkout's.
|
|
1818
|
+
const selfRoot = snapshotDir ?? root;
|
|
1819
|
+
const rightkitRepository = root && (() => {
|
|
1820
|
+
try { return JSON.parse(o.readFile(path.join(selfRoot, "packages", "release", "package.json"))).name === "@rightkit/release"; }
|
|
1821
|
+
catch { return false; }
|
|
1822
|
+
})();
|
|
1823
|
+
if (rightkitRepository && (!o.exists(path.join(selfRoot, "scripts", "preflight.mjs")) || !o.exists(path.join(selfRoot, "scripts", "ci", "exports-import-effects.mjs")))) {
|
|
1824
|
+
o.err("rightkit-gate: BLOCKED: RightKit's preflight or exports check is missing");
|
|
1825
|
+
selfRules = 1;
|
|
1826
|
+
}
|
|
1827
|
+
if (root && selfRules === 0 && snapshotDir && rightkitRepository && appCheck === 0 && classA === 0) {
|
|
1828
|
+
// The commit's own preflight reads the commit from the checkout's object database; its exports probe imports from the snapshot,
|
|
1829
|
+
// whose dependencies class A installed, and reports beside (never over) the commit-bound report publication reads.
|
|
1830
|
+
const launch = o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" }));
|
|
1831
|
+
const r = launch(process.execPath, [path.join(snapshotDir, "scripts", "preflight.mjs"), "--repo", root, "--commit", snapshotCommit.commit], { cwd: root, env: o.env });
|
|
1832
|
+
selfRules = r.status === 0 ? 0 : 1;
|
|
1833
|
+
const effects = path.join(snapshotDir, "scripts", "ci", "exports-import-effects.mjs");
|
|
1834
|
+
if (selfRules === 0 && o.exists(effects)) {
|
|
1835
|
+
const common = o.spawn("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: root });
|
|
1836
|
+
const report = path.join(failed(common) ? path.join(root, ".git") : text(common).trim(), "rightkit-gate", "import-effects", `snapshot-${snapshotCommit.commit}.json`);
|
|
1837
|
+
const e = launch(process.execPath, [effects, "--root", snapshotDir, "--no-export", "--report", report], { cwd: snapshotDir, env: o.env });
|
|
1838
|
+
if (e.status !== 0) selfRules = 1;
|
|
1839
|
+
}
|
|
1840
|
+
} else if (root && selfRules === 0 && !snapshotDir && (rightkitRepository || canon(self).startsWith(canon(root) + path.sep)) && o.exists(path.join(root, "scripts", "preflight.mjs")) && appCheck === 0 && classA === 0) {
|
|
1385
1841
|
const launch = o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" }));
|
|
1386
1842
|
const selector = args.includes("--worktree") ? ["--worktree"] : ["--commit", option("--commit") ?? "HEAD"];
|
|
1387
1843
|
const r = launch(process.execPath, [path.join(root, "scripts", "preflight.mjs"), "--repo", root, ...selector], { cwd: root, env: o.env });
|
|
@@ -1399,15 +1855,15 @@ export async function main(argv, io = {}) {
|
|
|
1399
1855
|
if (requested !== null && at(requested) !== at("HEAD")) {
|
|
1400
1856
|
o.err(`rightkit-gate: BLOCKED: the exports import check probes the checkout's HEAD (${String(at("HEAD")).slice(0, 12)}), not the requested ${requested}; check out that commit to check it`);
|
|
1401
1857
|
selfRules = 1;
|
|
1402
|
-
} else if (requested === null && dirty()) {
|
|
1403
|
-
o.err("rightkit-gate: BLOCKED: the exports import check probes the checkout's HEAD, and the working tree differs from it; commit or stash the changes, or check --commit HEAD");
|
|
1404
|
-
selfRules = 1;
|
|
1405
1858
|
} else {
|
|
1859
|
+
// A working-tree check of a dirty checkout probes the working tree's own bytes (--worktree: staged into a throwaway index
|
|
1860
|
+
// and exported like a commit), never HEAD's, so local development is checked on what it actually is.
|
|
1861
|
+
const worktreeProbe = requested === null && dirty();
|
|
1406
1862
|
// Like CI's scans job: exports that resolve to build output are INCOMPLETE here and handed, through --report, to the
|
|
1407
1863
|
// sealed-byte check that publication runs; without --report the probe exits 3 and a green tree could never pass.
|
|
1408
1864
|
const common = o.spawn("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: root });
|
|
1409
1865
|
const report = path.join(failed(common) ? path.join(root, ".git") : text(common).trim(), "rightkit-gate", "import-effects", "report.json");
|
|
1410
|
-
const e = launch(process.execPath, [effects, "--report", report], { cwd: root, env: o.env });
|
|
1866
|
+
const e = launch(process.execPath, [effects, "--report", report, ...(worktreeProbe ? ["--worktree"] : [])], { cwd: root, env: o.env });
|
|
1411
1867
|
if (e.status !== 0) selfRules = 1;
|
|
1412
1868
|
}
|
|
1413
1869
|
}
|
|
@@ -1424,9 +1880,37 @@ export async function main(argv, io = {}) {
|
|
|
1424
1880
|
if (!judged.inputs.length && !judged.findings.length) o.out("rightkit-gate: no declared compile inputs in the captured tree");
|
|
1425
1881
|
inputs = judged.findings.length ? 1 : 0;
|
|
1426
1882
|
}
|
|
1883
|
+
if (snapshotDir) {
|
|
1884
|
+
try { sc.verifySnapshotDir({ checkout: snapshotCheckout, dir: snapshotDir, ...snapshotCommit, when: "after the check" }); }
|
|
1885
|
+
catch (e) { o.err(`rightkit-gate: ${sc.CHECK_ERROR}: ${e.message}; the result covers no known bytes`); return 2; }
|
|
1886
|
+
o.out(`rightkit-gate: snapshot ${snapshotDir} is still tree ${snapshotCommit.tree} after the check`);
|
|
1887
|
+
}
|
|
1427
1888
|
return Math.max(appCheck, classA, selfRules, inputs);
|
|
1428
1889
|
}
|
|
1429
1890
|
const classAOnly = args.includes("--class-a");
|
|
1891
|
+
// --full-a: every class A check, the ones that execute the repository's code (tests, typecheck, lint, generated docs) included,
|
|
1892
|
+
// on the live working tree, before anything compiles. The door's dev/compile stages require it: a content-only check never
|
|
1893
|
+
// authorizes a build (Adrian: class A is everything mechanical and deterministic; reviewer audit 2026-10-28).
|
|
1894
|
+
const fullA = classAOnly && args.includes("--full-a");
|
|
1895
|
+
// Full class A of a gate-owned snapshot (`check --snapshot`, which verified it): the snapshot is the commit's bytes, so neither the
|
|
1896
|
+
// checkout's HEAD nor its cleanliness matters, and the repository's code runs in the snapshot, never in the checkout.
|
|
1897
|
+
const snapshotA = classAOnly ? (option("--snapshot") ?? null) : null;
|
|
1898
|
+
// The door's compile of a selected commit (`--all --commit <sha> --snapshot <dir>`, joint plan step 4c): every scan, class B's
|
|
1899
|
+
// compiles included, reads the verified gate-owned snapshot, never the checkout; outputs go to the checkout's own target
|
|
1900
|
+
// directory. A direct host only, until the broker's snapshot carrier lands (step 4d).
|
|
1901
|
+
const snapshotB = !classAOnly && args.includes("--all") ? (option("--snapshot") ?? null) : null;
|
|
1902
|
+
if (snapshotB && !option("--commit")) { o.err(`${tag} --all --snapshot <dir> needs --commit <sha>`); return 2; }
|
|
1903
|
+
if (fullA && !args.includes("--worktree") && !snapshotA) {
|
|
1904
|
+
// A commit is checked in full only where its bytes are the live checkout: it is HEAD and nothing tracked or untracked differs
|
|
1905
|
+
// (the unit and app stages already refuse a dirty tree). Then the working tree is that commit, and the checks run there.
|
|
1906
|
+
const at = (...a) => o.spawn("git", a, { cwd: o.cwd });
|
|
1907
|
+
const want = at("rev-parse", "--verify", `${option("--commit") ?? "HEAD"}^{commit}`), head = at("rev-parse", "--verify", "HEAD^{commit}");
|
|
1908
|
+
const dirty = at("status", "--porcelain", "--untracked-files=all");
|
|
1909
|
+
if (failed(want) || failed(head) || text(want).trim() !== text(head).trim()) { o.err("rightkit-gate: --full-a checks a commit only when it is HEAD of this checkout: the repository's checks run on the live tree"); return 2; }
|
|
1910
|
+
if (failed(dirty) || text(dirty).trim()) { o.err("rightkit-gate: --full-a on a commit needs a clean checkout (the live tree must be exactly that commit); commit or remove the changes"); return 2; }
|
|
1911
|
+
const i = args.indexOf("--commit"); if (i >= 0) args.splice(i, 2);
|
|
1912
|
+
args.push("--worktree");
|
|
1913
|
+
}
|
|
1430
1914
|
const classACommit = classAOnly && !args.includes("--worktree") ? (option("--commit") ?? "HEAD") : null;
|
|
1431
1915
|
if (args.includes("--base") && (!option("--base") || option("--base").startsWith("--"))) { o.err("rightkit-gate: --base needs a ref"); return 2; }
|
|
1432
1916
|
|
|
@@ -1435,6 +1919,59 @@ export async function main(argv, io = {}) {
|
|
|
1435
1919
|
const root = path.resolve(text(top).trim());
|
|
1436
1920
|
const git = (...a) => o.spawn("git", a, { cwd: root });
|
|
1437
1921
|
if (sub === "install-hook") return await installHook(o, root, args.includes("--force"));
|
|
1922
|
+
// `env-digest`: only the environment identity a full class A verdict depends on (classAEnvironmentDigest), no scans. The broker
|
|
1923
|
+
// recomputes it when a queued compile starts and re-runs the check if it changed since the verdict.
|
|
1924
|
+
if (sub === "env-digest") {
|
|
1925
|
+
// `env-digest --snapshot <dir> --commit <sha>`: the same identity over a verified snapshot (the broker's START recheck of a
|
|
1926
|
+
// snapshot-aware verdict): the commit's files, the snapshot's installed dependencies.
|
|
1927
|
+
if (args.includes("--snapshot")) {
|
|
1928
|
+
const sc = await import("../snapshot-check.mjs");
|
|
1929
|
+
try {
|
|
1930
|
+
const dir = option("--snapshot"); const rev = option("--commit");
|
|
1931
|
+
if (!dir || !path.isAbsolute(dir) || !rev || rev.startsWith("--")) throw new sc.CheckError("env-digest --snapshot needs an absolute directory and --commit <sha>");
|
|
1932
|
+
const checkout = fs.realpathSync.native(root);
|
|
1933
|
+
const { commit, tree } = sc.commitOf(checkout, rev, o.spawn);
|
|
1934
|
+
const real = fs.realpathSync.native(dir);
|
|
1935
|
+
sc.verifySnapshotDir({ checkout, dir: real, commit, tree, when: "for env-digest" });
|
|
1936
|
+
o.out(`env=${classAEnvironmentDigest({ root: real, files: sc.commitFiles(checkout, commit, o.spawn).files, spawn: o.spawn })}`);
|
|
1937
|
+
return 0;
|
|
1938
|
+
} catch (e) { o.err(`rightkit-gate: ${e.code === sc.CHECK_ERROR ? `${sc.CHECK_ERROR}: ` : "env-digest: "}${e.message}`); return 2; }
|
|
1939
|
+
}
|
|
1940
|
+
const listed = git("ls-files", "-z", "--cached", "--others", "--exclude-standard");
|
|
1941
|
+
if (failed(listed)) { o.err(`rightkit-gate: env-digest cannot list the working tree: ${why(listed)}`); return 2; }
|
|
1942
|
+
try { const files = text(listed).split("\0").filter(Boolean).map(posix); o.out(`env=${classAEnvironmentDigest({ root, files, spawn: o.spawn })}`); }
|
|
1943
|
+
catch (error) { o.err(`rightkit-gate: env-digest: ${error.message}`); return 2; }
|
|
1944
|
+
return 0;
|
|
1945
|
+
}
|
|
1946
|
+
if (args.includes("--plan") && option("--to") !== undefined) { o.err("rightkit-gate: --plan plans the class A scans and runs nothing; it takes no --to stage (Astra, second pass)"); return 2; }
|
|
1947
|
+
// `--unit` is a gate flag only before a `--` separator: after it the arguments belong to right-release (an app's `--to dev -- …`).
|
|
1948
|
+
const separator = args.indexOf("--");
|
|
1949
|
+
const unitFlag = args.includes("--unit") && (separator < 0 || args.indexOf("--unit") < separator);
|
|
1950
|
+
if (option("--to") !== undefined && unitFlag) {
|
|
1951
|
+
const selector = option("--unit");
|
|
1952
|
+
const validArgs = args.length % 2 === 0 && args.every((a, i) => i % 2 ? !a.startsWith("--") : ["--to", "--unit", "--receipt-out"].includes(a)) && ["--to", "--unit", "--receipt-out"].every((flag) => args.filter((a) => a === flag).length <= 1);
|
|
1953
|
+
if (!validArgs || !selector) {
|
|
1954
|
+
o.err("rightkit-gate: unit stages take only --to, --unit and optional --receipt-out"); return 2;
|
|
1955
|
+
}
|
|
1956
|
+
const head = git("rev-parse", "HEAD"), treeResult = git("rev-parse", "HEAD^{tree}"), common = git("rev-parse", "--git-common-dir"), dirty = git("status", "--porcelain", "--untracked-files=no");
|
|
1957
|
+
const commit = text(head).trim(), tree = text(treeResult).trim();
|
|
1958
|
+
if (failed(head) || failed(treeResult) || failed(common) || !text(common).trim() || !/^[0-9a-f]{40}$/.test(commit) || !/^[0-9a-f]{40}$/.test(tree) || failed(dirty) || text(dirty).trim()) { o.err("rightkit-gate: BLOCKED: unit stages need clean committed HEAD and tree"); return 1; }
|
|
1959
|
+
const match = /^(?:npm:@rightkit\/([a-z0-9-]+)|cargo:(rightkit-[a-z0-9-]+))$/.exec(selector);
|
|
1960
|
+
const manifest = match && (match[1] ? `packages/${match[1]}/package.json` : `crates/${match[2]}/Cargo.toml`);
|
|
1961
|
+
if (!manifest || failed(git("ls-files", "--error-unmatch", "--", ".rightkit-local-build.json", manifest))) { o.err("rightkit-gate: BLOCKED: unit and local grant must be tracked canonical files"); return 1; }
|
|
1962
|
+
const which = (cmd) => { const r = o.spawn(o.platform === "win32" ? "where" : "which", [cmd]); return failed(r) ? [] : text(r).split(/\r?\n/).map((l) => l.trim()).filter(Boolean); };
|
|
1963
|
+
const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile }); applyExecutorEnv(o, executor);
|
|
1964
|
+
if (executor.kind === "refuse") { o.err(`rightkit-gate: BLOCKED: ${executor.problem}`); return 1; }
|
|
1965
|
+
const reportOut = option("--receipt-out");
|
|
1966
|
+
// Loaded here, not at the top: the unit path reads Cargo manifests (smol-toml), which an installed copy of this package without
|
|
1967
|
+
// node_modules does not carry; the app paths of this gate must keep loading there (app-check.test: the installed copy).
|
|
1968
|
+
const { runUnitGate } = await import("../unit-gate.mjs");
|
|
1969
|
+
return await runUnitGate({ selector, to: option("--to"), root, commonDir: path.resolve(root, text(common).trim()), commit, tree, executor, env: o.env, platform: o.platform,
|
|
1970
|
+
check: (tier) => main(["check", "--commit", "HEAD", "--full-a", "--rule-tier", tier], { ...io, cwd: root }), out: o.out, err: o.err,
|
|
1971
|
+
...(reportOut ? { writeReport: (report) => o.writeFile(path.resolve(o.cwd, reportOut), `${JSON.stringify(report, null, 2)}\n`) } : {}),
|
|
1972
|
+
});
|
|
1973
|
+
}
|
|
1974
|
+
if (unitFlag) { o.err("rightkit-gate: --unit requires --to"); return 2; }
|
|
1438
1975
|
if (option("--to") !== undefined) return await runStages(o, args, root, git, io);
|
|
1439
1976
|
if (sub === "stage") return stageForPublisher(o, args, root, git);
|
|
1440
1977
|
|
|
@@ -1447,13 +1984,18 @@ export async function main(argv, io = {}) {
|
|
|
1447
1984
|
// (git hands the local field over verbatim, so a rev expression such as `HEAD@{1 minute ago}` or `HEAD^{/one thing}` holds spaces)
|
|
1448
1985
|
// refuses the push on every host and in every repository, never a line that disappears: its pushed commit could not be compared with
|
|
1449
1986
|
// HEAD and its remote ref could not be judged. `pushedRemoteShas` are the remote commits being replaced.
|
|
1987
|
+
// A push of ref deletions alone carries no content: nothing to check, nothing to plan (the hook skips its check the same way).
|
|
1988
|
+
let deletionsOnly = false;
|
|
1450
1989
|
if (!planOnly && !classAOnly) {
|
|
1451
1990
|
const head = text(git("rev-parse", "HEAD")).trim();
|
|
1452
1991
|
const parsed = parsePushLines(o.readStdin());
|
|
1992
|
+
// Every line is read before any shortcut: a malformed line hides what is pushed, so it blocks even beside deletions (Astra, third pass).
|
|
1453
1993
|
if (parsed.malformed.length) {
|
|
1454
1994
|
o.err(`${tag} BLOCKED: pre-push stdin line ${parsed.malformed[0].number} is not "<local ref> <local sha> <remote ref> <remote sha>" (four fields separated by single spaces), so the pushed refs cannot all be read and the pushed commit cannot be compared with HEAD; push a plain ref (git push <remote> HEAD:<branch>)`);
|
|
1455
1995
|
return 1;
|
|
1456
1996
|
}
|
|
1997
|
+
deletionsOnly = parsed.lines.length > 0 && parsed.lines.every((l) => ZERO.test(l.localSha));
|
|
1998
|
+
if (deletionsOnly) { o.out(`${tag} only deletions are pushed; nothing to check, nothing planned`); return 0; }
|
|
1457
1999
|
for (const { localSha, remoteRef, remoteSha } of parsed.lines) {
|
|
1458
2000
|
if (ZERO.test(localSha)) continue; // a delete pushes no content
|
|
1459
2001
|
if (localSha !== head) {
|
|
@@ -1468,20 +2010,52 @@ export async function main(argv, io = {}) {
|
|
|
1468
2010
|
// The executor is chosen here, never by the caller (Adrian, 2026-10-25): the RustRight broker where this machine has it, a refusal
|
|
1469
2011
|
// on a broker-managed machine whose broker cannot be reached, direct cargo on any other host (a cloud container, a CI runner).
|
|
1470
2012
|
const which = (cmd) => { const r = o.spawn(o.platform === "win32" ? "where" : "which", [cmd]); return failed(r) ? [] : text(r).split(/\r?\n/).map((l) => l.trim()).filter(Boolean); };
|
|
1471
|
-
const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile });
|
|
1472
|
-
|
|
1473
|
-
|
|
2013
|
+
const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile }); applyExecutorEnv(o, executor);
|
|
2014
|
+
// A broker-managed host whose broker is unreachable refuses the moment a Cargo manifest is in the snapshot (below): a repository
|
|
2015
|
+
// with no Rust owes nothing to the broker (a git hook's minimal PATH, a JS-only app), and the class A scans never compile.
|
|
2016
|
+
const cargoCommand = executor.cargo ?? ["cargo"];
|
|
2017
|
+
if (snapshotB && executor.kind !== "direct") { o.err(`${tag} BLOCKED: a ${executor.kind} host compiles a commit snapshot only through the RustRight snapshot carrier (joint plan step 4d), which is not wired yet; nothing ran`); return 1; }
|
|
1474
2018
|
// The snapshot: the commit or tree being checked (check --commit), HEAD for the gate, or the working tree (check --worktree, --plan).
|
|
1475
2019
|
const allFilesMode = classAOnly || args.includes("--all");
|
|
1476
|
-
const snapshot = planOnly ? null : classAOnly ? classACommit : "HEAD";
|
|
2020
|
+
const snapshot = planOnly ? null : classAOnly ? classACommit : snapshotB ? option("--commit") : "HEAD";
|
|
2021
|
+
// Every effectful gate entry owes the app rules, not only `check` (Astra, 2026-10-26): a bare, --all or pre-push run on HEAD runs them
|
|
2022
|
+
// first, in the tier asked for (compile by default), and plans nothing while they are red. --plan and --class-a (which runs them in
|
|
2023
|
+
// `check`) are untouched.
|
|
2024
|
+
if (snapshot && !classAOnly) {
|
|
2025
|
+
const appRules = await (o.appCheck ?? (await import("../app-check.mjs")).main)(["--repo", root, "--commit", snapshot, "--rule-tier", option("--rule-tier") ?? "compile"], { out: o.out, err: o.err, label: "check", cwd: o.cwd });
|
|
2026
|
+
if (appRules !== 0) { const id = git("rev-parse", String(snapshot)); o.err(`${tag} BLOCKED: the app rules are red for ${(failed(id) ? String(snapshot) : text(id).trim()).slice(0, 12)}; no scan was planned or run`); return appRules; }
|
|
2027
|
+
}
|
|
1477
2028
|
|
|
1478
2029
|
const ac = o.abort ?? new AbortController();
|
|
1479
2030
|
let exportRoot = null;
|
|
1480
|
-
|
|
2031
|
+
let snapshotGitConfig = null;
|
|
2032
|
+
let cargoShimDir = null;
|
|
2033
|
+
const cleanup = () => {
|
|
2034
|
+
if (exportRoot) fs.rmSync(exportRoot, { recursive: true, force: true });
|
|
2035
|
+
if (cargoShimDir) fs.rmSync(cargoShimDir, { recursive: true, force: true });
|
|
2036
|
+
if (snapshotGitConfig) { fs.rmSync(snapshotGitConfig, { force: true }); try { fs.rmdirSync(path.dirname(snapshotGitConfig)); } catch { /* not empty: leave it, the next exact sync prunes it */ } }
|
|
2037
|
+
};
|
|
1481
2038
|
const signals = [];
|
|
1482
2039
|
try {
|
|
1483
2040
|
let repo = root;
|
|
1484
|
-
|
|
2041
|
+
let snapshotBCommit = null;
|
|
2042
|
+
if (snapshot && snapshotB) {
|
|
2043
|
+
// Direct host: no broker grant to satisfy, so no config-only .git; the snapshot is scanned and compiled in place.
|
|
2044
|
+
repo = snapshotB;
|
|
2045
|
+
} else if (snapshot && snapshotA) {
|
|
2046
|
+
// The verified gate-owned snapshot is the commit's bytes: it is scanned in place, no export.
|
|
2047
|
+
repo = snapshotA;
|
|
2048
|
+
// As for an export (rightkit#1215): rustright binds the tracked .rightkit-local-build.json grant to the origin URL in .git/config,
|
|
2049
|
+
// so a locked cargo metadata in the snapshot needs a config-only .git naming exactly the checkout's origin. It is not a
|
|
2050
|
+
// repository (no HEAD, no objects), the seeded identity never reads .git, and it is removed when the check ends.
|
|
2051
|
+
const origin = o.spawn("git", ["config", "--get", "remote.origin.url"], { cwd: root });
|
|
2052
|
+
const originUrl = failed(origin) ? "" : text(origin).trim();
|
|
2053
|
+
if (originUrl && !o.exists(path.join(repo, ".git"))) {
|
|
2054
|
+
o.mkdir(path.join(repo, ".git"), { recursive: true });
|
|
2055
|
+
snapshotGitConfig = path.join(repo, ".git", "config");
|
|
2056
|
+
o.writeFile(snapshotGitConfig, `[remote "origin"]\n\turl = ${originUrl}\n`);
|
|
2057
|
+
}
|
|
2058
|
+
} else if (snapshot) {
|
|
1485
2059
|
exportRoot = fs.mkdtempSync(path.join(exportTmpRoot({ env: o.env, platform: o.platform, exists: o.exists }), "rk-gate-"));
|
|
1486
2060
|
repo = path.join(exportRoot, "repo");
|
|
1487
2061
|
// Every file of the commit or tree: read-tree into a private index, then checkout-index. `git archive` would leave out files
|
|
@@ -1516,6 +2090,7 @@ export async function main(argv, io = {}) {
|
|
|
1516
2090
|
let rust; let npm; let npmRoots = []; let manifests = [];
|
|
1517
2091
|
try {
|
|
1518
2092
|
manifests = tracked("Cargo.toml");
|
|
2093
|
+
if (executor.kind === "refuse" && manifests.length) { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
|
|
1519
2094
|
// ci/gate.config.json `fixtures` (optional): directories whose manifests are consumer or sample crates, gated through the
|
|
1520
2095
|
// consumer rows of the crates they exercise (CI treats them the same), never as units.
|
|
1521
2096
|
let fixtures = [];
|
|
@@ -1532,14 +2107,59 @@ export async function main(argv, io = {}) {
|
|
|
1532
2107
|
} catch (e) { o.err(`${tag} BLOCKED: cannot discover the repository's units: ${e.message}`); return 1; }
|
|
1533
2108
|
for (const k of rust.skipped) o.out(`${tag} note: ${k.manifest} is skipped, not scanned: ${k.why}`);
|
|
1534
2109
|
|
|
2110
|
+
// The snapshot compile: verified as the commit's tree before anything runs, dependencies installed into it offline from the
|
|
2111
|
+
// checkout's store, and every Cargo workspace's output pinned to the target directory cargo resolves for it IN THE CHECKOUT
|
|
2112
|
+
// (CARGO_TARGET_DIR or build.target-dir honoured), so the warm outputs are the checkout's and the snapshot holds no target/.
|
|
2113
|
+
let targetDirs = null; let cargoShim = null;
|
|
2114
|
+
if (snapshotB) {
|
|
2115
|
+
const sc = await import("../snapshot-check.mjs");
|
|
2116
|
+
try {
|
|
2117
|
+
const checkout = fs.realpathSync.native(root);
|
|
2118
|
+
snapshotBCommit = sc.commitOf(checkout, String(snapshot), o.spawn);
|
|
2119
|
+
sc.verifySnapshotDir({ checkout, dir: snapshotB, ...snapshotBCommit, when: "before the compile" });
|
|
2120
|
+
o.out(`${tag} snapshot ${snapshotB} is commit ${snapshotBCommit.commit} tree ${snapshotBCommit.tree} (verified before the compile)`);
|
|
2121
|
+
for (const r of sc.installSnapshotDependencies({ checkout, snapshot: snapshotB, roots: npmRoots, env: o.env, spawn: o.spawn })) o.out(`${tag} ${sc.describeInstall(r)}`);
|
|
2122
|
+
} catch (error) { o.err(`${tag} ${error.code === "RK_CHECK_ERROR" ? "RK_CHECK_ERROR" : "BLOCKED"}: ${error.message}`); return error.code === "RK_CHECK_ERROR" ? 2 : 1; }
|
|
2123
|
+
targetDirs = {};
|
|
2124
|
+
for (const ws of rust.workspaces) {
|
|
2125
|
+
const manifest = path.join(root, ws.cargoDir, "Cargo.toml");
|
|
2126
|
+
if (!o.exists(manifest)) { o.err(`${tag} BLOCKED: the checkout has no ${posix(path.relative(root, manifest))}, so the output directory of workspace ${ws.cargoDir} cannot be resolved; nothing compiled`); return 1; }
|
|
2127
|
+
const r = o.spawn(cargoCommand[0], [...cargoCommand.slice(1), "metadata", "--offline", "--format-version", "1", "--no-deps", "--manifest-path", manifest], { cwd: path.dirname(manifest), env: o.env });
|
|
2128
|
+
let dir = null;
|
|
2129
|
+
try { dir = failed(r) ? null : JSON.parse(text(r)).target_directory; } catch { dir = null; }
|
|
2130
|
+
if (typeof dir !== "string" || !path.isAbsolute(dir)) { o.err(`${tag} BLOCKED: cargo metadata in the checkout reported no absolute target_directory for ${ws.cargoDir}${failed(r) ? ` (${why(r)})` : ""}; nothing compiled`); return 1; }
|
|
2131
|
+
targetDirs[ws.cargoDir] = path.resolve(dir);
|
|
2132
|
+
o.out(`${tag} output directory for workspace ${ws.cargoDir}: ${targetDirs[ws.cargoDir]} (the checkout's)`);
|
|
2133
|
+
}
|
|
2134
|
+
// CARGO_HOME, sccache & temp are inherited as absolute values, never snapshot-relative (GATE-CONTRACT "Output directory & cache"):
|
|
2135
|
+
// a relative one would be resolved inside the snapshot, a second cache or output location. Refused before anything compiles.
|
|
2136
|
+
const relativeEnv = SNAPSHOT_ABSOLUTE_ENV.filter((name) => typeof o.env[name] === "string" && o.env[name] !== "" && !path.isAbsolute(o.env[name]));
|
|
2137
|
+
if (relativeEnv.length) { o.err(`${tag} BLOCKED: ${relativeEnv.map((n) => `${n}=${o.env[n]}`).join(", ")} ${relativeEnv.length > 1 ? "are" : "is"} relative; a snapshot compile inherits CARGO_HOME, sccache and temp only as absolute paths, never resolved inside the snapshot; nothing compiled`); return 1; }
|
|
2138
|
+
// The runtime cargo wrapper (item 7): first on PATH and named in CARGO for every scan, it binds each real cargo invocation to its
|
|
2139
|
+
// own workspace's checkout target directory or refuses it (RK_CARGO_*), whatever path reaches cargo. Direct POSIX hosts only (a
|
|
2140
|
+
// broker host never reaches here; a direct Windows host keeps the fail-closed static walk). Removed when the gate ends (cleanup).
|
|
2141
|
+
if (o.platform !== "win32" && rust.workspaces.length) {
|
|
2142
|
+
const realCargo = which("cargo").find((c) => path.isAbsolute(c));
|
|
2143
|
+
if (!realCargo) { o.err(`${tag} BLOCKED: no cargo on PATH to wrap for the snapshot compile; nothing compiled`); return 1; }
|
|
2144
|
+
try {
|
|
2145
|
+
const shimModule = await import("../cargo-target-shim.mjs");
|
|
2146
|
+
cargoShim = shimModule.installCargoShim({ base: os.tmpdir(), snapshotRoot: snapshotB, targetDirs, realCargo, platform: o.platform, onDir: (dir) => { cargoShimDir = dir; } });
|
|
2147
|
+
cargoShim.scanEnv = shimModule.shimScanEnv(o.env, cargoShim);
|
|
2148
|
+
} catch (error) { o.err(`${tag} BLOCKED: the runtime cargo wrapper cannot be installed (${error.message}); nothing compiled`); return 1; }
|
|
2149
|
+
o.out(`${tag} runtime cargo wrapper ${cargoShim.shim} (identity ${cargoShim.identity.slice(0, 16)}) -> ${realCargo}: each cargo invocation binds its workspace's checkout target directory or is refused`);
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
|
|
1535
2153
|
// Class B scans (compile, lint, tests) run in the live checkout, so it must equal HEAD where they read it: an uncommitted fix could
|
|
1536
2154
|
// otherwise hide a committed failure. Class A reads only the snapshot.
|
|
1537
|
-
if (!planOnly && !classAOnly) {
|
|
2155
|
+
if (!planOnly && !classAOnly && !snapshotB) {
|
|
1538
2156
|
const npmRootFiles = ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc", ...ESLINT_CONFIG_FILES, ...BIOME_CONFIG_FILES];
|
|
1539
2157
|
const inputs = [...new Set([...rust.units.map((u) => u.dir), ...npm.map((u) => u.dir), ...rust.workspaces.map((w) => w.cargoDir), "ci", "scripts", "vendor", "Cargo.toml", "Cargo.lock", ".cargo", "rust-toolchain.toml", "rust-toolchain",
|
|
1540
2158
|
"deny.toml", "clippy.toml", "rustfmt.toml", ...npmRootFiles, ...npmRoots.filter((r) => r !== ".").flatMap((r) => npmRootFiles.map((f) => `${r}/${f}`))])];
|
|
1541
|
-
|
|
1542
|
-
const
|
|
2159
|
+
// Batched like cargoInputsDiffer: one argv with every input path overflows Windows' command line in a large repository.
|
|
2160
|
+
const dirtyRuns = pathspecBatches(inputs).map((batch) => git("status", "--porcelain", "--untracked-files=all", "--", ...batch));
|
|
2161
|
+
const dirtyFailed = dirtyRuns.find((r) => failed(r));
|
|
2162
|
+
const dirtyFiles = dirtyFailed ? [`git status failed: ${why(dirtyFailed)}`] : dirtyRuns.flatMap((r) => text(r).split(/\r?\n/)).filter(Boolean).filter((l) => !l.includes("node_modules"));
|
|
1543
2163
|
if (dirtyFiles.length) {
|
|
1544
2164
|
o.err(`${tag} BLOCKED: the checkout differs from HEAD where compile scans read it; commit or stash first:`);
|
|
1545
2165
|
for (const l of dirtyFiles.slice(0, 20)) o.err(` ${l}`);
|
|
@@ -1573,18 +2193,60 @@ export async function main(argv, io = {}) {
|
|
|
1573
2193
|
}
|
|
1574
2194
|
const rustScope = restricted ? scopeNames : null;
|
|
1575
2195
|
|
|
1576
|
-
|
|
2196
|
+
// A broker host: rustright refuses every cargo command but a locked metadata outside the app's primary checkout
|
|
2197
|
+
// (RK_NOT_PRIMARY_CHECKOUT), so class A's cargo scans cannot read the export there. They run in the primary checkout, allowed only
|
|
2198
|
+
// when every Cargo input in it is blob-identical to the snapshot's; a live edit to a manifest, lock, deny.toml or .cargo/ refuses
|
|
2199
|
+
// the check with the paths named. A direct host (cloud, CI) keeps reading the export. (rightkit#1215, third cause)
|
|
2200
|
+
let cargoLiveRoot = null;
|
|
2201
|
+
if (classAOnly && snapshot && executor.kind === "broker" && manifests.length) {
|
|
2202
|
+
const differing = cargoInputsDiffer({ root, snapshot, manifests, spawn: (c, a, opts) => o.spawn(c, a, opts) });
|
|
2203
|
+
if (differing.length) {
|
|
2204
|
+
const resolved = git("rev-parse", "--verify", String(snapshot));
|
|
2205
|
+
o.err(`${tag} BLOCKED: the broker scans Cargo inputs in the primary checkout, and these differ from ${(failed(resolved) ? String(snapshot) : text(resolved).trim()).slice(0, 12)}: ${differing.join(", ")}; commit or restore them, then run the check again`);
|
|
2206
|
+
return 1;
|
|
2207
|
+
}
|
|
2208
|
+
// A snapshot check's cargo scans then read the checkout's WORKSPACE, which can hold more than its tracked inputs: an untracked
|
|
2209
|
+
// member, or a stray directory a members glob (crates/*) matches. Cargo's own member list there must equal the commit's (from
|
|
2210
|
+
// the snapshot), or the verdict would not be the commit's (Oracle review of 79c4427b). A checkout cargo cannot read refuses too.
|
|
2211
|
+
if (snapshotA) {
|
|
2212
|
+
const memberDiff = [];
|
|
2213
|
+
const membersOf = (base, dir) => workspaceMetadata({ root: base, spawn: (c, a, opts) => o.spawn(c, a, opts), cargoCommand, dir, realpath: fs.realpathSync }).members.map((m) => m.manifest).sort();
|
|
2214
|
+
for (const ws of rust.workspaces) {
|
|
2215
|
+
let inCommit; let inCheckout;
|
|
2216
|
+
try { inCommit = membersOf(repo, ws.cargoDir); } catch (e) { memberDiff.push(`${ws.cargoDir} (commit): ${e.message}`); continue; }
|
|
2217
|
+
try { inCheckout = membersOf(root, ws.cargoDir); } catch (e) { memberDiff.push(`${ws.cargoDir} (checkout): ${e.message}`); continue; }
|
|
2218
|
+
const extra = inCheckout.filter((m) => !inCommit.includes(m)); const missing = inCommit.filter((m) => !inCheckout.includes(m));
|
|
2219
|
+
if (extra.length || missing.length) memberDiff.push(`${ws.cargoDir}: checkout-only ${extra.join(", ") || "none"}; commit-only ${missing.join(", ") || "none"}`);
|
|
2220
|
+
}
|
|
2221
|
+
if (memberDiff.length) {
|
|
2222
|
+
o.err(`${tag} BLOCKED: the broker scans Cargo in the primary checkout, and cargo's workspace there is not the commit's: ${memberDiff.join("; ")}; remove or commit what differs, then run the check again`);
|
|
2223
|
+
return 1;
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
cargoLiveRoot = root;
|
|
2227
|
+
}
|
|
2228
|
+
let fullPlan;
|
|
2229
|
+
fullPlan = planScans({
|
|
1577
2230
|
// Class A of a commit or tree reads only that snapshot: every scan, cargo's included, runs in the export, never in the live
|
|
1578
2231
|
// checkout (which may hold other bytes). Compiles (class B) use the live checkout, whose tree the dirty check pinned to HEAD.
|
|
1579
|
-
changed, contentRoot: repo, liveRoot: classAOnly && snapshot ? repo : root, includeTests: true, rustScope, cargoCommand,
|
|
2232
|
+
changed, contentRoot: repo, liveRoot: (classAOnly && snapshot) || snapshotB ? repo : root, trustedRoot: snapshotA ?? snapshotB ?? root, includeTests: true, rustScope, cargoCommand, targetDirs, cargoShim: Boolean(cargoShim),
|
|
2233
|
+
tier: fullA ? "release" : (option("--rule-tier") ?? "compile"), cargoLiveRoot,
|
|
1580
2234
|
// The quiet tests (port, pipe, desktop and wall-clock bound) are proven by the CI shards, one crate per runner, unless
|
|
1581
2235
|
// RK_GATE_QUIET_TESTS=local asks for the serial quiet lane here (no CI, or a CI outage).
|
|
1582
|
-
|
|
2236
|
+
// Full class A runs every test before a compile: the quiet suites run here in the quiet lane, never left to CI.
|
|
2237
|
+
// The door's snapshot compile already executes the repository's own code (its tests) in the verified snapshot, guarded like
|
|
2238
|
+
// full class A, so the docs generator runs there too instead of being deferred (step 4c review).
|
|
2239
|
+
quietTests: fullA || o.env.RK_GATE_QUIET_TESTS === "local" ? "local" : "ci", packageFeatures, liveExecution: (fullA && (args.includes("--worktree") || Boolean(snapshotA))) || Boolean(snapshotB),
|
|
1583
2240
|
rustUnits: rust.units, npmUnits: npm, npmRoots, cargoDir: ".", rustLocks, crateFeatures,
|
|
1584
2241
|
// The broker (or, on a direct host, the gate itself) admits compiles; no separate grant file decides it here.
|
|
1585
2242
|
localCompile: true,
|
|
1586
2243
|
exists: o.exists, readFile: o.readFile, platform: o.platform,
|
|
1587
2244
|
});
|
|
2245
|
+
// A broker-managed host whose broker is unreachable runs no class B scan: npm tests and scripts can compile native code the
|
|
2246
|
+
// snapshot lists no manifest for (Astra, second pass); the class A scans never compile and still run.
|
|
2247
|
+
// Class A tests run code too (Astra, second pass: a test script can compile native code its text does not show), so a host whose
|
|
2248
|
+
// broker is unreachable runs neither; the gate stops rather than skip them.
|
|
2249
|
+
if (executor.kind === "refuse" && fullPlan.scans.some((x) => x.class === "B" || (x.kind === "test" && (!classAOnly || fullA)))) { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
|
|
1588
2250
|
let plan = args.includes("--pre-push") ? prePushPlan(fullPlan) : fullPlan;
|
|
1589
2251
|
const jobs = Math.max(1, Number(o.env.RK_GATE_JOBS ?? 4) || 4);
|
|
1590
2252
|
const testJobs = Math.max(1, Number(o.env.RK_GATE_TEST_JOBS ?? jobs) || jobs);
|
|
@@ -1602,7 +2264,7 @@ export async function main(argv, io = {}) {
|
|
|
1602
2264
|
for (const n of plan.notes) o.out(`${tag} note: ${n}`);
|
|
1603
2265
|
o.out(`${tag} executor: ${executor.kind}${executor.kind === "broker" ? ` (${cargoCommand.join(" ")})` : " (no broker on this host: cargo runs directly, through the same gates)"}`);
|
|
1604
2266
|
const quietCount = plan.scans.filter((s) => s.quiet === true).length;
|
|
1605
|
-
o.out(`${tag} ${plan.scans.length} scan(s) for ${changed.length} changed file(s) vs ${base.slice(0, 12)};
|
|
2267
|
+
o.out(`${tag} ${plan.scans.length} scan(s) for ${changed.length} changed file(s) vs ${base.slice(0, 12)}; class A scans ${jobs} at a time, class B scans 1 at a time${quietCount ? `, ${quietCount} quiet test scan(s) last under the broker's quiet lease` : ""}`);
|
|
1606
2268
|
let signalCode = 0;
|
|
1607
2269
|
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
1608
2270
|
const handler = () => {
|
|
@@ -1615,9 +2277,82 @@ export async function main(argv, io = {}) {
|
|
|
1615
2277
|
}
|
|
1616
2278
|
// The result cache keys on the gated commit's object ids; the pre-push check above made HEAD the pushed commit.
|
|
1617
2279
|
let cache = null;
|
|
2280
|
+
// Full class A on the working tree (the door, and broker admission of the live checkout) caches on the LIVE bytes: every
|
|
2281
|
+
// tracked and untracked (not ignored) file is hashed raw, so the door's run and a repeat admission of the same bytes hit the
|
|
2282
|
+
// cache, and any edit misses it.
|
|
2283
|
+
const liveFullA = fullA && args.includes("--worktree");
|
|
2284
|
+
// Full class A of a snapshot binds like the live tree does: its bytes are the commit's (blob ids from the object database), its
|
|
2285
|
+
// environment includes the dependencies installed into it below.
|
|
2286
|
+
const snapFullA = fullA && Boolean(snapshotA);
|
|
2287
|
+
const boundFullA = liveFullA || snapFullA;
|
|
2288
|
+
const liveBase = snapFullA ? snapshotA : root;
|
|
2289
|
+
// The live bytes full class A judges: every tracked and untracked (not ignored) file, hashed raw. Computed whether or not the
|
|
2290
|
+
// cache is used; a failure to compute it blocks (the verdict must name the bytes it covers).
|
|
2291
|
+
const liveTree = () => {
|
|
2292
|
+
const listed = git("ls-files", "-z", "--cached", "--others", "--exclude-standard");
|
|
2293
|
+
if (failed(listed)) throw new Error(`cannot list the working tree: ${why(listed)}`);
|
|
2294
|
+
const files = [...new Set(text(listed).split("\0").filter(Boolean).map(posix))].filter((f) => { try { return fs.statSync(path.join(root, f)).isFile(); } catch { return false; } }).sort();
|
|
2295
|
+
const ids = new Map();
|
|
2296
|
+
for (const batch of pathspecBatches(files, 60000)) {
|
|
2297
|
+
const r = o.spawn("git", ["hash-object", "--no-filters", "--stdin-paths"], { cwd: root, input: `${batch.join("\n")}\n` });
|
|
2298
|
+
if (failed(r)) throw new Error(`git hash-object failed: ${why(r)}`);
|
|
2299
|
+
const out = text(r).split(/\r?\n/).filter(Boolean);
|
|
2300
|
+
if (out.length !== batch.length) throw new Error("git hash-object returned a short list");
|
|
2301
|
+
batch.forEach((f, i) => ids.set(f, out[i]));
|
|
2302
|
+
}
|
|
2303
|
+
const digestOf = (list) => createHash("sha256").update(list.map((f) => `${f}\t${ids.get(f)}\n`).join("")).digest("hex");
|
|
2304
|
+
return { files, ids, digestOf, digest: digestOf(files) };
|
|
2305
|
+
};
|
|
2306
|
+
let live = null;
|
|
2307
|
+
if (liveFullA) {
|
|
2308
|
+
try { live = liveTree(); } catch (error) { o.err(`${tag} BLOCKED: cannot bind full class A to the working tree's bytes (${error.message})`); return 1; }
|
|
2309
|
+
}
|
|
2310
|
+
let snapshotCommitId = null;
|
|
2311
|
+
if (snapFullA) {
|
|
2312
|
+
const sc = await import("../snapshot-check.mjs");
|
|
2313
|
+
try {
|
|
2314
|
+
const checkout = fs.realpathSync.native(root);
|
|
2315
|
+
const ct = sc.commitOf(checkout, String(snapshot), o.spawn);
|
|
2316
|
+
snapshotCommitId = ct;
|
|
2317
|
+
sc.verifySnapshotDir({ checkout, dir: snapshotA, ...ct, when: "before class A" });
|
|
2318
|
+
live = sc.commitFiles(checkout, ct.commit, o.spawn);
|
|
2319
|
+
// Dependencies for the checks that execute repository code: installed into the snapshot from the commit's lockfile and the
|
|
2320
|
+
// primary checkout's existing store (coordinator decision A, rightkit#1257). A store miss stops here, naming the package.
|
|
2321
|
+
const installed = sc.installSnapshotDependencies({ checkout, snapshot: snapshotA, roots: npmRoots, env: o.env, spawn: o.spawn });
|
|
2322
|
+
for (const r of installed) o.out(`${tag} ${sc.describeInstall(r)}`);
|
|
2323
|
+
} catch (error) { o.err(`${tag} ${error.code === "RK_CHECK_ERROR" ? "RK_CHECK_ERROR" : "BLOCKED"}: ${error.message}`); return error.code === "RK_CHECK_ERROR" ? 2 : 1; }
|
|
2324
|
+
}
|
|
2325
|
+
const liveSnapshot = live?.digest ?? null;
|
|
2326
|
+
// The environment, installed dependencies included, is bound at the start and compared at the end, so a dependency that
|
|
2327
|
+
// changes while class A runs voids the verdict.
|
|
2328
|
+
// envStartWithCaches records, for audit, the tool caches as the run found them (what its checks could read); the pass line
|
|
2329
|
+
// carries it next to the env the verdict binds.
|
|
2330
|
+
let envAtStart = null; let envStartWithCaches = null; let cachesAtStart = null;
|
|
2331
|
+
if (boundFullA) {
|
|
2332
|
+
const orphans = orphanedPnpmStores({ root: liveBase, files: live.files });
|
|
2333
|
+
if (orphans.length) { o.err(`${tag} BLOCKED: orphaned nested pnpm store(s) ${orphans.join(", ")}: left by a standalone install in a workspace member; they can hold stale dependencies and make concurrent vitest runs clear their shared module cache under each other. Move them aside (or reinstall) and run again.`); return 1; }
|
|
2334
|
+
try { envAtStart = classAEnvironmentDigest({ root: liveBase, files: live.files, spawn: o.spawn, includeCaches: false }); envStartWithCaches = classAEnvironmentDigest({ root: liveBase, files: live.files, spawn: o.spawn }); cachesAtStart = toolCacheManifest({ root: liveBase, files: live.files }); }
|
|
2335
|
+
catch (error) { o.err(`${tag} BLOCKED: ${error.message}`); return 1; }
|
|
2336
|
+
}
|
|
2337
|
+
if (boundFullA && !args.includes("--no-cache") && o.env.RK_GATE_CACHE !== "0") {
|
|
2338
|
+
try {
|
|
2339
|
+
const common = git("rev-parse", "--git-common-dir");
|
|
2340
|
+
if (failed(common)) throw new Error("no git directory");
|
|
2341
|
+
const { files, ids, digestOf } = live;
|
|
2342
|
+
const under = (dir) => (dir === "." || dir === "" ? files : files.filter((f) => f === dir || f.startsWith(`${dir}/`)));
|
|
2343
|
+
const objectId = (p) => (ids.has(p) ? ids.get(p) : under(p).length ? digestOf(under(p)) : null);
|
|
2344
|
+
const store = fileScanCache(path.join(path.resolve(root, text(common).trim()), "rightkit-gate-cache"));
|
|
2345
|
+
const listFiles = (dir) => under(dir);
|
|
2346
|
+
const readBlob = (p) => fs.readFileSync(path.join(liveBase, p), "utf8");
|
|
2347
|
+
// The verdict also depends on what is not in the tree: the gate's own code and advisory data, the node and pnpm that run
|
|
2348
|
+
// the checks, the installed dependencies (pnpm's install state) and the platform. Any change there is a cache miss.
|
|
2349
|
+
const environment = classAEnvironmentDigest({ root: liveBase, files, spawn: o.spawn });
|
|
2350
|
+
cache = { ...store, digest: `${buildCacheDigest({ objectId, headTree: digestOf(files), rustUnits: rust.units, npmUnits: npm, cargoDir: ".", rustLocks, crateFeatures, listFiles, readBlob })}:${environment}` };
|
|
2351
|
+
} catch { cache = null; }
|
|
2352
|
+
}
|
|
1618
2353
|
if (!classAOnly && !args.includes("--no-cache") && o.env.RK_GATE_CACHE !== "0") {
|
|
1619
2354
|
try {
|
|
1620
|
-
const headSha = text(git("rev-parse", "HEAD")).trim();
|
|
2355
|
+
const headSha = snapshotBCommit?.commit ?? text(git("rev-parse", "HEAD")).trim();
|
|
1621
2356
|
const treeId = text(git("rev-parse", `${headSha}^{tree}`)).trim();
|
|
1622
2357
|
const common = git("rev-parse", "--git-common-dir");
|
|
1623
2358
|
if (headSha && treeId && !failed(common) && text(common).trim()) {
|
|
@@ -1644,7 +2379,26 @@ export async function main(argv, io = {}) {
|
|
|
1644
2379
|
}
|
|
1645
2380
|
const scanResult = await (o.runScans ?? runScans)(plan, {
|
|
1646
2381
|
classes: classAOnly ? ["A"] : ["A", "B"], jobs, testJobs, signal: ac.signal, ...(cache ? { cache } : {}),
|
|
1647
|
-
|
|
2382
|
+
// The runtime cargo wrapper is first on PATH and named in CARGO for every scan of a snapshot compile; CARGO (a path named by the
|
|
2383
|
+
// wrapper's identity) is in every scan's cache key, so no result cached without the wrapper or with another map is reused.
|
|
2384
|
+
...(cargoShim ? { env: cargoShim.scanEnv, ...(cargoShim.sentinel ? { cargoSentinel: cargoShim.sentinel } : {}) } : {}),
|
|
2385
|
+
// `check` (and the broker's compile admission, which runs it on a captured copy of the tree without node_modules) judges the
|
|
2386
|
+
// content: it never executes the repository's own code. The class A checks that do (tests, typecheck, lint, generated docs:
|
|
2387
|
+
// `executes`) run in the full gate (--all, the push hook, the door's compile stage), after the content checks and before any compile.
|
|
2388
|
+
...(classAOnly && !fullA ? { exclude: (s) => s.executes === true } : {}),
|
|
2389
|
+
// A snapshot check names its commit; it never reads the checkout's HEAD.
|
|
2390
|
+
quietLane: quietLeaseLane(o, { tag, reason: `rightkit-gate-quiet-tests@${snapshotCommitId ? snapshotCommitId.commit.slice(0, 12) : snapshotBCommit ? snapshotBCommit.commit.slice(0, 12) : text(git("rev-parse", "--short", "HEAD")).trim()}` }),
|
|
2391
|
+
// On a broker-managed host the quiet suites need the lease or they are refused (never run unleashed).
|
|
2392
|
+
requireQuietLease: executor.kind === "broker",
|
|
2393
|
+
// Failing scans' complete output: never inside the repository, on the host's scratch root (RIGHTKIT_SCRATCH_ROOT, else the
|
|
2394
|
+
// managed data root's runs/), in a folder unique to this run so evidence is never overwritten.
|
|
2395
|
+
scanLogDir: (() => {
|
|
2396
|
+
try {
|
|
2397
|
+
const base = o.env.RIGHTKIT_SCRATCH_ROOT || path.join(resolveManagedRoots({ env: o.env }).dataRoot, "runs");
|
|
2398
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2399
|
+
return path.join(base, "rightkit-gate-scan-logs", `${path.basename(root)}-${stamp}-${process.pid}`);
|
|
2400
|
+
} catch { return null; }
|
|
2401
|
+
})(),
|
|
1648
2402
|
onResult: (row, p) => o.out(`${tag} [${p.done}/${p.total}] ${(row.cached ? "cached" : row.status).padEnd(7)} ${row.id} (${p.seconds}s)${row.status === "fail" ? ` :: ${String(row.detail ?? "").slice(0, 300)}` : ""}`),
|
|
1649
2403
|
});
|
|
1650
2404
|
if (ac.signal.aborted) {
|
|
@@ -1658,14 +2412,98 @@ export async function main(argv, io = {}) {
|
|
|
1658
2412
|
const deferrals = scanDeferrals(scanResult);
|
|
1659
2413
|
for (const line of deferrals) failures.push(`DEFERRED ${line}`);
|
|
1660
2414
|
for (const note of scanResult.notes) o.out(`${tag} ${note}`);
|
|
2415
|
+
// What a full class A verdict binds, green or red: the bytes it judged, unchanged from start to end, and the environment it judged
|
|
2416
|
+
// in (gate, tools, installed dependencies, tool caches), unchanged too. A verdict whose binding moved covers nothing: a green one is
|
|
2417
|
+
// refused and a red one names no reusable deterministic cause (rightkit#1257). Returns { env, notes } or { prefix, problem, exit }.
|
|
2418
|
+
const judgedBinding = async () => {
|
|
2419
|
+
if (liveFullA) {
|
|
2420
|
+
let digestNow = null;
|
|
2421
|
+
try { digestNow = liveTree().digest; } catch { digestNow = null; }
|
|
2422
|
+
if (digestNow !== liveSnapshot) return { prefix: "BLOCKED", exit: 1, problem: "the working tree changed while class A ran; the verdict does not cover the current bytes. Run again." };
|
|
2423
|
+
}
|
|
2424
|
+
if (snapFullA) {
|
|
2425
|
+
try { (await import("../snapshot-check.mjs")).verifySnapshotDir({ checkout: fs.realpathSync.native(root), dir: snapshotA, ...snapshotCommitId, when: "after class A" }); }
|
|
2426
|
+
catch (error) { return { prefix: "RK_CHECK_ERROR", exit: 2, problem: `${error.message}; the verdict covers no known bytes` }; }
|
|
2427
|
+
}
|
|
2428
|
+
if (!live) return { env: null, notes: [] };
|
|
2429
|
+
let envDigest; let envNoCaches;
|
|
2430
|
+
try { envDigest = classAEnvironmentDigest({ root: liveBase, files: live.files, spawn: o.spawn }); envNoCaches = classAEnvironmentDigest({ root: liveBase, files: live.files, spawn: o.spawn, includeCaches: false }); }
|
|
2431
|
+
catch (error) { return { prefix: "BLOCKED", exit: 1, problem: error.message }; }
|
|
2432
|
+
const notes = [];
|
|
2433
|
+
if (cachesAtStart !== null) {
|
|
2434
|
+
let drift;
|
|
2435
|
+
try { drift = toolCacheDrift(cachesAtStart, toolCacheManifest({ root: liveBase, files: live.files })); } catch (error) { return { prefix: "BLOCKED", exit: 1, problem: error.message }; }
|
|
2436
|
+
if (drift.changed.length) return { prefix: "BLOCKED", exit: 1, problem: `${drift.changed.length} tool cache file(s) that existed when class A started changed or vanished while it ran, so the checks may have read bytes the verdict cannot name: ${drift.changed.slice(0, 5).join(", ")}${drift.changed.length > 5 ? ", …" : ""}. Run again.` };
|
|
2437
|
+
if (drift.added.length) notes.push(`note: ${drift.added.length} tool cache file(s) appeared while class A ran (by it or another process), bound in env as they stand: ${drift.added.slice(0, 5).join(", ")}${drift.added.length > 5 ? ", …" : ""}`);
|
|
2438
|
+
}
|
|
2439
|
+
if (envAtStart !== null && envNoCaches !== envAtStart) return { prefix: "BLOCKED", exit: 1, problem: "the class A environment (gate, tools or installed dependencies) changed while class A ran; the verdict does not cover it. Run again." };
|
|
2440
|
+
return { env: envDigest, notes };
|
|
2441
|
+
};
|
|
2442
|
+
if (snapshotB) {
|
|
2443
|
+
// The compile covers the commit only if the snapshot still is its tree after every scan ran (E6).
|
|
2444
|
+
try { (await import("../snapshot-check.mjs")).verifySnapshotDir({ checkout: fs.realpathSync.native(root), dir: snapshotB, ...snapshotBCommit, when: "after the compile" }); }
|
|
2445
|
+
catch (error) { o.err(`${tag} RK_CHECK_ERROR: ${error.message}; the compile covers no known bytes`); return 2; }
|
|
2446
|
+
o.out(`${tag} snapshot ${snapshotB} is still tree ${snapshotBCommit.tree} after the compile`);
|
|
2447
|
+
// The direct host's own receipt (GATE-CONTRACT "Broker interface": the same field names as the broker's evidence, without the
|
|
2448
|
+
// broker-only ones): this run, the commit, the snapshot's tree at start and exit, the ownership, where the outputs went, the
|
|
2449
|
+
// result and when it was issued. Rebuilt units are the freshness census of joint plan step 6, not measured here.
|
|
2450
|
+
const workspaces = Object.keys(targetDirs ?? {});
|
|
2451
|
+
const directReceipt = {
|
|
2452
|
+
schema: "rightkit.gate-direct-receipt/1", requestId: randomUUID(), executor: "direct", issuedAt: new Date().toISOString(),
|
|
2453
|
+
commit: { sha: snapshotBCommit.commit, tree: snapshotBCommit.tree },
|
|
2454
|
+
snapshot: { path: snapshotB, treeAtStart: snapshotBCommit.tree, treeAtExit: snapshotBCommit.tree },
|
|
2455
|
+
ownership: { handle: o.buildOwnership?.handle ?? null },
|
|
2456
|
+
outputDir: workspaces.length === 1 ? targetDirs[workspaces[0]] : null, outputDirs: { ...(targetDirs ?? {}) },
|
|
2457
|
+
cargoWrapper: cargoShim ? { identity: cargoShim.identity, workspaces: cargoShim.workspaces } : null,
|
|
2458
|
+
exitStatus: failures.length ? 1 : 0, rebuilt: null,
|
|
2459
|
+
};
|
|
2460
|
+
o.onDirectReceipt?.(directReceipt);
|
|
2461
|
+
o.out(`${tag} direct receipt ${JSON.stringify(directReceipt)}`);
|
|
2462
|
+
}
|
|
1661
2463
|
if (failures.length) {
|
|
1662
2464
|
o.err(`${tag} BLOCKED (${failures.length})`);
|
|
1663
2465
|
for (const f of failures) o.err(` - ${f}`);
|
|
2466
|
+
// A red full class A names its cause when every failure is one a re-run of the same inputs reproduces AND the bytes and
|
|
2467
|
+
// environment it was judged in held from start to end, so the broker may reuse it (rightkit#1257); a live scan, a timeout, a
|
|
2468
|
+
// missing tool, anything undecided, or a binding that moved names none.
|
|
2469
|
+
if (boundFullA) {
|
|
2470
|
+
const binding = await judgedBinding();
|
|
2471
|
+
if (binding.problem) {
|
|
2472
|
+
o.err(`${tag} ${binding.prefix}: ${binding.problem} No deterministic verdict is named.`);
|
|
2473
|
+
if (binding.exit === 2) return 2;
|
|
2474
|
+
} else {
|
|
2475
|
+
for (const n of binding.notes) o.out(`${tag} ${n}`);
|
|
2476
|
+
const marker = (await import("../snapshot-check.mjs")).deterministicMarker(scanResult, binding.env);
|
|
2477
|
+
if (marker) o.out(marker);
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
1664
2480
|
o.err(classAOnly ? "Class A is red: nothing compiles until it is green." : "Fix and push again. Never bypass with --no-verify.");
|
|
1665
2481
|
return 1;
|
|
1666
2482
|
}
|
|
2483
|
+
if (fullA) {
|
|
2484
|
+
// The verdict is for the bytes that were checked, in the environment that was checked: either moving makes it a verdict for nothing.
|
|
2485
|
+
const binding = await judgedBinding();
|
|
2486
|
+
if (binding.problem) { o.err(`${tag} ${binding.prefix}: ${binding.problem}`); return binding.exit; }
|
|
2487
|
+
for (const n of binding.notes) o.out(`${tag} ${n}`);
|
|
2488
|
+
// The bytes this verdict covers, for the broker to compare with the snapshot it compiles: the tracked files only (what a
|
|
2489
|
+
// captured snapshot holds), and every file including untracked ones. Each is sha256 over sorted "<path>\t<git blob id of the
|
|
2490
|
+
// raw bytes>\n" lines (git hash-object --no-filters), so the broker can recompute it over its snapshot without this gate.
|
|
2491
|
+
if (live) {
|
|
2492
|
+
// A snapshot holds only the commit's files: every one is tracked.
|
|
2493
|
+
const trackedList = snapFullA ? null : git("ls-files", "-z", "--cached");
|
|
2494
|
+
if (trackedList && failed(trackedList)) { o.err(`${tag} BLOCKED: cannot list tracked files to name the checked bytes`); return 1; }
|
|
2495
|
+
const tracked = new Set(snapFullA ? live.files : text(trackedList).split("\0").filter(Boolean).map(posix));
|
|
2496
|
+
o.out(`${tag} class A bytes tracked=${live.digestOf(live.files.filter((f) => tracked.has(f)))} all=${live.digest}${envStartWithCaches ? ` env-at-start=${envStartWithCaches}` : ""} env=${binding.env}`);
|
|
2497
|
+
}
|
|
2498
|
+
o.out(`${tag} class A full pass: ${scanResult.scans.length} deterministic check(s), the ones that execute repository code included, over ${changed.length} file(s) of ${base}`);
|
|
2499
|
+
return 0;
|
|
2500
|
+
}
|
|
1667
2501
|
if (classAOnly) {
|
|
1668
|
-
|
|
2502
|
+
// `check` judged the content only: the class A checks that execute the repository's code did not run here, so this is not a
|
|
2503
|
+
// full class A pass, and it says so (reviewer, 2026-10-27). The full gate runs them before any compile.
|
|
2504
|
+
const notRun = plan.scans.filter((s) => s.class === "A" && s.executes === true).map((s) => s.id);
|
|
2505
|
+
if (notRun.length) o.out(`${tag} class A partial: ${notRun.length} check(s) that execute repository code (tests, typecheck, lint, generated docs) run in the full gate, not in check: ${notRun.slice(0, 8).join(", ")}${notRun.length > 8 ? ", …" : ""}`);
|
|
2506
|
+
o.out(`${tag} class A content pass: ${scanResult.scans.length} deterministic scan(s) over ${changed.length} file(s) of ${base}`);
|
|
1669
2507
|
return 0;
|
|
1670
2508
|
}
|
|
1671
2509
|
o.out(`${tag} pass (${changed.length} changed file(s) vs ${base}); ${deferrals.length} scan(s) deferred, not passed${scanResult.cacheHits.length ? `; ${scanResult.cacheHits.length} reused from the result cache` : ""}`);
|
|
@@ -1676,9 +2514,17 @@ export async function main(argv, io = {}) {
|
|
|
1676
2514
|
}
|
|
1677
2515
|
}
|
|
1678
2516
|
|
|
2517
|
+
// The inherited locations a snapshot compile requires as absolute paths (GATE-CONTRACT "Output directory & cache").
|
|
2518
|
+
const SNAPSHOT_ABSOLUTE_ENV = ["CARGO_HOME", "RUSTUP_HOME", "SCCACHE_DIR", "TMPDIR", "TMP", "TEMP"];
|
|
2519
|
+
|
|
1679
2520
|
const invokedDirectly = (() => {
|
|
1680
2521
|
try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
|
|
1681
2522
|
})();
|
|
2523
|
+
/** This gate's own resolved entry file: the broker admits a compile only when the request names the pinned trusted gate by this
|
|
2524
|
+
* path (rustright reads it from RIGHTKIT_CALLING_GATE; a missing or different path is refused). */
|
|
2525
|
+
export const callingGatePath = () => fs.realpathSync(fileURLToPath(import.meta.url));
|
|
1682
2526
|
if (invokedDirectly) {
|
|
2527
|
+
// Every child this run starts that can reach rustright (scans, unit stages, right-release dev/test, their builds) inherits it.
|
|
2528
|
+
process.env.RIGHTKIT_CALLING_GATE = callingGatePath();
|
|
1683
2529
|
main(process.argv.slice(2)).then((code) => process.exit(code), (e) => { console.error(`rightkit-gate: ${e?.stack ?? e}`); process.exit(2); });
|
|
1684
2530
|
}
|