@rightkit/release 0.6.8 → 0.6.11
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 +30 -12
- package/admission-files.mjs +4 -1
- package/admission.mjs +2 -2
- package/advisory-exceptions.mjs +41 -5
- package/android-play-upload.mjs +16 -15
- package/app-check.mjs +105 -27
- 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 +1608 -345
- package/broker-pins.mjs +152 -0
- package/build-launch.mjs +88 -0
- package/build-ownership.mjs +1231 -0
- package/build-release.mjs +82 -28
- package/cargo-contract.mjs +3 -1
- package/cargo-ledger.mjs +62 -0
- package/cargo-target-shim.mjs +238 -0
- package/class-a-launch-guard.mjs +126 -0
- package/cli/right-release.mjs +31 -5
- package/clippy-equivalence.mjs +202 -0
- package/clippy-route.mjs +543 -0
- package/codesign-shim.mjs +11 -1
- package/committed-source.mjs +39 -5
- package/crate-workspaces.mjs +87 -0
- package/create-mac-updater.mjs +14 -2
- package/deterministic-scans.mjs +997 -71
- package/development-signing.mjs +4 -1
- package/development.mjs +120 -21
- package/direct-receipt.mjs +135 -0
- package/door-policy.mjs +71 -6
- package/door.mjs +6 -6
- package/freshness-census.mjs +257 -0
- package/gate-executor.mjs +14 -2
- package/gate-stages.mjs +91 -15
- package/heavy-command.mjs +3 -2
- package/hook-retirement.mjs +65 -0
- package/init-command.mjs +1 -1
- package/install-app.mjs +11 -2
- package/install-assembled-app.mjs +423 -0
- package/inventory-check.mjs +11 -8
- package/ios-dev.mjs +70 -3
- package/ios-upload.mjs +20 -15
- package/linux-artifacts.mjs +93 -0
- package/local-publish.mjs +224 -53
- package/local-qualify.mjs +5 -3
- package/local-runtime.mjs +5 -4
- package/mac-package-recipe.mjs +4 -1
- package/managed-cargo.mjs +47 -5
- package/managed-root-guard.mjs +34 -0
- package/mirror-root-artifact.mjs +18 -5
- package/native-helpers.mjs +355 -0
- package/ownership-class-rules.mjs +1 -1
- package/ownership-evidence.mjs +37 -13
- package/ownership-hooks.mjs +20 -59
- package/ownership-lockfiles.mjs +14 -1
- package/ownership-policy-bundle.mjs +3 -2
- package/ownership-staged.mjs +2 -1
- package/ownership.mjs +24 -0
- package/package-recipe-targets.mjs +84 -10
- package/package.json +4 -3
- package/pipeline-admission.mjs +4 -4
- package/pipeline-receipts.mjs +302 -302
- package/preflight.mjs +27 -4
- package/project-type.mjs +2 -2
- package/provenance-verify.mjs +115 -0
- package/publish-cargo.mjs +12 -9
- package/qa-admission-policy.json +10 -1
- package/recipe-build.mjs +106 -14
- package/release-artifact-path.mjs +341 -0
- package/release-config.mjs +1 -1
- package/release-invocation.mjs +8 -1
- package/release-qa-command.mjs +25 -9
- package/release-source-brand.mjs +17 -0
- package/release-source-identity.mjs +171 -0
- package/release.mjs +59 -22
- package/rightkit-versions.json +73 -47
- package/runtime-budgets.mjs +6 -2
- package/scan-digest.mjs +71 -5
- package/sign-updater.mjs +6 -3
- package/snapshot-broker.mjs +172 -0
- package/snapshot-check.mjs +312 -0
- package/snapshot-git.mjs +166 -0
- package/snapshot-request.mjs +363 -0
- package/snapshot-root-config.mjs +76 -0
- package/snapshot.mjs +969 -0
- package/source-split.mjs +73 -0
- package/stage-commands.mjs +60 -0
- package/stage-ledger.mjs +204 -0
- package/stage-sidecar-command.mjs +5 -1
- package/stage-sidecar.mjs +157 -27
- package/swap-helper-bundle.mjs +5 -2
- package/test-command.mjs +17 -4
- package/unit-gate.mjs +301 -0
- package/web-build.mjs +40 -6
- package/win-package-recipe.mjs +20 -7
package/bin/rightkit-gate.mjs
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// rightkit-gate: the
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// scan module (../deterministic-scans.mjs); Rust units come from `cargo metadata` (one run per Cargo workspace), npm units from
|
|
2
|
+
// rightkit-gate: the local deterministic gate for any repository that uses RightKit. The gate is for building: commit and push
|
|
3
|
+
// run no gate and no local hook check (docs/GATE-CONTRACT.md), and `rightkit-gate install-hook` writes no git hook. Every
|
|
4
|
+
// applicable scan for the gated change runs through the one scan module (../deterministic-scans.mjs); Rust units come from `cargo metadata` (one run per Cargo workspace), npm units from
|
|
6
5
|
// the pnpm roots' workspace files, so the layout is the repository's own. CI still compiles, lints and tests everything.
|
|
7
6
|
//
|
|
8
7
|
// Layout it covers (nothing is partly covered silently):
|
|
@@ -10,7 +9,9 @@
|
|
|
10
9
|
// workspace (`cargo metadata --no-deps --locked` there, through the broker). A unit of a workspace below the repository root is
|
|
11
10
|
// named `<cargoDir>:<crate>`; its scans run with that directory as cwd, its reverse dependents and cache keys come from that
|
|
12
11
|
// workspace's own Cargo.lock, and ci/gate.config.json `rust` may key its feature sets by `<cargoDir>:<crate>` or by the plain crate
|
|
13
|
-
// name.
|
|
12
|
+
// name. One workspace and lock per crate (RightKit itself, rightkit#1285): a crate whose workspace is its own crates/<crate> directory keeps
|
|
13
|
+
// its plain name and unsuffixed scan ids; a selected crate also selects every crate that path-depends on it, across workspaces, read from
|
|
14
|
+
// the manifests (packages/release/crate-workspaces.mjs); a vendored source a crate patches in selects that crate. A tracked Cargo.toml that is not a member and cannot stand alone, a member that is untracked or ignored, and an untracked
|
|
14
15
|
// Cargo.toml are skipped with a note on the first lines of the run, never silently.
|
|
15
16
|
// - npm: every pnpm root (discoverPnpmRoots: the repository root, and any tracked directory with a pnpm-lock.yaml or pnpm-workspace.yaml
|
|
16
17
|
// beside a package.json, outside node_modules) has its own units: the pnpm-workspace.yaml globs over tracked package.json files; when that
|
|
@@ -27,30 +28,27 @@
|
|
|
27
28
|
// rightkit-gate [--base <ref>] [--plan] [--no-cache]
|
|
28
29
|
// run the gate (default base origin/main); --plan prints the scans and runs none;
|
|
29
30
|
// --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>]
|
|
31
|
+
// rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--rule-tier compile|release] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]
|
|
31
32
|
// the deterministic stage: the app check (../app-check.mjs), then every class A scan
|
|
32
33
|
// (lockfiles, cargo-deny/audit, secrets, licences, manifests, workflow lint, syntax) over the
|
|
33
34
|
// whole tree of the commit, not a diff. Nothing compiles; without a broker class A uses
|
|
34
35
|
// plain cargo for metadata/deny/audit only. The RustRight broker runs this before it admits
|
|
35
36
|
// any compile. --list/--print-baseline/--only show the app check alone. Exit 0 pass,
|
|
36
37
|
// 1 findings, 2 could not run
|
|
37
|
-
// rightkit-gate install-hook
|
|
38
|
-
// pre-push
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
// installed it also writes the advisory ownership pre-commit step (`right-release hooks install`
|
|
45
|
-
// is an alias of this command)
|
|
38
|
+
// rightkit-gate install-hook retire the git hooks an earlier release wrote and add the agent hook: it removes
|
|
39
|
+
// .githooks/pre-push, the .githooks/<name> forwarders and the advisory ownership pre-commit
|
|
40
|
+
// hook, each only when its own header or marker says RightKit wrote it (a foreign hook is never
|
|
41
|
+
// touched; pre-commit.rightkit-foreign goes back to pre-commit), unsets a local core.hooksPath
|
|
42
|
+
// of `.githooks` once nothing foreign remains there, and registers the one-door agent hook in
|
|
43
|
+
// .claude/settings.json. It writes no pre-push, pre-commit or other git hook
|
|
44
|
+
// (`right-release hooks install` is an alias of this command; --force is ignored)
|
|
46
45
|
//
|
|
47
|
-
//
|
|
48
|
-
// read the live checkout), and the remote's current commit is the diff base.
|
|
49
|
-
// check it out first (a detached HEAD is fine), push HEAD:<branch>, return.
|
|
46
|
+
// Git ref lines on stdin (the form git hands a pre-push hook; nothing here installs one) are still read when given: the commit
|
|
47
|
+
// pushed must be the checked-out HEAD (broker scans read the live checkout), and the remote's current commit is the diff base.
|
|
50
48
|
// The executor is chosen by the gate, never the caller (gate-executor.mjs resolveExecutor): the RustRight broker where this machine
|
|
51
49
|
// has it (`rustright` on PATH, or its managed cargo shim), a refusal on a broker-managed machine whose broker is unreachable, and
|
|
52
50
|
// direct cargo on any other host (a cloud container, a CI runner). Every host runs the same gates in the same order (app rules, class A,
|
|
53
|
-
// the compile scans); an unmanaged host is never told to find a broker.
|
|
51
|
+
// the compile scans); an unmanaged host is never told to find a broker.
|
|
54
52
|
// ci/gate.config.json `rust` (optional) sets per-crate feature sets, exactly as CI's gate reads it; `npm.<unit>` sets `quietTests` and
|
|
55
53
|
// `requiresBroker: { "reason": "..." }` (a test the package scripts do not show reaching the broker: node -e, node scripts, sh -c, pnpm --dir).
|
|
56
54
|
// Result cache: a passing class B scan is reused only when its scan id, command, tool version, environment and input digest
|
|
@@ -59,20 +57,27 @@
|
|
|
59
57
|
// rust.<crate>.testInputs lists the paths they read, for npm, node and docs scans, and whenever a reference cannot be resolved
|
|
60
58
|
// statically) are all identical. Stored in <git common dir>/rightkit-gate-cache; a hit prints
|
|
61
59
|
// `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
|
-
//
|
|
60
|
+
// RK_GATE_JOBS (default 4) sets class A scan concurrency. RK_GATE_CLASS_B_JOBS (default 3, the Mac broker's build capacity; the
|
|
61
|
+
// broker queues beyond its own) sets how many components' class B scans run at once; one component's scans stay in order (rightkit#1285).
|
|
62
|
+
// A crate whose ci/gate.config.json entry says `quietTests: true` has its test scans run last,
|
|
64
63
|
// one at a time, under the RustRight quiet lease (`rustright quiet acquire`, RUSTRIGHT_QUIET_LEASE in their environment).
|
|
65
64
|
// SIGINT/SIGTERM stop scheduling, kill every running scan's process tree and exit non-zero listing the scans not run.
|
|
66
65
|
import fs from "node:fs";
|
|
66
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
67
|
+
import { resolveManagedRoots } from "../managed-roots.mjs";
|
|
67
68
|
import os from "node:os";
|
|
68
69
|
import path from "node:path";
|
|
70
|
+
import { FOREIGN_PRE_COMMIT, retireHooksIn } from "../hook-retirement.mjs";
|
|
69
71
|
import { fileURLToPath } from "node:url";
|
|
70
|
-
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";
|
|
72
|
+
import { BIOME_CONFIG_FILES, ESLINT_CONFIG_FILES, WORKSPACE_RUST_FILE, ancestorConfigChanged, ancestorDirsOf, canonicalJson, defaultSpawn, defaultSpawnAsync, 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, STAGE_SOURCES, writeReceipt } from "../gate-stages.mjs";
|
|
74
77
|
import { spawnSync as spawnInherit } from "../background-process.mjs";
|
|
75
78
|
import { readDiskPolicy, scratchRoots } from "../disk-policy.mjs";
|
|
79
|
+
import { buildDirectReceipt, directVerdict, resolveTargetDirectory, verifyDirectReceipt, writeDirectDiagnostics } from "../direct-receipt.mjs";
|
|
80
|
+
import { createFreshnessCensus } from "../freshness-census.mjs";
|
|
76
81
|
|
|
77
82
|
const ZERO = /^0+$/;
|
|
78
83
|
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
@@ -83,22 +88,13 @@ const why = (r) => String(r?.error?.message ?? r?.stderr ?? "").trim().split(/\r
|
|
|
83
88
|
|
|
84
89
|
// 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
90
|
export const PARTIAL_EXIT = 3;
|
|
86
|
-
|
|
91
|
+
/** The platforms a door run can package: desktop (signed, sealed, installed QA, publisher upload) and mobile (signed, sealed, store test track). */
|
|
92
|
+
export const PLATFORMS = Object.freeze(["mac", "win", "ios", "android"]);
|
|
93
|
+
export const MOBILE_PLATFORMS = Object.freeze(new Set(["ios", "android"]));
|
|
94
|
+
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 [--commit <rev>] [-- <right-release args>] | rightkit-gate stage --platform mac|win [--tier patch|update] | rightkit-gate [--base <ref>] [--plan] [--no-cache] | rightkit-gate install-hook | rightkit-gate env-digest | rightkit-gate snapshot [--commit <rev>] [--verify [--env <sha256>]] [--json] | rightkit-gate live-scans --repo <root> | rightkit-gate clippy [<cargo clippy args>] | 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
95
|
|
|
88
96
|
// ---- pure helpers (exported for tests) ----------------------------------------------------------------------
|
|
89
97
|
|
|
90
|
-
/** Repository pre-push policy only. Full gates & publication qualification retain native checks. */
|
|
91
|
-
export function prePushPlan(plan) {
|
|
92
|
-
const scans = plan.scans.filter((s) => s.class === "A" || (s.class === "B" && ["test:scripts-ci", "generated-docs"].includes(s.id)));
|
|
93
|
-
const ids = new Set(scans.map((s) => s.id));
|
|
94
|
-
return {
|
|
95
|
-
...plan, scans, deferred: plan.deferred.filter((id) => ids.has(id)),
|
|
96
|
-
scanSetDigest: digestScanSet(scans),
|
|
97
|
-
classDigests: { A: digestScanSet(scans.filter((s) => s.class === "A")), B: digestScanSet(scans.filter((s) => s.class === "B")) },
|
|
98
|
-
notes: [...plan.notes, "pre-push mode: class A, focused scripts/ci tests & generated docs only; native checks remain required for publication qualification"],
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
98
|
/** The `packages:` globs of a pnpm-workspace.yaml (block or flow list; the subset pnpm repositories use). */
|
|
103
99
|
export function parseWorkspaceGlobs(source) {
|
|
104
100
|
const out = []; let inPackages = false;
|
|
@@ -252,6 +248,246 @@ export function exportTmpRoot({ env = process.env, platform = process.platform,
|
|
|
252
248
|
return os.tmpdir();
|
|
253
249
|
}
|
|
254
250
|
|
|
251
|
+
/** The files cargo metadata, deny and audit read, in the repository root and in every workspace that has a manifest. */
|
|
252
|
+
export const CARGO_INPUT_NAMES = Object.freeze(["Cargo.toml", "Cargo.lock", "deny.toml", "rust-toolchain.toml", "rust-toolchain", ".cargo"]);
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The Cargo inputs of the live checkout `root` that are not blob-identical to `snapshot`'s (a commit or tree): a changed or deleted
|
|
256
|
+
* tracked input, or any untracked file (ignored ones included, cargo reads them) under those paths. Empty means the broker may scan
|
|
257
|
+
* the primary checkout for exactly the snapshot's result. `manifests` are the snapshot's tracked Cargo.toml paths (posix).
|
|
258
|
+
*/
|
|
259
|
+
export function cargoInputsDiffer({ root, snapshot, manifests, spawn = defaultSpawn }) {
|
|
260
|
+
const git = (...a) => spawn("git", a, { cwd: root });
|
|
261
|
+
// 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.
|
|
262
|
+
const dirs = new Set([""]);
|
|
263
|
+
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("/")); }
|
|
264
|
+
const dirList = [...dirs];
|
|
265
|
+
const paths = dirList.flatMap((d) => CARGO_INPUT_NAMES.map((n) => (d ? `${d}/${n}` : n)));
|
|
266
|
+
// Pathspecs go to git in bounded batches: a repository with many crates (RightKit: ~1000 input paths) overflows Windows' 32767-
|
|
267
|
+
// character command line in one argv (ENAMETOOLONG on the Dell, 2026-10-27; G4 was the same for rustfmt). Each batch's results
|
|
268
|
+
// are merged; a failed batch fails the whole listing closed.
|
|
269
|
+
const batches = pathspecBatches(paths);
|
|
270
|
+
const listedAll = batches.map((b) => git("ls-tree", "-r", "-z", snapshot, "--", ...b));
|
|
271
|
+
const badList = listedAll.find((r) => failed(r));
|
|
272
|
+
if (badList) return [`(cannot list ${snapshot}: ${why(badList)})`];
|
|
273
|
+
const snap = new Map();
|
|
274
|
+
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); }
|
|
275
|
+
const differ = new Set();
|
|
276
|
+
for (const [p, id] of snap) {
|
|
277
|
+
const live = path.join(root, ...p.split("/"));
|
|
278
|
+
let liveId = null;
|
|
279
|
+
// Cargo reads the live bytes, so the live bytes are hashed raw (`--no-filters`: no clean filter, eol or text attribute can make
|
|
280
|
+
// two different byte strings look the same). The one accepted difference is an exact CRLF checkout of an LF blob (D13, Dell
|
|
281
|
+
// 2026-10-27: core.autocrlf), which every TOML/toolchain reader treats as the same newline; anything else differs.
|
|
282
|
+
try { if (fs.statSync(live).isFile()) { const h = git("hash-object", "--no-filters", "--", live); liveId = failed(h) ? null : text(h).trim(); } } catch { liveId = null; }
|
|
283
|
+
if (liveId !== id && !isCrlfCheckoutOf({ git, id, live })) differ.add(p);
|
|
284
|
+
}
|
|
285
|
+
// Untracked under the input paths, ignored files included: cargo reads a .cargo/config.toml or a deny.toml whether or not git tracks it.
|
|
286
|
+
for (const batch of batches) {
|
|
287
|
+
const others = git("ls-files", "-z", "--others", "--", ...batch);
|
|
288
|
+
if (failed(others)) differ.add(`(cannot list untracked Cargo inputs: ${why(others)})`); // fail closed: an unlisted input is an unknown input
|
|
289
|
+
else for (const p of text(others).split("\0").filter(Boolean)) if (!snap.has(p)) differ.add(p);
|
|
290
|
+
// A tracked input the snapshot lacks (added in HEAD after the snapshot, say) is live-only too.
|
|
291
|
+
const tracked = git("ls-files", "-z", "--", ...batch);
|
|
292
|
+
if (failed(tracked)) differ.add(`(cannot list tracked Cargo inputs: ${why(tracked)})`);
|
|
293
|
+
else for (const p of text(tracked).split("\0").filter(Boolean)) if (!snap.has(p)) differ.add(p);
|
|
294
|
+
}
|
|
295
|
+
return [...differ].sort();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** True only when the live file's bytes are exactly blob `id` with every LF written as CRLF (an autocrlf checkout); fails closed. */
|
|
299
|
+
export function isCrlfCheckoutOf({ git, id, live, readFile = fs.readFileSync }) {
|
|
300
|
+
try {
|
|
301
|
+
const blob = git("cat-file", "blob", id);
|
|
302
|
+
if (failed(blob)) return false;
|
|
303
|
+
const committed = text(blob);
|
|
304
|
+
if (committed.includes("\r")) return false;
|
|
305
|
+
return readFile(live, "utf8") === committed.replace(/\n/g, "\r\n");
|
|
306
|
+
} catch { return false; }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Tool caches inside node_modules that nothing installs: test-result caches (vitest), the vite optimizer and bundled-config temp
|
|
311
|
+
* files, transpile and other tool caches (.cache). Some hold code a later step executes, so the environment a verdict carries binds
|
|
312
|
+
* their bytes as they stand when class A ends, and the broker's START recomputes it: a cache edited, added or removed after the
|
|
313
|
+
* verdict (by a dev server, another tool, or by hand) voids the ticket and class A runs again. Nothing is deleted. Inside one run
|
|
314
|
+
* the start/end comparison leaves them out, because the run itself writes them. pnpm's task-run state holds only completion
|
|
315
|
+
* markers pnpm reads, never code, and is not bound.
|
|
316
|
+
*/
|
|
317
|
+
/** Longest the class A environment waits for `pnpm --version`. */
|
|
318
|
+
export const PNPM_PROBE_TIMEOUT_MS = 60_000;
|
|
319
|
+
export const TOOL_CACHE_DIRS = new Set([".vite", ".vite-temp", ".vitest-cache", ".cache"]);
|
|
320
|
+
export const INSTALL_STATE_NOT_COVERED = new Set([".pnpm-task-run-state-v1"]);
|
|
321
|
+
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();
|
|
322
|
+
function isLink(p) { try { return fs.lstatSync(p).isSymbolicLink(); } catch { return false; } }
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Members of the root pnpm workspace holding their own pnpm virtual store (node_modules/.pnpm) while the root has one, without
|
|
326
|
+
* 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
|
|
327
|
+
* the root store, but the leftover can still be read, and vitest hashes the nearest lockfile it finds (node_modules/.pnpm/
|
|
328
|
+
* lock.yaml) for a module cache it shares across the workspace, so one such package makes concurrent vitest runs clear the
|
|
329
|
+
* shared cache under each other (ENOTEMPTY, fullA-16).
|
|
330
|
+
*/
|
|
331
|
+
export function orphanedPnpmStores({ root, files }) {
|
|
332
|
+
// Only proven orphans: the repository root is a pnpm workspace with its own virtual store, and the package is a member of it by
|
|
333
|
+
// the root pnpm-workspace.yaml globs (negations honoured) and not an install root of its own. A standalone nested project is
|
|
334
|
+
// not judged.
|
|
335
|
+
const ws = path.join(root, "pnpm-workspace.yaml");
|
|
336
|
+
if (!fs.existsSync(ws) || !fs.existsSync(path.join(root, "node_modules", ".pnpm"))) return [];
|
|
337
|
+
const globs = parseWorkspaceGlobs(fs.readFileSync(ws, "utf8"));
|
|
338
|
+
const include = globs.filter((g) => !g.startsWith("!")).map(globToRegExp);
|
|
339
|
+
const exclude = globs.filter((g) => g.startsWith("!")).map((g) => globToRegExp(g.slice(1)));
|
|
340
|
+
return nodeModulesDirs(root, files).filter((nm) => {
|
|
341
|
+
const pkg = path.dirname(nm); const rel = path.relative(root, pkg).split(path.sep).join("/");
|
|
342
|
+
if (!rel || !include.some((re) => re.test(rel)) || exclude.some((re) => re.test(rel))) return false;
|
|
343
|
+
return fs.existsSync(path.join(nm, ".pnpm")) && !fs.existsSync(path.join(pkg, "pnpm-lock.yaml"));
|
|
344
|
+
}).map((nm) => path.relative(root, path.join(nm, ".pnpm")).split(path.sep).join("/"));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Cache files that hold only a tool's own bookkeeping, never code a check executes, at exact paths: vitest's result ordering,
|
|
349
|
+
* node_modules/.vite/vitest/results.json and node_modules/.vite/vitest/<40-hex project hash>/results.json, which vitest reads only
|
|
350
|
+
* with JSON.parse to order test files (vitest 5.0.5 dist/chunks/cli-api.*.js ResultsCache.readFromCache).
|
|
351
|
+
*/
|
|
352
|
+
export const CACHE_NON_CODE = [/(^|\/)node_modules\/\.vite\/vitest\/([0-9a-f]{40}\/)?results\.json$/];
|
|
353
|
+
/** Every file in the workspace's tool caches (TOOL_CACHE_DIRS, not following links) with its content hash, keyed by path. */
|
|
354
|
+
export function toolCacheManifest({ root, files }) {
|
|
355
|
+
const out = new Map();
|
|
356
|
+
const walk = (dir) => {
|
|
357
|
+
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})`); }
|
|
358
|
+
for (const e of entries) {
|
|
359
|
+
const p = path.join(dir, e.name); const rel = path.relative(root, p).split(path.sep).join("/");
|
|
360
|
+
if (e.isDirectory()) walk(p);
|
|
361
|
+
else if (e.isSymbolicLink()) out.set(rel, `link:${fs.readlinkSync(p)}`);
|
|
362
|
+
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})`); } }
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
for (const d of nodeModulesDirs(root, files)) for (const name of TOOL_CACHE_DIRS) walk(path.join(d, name));
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* 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
|
|
370
|
+
* are no longer there (a concurrent writer, an interrupted or partial write), so the verdict cannot name them. Files added while
|
|
371
|
+
* it ran are listed and bound in env, though another process may have written them. Limits: a file changed and restored between
|
|
372
|
+
* 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
|
|
373
|
+
* files may change.
|
|
374
|
+
*/
|
|
375
|
+
export function toolCacheDrift(before, after) {
|
|
376
|
+
const changed = []; const added = [];
|
|
377
|
+
for (const [p, h] of before) if (!CACHE_NON_CODE.some((re) => re.test(p)) && after.get(p) !== h) changed.push(p);
|
|
378
|
+
for (const p of after.keys()) if (!before.has(p)) added.push(p);
|
|
379
|
+
return { changed: changed.sort(), added: added.sort() };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The installed dependency bytes a class A verdict executes: every node_modules directory of the workspace (the root one holds
|
|
384
|
+
* pnpm's virtual store) walked without following links, plus everything a link there resolves to outside those directories (a
|
|
385
|
+
* workspace package's built and ignored files, a linked package elsewhere on disk), each resolved target hashed once. A real file
|
|
386
|
+
* 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
|
|
387
|
+
* resolved-path set; a target that is the root, an ancestor of it or the filesystem root, any other entry type, an unreadable
|
|
388
|
+
* 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
|
|
389
|
+
* and end of the run and again at START.
|
|
390
|
+
*/
|
|
391
|
+
export function installedDependencyDigest({ root, files, maxFiles = 400000, includeCaches = true }) {
|
|
392
|
+
const h = createHash("sha256");
|
|
393
|
+
const realRoot = fs.realpathSync(root);
|
|
394
|
+
const walkedRoots = nodeModulesDirs(root, files);
|
|
395
|
+
const realWalked = walkedRoots.map((d) => { try { return fs.realpathSync(d); } catch (error) { if (error?.code === "ENOENT") return null; throw error; } }).filter(Boolean);
|
|
396
|
+
const inside = (p, dir) => p === dir || p.startsWith(dir + path.sep);
|
|
397
|
+
const rel = (p) => (inside(p, realRoot) ? path.relative(realRoot, p).split(path.sep).join("/") : p);
|
|
398
|
+
const seen = new Set(); const pending = []; let count = 0;
|
|
399
|
+
const fail = (what, error) => { throw new Error(`cannot read ${what} for the class A environment (${error?.code ?? error?.message ?? error})`); };
|
|
400
|
+
const entry = (p, name, parentIsNodeModules, fromLink) => {
|
|
401
|
+
const st = (() => { try { return fs.lstatSync(p); } catch (error) { return fail(rel(p), error); } })();
|
|
402
|
+
if (st.isSymbolicLink()) {
|
|
403
|
+
let target; let real;
|
|
404
|
+
try { target = fs.readlinkSync(p); } catch (error) { return fail(`the link ${rel(p)}`, error); }
|
|
405
|
+
// A dangling link (pnpm hoists optional packages for other platforms that were never installed) resolves nothing, so it
|
|
406
|
+
// supplies no bytes; it is bound as dangling, and a target that later appears changes the digest.
|
|
407
|
+
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); }
|
|
408
|
+
h.update(`${rel(p)}\tlink\t${target}\n`);
|
|
409
|
+
if (!realWalked.some((d) => inside(real, d))) pending.push(real);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
// pnpm's task-run state holds only completion markers, never code, and leaves no trace. Tool caches are bound byte for byte
|
|
413
|
+
// unless `includeCaches` is false (only for the start/end comparison inside one run, which writes them itself).
|
|
414
|
+
if (parentIsNodeModules && !fromLink && INSTALL_STATE_NOT_COVERED.has(name)) return;
|
|
415
|
+
if (parentIsNodeModules && !fromLink && TOOL_CACHE_DIRS.has(name) && !includeCaches) return;
|
|
416
|
+
if (st.isDirectory()) return walk(p, fromLink);
|
|
417
|
+
if (st.isFile()) {
|
|
418
|
+
if (++count > maxFiles) throw new Error(`more than ${maxFiles} installed dependency files; refusing an unbounded class A environment`);
|
|
419
|
+
let bytes; try { bytes = fs.readFileSync(p); } catch (error) { return fail(rel(p), error); }
|
|
420
|
+
h.update(`${rel(p)}\tfile\t${st.mode & 0o111 ? "x" : "-"}\t${createHash("sha256").update(bytes).digest("hex")}\n`);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
throw new Error(`unsupported entry ${rel(p)} in the installed dependencies`);
|
|
424
|
+
};
|
|
425
|
+
const walk = (dir, fromLink) => {
|
|
426
|
+
let names; try { names = fs.readdirSync(dir).sort(); } catch (error) { return fail(rel(dir), error); }
|
|
427
|
+
const nm = path.basename(dir) === "node_modules";
|
|
428
|
+
for (const name of names) {
|
|
429
|
+
const p = path.join(dir, name);
|
|
430
|
+
if (fromLink && (name === ".git" || (name === "node_modules" && walkedRoots.includes(p)))) continue;
|
|
431
|
+
entry(p, name, nm, fromLink);
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
for (const d of walkedRoots) {
|
|
435
|
+
h.update(`root\t${rel(d)}\n`);
|
|
436
|
+
try { fs.lstatSync(d); } catch (error) { if (error?.code === "ENOENT" || error?.code === "ENOTDIR") { h.update("absent\n"); continue; } fail(rel(d), error); }
|
|
437
|
+
walk(d, false);
|
|
438
|
+
}
|
|
439
|
+
while (pending.length) {
|
|
440
|
+
const real = pending.shift();
|
|
441
|
+
if (seen.has(real)) continue;
|
|
442
|
+
seen.add(real);
|
|
443
|
+
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`);
|
|
444
|
+
h.update(`target\t${rel(real)}\n`);
|
|
445
|
+
const st = fs.lstatSync(real);
|
|
446
|
+
// A linked package outside the walked node_modules trees (a workspace package, or one elsewhere on disk) is hashed whole:
|
|
447
|
+
// any of its files can be executed by a consumer (an ignored dist/, an implicit index.js, a file its entry imports).
|
|
448
|
+
if (st.isDirectory()) walk(real, true); else entry(real, path.basename(real), false, true);
|
|
449
|
+
}
|
|
450
|
+
return h.digest("hex");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* What a class A verdict depends on outside the checked tree: every file of this gate package (code at any depth, the advisory
|
|
455
|
+
* database, contracts, assets), node and pnpm versions, platform/arch, and each package's installed state
|
|
456
|
+
* (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
|
|
457
|
+
* (`rightkit-gate env-digest`), so a dependency or tool change while a compile waits invalidates the ticket.
|
|
458
|
+
*/
|
|
459
|
+
export function classAEnvironmentDigest({ root, files, spawn = defaultSpawn, includeCaches = true, gatePackage = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") }) {
|
|
460
|
+
// Only a file that does not exist is "absent"; any other read failure throws: an input whose bytes cannot be read is never
|
|
461
|
+
// identified by a placeholder that would match the same failure later.
|
|
462
|
+
// `optional`: only an install-state file may legitimately not exist; a gate file found by the walk must be readable.
|
|
463
|
+
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})`); } };
|
|
464
|
+
// `gatePackage` defaults to this very package; only a test that must not be disturbed by other writers of the live package injects another.
|
|
465
|
+
const gatePkg = gatePackage;
|
|
466
|
+
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)]));
|
|
467
|
+
const gateFiles = walkGate(gatePkg).sort().map((f) => `${path.relative(gatePkg, f)}:${fileDigest(f)}`);
|
|
468
|
+
// Bounded: a pnpm that switches itself to the repository's packageManager version on first use, or waits on the network,
|
|
469
|
+
// must fail the probe (and its process tree) within PNPM_PROBE_TIMEOUT_MS, never hang an admission.
|
|
470
|
+
const pnpmVersion = (() => {
|
|
471
|
+
const r = spawn("pnpm", ["--version"], { cwd: root, boundedMs: PNPM_PROBE_TIMEOUT_MS });
|
|
472
|
+
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)`);
|
|
473
|
+
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)})`);
|
|
474
|
+
return text(r).trim();
|
|
475
|
+
})();
|
|
476
|
+
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 })}`);
|
|
477
|
+
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");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Split pathspecs into batches whose joined length stays under `limit` characters (well below Windows' 32767 command line). */
|
|
481
|
+
export function pathspecBatches(paths, limit = 8000) {
|
|
482
|
+
const batches = []; let current = []; let size = 0;
|
|
483
|
+
for (const p of paths) {
|
|
484
|
+
if (current.length && size + p.length + 1 > limit) { batches.push(current); current = []; size = 0; }
|
|
485
|
+
current.push(p); size += p.length + 1;
|
|
486
|
+
}
|
|
487
|
+
if (current.length) batches.push(current);
|
|
488
|
+
return batches;
|
|
489
|
+
}
|
|
490
|
+
|
|
255
491
|
export function discoverRustUnits({ root, spawn, cargoCommand, tracked, readFile, realpath = fs.realpathSync }) {
|
|
256
492
|
const manifests = tracked("Cargo.toml");
|
|
257
493
|
if (!manifests.length) return { present: false, units: [], cargoDir: ".", uncovered: [] };
|
|
@@ -268,8 +504,30 @@ export function discoverRustUnits({ root, spawn, cargoCommand, tracked, readFile
|
|
|
268
504
|
return { present: true, units, cargoDir, uncovered };
|
|
269
505
|
}
|
|
270
506
|
|
|
271
|
-
/**
|
|
272
|
-
|
|
507
|
+
/**
|
|
508
|
+
* The unit name in scan ids and ci/gate.config.json: the plain crate in a root workspace and in a workspace that is exactly that crate's
|
|
509
|
+
* own directory (`crates/rightkit-fs` holding rightkit-fs: one workspace and lock per crate, rightkit#1285), `<cargoDir>:<crate>` below
|
|
510
|
+
* any other workspace.
|
|
511
|
+
*/
|
|
512
|
+
export const rustUnitName = (cargoDir, crate) => (cargoDir === "." || cargoDir === `crates/${crate}` ? crate : `${cargoDir}:${crate}`);
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* The vendored sources a crate compiles: `{ "vendor/<name>": [unit names] }`, from every quoted `../../vendor/<name>` path in the crate
|
|
516
|
+
* manifests (a [patch.crates-io] or dependency `path`, rightkit-typeset's vendor/citationberg). A changed file under the directory
|
|
517
|
+
* selects those units. `readManifest(unit)` returns the manifest text, or null.
|
|
518
|
+
*/
|
|
519
|
+
export function vendorOwners(units, readManifest) {
|
|
520
|
+
const owners = {};
|
|
521
|
+
for (const u of units) {
|
|
522
|
+
let manifest = null;
|
|
523
|
+
try { manifest = readManifest(u); } catch { manifest = null; }
|
|
524
|
+
for (const m of String(manifest ?? "").matchAll(/["'](?:\.\.\/)+vendor\/([A-Za-z0-9_.@+-]+)\/?["']/g)) {
|
|
525
|
+
const list = (owners[`vendor/${m[1]}`] ??= []);
|
|
526
|
+
if (!list.includes(u.name)) list.push(u.name);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return owners;
|
|
530
|
+
}
|
|
273
531
|
|
|
274
532
|
/**
|
|
275
533
|
* The top-level TOML tables of a Cargo manifest: name -> body text (the lines under `[name]` up to the next header, trimmed).
|
|
@@ -379,8 +637,7 @@ export function manifestChangeIsLockReflected(before, after) {
|
|
|
379
637
|
* A member whose Cargo.toml is not tracked (untracked or ignored) is dropped into `skipped`, as are `untracked()` manifests.
|
|
380
638
|
* Returns { present, workspaces: [{ cargoDir, units }], units, cargoDir, skipped: [{ manifest, why }] }; `units` is every workspace's
|
|
381
639
|
* [{ name, crate, dir, publish, cargoDir }] and `cargoDir` the first workspace's (the repository root's when it has one).
|
|
382
|
-
* `metadata(dir)` (optional) replaces the cargo call with a reader of the same { cargoDir, members } shape (extra fields are ignored)
|
|
383
|
-
* deferred pre-push gate passes manifestWorkspaceMetadata where no broker can run cargo.
|
|
640
|
+
* `metadata(dir)` (optional) replaces the cargo call with a reader of the same { cargoDir, members } shape (extra fields are ignored).
|
|
384
641
|
*/
|
|
385
642
|
export function discoverRustWorkspaces({ root, spawn, cargoCommand, tracked, untracked = () => [], realpath = fs.realpathSync, fixtures = [], readFile = (f) => fs.readFileSync(f, "utf8"), metadata = null }) {
|
|
386
643
|
const manifests = tracked("Cargo.toml");
|
|
@@ -491,78 +748,6 @@ export function lockScope({ changed, cargoDir = ".", unitNames, showBase, readLo
|
|
|
491
748
|
return scope;
|
|
492
749
|
}
|
|
493
750
|
|
|
494
|
-
/**
|
|
495
|
-
* The pre-push hook. git's ref lines arrive on stdin and the gate consumes them, so stdin is buffered once and fed to both the
|
|
496
|
-
* repository's own .git/hooks/pre-push (which core.hooksPath=.githooks would otherwise hide; it runs first and a failure stops the
|
|
497
|
-
* push) and the gate. Between them runs the app check, `rightkit-gate check --commit HEAD`: seconds, committed files only, so a
|
|
498
|
-
* red tree stops the push before the gate's scans start; a push that only deletes refs has no content to check and skips it. The
|
|
499
|
-
* gate binary is the root node_modules/.bin/rightkit-gate, else any workspace package's (up to three levels down: tauri-app-next,
|
|
500
|
-
* apps/*, packages/*), else `pnpm exec rightkit-gate`, else node with a package-relative path when pnpm is absent; the line it
|
|
501
|
-
* chose is printed once. No absolute path is written: everything is resolved from git at run time.
|
|
502
|
-
*
|
|
503
|
-
* `packages` (default none) is the multi-root form: the repository-relative directories of the packages that declare @rightkit/release when the
|
|
504
|
-
* repository root does not declare it (CutRight's apps/cutright, HeardRight's tauri-app-next). Each one's node_modules/.bin/rightkit-gate is tried
|
|
505
|
-
* right after the root's (the glob above stops three levels down), and the pnpm fallback runs in the first of them (`pnpm --dir <package> exec`;
|
|
506
|
-
* pnpm at the root of such a repository has no rightkit-gate to run). With none, the text is exactly what 0.6.4 and 0.6.5 wrote
|
|
507
|
-
* (fixtures/published-gate-hooks.json holds its digest, and a repository whose root installs the package must keep getting it, byte for byte,
|
|
508
|
-
* or the committed hook of every app copied from the template stops being "already current"). `nodePath` and every package are written into
|
|
509
|
-
* a shell script: installHook passes only plain relative paths (hookGatePlan).
|
|
510
|
-
*/
|
|
511
|
-
export function hookText(nodePath, packages = []) {
|
|
512
|
-
const bins = packages.map((dir) => ` ${dir}/node_modules/.bin/rightkit-gate`).join("");
|
|
513
|
-
const pnpmExec = packages.length ? `pnpm --dir ${packages[0]} exec rightkit-gate` : "pnpm exec rightkit-gate";
|
|
514
|
-
return `#!/bin/sh
|
|
515
|
-
# Mandatory local check and gate before every push (rightkit-gate install-hook). Never bypass with --no-verify.
|
|
516
|
-
cd "$(git rev-parse --show-toplevel)" || exit 1
|
|
517
|
-
stdin=$(mktemp "\${TMPDIR:-/tmp}/rightkit-gate.XXXXXX") || exit 1
|
|
518
|
-
trap 'rm -f "$stdin"' EXIT
|
|
519
|
-
cat > "$stdin"
|
|
520
|
-
# core.hooksPath hides .git/hooks: run the repository's own pre-push hook first.
|
|
521
|
-
own="$(git rev-parse --git-dir)/hooks/pre-push"
|
|
522
|
-
if [ -x "$own" ]; then
|
|
523
|
-
echo "rightkit-gate: running $own" >&2
|
|
524
|
-
"$own" "$@" < "$stdin" || exit $?
|
|
525
|
-
fi
|
|
526
|
-
# The gate binary: the root install, ${packages.length ? "the install of each package that declares @rightkit/release, any other workspace package's install, then pnpm exec in the first of them" : "any workspace package's install, then pnpm exec"}.
|
|
527
|
-
found=""
|
|
528
|
-
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
|
-
if [ -x "$bin" ]; then found="$bin"; break; fi
|
|
530
|
-
done
|
|
531
|
-
if [ -n "$found" ]; then
|
|
532
|
-
echo "rightkit-gate: using $found" >&2
|
|
533
|
-
rightkit_gate() { "$found" "$@"; }
|
|
534
|
-
elif command -v pnpm >/dev/null 2>&1; then
|
|
535
|
-
echo "rightkit-gate: using ${pnpmExec}" >&2
|
|
536
|
-
rightkit_gate() { ${pnpmExec} "$@"; }
|
|
537
|
-
else
|
|
538
|
-
echo "rightkit-gate: using node ${nodePath}" >&2
|
|
539
|
-
rightkit_gate() { node "${nodePath}" "$@"; }
|
|
540
|
-
fi
|
|
541
|
-
# The check first, over the committed files of HEAD; a push that only deletes refs has nothing to check.
|
|
542
|
-
if [ -s "$stdin" ] && ! awk '$2 !~ /^0+$/ { found = 1 } END { exit !found }' "$stdin"; then
|
|
543
|
-
echo "rightkit-gate: only deletions are pushed; nothing to check" >&2
|
|
544
|
-
else
|
|
545
|
-
rightkit_gate check --commit HEAD < /dev/null >&2 || exit $?
|
|
546
|
-
fi
|
|
547
|
-
rightkit_gate --base origin/main < "$stdin"; exit $?
|
|
548
|
-
`;
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
/** A trampoline for a hook other than pre-push: core.hooksPath hides .git/hooks/<name>, so run that one when it exists. */
|
|
552
|
-
export function trampolineText(name) {
|
|
553
|
-
return `#!/bin/sh
|
|
554
|
-
# rightkit-gate install-hook: core.hooksPath=.githooks hides .git/hooks, so run this repository's own ${name} hook from there.
|
|
555
|
-
hook="$(git rev-parse --git-dir)/hooks/${name}"
|
|
556
|
-
if [ -x "$hook" ]; then exec "$hook" "$@"; fi
|
|
557
|
-
exit 0
|
|
558
|
-
`;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// EVERY file present in .git/hooks is chained, whatever git version or hook family it belongs to (pre-receive, update and
|
|
562
|
-
// post-receive included), except git's own `.sample` templates. The name goes into a committed shell script, so only plain file
|
|
563
|
-
// names are chained; anything else is reported and skipped, never interpolated.
|
|
564
|
-
const HOOK_FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
565
|
-
const chainableHook = (name) => !name.endsWith(".sample") && HOOK_FILE_NAME.test(name);
|
|
566
751
|
// ---- result cache inputs ------------------------------------------------------------------------------------
|
|
567
752
|
|
|
568
753
|
// The build inputs that change how every crate compiles, relative to the Cargo workspace root, plus the gate's own config.
|
|
@@ -725,8 +910,8 @@ const plainRelative = (p) => typeof p === "string" && p !== "" && !path.posix.is
|
|
|
725
910
|
* when declared. Without a declaration, a closure source that navigates out of its crate at run time without a literal path
|
|
726
911
|
* (externalInputs `leavesCrate`: workspace root, current_dir, a bare "..", the manifest directory's parent) keys the scan on the
|
|
727
912
|
* 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
|
|
913
|
+
* - An npm package scan (test:npm, tsc, eslint, biome) keys on the package's workspace closure (every dependency that is a workspace
|
|
914
|
+
* 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
915
|
* root install graph. Every other class B scan (generated-docs, swift-build) uses the whole tree.
|
|
731
916
|
* - Class A: cargo-deny, cargo-audit, pnpm-audit, pnpm-lockfile and advisory-exceptions are never cached (live answers); rustfmt,
|
|
732
917
|
* node-check, actionlint and the parse scans key on their files' blob ids; cargo-package-list on the crate, the manifest projection
|
|
@@ -764,9 +949,10 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
|
|
|
764
949
|
if (!m) { unresolved = true; continue; }
|
|
765
950
|
for (const section of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
|
|
766
951
|
for (const [name, spec] of Object.entries(m[section] ?? {})) {
|
|
767
|
-
|
|
952
|
+
// A workspace package is a member whatever the spec: pnpm links it for a version range too (linkWorkspacePackages), and the
|
|
953
|
+
// tsc row checks it from its committed source (deterministic-scans.mjs TSC_PROJECT). Only an explicit local spec must resolve.
|
|
768
954
|
const dep = npmByPkgName.get(`${u.root ?? "."}\0${name}`);
|
|
769
|
-
if (!dep) { unresolved = true; continue; }
|
|
955
|
+
if (!dep) { if (/^(workspace:|link:|file:)/.test(String(spec))) unresolved = true; continue; }
|
|
770
956
|
if (!seen.has(dep.name)) { seen.set(dep.name, dep); stack.push(dep); }
|
|
771
957
|
}
|
|
772
958
|
}
|
|
@@ -794,6 +980,7 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
|
|
|
794
980
|
const byName = new Map(rustUnits.map((u) => [u.name, u]));
|
|
795
981
|
const known = new Set(byName.keys());
|
|
796
982
|
const byCrate = new Map(rustUnits.map((u) => [`${wsOf(u)}\0${crateOf(u)}`, u]));
|
|
983
|
+
const siblingByCrate = new Map(rustUnits.filter((u) => wsOf(u) === `crates/${crateOf(u)}`).map((u) => [crateOf(u), u]));
|
|
797
984
|
const whole = () => canonicalJson({ kind: "tree", tree: headTree });
|
|
798
985
|
const externalOf = (member) => {
|
|
799
986
|
if (!externalMemo.has(member.dir)) {
|
|
@@ -835,6 +1022,12 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
|
|
|
835
1022
|
}
|
|
836
1023
|
if (scan.class !== "B") return null;
|
|
837
1024
|
try {
|
|
1025
|
+
// A batched Clippy scan (clippy:batch:*, planScans `clippyBatching`) is keyed on the inputs of every member it names: each member's
|
|
1026
|
+
// own compile key, in member order. Any member that falls back to the whole tree makes the batch fall back too.
|
|
1027
|
+
if (/^clippy:batch:/.test(String(scan.id)) && Array.isArray(scan.batch?.units) && scan.batch.units.length && scan.batch.units.every((n) => known.has(n))) {
|
|
1028
|
+
const keys = scan.batch.units.map((n) => rustUnitKey(n, scan));
|
|
1029
|
+
return keys.includes(undefined) ? whole() : canonicalJson({ kind: "rust-batch", id: scan.id, units: scan.batch.units.map((n, i) => [n, keys[i]]) });
|
|
1030
|
+
}
|
|
838
1031
|
const unit = rustUnitOfScanId(scan.id, known);
|
|
839
1032
|
if (!unit) {
|
|
840
1033
|
if (typeof readBlob !== "function") return whole();
|
|
@@ -848,27 +1041,35 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
|
|
|
848
1041
|
if (scan.id === "test:scripts-ci") return canonicalJson({ kind: "dirs", id: scan.id, inputs: SCRIPTS_CI_INPUTS.map((p) => [p, idOf(p)]) });
|
|
849
1042
|
return whole();
|
|
850
1043
|
}
|
|
1044
|
+
const keyed = rustUnitKey(unit, scan);
|
|
1045
|
+
return keyed === undefined ? whole() : keyed;
|
|
1046
|
+
} catch { return null; }
|
|
1047
|
+
};
|
|
1048
|
+
// The cache key of one Rust unit's compile or test scan, or undefined when the scan must fall back to the whole tree.
|
|
1049
|
+
function rustUnitKey(unit, scan) {
|
|
851
1050
|
const head = byName.get(unit);
|
|
852
1051
|
const ws = wsOf(head); const lock = lockFor(ws);
|
|
853
|
-
if (!lock || typeof listFiles !== "function" || typeof readBlob !== "function") return
|
|
1052
|
+
if (!lock || typeof listFiles !== "function" || typeof readBlob !== "function") return undefined;
|
|
854
1053
|
const testScan = /^test:rust(?:-serial)?:/.test(scan.id);
|
|
855
1054
|
const ownConfig = crateFeatures?.[unit] ?? crateFeatures?.[crateOf(head)] ?? null;
|
|
856
1055
|
let testInputs = []; let declaredInputs = false;
|
|
857
1056
|
if (testScan) {
|
|
858
1057
|
const declared = ownConfig?.testInputs;
|
|
859
1058
|
if (declared !== undefined) {
|
|
860
|
-
if (!Array.isArray(declared) || declared.length === 0 || !declared.every(plainRelative)) return
|
|
1059
|
+
if (!Array.isArray(declared) || declared.length === 0 || !declared.every(plainRelative)) return undefined;
|
|
861
1060
|
testInputs = [...new Set(declared)].sort(); declaredInputs = true;
|
|
862
1061
|
}
|
|
863
|
-
} else if (!COMPILE_SCAN_PREFIXES.has(String(scan.id).split(":")[0])) return
|
|
1062
|
+
} else if (!COMPILE_SCAN_PREFIXES.has(String(scan.id).split(":")[0])) return undefined;
|
|
864
1063
|
if (!reachMemos.has(ws)) reachMemos.set(ws, new Map());
|
|
865
1064
|
const reached = [...reach(lock, crateOf(head), reachMemos.get(ws))];
|
|
866
|
-
|
|
1065
|
+
// A crate that is its own workspace (one workspace and lock per crate) holds its sibling crates in its lock as path packages: the
|
|
1066
|
+
// sibling's source is part of the closure whichever workspace it lives in, or a change to it would leave a stale key.
|
|
1067
|
+
const closure = [...new Set([head, ...reached.map((n) => byCrate.get(`${ws}\0${n}`) ?? siblingByCrate.get(n)).filter(Boolean)])].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
867
1068
|
const extra = new Set(testInputs);
|
|
868
1069
|
for (const member of closure) {
|
|
869
1070
|
const found = externalOf(member);
|
|
870
|
-
if (found.unresolved) return
|
|
871
|
-
if (testScan && !declaredInputs && found.leavesCrate) return
|
|
1071
|
+
if (found.unresolved) return undefined;
|
|
1072
|
+
if (testScan && !declaredInputs && found.leavesCrate) return undefined;
|
|
872
1073
|
for (const p of found.paths) extra.add(p);
|
|
873
1074
|
}
|
|
874
1075
|
// The shared inputs, projected to what this crate consumes.
|
|
@@ -894,8 +1095,7 @@ export function buildCacheDigest({ objectId, headTree, rustUnits = [], npmUnits
|
|
|
894
1095
|
shared: [...new Set(toolFiles)].sort().map((p) => [p, idOf(p)]),
|
|
895
1096
|
external: [...extra].sort().map((p) => [p, idOf(p)]),
|
|
896
1097
|
});
|
|
897
|
-
|
|
898
|
-
};
|
|
1098
|
+
}
|
|
899
1099
|
}
|
|
900
1100
|
|
|
901
1101
|
// ---- quiet lease ------------------------------------------------------------------------------------------------
|
|
@@ -911,13 +1111,18 @@ export const QUIET_LEASE_WAIT_DRAIN_MS = 2 * 60 * 1000;
|
|
|
911
1111
|
* 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
1112
|
* their verdict is simply taken on a possibly busy host. `o.spawn` is the gate's synchronous spawner.
|
|
913
1113
|
*/
|
|
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 } = {}) {
|
|
1114
|
+
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
1115
|
const which = (cmd) => {
|
|
916
1116
|
const r = o.spawn(o.platform === "win32" ? "where" : "which", [cmd]);
|
|
917
1117
|
return failed(r) ? null : (text(r).split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? null);
|
|
918
1118
|
};
|
|
919
1119
|
return {
|
|
920
|
-
|
|
1120
|
+
// `want.ttlMs` (from the scan runner): the lane's own bound, every quiet scan's timeout plus a margin, so the lease cannot
|
|
1121
|
+
// expire while a quiet suite still runs (each scan is killed at its timeout first).
|
|
1122
|
+
acquire: async (want = {}) => {
|
|
1123
|
+
const ttlMs = Number.isFinite(want.ttlMs) && want.ttlMs > baseTtlMs ? want.ttlMs : baseTtlMs;
|
|
1124
|
+
// A direct (Linux) host never takes a broker lease, whatever is on PATH (managed-cargo directPlatform).
|
|
1125
|
+
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
1126
|
if (!which("rustright")) { o.out(`${tag} note: no rustright on PATH; quiet test scans run without a broker lease`); return null; }
|
|
922
1127
|
const started = Date.now();
|
|
923
1128
|
const r = o.spawn("rustright", ["quiet", "acquire", "--ttl-ms", String(ttlMs), "--reason", reason, "--wait-drain-ms", String(waitDrainMs), "--holder-pid", String(pid), "--json"]);
|
|
@@ -942,137 +1147,60 @@ export function quietLeaseLane(o, { tag = "rightkit-gate:", reason = "rightkit-g
|
|
|
942
1147
|
// ---- install-hook -------------------------------------------------------------------------------------------
|
|
943
1148
|
|
|
944
1149
|
/**
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
*
|
|
1150
|
+
* `rightkit-gate install-hook` installs no git hook: commit and push run no gate and no local hook check (docs/GATE-CONTRACT.md).
|
|
1151
|
+
* It retires what an earlier release wrote, each file only by its own header or marker (../hook-retirement.mjs), never by name or place:
|
|
1152
|
+
* - .githooks/pre-push, the gate hook, and the .githooks/<name> forwarders that chained .git/hooks beside it;
|
|
1153
|
+
* - the advisory ownership pre-commit hook (in .githooks, in .git/hooks or in the directory core.hooksPath names), with the foreign
|
|
1154
|
+
* pre-commit it displaced (pre-commit.rightkit-foreign) put back as pre-commit;
|
|
1155
|
+
* - the repository-local core.hooksPath, only when it is exactly `.githooks` and nothing foreign remains in that directory.
|
|
1156
|
+
* A foreign hook (any other file in .githooks, .git/hooks or the configured directory) is never read for change, moved or deleted, and
|
|
1157
|
+
* a foreign .githooks directory keeps its core.hooksPath. The global and system git configuration is never touched. It still registers
|
|
1158
|
+
* the one-door agent hook in .claude/settings.json (the agent side, not a git hook).
|
|
948
1159
|
*/
|
|
949
|
-
function
|
|
950
|
-
let files = ["package.json"];
|
|
951
|
-
const listed = git("ls-files", "-z", "--", ":(glob)**/package.json");
|
|
952
|
-
if (!failed(listed)) files = text(listed).split("\0").filter(Boolean).map(posix);
|
|
953
|
-
let provider = null; const declaring = [];
|
|
954
|
-
for (const file of files) {
|
|
955
|
-
const abs = path.join(root, file);
|
|
956
|
-
if (!o.exists(abs)) continue;
|
|
957
|
-
let manifest;
|
|
958
|
-
try { manifest = JSON.parse(o.readFile(abs).replace(/^\uFEFF/, "")); } catch { continue; }
|
|
959
|
-
if (manifest?.name === "@rightkit/release") { provider ??= file; continue; }
|
|
960
|
-
if (["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"].some((section) => manifest?.[section] && Object.hasOwn(manifest[section], "@rightkit/release"))) {
|
|
961
|
-
provider ??= file;
|
|
962
|
-
declaring.push(path.posix.dirname(file));
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
return { provider, declaring: [...new Set(declaring)].sort() };
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
// Where a package installs the gate, below its own directory. pnpm links it there for a package that declares @rightkit/release, so the path is the
|
|
969
|
-
// same for every version (node_modules/.pnpm/@rightkit+release@x.y.z/... is the store copy it points at, and goes stale with each bump).
|
|
970
|
-
const GATE_IN_PACKAGE = "node_modules/@rightkit/release/bin/rightkit-gate.mjs";
|
|
971
|
-
// What may be written into the committed shell script: a plain relative path of letters, digits and _ - . @ + /, no `..`, no `.` segment, no
|
|
972
|
-
// leading dash (it would read as an option of `pnpm --dir`).
|
|
973
|
-
const HOOK_PATH_SEGMENT = /^(?!\.{1,2}$)[A-Za-z0-9_@.+-]+$/;
|
|
974
|
-
export const hookPathIsPlain = (value) => typeof value === "string" && value !== "" && !value.startsWith("-") && value.split("/").every((segment) => HOOK_PATH_SEGMENT.test(segment));
|
|
975
|
-
|
|
976
|
-
/**
|
|
977
|
-
* What the pre-push hook is told about where the gate lives: { nodePath, packages, unsafe }. `nodePath` is the last resort the hook runs with
|
|
978
|
-
* node, `packages` the extra directories hookText searches (multi-root form), `unsafe` the declaring packages whose directory cannot be written
|
|
979
|
-
* into a shell script. `declaring` is releasePackages(...).declaring; `gateFile` is where this gate runs from.
|
|
980
|
-
*
|
|
981
|
-
* A function of what the repository's package.json files declare, never of what is installed: the same tree gives the same hook on a fresh
|
|
982
|
-
* clone, an exports checkout, after `pnpm install` and after a version bump, so `install-hook` (a `prepare` script) never meets a hook it
|
|
983
|
-
* wrote earlier as one that "already exists and differs". It takes no `exists`, so that cannot creep back in. Always a stable,
|
|
984
|
-
* version-independent path relative to the repository, never one outside it, absolute, or inside the pnpm store:
|
|
985
|
-
* 1. The root declares @rightkit/release (whatever else does): its link and no extra packages, the hook 0.6.4 and 0.6.5 wrote (Pulse declares
|
|
986
|
-
* it at the root and in hub/; RightKit itself at the root).
|
|
987
|
-
* 2. Only packages declare it (CutRight's apps/cutright, HeardRight's tauri-app-next): the link of the first of them in sorted order, whichever is
|
|
988
|
-
* installed, and every one of them is searched.
|
|
989
|
-
* 3. No package.json declares it (a checkout of the package itself that nothing depends on), or only under a directory the hook cannot name: if
|
|
990
|
-
* this gate runs from inside the repository and not from a node_modules, its path from the root, else the root's link, never a path
|
|
991
|
-
* outside the repository. This is the one case that reads where the gate runs from, because the repository says nothing.
|
|
992
|
-
*/
|
|
993
|
-
export function hookGatePlan({ root, gateFile, declaring }) {
|
|
994
|
-
const plain = declaring.filter((d) => d !== "." && hookPathIsPlain(d));
|
|
995
|
-
const unsafe = declaring.filter((d) => d !== "." && !hookPathIsPlain(d));
|
|
996
|
-
if (declaring.includes(".")) return { nodePath: GATE_IN_PACKAGE, packages: [], unsafe };
|
|
997
|
-
if (plain.length) return { nodePath: `${plain[0]}/${GATE_IN_PACKAGE}`, packages: plain, unsafe };
|
|
998
|
-
const relative = posix(path.relative(root, gateFile));
|
|
999
|
-
const inside = relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) && !relative.split("/").includes("node_modules") && hookPathIsPlain(relative);
|
|
1000
|
-
return { nodePath: inside ? relative : GATE_IN_PACKAGE, packages: [], unsafe };
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
// The advisory ownership pre-commit step (`right-release hooks install`, ownership-hooks.mjs) runs the pinned @rightkit/release, so
|
|
1004
|
-
// it is written only where that is installed. Its own hook and the repository's .git/hooks/pre-commit both keep running.
|
|
1005
|
-
const PINNED_CLI = "node_modules/@rightkit/release/cli/right-release.mjs";
|
|
1006
|
-
const FOREIGN_PRE_COMMIT = "pre-commit.rightkit-foreign";
|
|
1007
|
-
|
|
1008
|
-
async function installHook(o, root, force) {
|
|
1160
|
+
async function installHook(o, root) {
|
|
1009
1161
|
const git = (...a) => o.spawn("git", a, { cwd: root });
|
|
1010
|
-
const
|
|
1011
|
-
const hookPath = path.join(hooksDir, "pre-push");
|
|
1012
|
-
const { provider, declaring } = releasePackages(o, root, git);
|
|
1013
|
-
const { nodePath, packages, unsafe } = hookGatePlan({ root, gateFile: o.gateFile, declaring });
|
|
1014
|
-
if (unsafe.length) o.err(`rightkit-gate: WARNING: the pre-push hook does not look for the gate in ${unsafe.join(", ")}: not a plain directory name (letters, digits and _ - . @ + only, with / between); it still finds a root or workspace install by glob.`);
|
|
1015
|
-
const wanted = hookText(nodePath, packages);
|
|
1016
|
-
const hasHook = o.exists(hookPath);
|
|
1017
|
-
const current = hasHook ? o.readFile(hookPath).replace(/\r\n/g, "\n") : null;
|
|
1018
|
-
if (hasHook && current !== wanted && !force) {
|
|
1019
|
-
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
|
-
return 1;
|
|
1021
|
-
}
|
|
1022
|
-
// core.hooksPath=.githooks makes git ignore .git/hooks: chain the hooks that are there now (pre-push at run time inside the gate hook).
|
|
1023
|
-
const gitDirResult = git("rev-parse", "--git-dir");
|
|
1024
|
-
let present = [];
|
|
1025
|
-
if (!failed(gitDirResult) && text(gitDirResult).trim()) { try { present = o.readdir(path.join(path.resolve(root, text(gitDirResult).trim()), "hooks")); } catch { present = []; } }
|
|
1026
|
-
const gitHooksDir = !failed(gitDirResult) && text(gitDirResult).trim() ? path.join(path.resolve(root, text(gitDirResult).trim()), "hooks") : null;
|
|
1027
|
-
const own = present.filter(chainableHook).sort();
|
|
1028
|
-
// The ownership step takes .githooks/pre-commit. The repository's own pre-commit hook keeps running first, through a trampoline
|
|
1029
|
-
// beside it, unless it is the ownership hook a plain `right-release hooks install` wrote (this one replaces that), whose own
|
|
1030
|
-
// chained hook, pre-commit.rightkit-foreign, then runs instead. A name that is no plain hook name is reported, never chained.
|
|
1031
|
-
const ownership = o.exists(path.join(root, PINNED_CLI)) ? await import("../ownership-hooks.mjs") : null;
|
|
1032
|
-
const readHook = (name) => { try { return o.readFile(path.join(gitHooksDir, name)); } catch { return ""; } };
|
|
1033
|
-
const chainName = !ownership ? null : own.includes("pre-commit") && !readHook("pre-commit").includes(ownership.HOOK_MARKER) ? "pre-commit" : present.includes(FOREIGN_PRE_COMMIT) ? FOREIGN_PRE_COMMIT : null;
|
|
1034
|
-
const skipped = present.filter((name) => !name.endsWith(".sample") && !chainableHook(name) && !(ownership && name === FOREIGN_PRE_COMMIT)).sort();
|
|
1035
|
-
if (skipped.length) o.err(`rightkit-gate: WARNING: not chaining .git/hooks/${skipped.join(", .git/hooks/")}: not a plain hook file name (letters, digits, - and _ only); run it from a hook of its own.`);
|
|
1036
|
-
const trampolines = own.filter((name) => name !== "pre-push" && !(ownership && name === "pre-commit")).map((name) => ({ name, file: path.join(hooksDir, name), wanted: trampolineText(name), note: `runs .git/hooks/${name}` }));
|
|
1037
|
-
if (ownership) {
|
|
1038
|
-
trampolines.push({ name: "pre-commit", file: path.join(hooksDir, "pre-commit"), wanted: ownership.renderPreCommitHook(), ownership: true, replaces: [trampolineText("pre-commit")], marker: ownership.HOOK_MARKER, note: "the advisory ownership gate: right-release ownership --staged" });
|
|
1039
|
-
if (chainName) trampolines.push({ name: FOREIGN_PRE_COMMIT, file: path.join(hooksDir, FOREIGN_PRE_COMMIT), wanted: trampolineText(chainName), note: `runs .git/hooks/${chainName}, before the ownership gate` });
|
|
1040
|
-
}
|
|
1041
|
-
for (const t of trampolines) {
|
|
1042
|
-
t.exists = o.exists(t.file);
|
|
1043
|
-
const found = t.exists ? o.readFile(t.file).replace(/\r\n/g, "\n") : null;
|
|
1044
|
-
t.current = found === t.wanted;
|
|
1045
|
-
// Our own earlier artifacts are updated in place: a plain trampoline where the ownership hook now goes, and an older ownership hook.
|
|
1046
|
-
const ours = found !== null && (t.current || t.replaces?.includes(found) || (t.marker && found.includes(t.marker)));
|
|
1047
|
-
if (t.exists && !ours && !force) {
|
|
1048
|
-
o.err(`rightkit-gate: .githooks/${t.name} already exists and differs; nothing changed. Review it, or run \`rightkit-gate install-hook --force\` to replace it with ${t.ownership ? "the ownership pre-commit hook" : `a trampoline to .git/hooks/${chainName ?? t.name}`}.`);
|
|
1049
|
-
return 1;
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1052
|
-
const currentPath = git("config", "--get", "core.hooksPath");
|
|
1053
|
-
const existingPath = failed(currentPath) ? "" : text(currentPath).trim();
|
|
1054
|
-
if (existingPath && existingPath !== ".githooks" && !force) {
|
|
1055
|
-
o.err(`rightkit-gate: core.hooksPath is already ${JSON.stringify(existingPath)}; nothing changed. Run \`rightkit-gate install-hook --force\` to point it at .githooks.`);
|
|
1056
|
-
return 1;
|
|
1057
|
-
}
|
|
1162
|
+
const ops = { readdir: o.readdir, readFile: o.readFile, lstat: o.lstat ?? fs.lstatSync, unlink: o.unlink ?? fs.unlinkSync, rename: o.rename ?? fs.renameSync, rmdir: o.rmdir ?? fs.rmdirSync };
|
|
1058
1163
|
const done = [];
|
|
1059
|
-
const
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1164
|
+
const isRealDirectory = (dir) => { try { const stat = ops.lstat(dir); return stat.isDirectory() && !stat.isSymbolicLink(); } catch { return false; } };
|
|
1165
|
+
const label = (dir) => posix(path.relative(root, dir)) || ".";
|
|
1166
|
+
const retire = (dir, options) => {
|
|
1167
|
+
if (!isRealDirectory(dir)) return null;
|
|
1168
|
+
const result = retireHooksIn(dir, ops, options);
|
|
1169
|
+
for (const name of result.removed) done.push(`retired ${label(dir)}/${name}`);
|
|
1170
|
+
for (const name of result.restored) done.push(`restored ${label(dir)}/${name} from ${FOREIGN_PRE_COMMIT}`);
|
|
1171
|
+
if (result.removedDir) done.push(`removed the empty ${label(dir)}`);
|
|
1172
|
+
return result;
|
|
1063
1173
|
};
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1174
|
+
|
|
1175
|
+
// The gate hook, its forwarders and the ownership step in .githooks, then the ownership step wherever else git ran hooks from.
|
|
1176
|
+
const hooksDir = path.join(root, ".githooks");
|
|
1177
|
+
retire(hooksDir, { scripts: true });
|
|
1178
|
+
const gitHooksResult = git("rev-parse", "--git-path", "hooks");
|
|
1179
|
+
const gitHooksDir = !failed(gitHooksResult) && text(gitHooksResult).trim() ? path.resolve(root, text(gitHooksResult).trim()) : null;
|
|
1180
|
+
const configured = git("config", "--local", "--get", "core.hooksPath");
|
|
1181
|
+
const hooksPath = failed(configured) ? "" : text(configured).trim();
|
|
1182
|
+
const seen = new Set([path.resolve(hooksDir)]);
|
|
1183
|
+
for (const dir of [gitHooksDir, hooksPath && hooksPath !== ".githooks" ? path.resolve(root, hooksPath) : null]) {
|
|
1184
|
+
if (!dir || seen.has(path.resolve(dir))) continue;
|
|
1185
|
+
seen.add(path.resolve(dir));
|
|
1186
|
+
retire(dir, { scripts: false });
|
|
1075
1187
|
}
|
|
1188
|
+
|
|
1189
|
+
// core.hooksPath: unset only when it is `.githooks` and that directory holds nothing foreign (it is gone, or empty).
|
|
1190
|
+
let remaining = [];
|
|
1191
|
+
if (hooksPath === ".githooks") {
|
|
1192
|
+
if (isRealDirectory(hooksDir)) { try { remaining = [...o.readdir(hooksDir)].map(String).sort(); } catch { remaining = ["(unreadable)"]; } }
|
|
1193
|
+
else if (o.exists(hooksDir)) remaining = ["(not a plain directory)"];
|
|
1194
|
+
if (!remaining.length) {
|
|
1195
|
+
const unset = git("config", "--local", "--unset", "core.hooksPath");
|
|
1196
|
+
if (failed(unset)) { o.err(`rightkit-gate: git config --unset core.hooksPath failed: ${why(unset)}`); return 2; }
|
|
1197
|
+
done.push("unset core.hooksPath (was .githooks)");
|
|
1198
|
+
} else {
|
|
1199
|
+
done.push(`core.hooksPath stays .githooks: foreign file(s) remain there (${remaining.join(", ")}); the hooks in .git/hooks do not run while it is set`);
|
|
1200
|
+
}
|
|
1201
|
+
} else if (hooksPath) done.push(`core.hooksPath is ${JSON.stringify(hooksPath)}: left as it is`);
|
|
1202
|
+
if (!done.length) done.push("no git hook of RightKit's to retire");
|
|
1203
|
+
|
|
1076
1204
|
// The agent side of the one door: a Claude Code PreToolUse hook in the repository's .claude/settings.json runs door-policy.mjs on every
|
|
1077
1205
|
// shell command, on every host (the broker-less cloud included), and denies any build that does not go through rightkit-gate.
|
|
1078
1206
|
const agentHookFile = "node_modules/@rightkit/release/bin/rightkit-agent-hook.mjs";
|
|
@@ -1093,13 +1221,7 @@ async function installHook(o, root, force) {
|
|
|
1093
1221
|
}
|
|
1094
1222
|
}
|
|
1095
1223
|
for (const line of done) o.out(`rightkit-gate: ${line}`);
|
|
1096
|
-
|
|
1097
|
-
o.out(`rightkit-gate: chained hooks from .git/hooks: ${chained.length ? chained.join(", ") : "none"}${own.includes("pre-push") ? "" : "; the pre-push hook still runs .git/hooks/pre-push first if one appears later"}`);
|
|
1098
|
-
if (!ownership) o.out(`rightkit-gate: no ownership pre-commit step: ${PINNED_CLI} is not installed here; install @rightkit/release, then run install-hook again`);
|
|
1099
|
-
const files = [".githooks/pre-push", ...trampolines.map((t) => `.githooks/${t.name}`)];
|
|
1100
|
-
o.out(`rightkit-gate: commit the hooks with executable mode (git add --chmod=+x ${files.join(" ")}); the pre-push hook finds rightkit-gate in node_modules/.bin of the repository root, ${packages.length ? `else of ${packages.join(", ")} (the packages that declare @rightkit/release), else of any workspace package, else through \`pnpm --dir ${packages[0]} exec\`` : "else of any workspace package, else through \`pnpm exec\`"}.`);
|
|
1101
|
-
if (!provider) o.err("rightkit-gate: WARNING: no package.json in this repository depends on @rightkit/release, so the pre-push hook cannot find the rightkit-gate binary; add @rightkit/release as a devDependency of the root or of a workspace package and install.");
|
|
1102
|
-
else o.out(`rightkit-gate: @rightkit/release is declared in ${provider}`);
|
|
1224
|
+
o.out("rightkit-gate: no git hook is written: commit and push run no gate and no local hook check (the gate is for building)");
|
|
1103
1225
|
return 0;
|
|
1104
1226
|
}
|
|
1105
1227
|
|
|
@@ -1112,33 +1234,104 @@ async function installHook(o, root, force) {
|
|
|
1112
1234
|
* 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
1235
|
* caller (gate-stages.mjs requireDoorRun).
|
|
1114
1236
|
*/
|
|
1237
|
+
/**
|
|
1238
|
+
* 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
|
|
1239
|
+
* apps/coderight-tauri), else the repository root. Every right-release stage runs there, the sealed set is read from there, and the
|
|
1240
|
+
* receipt is keyed to it (receiptKey). Returns { appRoot, appDir } with appDir relative to the repository root, "." at the root.
|
|
1241
|
+
*/
|
|
1242
|
+
export function appRootOf({ root, cwd, exists = fs.existsSync }) {
|
|
1243
|
+
const inside = cwd && (cwd === root || cwd.startsWith(root + path.sep));
|
|
1244
|
+
const hasConfig = (d) => ["right-release.config.json", "right-release.config.mjs"].some((f) => exists(path.join(d, f)));
|
|
1245
|
+
// 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.
|
|
1246
|
+
let appRoot = root;
|
|
1247
|
+
for (let d = path.resolve(cwd); d.startsWith(root) && d !== root; d = path.dirname(d)) { if (hasConfig(d)) { appRoot = d; break; } }
|
|
1248
|
+
return { appRoot, appDir: path.relative(root, appRoot).split(path.sep).join("/") || "." };
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1115
1251
|
async function runStages(o, args, root, git, io) {
|
|
1116
1252
|
const option = (name) => { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; };
|
|
1117
1253
|
const tag = "rightkit-gate:";
|
|
1254
|
+
const { appRoot, appDir } = appRootOf({ root, cwd: o.cwd, exists: o.exists });
|
|
1118
1255
|
const to = option("--to");
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1256
|
+
// dev and test are door stages like the rest: the same commit-only route (select a commit, sync its snapshot under ownership, class A
|
|
1257
|
+
// of the snapshot), then right-release dev|test with the snapshot's app root as its cwd. They have no stage list of their own.
|
|
1258
|
+
const devTest = to === "dev" || to === "test";
|
|
1259
|
+
let stages = devTest ? [] : stagesTo(to);
|
|
1260
|
+
if (!stages) { o.err(`${tag} --to must be one of ${[...STAGES, "dev", "test"].join(", ")}`); return 2; }
|
|
1261
|
+
// Every stage the door runs has a row naming where it reads its source from (gate-stages.mjs STAGE_SOURCES, drift-tested in
|
|
1262
|
+
// door-stages.test.mjs); a stage added without one is refused rather than run with an unstated source.
|
|
1263
|
+
if (!STAGE_SOURCES[to]) { o.err(`${tag} BLOCKED: stage ${to} has no STAGE_SOURCES row naming its source; nothing ran`); return 2; }
|
|
1122
1264
|
const platform = option("--platform");
|
|
1123
1265
|
const tier = option("--tier");
|
|
1124
|
-
if (stages.includes("package") && !
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1266
|
+
if (stages.includes("package") && !PLATFORMS.includes(platform)) { o.err(`${tag} --to ${to} needs --platform ${PLATFORMS.join("|")}`); return 2; }
|
|
1267
|
+
// iOS and Android (Adrian, 2026-10-26: every app build goes through the door, class A first): check, compile and package run here as
|
|
1268
|
+
// for desktop; there is no installed-QA lane for them, so the qualify stage does not exist for a mobile platform and is never
|
|
1269
|
+
// recorded as passed. Qualification is the store's test track, after the release stage uploads the sealed build from this host
|
|
1270
|
+
// (TestFlight, Play internal track; right-release upload --apply, no patch/update tier).
|
|
1271
|
+
const mobile = MOBILE_PLATFORMS.has(platform);
|
|
1272
|
+
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; }
|
|
1273
|
+
if (mobile) stages = stages.filter((s) => s !== "qualify");
|
|
1274
|
+
if (stages.includes("release") && !mobile && !["patch", "update"].includes(tier)) { o.err(`${tag} --to release needs --tier patch|update (chosen explicitly, never defaulted)`); return 2; }
|
|
1275
|
+
// Commit-only build (GATE-CONTRACT §1): on every host (not hosted CI, whose runner checkout is the commit) the door selects a commit
|
|
1276
|
+
// (--commit <rev>, default HEAD), takes the host's build ownership, syncs the commit's gate-owned snapshot once, and every stage
|
|
1277
|
+
// below reads that snapshot; the checkout may hold anything and is never a build source. Mac and Dell compile it through the
|
|
1278
|
+
// RustRight snapshot carrier (`deps.snapshotBroker`, item 7 rows 7.20 to 7.24). Interim, until the one cutover commit (decision 2,
|
|
1279
|
+
// "Interim"): Mac and Dell keep the checkout route exactly as it was (clean HEAD, today's package, qualify and `runWorktreeCompile`),
|
|
1280
|
+
// and take the snapshot route ONLY when the carrier is wired (`deps.snapshotBroker`, today only in tests); no flag or environment
|
|
1281
|
+
// variable selects it. Once it is selected any failure refuses; there is no retry on the checkout.
|
|
1282
|
+
if (!o.doorSnapshot && o.env.GITHUB_ACTIONS !== "true" && snapshotRouteSelected(o)) {
|
|
1283
|
+
// The door never sets TAURI_CONFIG or any externalBin override, and refuses to inherit one: Tauri merges it over the commit's own
|
|
1284
|
+
// configuration (tauri-build lib.rs), which would build from a configuration that is not the commit's.
|
|
1285
|
+
if (o.env.TAURI_CONFIG !== undefined) { o.err(`${tag} BLOCKED: RK_TAURI_CONFIG_OVERRIDE: TAURI_CONFIG is set in the environment; it would replace the commit's Tauri configuration (externalBin included) for every build below, so a commit-only build refuses it; nothing ran`); return 1; }
|
|
1286
|
+
// `--to check` compiles nothing (class A of the snapshot: its Cargo scans go through the broker on their own), so it needs no carrier.
|
|
1287
|
+
const refused = to === "check" ? null : snapshotRouteRefusal(o, { rightRelease: devTest });
|
|
1288
|
+
if (refused) { o.err(`${tag} BLOCKED: ${refused.code}: ${refused.message}; nothing ran and the checkout is never built`); return 1; }
|
|
1289
|
+
const snap = await import("../snapshot.mjs");
|
|
1290
|
+
const { acquireBuildOwnership, ownershipFile } = await import("../build-ownership.mjs");
|
|
1291
|
+
let selected;
|
|
1292
|
+
try { selected = snap.resolveCommit(root, option("--commit") ?? "HEAD"); }
|
|
1293
|
+
catch (e) { o.err(`${tag} cannot resolve ${option("--commit") ?? "HEAD"} to a commit: ${e.message}`); return 2; }
|
|
1294
|
+
const snapshotsAt = o.snapshotRoot ?? snap.snapshotRoot({ platform: o.platform, env: o.env });
|
|
1295
|
+
let own;
|
|
1296
|
+
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 }); }
|
|
1297
|
+
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; }
|
|
1298
|
+
o.out(`${tag} build ownership ${own.joined ? "joined" : "held"} ${own.handle}${own.reclaimed ? ` (reclaimed from ${own.reclaimed.from}: ${own.reclaimed.proofs.join("; ")})` : ""}`);
|
|
1299
|
+
try {
|
|
1300
|
+
// One sync per door run: a second one would prune what class A installed into the snapshot.
|
|
1301
|
+
let synced;
|
|
1302
|
+
try { synced = (o.syncSnapshot ?? snap.syncSnapshot)({ checkout: root, rev: selected.commit, root: snapshotsAt }); }
|
|
1303
|
+
catch (e) { o.err(`${tag} ${e.code === "error" ? "RK_CHECK_ERROR" : "BLOCKED"}: snapshot ${e.message}; nothing ran`); return e.code === "error" ? 2 : 1; }
|
|
1304
|
+
o.out(`${tag} snapshot ${synced.path} is commit ${synced.commit} tree ${synced.tree} (written=${synced.written} removed=${synced.removed} unchanged=${synced.unchanged})`);
|
|
1305
|
+
return await runStages({ ...o, buildOwnership: own, doorSnapshot: { own, path: synced.path, commit: synced.commit, tree: synced.tree, root: snapshotsAt, ledger: { entries: [] } } }, args, root, git, { ...io, buildOwnership: own });
|
|
1306
|
+
} finally {
|
|
1307
|
+
const r = own.release();
|
|
1308
|
+
if (r?.released === false && !own.joined) o.err(`${tag} build ownership ${own.handle} kept: ${r.why}`);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
if (devTest) {
|
|
1312
|
+
// Mac and Dell, until the cutover: the checkout route as it was (class A of the working tree, then right-release on it). Hosted CI's
|
|
1313
|
+
// runner checkout is the commit, so it takes the commit route below without a snapshot.
|
|
1314
|
+
if (!o.doorSnapshot && o.env.GITHUB_ACTIONS !== "true") return await runWorktreeCompile(o, args, root, git, io, to, appRoot);
|
|
1315
|
+
return await runDevTest(o, args, root, git, io, to, appRoot, appDir);
|
|
1316
|
+
}
|
|
1317
|
+
const ds = o.doorSnapshot ?? null;
|
|
1318
|
+
const commit = ds ? ds.commit : text(git("rev-parse", "HEAD")).trim();
|
|
1319
|
+
const tree = ds ? ds.tree : text(git("rev-parse", "HEAD^{tree}")).trim();
|
|
1128
1320
|
const common = text(git("rev-parse", "--git-common-dir")).trim();
|
|
1129
1321
|
if (!/^[0-9a-f]{40}$/.test(commit) || !tree || !common) { o.err(`${tag} cannot read HEAD and the git directory`); return 2; }
|
|
1130
1322
|
const commonDir = path.resolve(root, common);
|
|
1131
|
-
|
|
1132
|
-
|
|
1323
|
+
// The snapshot route reads only the commit; the checkout may hold anything (GATE-CONTRACT §1: no dirty-tree condition).
|
|
1324
|
+
const dirty = ds ? null : git("status", "--porcelain", "--untracked-files=no");
|
|
1325
|
+
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
1326
|
|
|
1134
1327
|
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 });
|
|
1328
|
+
const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile }); applyExecutorEnv(o, executor);
|
|
1136
1329
|
if (executor.kind === "refuse") { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
|
|
1137
1330
|
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",
|
|
1331
|
+
const listed = git("ls-tree", "-r", "--name-only", "-z", commit);
|
|
1139
1332
|
const lockFiles = {};
|
|
1140
1333
|
for (const f of failed(listed) ? [] : text(listed).split("\0").filter((x) => /(^|\/)(Cargo\.lock|pnpm-lock\.yaml)$/.test(x))) {
|
|
1141
|
-
const shown = git("show",
|
|
1334
|
+
const shown = git("show", `${commit}:${f}`); if (!failed(shown)) lockFiles[f] = text(shown);
|
|
1142
1335
|
}
|
|
1143
1336
|
const binding = bindingOf({
|
|
1144
1337
|
tree, lockFiles, gateFile: fileURLToPath(import.meta.url), executor: executor.kind,
|
|
@@ -1147,7 +1340,7 @@ async function runStages(o, args, root, git, io) {
|
|
|
1147
1340
|
// Release writes to R2. The write credential lives only in the trusted publisher: a GitHub Actions job in the protected
|
|
1148
1341
|
// publication environment, which sets RIGHTKIT_PUBLISHER=github-environment. Hosts and agents never hold it, so the door refuses the
|
|
1149
1342
|
// 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")) {
|
|
1343
|
+
if (stages.includes("release") && !mobile && !(o.env.GITHUB_ACTIONS === "true" && o.env.RIGHTKIT_PUBLISHER === "github-environment")) {
|
|
1151
1344
|
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
1345
|
return 1;
|
|
1153
1346
|
}
|
|
@@ -1164,12 +1357,12 @@ async function runStages(o, args, root, git, io) {
|
|
|
1164
1357
|
}
|
|
1165
1358
|
(o.writeReceipt ?? writeReceipt)({ commonDir, receipt: imported });
|
|
1166
1359
|
}
|
|
1167
|
-
const previous = (o.readReceipt ?? readReceipt)({ commonDir, commit });
|
|
1360
|
+
const previous = (o.readReceipt ?? readReceipt)({ commonDir, commit, app: appDir });
|
|
1168
1361
|
// Same host and inputs: every recorded stage counts. Same source on another host: only the stages bound to sealed bytes (package and
|
|
1169
1362
|
// later) carry over; check and compile run again here.
|
|
1170
1363
|
const receipt = previous && sameBinding(previous.binding, binding) ? previous
|
|
1171
1364
|
: 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: {} };
|
|
1365
|
+
: { schema: RECEIPT_SCHEMA, commit, app: appDir, binding, stages: {} };
|
|
1173
1366
|
const receiptOut = option("--receipt-out");
|
|
1174
1367
|
const save = () => {
|
|
1175
1368
|
(o.writeReceipt ?? writeReceipt)({ commonDir, receipt });
|
|
@@ -1177,22 +1370,36 @@ async function runStages(o, args, root, git, io) {
|
|
|
1177
1370
|
};
|
|
1178
1371
|
|
|
1179
1372
|
const cli = fileURLToPath(new URL("../cli/right-release.mjs", import.meta.url));
|
|
1180
|
-
|
|
1181
|
-
|
|
1373
|
+
// On the snapshot route right-release runs INSIDE the snapshot (its sources, config and every command it spawns resolve there);
|
|
1374
|
+
// the door run records the split, so the child can prove which snapshot it may read and which checkout holds its outputs
|
|
1375
|
+
// (source-split.mjs). Anywhere else it runs in the app root of the checkout, which hosted CI's runner holds at the commit.
|
|
1376
|
+
const sourceOf = ds ? sourceSplitOf({ root, ds, appDir }) : null;
|
|
1377
|
+
const rightRelease = (stage, cliArgs, extraEnv = {}) => {
|
|
1378
|
+
const run = startRun({ commonDir, commit, tree, stage, ...(sourceOf ? { source: sourceOf } : {}) });
|
|
1182
1379
|
try {
|
|
1183
|
-
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:
|
|
1380
|
+
o.out(`${tag} [${stage}] right-release ${cliArgs.join(" ")}${sourceOf ? ` (from the snapshot ${ds.path})` : ""}`);
|
|
1381
|
+
const r = (o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" })))(process.execPath, [cli, ...cliArgs], { cwd: sourceOf ? snapshotAppRootOf(ds.path, appDir) : appRoot, env: { ...o.env, ...run.env, ...extraEnv } });
|
|
1185
1382
|
return r.status === 0;
|
|
1186
1383
|
} finally { endRun(run); }
|
|
1187
1384
|
};
|
|
1188
1385
|
|
|
1189
|
-
o.out(`${tag} --to ${to} for ${commit.slice(0, 12)}: ${stages.join(" -> ")}; executor ${executor.kind}`);
|
|
1386
|
+
o.out(`${tag} --to ${to} for ${commit.slice(0, 12)}${appDir === "." ? "" : ` (app ${appDir})`}: ${stages.join(" -> ")}; executor ${executor.kind}`);
|
|
1190
1387
|
// 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
1388
|
// the ones they sealed, so they are not run again.
|
|
1192
|
-
|
|
1389
|
+
// The release configs of the commit (under this app) that declare `sidecars`: the commit's config decides on the snapshot route, never the checkout's.
|
|
1390
|
+
const sidecarConfigsOf = () => {
|
|
1391
|
+
const listedConfigs = git("ls-tree", "-r", "--name-only", "-z", commit);
|
|
1392
|
+
return (failed(listedConfigs) ? [] : text(listedConfigs).split("\0").filter((f) => /(^|\/)right-release\.config\.(json|mjs)$/.test(f)).filter((f) => appDir === "." || f.startsWith(`${appDir}/`)))
|
|
1393
|
+
.filter((f) => { try { return /\bsidecars\b/.test(o.readFile(path.join(ds ? ds.path : root, f))); } catch { return false; } });
|
|
1394
|
+
};
|
|
1395
|
+
const sealedNow = receipt.stages.package?.ok ? (o.sealedFor ?? sealedFor)({ root: appRoot, commit, platform }) : null;
|
|
1193
1396
|
const sealedIntact = Boolean(sealedNow && sealedNow.id === receipt.stages.package.sealed && sealedNow.digest === receipt.stages.package.manifestSha256);
|
|
1397
|
+
// Every deterministic rule and scan must run before compilation, for every door stage.
|
|
1398
|
+
const earlyTier = "release";
|
|
1399
|
+
const tierAtLeast = (have, need) => have === "release" || need === "compile";
|
|
1194
1400
|
for (const stage of stages) {
|
|
1195
|
-
if (
|
|
1401
|
+
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; }
|
|
1402
|
+
if (stage === "compile" && receipt.stages.compile?.ok) { o.out(`${tag} [${stage}] already passed for this exact input (commit, tree, lockfiles, gate, toolchain)`); continue; }
|
|
1196
1403
|
if ((stage === "check" || stage === "compile" || stage === "package") && sealedIntact) {
|
|
1197
1404
|
o.out(`${tag} [${stage}] already passed for the sealed set ${sealedNow.id} (manifest ${sealedNow.digest.slice(0, 12)})`); continue;
|
|
1198
1405
|
}
|
|
@@ -1203,41 +1410,116 @@ async function runStages(o, args, root, git, io) {
|
|
|
1203
1410
|
}
|
|
1204
1411
|
// The publisher only uploads. It never checks out a build environment, signs or runs installed QA, so every earlier stage must
|
|
1205
1412
|
// 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") {
|
|
1413
|
+
if (to === "release" && !mobile && stage !== "release") {
|
|
1207
1414
|
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
1415
|
return 1;
|
|
1209
1416
|
}
|
|
1210
|
-
|
|
1211
|
-
if (stage
|
|
1417
|
+
// The start of every stage: the snapshot is exactly tree + dependencies + the ledger of the stages before it (7.08, decision 2 Q1).
|
|
1418
|
+
if (ds && !(await openSnapshotStage({ o, ds, root, stage, tag }))) { receipt.stages[stage] = { ok: false, at: new Date().toISOString(), snapshot: { path: ds.path, tree: ds.tree, intactAfter: false }, ownership: ds.own.handle }; save(); o.err(`${tag} --to ${to} stopped before ${stage}: the snapshot is not the commit's bytes plus the ledger; nothing ran`); return 2; }
|
|
1419
|
+
let ok = false; const at = new Date().toISOString(); let extra = {}; let directReceipt = null;
|
|
1420
|
+
if (stage === "check") {
|
|
1421
|
+
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;
|
|
1422
|
+
extra = { ruleTier: earlyTier };
|
|
1423
|
+
}
|
|
1212
1424
|
else if (stage === "compile") {
|
|
1213
1425
|
// A Tauri crate with externalBin needs its sidecars in src-tauri/binaries before it compiles (its build script refuses
|
|
1214
1426
|
// otherwise), so every release config that declares `sidecars` is staged first, through right-release under this door run.
|
|
1215
|
-
const
|
|
1216
|
-
const sidecarConfigs = (failed(listedConfigs) ? [] : text(listedConfigs).split("\0").filter((f) => /(^|\/)right-release\.config\.(json|mjs)$/.test(f)))
|
|
1217
|
-
.filter((f) => { try { return /\bsidecars\b/.test(o.readFile(path.join(root, f))); } catch { return false; } });
|
|
1427
|
+
const sidecarConfigs = sidecarConfigsOf();
|
|
1218
1428
|
ok = true;
|
|
1219
|
-
|
|
1220
|
-
|
|
1429
|
+
// On the snapshot route the sidecars are staged from the snapshot's own config and sources through right-release's source split
|
|
1430
|
+
// (the staged files and receipt land beside the sources the compile reads; the caches and build trees stay the checkout's), before
|
|
1431
|
+
// `main --all` compiles. A broker host reaches Cargo here only through the snapshot carrier, which right-release's children do not
|
|
1432
|
+
// have yet (item 7 row 7.24): it is refused with a code, never staged from the checkout.
|
|
1433
|
+
if (ds && sidecarConfigs.length && executor.kind === "broker") { o.err(`${tag} BLOCKED: RK_SNAPSHOT_CARRIER_NOT_WIRED: [compile] ${sidecarConfigs.join(", ")} stage sidecars through right-release, whose Cargo calls reach the RustRight broker only through the snapshot carrier (item 7 row 7.24), which is not wired; nothing compiled`); ok = false; }
|
|
1434
|
+
// right-release runs in appRoot (nested apps), so the repository-relative config is handed over absolute (Astra, second pass).
|
|
1435
|
+
// On the snapshot route sidecars are staged ONCE per door run by the ledger bookkeeping (attribution diff, immutable staged entries).
|
|
1436
|
+
if (ok && ds) ok = await stageSidecarsOnce({ o, ds, root, configs: sidecarConfigs, appDir, cli, commonDir, commit, tree, stage });
|
|
1437
|
+
else for (const config of ok ? sidecarConfigs : []) if (!rightRelease(stage, ["stage-sidecar", "--config", path.join(root, config)])) { ok = false; break; }
|
|
1438
|
+
if (ok) ok = (await (o.runCompile ?? (() => main(ds ? ["--all", "--commit", commit, "--snapshot", ds.path] : ["--all"], { ...io, cwd: root, readStdin: () => "", ...(ds ? { buildOwnership: ds.own, snapshotLedger: ds.ledger?.entries ?? [], onCompileLedger: (entries) => { ds.compileAdmitted = entries; }, onDirectReceipt: (r) => { directReceipt = r; } } : {}) })))()) === 0;
|
|
1439
|
+
if (directReceipt) extra = { ...extra, direct: directReceipt };
|
|
1440
|
+
// The cross-check with `.rightkit/compile-inputs.json`: a declared input that covers ledger entries must carry the digest of those
|
|
1441
|
+
// very bytes in the compile receipt (the broker digests declared inputs in place in the snapshot, BC:39).
|
|
1442
|
+
if (ok && ds && directReceipt?.declaredInputDigests) {
|
|
1443
|
+
const { declaredInputProblems } = await import("../stage-ledger.mjs");
|
|
1444
|
+
const { judgeCompileInputs } = await import("../compile-inputs.mjs");
|
|
1445
|
+
const bad = declaredInputProblems({ snapshot: ds.path, entries: ds.ledger?.entries ?? [], receiptDigests: directReceipt.declaredInputDigests, judge: (dir) => judgeCompileInputs(dir) });
|
|
1446
|
+
if (bad.length) { for (const p of bad.slice(0, 10)) o.err(`${tag} RK_CHECK_ERROR: [compile] ${p}`); ok = false; }
|
|
1447
|
+
}
|
|
1221
1448
|
}
|
|
1222
1449
|
else if (stage === "package") {
|
|
1223
|
-
|
|
1224
|
-
|
|
1450
|
+
// On the snapshot route the sidecars are staged ONCE per door run, before the first stage that builds the Tauri crate: here when
|
|
1451
|
+
// compile was already passed (a separately invoked `--to package` re-stages after the sync, so it never meets a missing externalBin).
|
|
1452
|
+
// right-release then takes its identity from the selected commit and the verified snapshot (release-source-identity.mjs, row 7.04),
|
|
1453
|
+
// never the checkout's HEAD, status or branch; its outputs, state and the sealed directory stay in the checkout's `.right-release`.
|
|
1454
|
+
ok = ds ? await stageSidecarsOnce({ o, ds, root, configs: sidecarConfigsOf(), appDir, cli, commonDir, commit, tree, stage, executor }) : true;
|
|
1455
|
+
// Source QA (right-release qa --stage source) exists for mac and win only; a mobile package is the signed build alone.
|
|
1456
|
+
// Row 7.04c (R-T1, 7.09): the snapshot holds no Cargo target. On a direct host the checkout's output directory is resolved here, in the primary
|
|
1457
|
+
// checkout, and handed to right-release absolute: it binds every manifest's target for this stage, and the sealed manifest must record it back
|
|
1458
|
+
// (the door never trusts the child's value alone). One workspace is pinned as a whole; several are refused, naming them. A broker host's target
|
|
1459
|
+
// is the carrier's (rows 7.20 to 7.24), which this gate does not call yet.
|
|
1460
|
+
let packagePin = { dir: null };
|
|
1461
|
+
if (ok && ds && executor.kind !== "broker") {
|
|
1462
|
+
packagePin = cargoOutputPin({ o, git, root, commit, executor });
|
|
1463
|
+
if (packagePin.refusal) { o.err(`${tag} BLOCKED: RK_SNAPSHOT_OUTPUT_UNPINNED: [package] ${packagePin.refusal}; nothing was built`); ok = false; }
|
|
1464
|
+
else if (packagePin.dir) o.out(`${tag} [package] output directory for the Cargo workspace: ${packagePin.dir} (the checkout's)`);
|
|
1465
|
+
}
|
|
1466
|
+
const packageEnv = packagePin.dir ? { CARGO_TARGET_DIR: packagePin.dir } : {};
|
|
1467
|
+
ok = ok && (mobile || rightRelease(stage, ["qa", "--stage", "source", "--platform", platform], packageEnv)) && rightRelease(stage, ["build", "--platform", platform], packageEnv);
|
|
1468
|
+
const sealed = ok ? (o.sealedFor ?? sealedFor)({ root: appRoot, commit, platform }) : null;
|
|
1225
1469
|
if (ok && !sealed) { o.err(`${tag} [package] the build reported success but sealed nothing for ${commit.slice(0, 8)} on ${platform}`); ok = false; }
|
|
1226
1470
|
if (sealed) extra = { platform, sealed: sealed.id, manifestSha256: sealed.digest };
|
|
1471
|
+
// The cross-check: the Cargo targets the sealed manifest records are the directory this door resolved itself. A different one voids the stage.
|
|
1472
|
+
if (ok && sealed && ds && packagePin.dir) {
|
|
1473
|
+
let recorded = null;
|
|
1474
|
+
try { recorded = JSON.parse(String(o.readFile(sealed.manifest))).boundTargets ?? null; } catch { recorded = null; }
|
|
1475
|
+
const { crossCheckBoundTargets, SPLIT_TARGET_DRIFT_CODE } = await import("../release-artifact-path.mjs");
|
|
1476
|
+
const drift = crossCheckBoundTargets({ recorded, expected: [packagePin.dir] });
|
|
1477
|
+
if (drift.length) {
|
|
1478
|
+
for (const problem of drift.slice(0, 10)) o.err(`${tag} ${SPLIT_TARGET_DRIFT_CODE}: [package] ${problem}`);
|
|
1479
|
+
const closedDrift = await closeSnapshotStage({ o, ds, root, stage, step: stage, tag });
|
|
1480
|
+
receipt.stages[stage] = { ok: false, at, voided: SPLIT_TARGET_DRIFT_CODE, ...extra, snapshot: { path: ds.path, tree: ds.tree, intactAfter: closedDrift.ok }, ownership: ds.own.handle };
|
|
1481
|
+
save();
|
|
1482
|
+
o.err(`${tag} --to ${to} stopped at ${stage}: ${SPLIT_TARGET_DRIFT_CODE}; the sealed set is void and nothing after it ran`);
|
|
1483
|
+
return 2;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1227
1486
|
} else {
|
|
1228
1487
|
const sealed = receipt.stages.package?.ok ? receipt.stages.package : null;
|
|
1229
|
-
const current = sealed ? (o.sealedFor ?? sealedFor)({ root, commit, platform }) : null;
|
|
1488
|
+
const current = sealed ? (o.sealedFor ?? sealedFor)({ root: appRoot, commit, platform }) : null;
|
|
1230
1489
|
if (!sealed || !current || current.id !== sealed.sealed || current.digest !== sealed.manifestSha256) {
|
|
1231
1490
|
o.err(`${tag} BLOCKED: [${stage}] needs the sealed set the package stage produced for this commit, unchanged (sealed manifest digest)`);
|
|
1232
1491
|
ok = false;
|
|
1492
|
+
} else if (stage === "release" && mobile) {
|
|
1493
|
+
// The host uploads the sealed mobile build to the store's test track itself (the credentials live in the host's credential
|
|
1494
|
+
// store); the release tier is owed first, as for desktop qualification.
|
|
1495
|
+
const releaseTier = receipt.stages.check?.ok && receipt.stages.check.ruleTier === "release"
|
|
1496
|
+
? (o.out(`${tag} [release] the release tier already passed at check for this exact input; not run again`), 0)
|
|
1497
|
+
: 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 } : {}) })))();
|
|
1498
|
+
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; }
|
|
1499
|
+
else ok = rightRelease(stage, ["upload", "--platform", platform, "--release", sealed.sealed, "--apply"]);
|
|
1500
|
+
extra = { sealed: sealed.sealed, manifestSha256: sealed.manifestSha256, track: platform === "ios" ? "testflight" : "play-internal", ruleTier: "release" };
|
|
1233
1501
|
} else if (stage === "qualify") {
|
|
1234
|
-
|
|
1235
|
-
|
|
1502
|
+
// Qualification owes the release tier: every rule and scan, the ones held out of compile admission included (app-check.mjs TIERS).
|
|
1503
|
+
const releaseTier = receipt.stages.check?.ok && receipt.stages.check.ruleTier === "release"
|
|
1504
|
+
? (o.out(`${tag} [qualify] the release tier already passed at check for this exact input; not run again`), 0)
|
|
1505
|
+
: 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 } : {}) })))();
|
|
1506
|
+
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; }
|
|
1507
|
+
else ok = rightRelease(stage, ["qa", "--stage", "installed", "--platform", platform, "--release", sealed.sealed]);
|
|
1508
|
+
extra = { sealed: sealed.sealed, manifestSha256: sealed.manifestSha256, ruleTier: "release" };
|
|
1236
1509
|
} else {
|
|
1237
1510
|
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
1511
|
else { ok = rightRelease(stage, ["upload", "--release", sealed.sealed, "--platform", platform, "--tier", tier]); extra = { sealed: sealed.sealed, manifestSha256: sealed.manifestSha256, tier }; }
|
|
1239
1512
|
}
|
|
1240
1513
|
}
|
|
1514
|
+
if (ds) {
|
|
1515
|
+
// The stage covers the commit only if the snapshot is still its tree plus the ledger and the host is still this door's (E6, 7.08):
|
|
1516
|
+
// what the stage's children wrote may remain only as ledger entries, recorded by this gate's own walk and kept in the receipt (never
|
|
1517
|
+
// in the snapshot); a staged entry changed since staging voids the stage.
|
|
1518
|
+
const closed = await closeSnapshotStage({ o, ds, root, stage, step: stage, tag });
|
|
1519
|
+
const { receiptLedger } = await import("../stage-ledger.mjs");
|
|
1520
|
+
extra = { ...extra, snapshot: { path: ds.path, tree: ds.tree, intactAfter: closed.ok, ledger: receiptLedger(closed.entries) }, ownership: ds.own.handle };
|
|
1521
|
+
if (!closed.ok) { receipt.stages[stage] = { ok: false, at, ...extra }; save(); o.err(`${tag} --to ${to} stopped at ${stage}: the result covers no known bytes`); return 2; }
|
|
1522
|
+
}
|
|
1241
1523
|
receipt.stages[stage] = { ok, at, ...extra };
|
|
1242
1524
|
save();
|
|
1243
1525
|
if (!ok) { o.err(`${tag} --to ${to} stopped at ${stage}: nothing after it ran`); return 1; }
|
|
@@ -1265,20 +1547,22 @@ function stageForPublisher(o, args, root, git) {
|
|
|
1265
1547
|
const common = text(git("rev-parse", "--git-common-dir")).trim();
|
|
1266
1548
|
if (!/^[0-9a-f]{40}$/.test(commit) || !common) { o.err(`${tag} cannot read HEAD and the git directory`); return 2; }
|
|
1267
1549
|
const commonDir = path.resolve(root, common);
|
|
1268
|
-
const
|
|
1550
|
+
const { appRoot, appDir } = appRootOf({ root, cwd: o.cwd, exists: o.exists });
|
|
1551
|
+
const receipt = (o.readReceipt ?? readReceipt)({ commonDir, commit, app: appDir });
|
|
1269
1552
|
const pack = receipt?.stages?.package; const qual = receipt?.stages?.qualify;
|
|
1270
|
-
const sealed = pack?.ok ? (o.sealedFor ?? sealedFor)({ root, commit, platform }) : null;
|
|
1553
|
+
const sealed = pack?.ok ? (o.sealedFor ?? sealedFor)({ root: appRoot, commit, platform }) : null;
|
|
1271
1554
|
if (!pack?.ok || !qual?.ok || pack.platform !== platform || qual.manifestSha256 !== pack.manifestSha256 || !sealed || sealed.id !== pack.sealed || sealed.digest !== pack.manifestSha256) {
|
|
1272
1555
|
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
1556
|
return 1;
|
|
1274
1557
|
}
|
|
1275
|
-
|
|
1558
|
+
// One draft per commit and app (receiptKey): two apps of one repository staged at one commit never clobber each other's assets.
|
|
1559
|
+
const releaseTag = `rightkit-staging-${receiptKey(commit, appDir)}`;
|
|
1276
1560
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), "rightkit-stage-"));
|
|
1277
1561
|
try {
|
|
1278
1562
|
const receiptFile = path.join(work, `receipt-${platform}.json`);
|
|
1279
1563
|
fs.writeFileSync(receiptFile, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
1280
1564
|
const archive = path.join(work, `sealed-${platform}.tar.gz`);
|
|
1281
|
-
const tar = o.spawn("tar", ["-czf", archive, "-C",
|
|
1565
|
+
const tar = o.spawn("tar", ["-czf", archive, "-C", appRoot, path.join(".right-release", "sealed", sealed.id, platform)]);
|
|
1282
1566
|
if (failed(tar)) { o.err(`${tag} could not pack ${sealed.id}/${platform}`); return 1; }
|
|
1283
1567
|
const gh = (...a) => o.spawn("gh", a, { cwd: root });
|
|
1284
1568
|
if (failed(gh("release", "view", releaseTag))) {
|
|
@@ -1291,7 +1575,6 @@ function stageForPublisher(o, args, root, git) {
|
|
|
1291
1575
|
if (tier) {
|
|
1292
1576
|
// The trusted publisher is RightKit's publish-app workflow: app repositories are private and run no Actions.
|
|
1293
1577
|
const slug = text(gh("repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner")).trim();
|
|
1294
|
-
const appDir = path.relative(root, o.cwd).replaceAll("\\", "/") || ".";
|
|
1295
1578
|
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
1579
|
if (!slug || failed(run)) { o.err(`${tag} staged, but could not dispatch ${PUBLISHER_REPO} publish-app.yml: ${text(run).trim()}`); return 1; }
|
|
1297
1580
|
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`);
|
|
@@ -1300,34 +1583,428 @@ function stageForPublisher(o, args, root, git) {
|
|
|
1300
1583
|
} finally { fs.rmSync(work, { recursive: true, force: true }); }
|
|
1301
1584
|
}
|
|
1302
1585
|
|
|
1586
|
+
// ---- the stage-output ledger (stage-ledger.mjs; decision 2 Q1, rows 7.05/7.05b/7.07/7.08) ----------------------------------------
|
|
1587
|
+
|
|
1588
|
+
/** What the snapshot holds beyond the commit right now: { entries, problems } by this gate's own walk. */
|
|
1589
|
+
async function walkDoorSnapshot(o, ds, root) {
|
|
1590
|
+
const snap = await import("../snapshot.mjs");
|
|
1591
|
+
const tracked = new Set((o.treeEntries ?? snap.treeEntries)(root, ds.commit).map((e) => e.path));
|
|
1592
|
+
const dependencyRoots = (o.dependencyDirs ?? snap.dependencyDirs)((o.treeEntries ?? snap.treeEntries)(root, ds.commit), o.platform);
|
|
1593
|
+
const { walkSnapshot } = await import("../stage-ledger.mjs");
|
|
1594
|
+
return (o.walkSnapshot ?? walkSnapshot)({ dir: ds.path, tracked, dependencyRoots, env: o.env, platform: o.platform });
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/** `verifySnapshot` with the ledger (item 5 row 5.09d: `ledger` admits exactly these entries, `admitNew` only at a stage's end). */
|
|
1598
|
+
async function verifyDoorSnapshot(o, ds, root, { ledger, admitNew = false }) {
|
|
1599
|
+
const verify = o.verifySnapshot ?? (await import("../snapshot.mjs")).verifySnapshot;
|
|
1600
|
+
return verify({ checkout: root, rev: ds.commit, root: ds.root, dir: ds.path, ledger, admitNew });
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
/**
|
|
1604
|
+
* The start of a stage: the snapshot is exactly tree + dependencies + the ledger of the stages before it, no entry new, changed or
|
|
1605
|
+
* missing, and the host's ownership is still held (7.08, decision 2 Q1). An ignored file planted before the stage is a new entry here.
|
|
1606
|
+
*/
|
|
1607
|
+
export async function openSnapshotStage({ o, ds, root, stage, tag = "rightkit-gate:" }) {
|
|
1608
|
+
if (!ds.own.held()) { o.err(`${tag} RK_CHECK_ERROR: [${stage}] the build ownership ${ds.own.handle} is no longer held`); return false; }
|
|
1609
|
+
const { diffEntries, sameEntries } = await import("../stage-ledger.mjs");
|
|
1610
|
+
let walked;
|
|
1611
|
+
try { walked = await walkDoorSnapshot(o, ds, root); } catch (e) { o.err(`${tag} RK_CHECK_ERROR: [${stage}] ${e.message}`); return false; }
|
|
1612
|
+
const prior = ds.ledger?.entries ?? [];
|
|
1613
|
+
if (walked.problems.length) { for (const p of walked.problems.slice(0, 10)) o.err(`${tag} RK_CHECK_ERROR: [${stage}] before the stage: ${p}`); return false; }
|
|
1614
|
+
if (!sameEntries(prior, walked.entries)) {
|
|
1615
|
+
const d = diffEntries(prior, walked.entries);
|
|
1616
|
+
o.err(`${tag} RK_CHECK_ERROR: [${stage}] before the stage the snapshot differs from the ledger of the stages before it (new: ${d.added.join(", ") || "none"}; changed: ${d.changed.join(", ") || "none"}; missing: ${d.removed.join(", ") || "none"})`);
|
|
1617
|
+
return false;
|
|
1618
|
+
}
|
|
1619
|
+
try { await verifyDoorSnapshot(o, ds, root, { ledger: prior }); } catch (e) { o.err(`${tag} RK_CHECK_ERROR: [${stage}] before the stage: ${e.message}`); return false; }
|
|
1620
|
+
return true;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* The end of a stage: what its children wrote may remain only as ledger entries. A staged entry is immutable for the rest of the run
|
|
1625
|
+
* (the bytes the compile read are the bytes sealed); a generated entry may be rewritten or removed and is re-recorded here. Returns
|
|
1626
|
+
* `{ ok, entries }` and replaces the door's ledger with the entries just recorded.
|
|
1627
|
+
*/
|
|
1628
|
+
export async function closeSnapshotStage({ o, ds, root, stage, step = "stage", tag = "rightkit-gate:" }) {
|
|
1629
|
+
if (!ds.own.held()) { o.err(`${tag} RK_CHECK_ERROR: [${stage}] the build ownership ${ds.own.handle} is no longer held`); return { ok: false, entries: ds.ledger?.entries ?? [] }; }
|
|
1630
|
+
const { entryKey } = await import("../stage-ledger.mjs");
|
|
1631
|
+
let walked;
|
|
1632
|
+
try { walked = await walkDoorSnapshot(o, ds, root); } catch (e) { o.err(`${tag} RK_CHECK_ERROR: [${stage}] ${e.message}`); return { ok: false, entries: ds.ledger?.entries ?? [] }; }
|
|
1633
|
+
const prior = new Map((ds.ledger?.entries ?? []).map((e) => [e.path, e]));
|
|
1634
|
+
const now = new Map(walked.entries.map((e) => [e.path, e]));
|
|
1635
|
+
const problems = [...walked.problems];
|
|
1636
|
+
for (const [p, e] of prior) if (e.kind === "staged" && (!now.has(p) || entryKey(now.get(p)) !== entryKey(e))) problems.push(`${p} is a staged sidecar output and ${now.has(p) ? "changed" : "was removed"} after it was staged (a staged entry is immutable for the door run)`);
|
|
1637
|
+
if (problems.length) { for (const p of problems.slice(0, 10)) o.err(`${tag} RK_CHECK_ERROR: [${stage}] ${p}`); return { ok: false, entries: ds.ledger?.entries ?? [] }; }
|
|
1638
|
+
// The compile's own verify (snapshot-check.mjs, 5.09d) returned the entries it admitted: they must be the entries this walk found.
|
|
1639
|
+
if (Array.isArray(ds.compileAdmitted) && stage === "compile") {
|
|
1640
|
+
const { admittedMismatch } = await import("../stage-ledger.mjs");
|
|
1641
|
+
const bad = admittedMismatch(ds.compileAdmitted, walked.entries);
|
|
1642
|
+
if (bad) { o.err(`${tag} RK_CHECK_ERROR: [${stage}] the compile's snapshot verify admitted other ledger entries than the door's own walk recorded: ${bad}`); return { ok: false, entries: ds.ledger?.entries ?? [] }; }
|
|
1643
|
+
}
|
|
1644
|
+
ds.compileAdmitted = null;
|
|
1645
|
+
const entries = walked.entries.map((e) => {
|
|
1646
|
+
const was = prior.get(e.path);
|
|
1647
|
+
if (was?.kind === "staged") return { ...e, kind: "staged", producer: was.producer };
|
|
1648
|
+
return { ...e, kind: "generated", producer: was && entryKey(was) === entryKey(e) ? was.producer : { stage, step } };
|
|
1649
|
+
});
|
|
1650
|
+
try {
|
|
1651
|
+
const verified = await verifyDoorSnapshot(o, ds, root, { ledger: entries, admitNew: true });
|
|
1652
|
+
// The verify side (item 5 row 5.09d) returns the entries it admitted; if it does, they must be the ones this walk recorded.
|
|
1653
|
+
if (Array.isArray(verified?.ledger)) {
|
|
1654
|
+
const { admittedMismatch } = await import("../stage-ledger.mjs");
|
|
1655
|
+
const bad = admittedMismatch(verified.ledger, entries);
|
|
1656
|
+
if (bad) throw new Error(`the snapshot verify admitted other ledger entries than the door's own walk recorded: ${bad}`);
|
|
1657
|
+
}
|
|
1658
|
+
} catch (e) { o.err(`${tag} RK_CHECK_ERROR: [${stage}] ${e.message}`); return { ok: false, entries: ds.ledger?.entries ?? [] }; }
|
|
1659
|
+
ds.ledger = { entries };
|
|
1660
|
+
return { ok: true, entries };
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
/**
|
|
1664
|
+
* Sidecars are staged ONCE per door run, after the sync and before the first stage that builds the Tauri crate. The step is
|
|
1665
|
+
* right-release's own `stage-sidecar --json` run inside the snapshot (the split); this gate then walks the snapshot itself and the
|
|
1666
|
+
* step's additions must equal the files it reported plus its receipt (anything else is an unattributed write, RK_CHECK_ERROR). The
|
|
1667
|
+
* staged files become immutable `staged` ledger entries. Returns true when nothing is owed or staging passed.
|
|
1668
|
+
*/
|
|
1669
|
+
export async function stageSidecarsOnce({ o, ds, root, configs, appDir, cli, commonDir, commit, tree, stage, executor = null, tag = "rightkit-gate:" }) {
|
|
1670
|
+
if (ds.sidecarsStaged) return true;
|
|
1671
|
+
ds.sidecarsStaged = true;
|
|
1672
|
+
if (!configs.length) return true;
|
|
1673
|
+
if (executor?.kind === "broker") { o.err(`${tag} BLOCKED: RK_SNAPSHOT_CARRIER_NOT_WIRED: [${stage}] ${configs.join(", ")} stage sidecars through right-release, whose Cargo calls reach the RustRight broker only through the snapshot carrier (item 7 row 7.24); nothing was staged`); return false; }
|
|
1674
|
+
const { attributionProblems, reportedPaths } = await import("../stage-ledger.mjs");
|
|
1675
|
+
const source = sourceSplitOf({ root, ds, appDir });
|
|
1676
|
+
const { TAURI_CONFIG: inherited, ...cleanEnv } = o.env; void inherited; // never set or pass one (refused at the door's start)
|
|
1677
|
+
for (const config of configs) {
|
|
1678
|
+
const before = ds.ledger?.entries ?? [];
|
|
1679
|
+
const run = startRun({ commonDir, commit, tree, stage, source });
|
|
1680
|
+
let r;
|
|
1681
|
+
try {
|
|
1682
|
+
o.out(`${tag} [${stage}] right-release stage-sidecar --config ${path.join(ds.path, config)} (from the snapshot ${ds.path})`);
|
|
1683
|
+
r = (o.spawnCapture ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, encoding: "utf8", maxBuffer: 1 << 26 })))(process.execPath, [cli, "stage-sidecar", "--config", path.join(ds.path, config), "--json"], { cwd: snapshotAppRootOf(ds.path, appDir), env: { ...cleanEnv, ...run.env } });
|
|
1684
|
+
} finally { endRun(run); }
|
|
1685
|
+
if (r?.stderr) o.err(String(r.stderr).trimEnd());
|
|
1686
|
+
if (!r || r.status !== 0) { o.err(`${tag} [${stage}] stage-sidecar for ${config} failed${r?.error ? ` (${r.error.message})` : ""}`); return false; }
|
|
1687
|
+
let result;
|
|
1688
|
+
try { result = JSON.parse(String(r.stdout ?? "").trim()); } catch { o.err(`${tag} RK_CHECK_ERROR: [${stage}] stage-sidecar --json for ${config} did not print its result; its writes cannot be attributed`); return false; }
|
|
1689
|
+
let walked;
|
|
1690
|
+
try { walked = await walkDoorSnapshot(o, ds, root); } catch (e) { o.err(`${tag} RK_CHECK_ERROR: [${stage}] ${e.message}`); return false; }
|
|
1691
|
+
const underApp = (rel) => (appDir === "." ? rel : `${appDir}/${rel}`).replaceAll("\\", "/");
|
|
1692
|
+
const receiptAbs = typeof result.receiptPath === "string" ? path.relative(ds.path, result.receiptPath).split(path.sep).join("/") : null;
|
|
1693
|
+
const problems = [...walked.problems, ...attributionProblems({ before, after: walked.entries, reported: [...reportedPaths(result)].map(underApp), receipt: receiptAbs })];
|
|
1694
|
+
if (problems.length) { for (const p of problems.slice(0, 10)) o.err(`${tag} RK_CHECK_ERROR: [${stage}] stage-sidecar ${config}: ${p}`); return false; }
|
|
1695
|
+
const priorPaths = new Set(before.map((e) => e.path));
|
|
1696
|
+
const priorByPath = new Map(before.map((e) => [e.path, e]));
|
|
1697
|
+
ds.ledger = { entries: walked.entries.map((e) => (priorPaths.has(e.path) && priorByPath.get(e.path).sha256 === e.sha256 ? priorByPath.get(e.path) : { ...e, kind: "staged", producer: { stage: "sidecar", step: config } })) };
|
|
1698
|
+
}
|
|
1699
|
+
return true;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* The Cargo binding of a unit stage built from a snapshot (row 7.06a; decision 2 Q3, as for any npm unit): a unit's test or pack that
|
|
1704
|
+
* reaches Cargo must never write `target/` into the snapshot. Every tracked Cargo.toml of the commit is asked, in the CHECKOUT, for its
|
|
1705
|
+
* workspace and `target_directory`. Linux and any POSIX host with a direct executor put the runtime wrapper (cargo-target-shim.mjs)
|
|
1706
|
+
* first on PATH and name it in CARGO, so each cargo call binds its own workspace's checkout target or is refused. A broker host
|
|
1707
|
+
* (`o.snapshotBroker`) uses the same wrapper in broker mode, one carried request per call with its own receipt. A direct Windows host has
|
|
1708
|
+
* no wrapper: one workspace is pinned by CARGO_TARGET_DIR, several are refused. Returns `{ env, note, cleanup }` or `{ refusal }`.
|
|
1709
|
+
*/
|
|
1710
|
+
async function bindUnitCargo({ o, git, root, ds, executor, which }) {
|
|
1711
|
+
const listed = git("ls-tree", "-r", "--name-only", "-z", ds.commit);
|
|
1712
|
+
if (failed(listed)) return { refusal: `cannot list the files of ${ds.commit.slice(0, 12)}: ${why(listed)}` };
|
|
1713
|
+
const manifests = text(listed).split("\0").filter((f) => f === "Cargo.toml" || f.endsWith("/Cargo.toml"));
|
|
1714
|
+
if (!manifests.length) return { env: {}, note: null, cleanup() {} };
|
|
1715
|
+
let checkoutReal = root; try { checkoutReal = fs.realpathSync.native(root); } catch { /* as given */ }
|
|
1716
|
+
const cargo = executor.cargo ?? ["cargo"];
|
|
1717
|
+
const targetDirs = {};
|
|
1718
|
+
for (const m of manifests) {
|
|
1719
|
+
const file = path.join(root, ...m.split("/"));
|
|
1720
|
+
if (!o.exists(file)) return { refusal: `the checkout has no ${m}, so its Cargo output directory cannot be resolved` };
|
|
1721
|
+
const r = o.spawn(cargo[0], [...cargo.slice(1), "metadata", "--offline", "--format-version", "1", "--no-deps", "--manifest-path", file], { cwd: path.dirname(file), env: o.env });
|
|
1722
|
+
let doc = null;
|
|
1723
|
+
try { doc = failed(r) ? null : JSON.parse(text(r)); } catch { doc = null; }
|
|
1724
|
+
if (!doc || typeof doc.target_directory !== "string" || !path.isAbsolute(doc.target_directory) || typeof doc.workspace_root !== "string") return { refusal: `cargo metadata in the checkout reported no absolute target_directory for ${m}${failed(r) ? ` (${why(r)})` : ""}` };
|
|
1725
|
+
const cargoDir = posix(path.relative(checkoutReal, (() => { try { return fs.realpathSync.native(doc.workspace_root); } catch { return doc.workspace_root; } })())) || ".";
|
|
1726
|
+
targetDirs[cargoDir] = path.resolve(doc.target_directory);
|
|
1727
|
+
}
|
|
1728
|
+
const workspaces = Object.keys(targetDirs);
|
|
1729
|
+
if (o.platform === "win32" && executor.kind !== "broker") {
|
|
1730
|
+
if (workspaces.length > 1) return { refusal: `the commit holds ${workspaces.length} Cargo workspaces (${workspaces.join(", ")}) and a direct Windows host has no per-call wrapper to bind each to its own target directory` };
|
|
1731
|
+
return { env: { CARGO_TARGET_DIR: Object.values(targetDirs)[0] }, note: `output directory for the Cargo workspace: ${Object.values(targetDirs)[0]} (the checkout's)`, cleanup() {} };
|
|
1732
|
+
}
|
|
1733
|
+
const shimModule = await import("../cargo-target-shim.mjs");
|
|
1734
|
+
let broker = null; let realCargo = null;
|
|
1735
|
+
if (executor.kind === "broker") {
|
|
1736
|
+
if (!o.snapshotBroker) return { refusal: "a broker host reaches Cargo from a unit stage only through the snapshot carrier (deps.snapshotBroker), which is not wired" };
|
|
1737
|
+
const sb = o.snapshotBroker;
|
|
1738
|
+
const marked = sb.markBrokerSubmit?.();
|
|
1739
|
+
if (marked?.broker?.submitted !== true || marked.handle !== sb.handle) return { refusal: `the build ownership did not record the broker submit${sb.handle ? ` under ${sb.handle}` : ""}` };
|
|
1740
|
+
const { brokerSection } = await import("../snapshot-broker.mjs");
|
|
1741
|
+
broker = { checkoutDirs: Object.fromEntries(workspaces.map((w) => [w, path.join(checkoutReal, ...(w === "." ? [] : w.split("/")))])), section: (o.brokerSection ?? brokerSection)(sb) };
|
|
1742
|
+
} else {
|
|
1743
|
+
realCargo = which("cargo").find((c) => path.isAbsolute(c));
|
|
1744
|
+
if (!realCargo) return { refusal: "no cargo on PATH to wrap for the unit stages" };
|
|
1745
|
+
}
|
|
1746
|
+
let installed; let dir = null;
|
|
1747
|
+
try { installed = (o.installCargoShim ?? shimModule.installCargoShim)({ base: os.tmpdir(), snapshotRoot: ds.path, targetDirs, broker, realCargo, platform: o.platform, onDir: (d) => { dir = d; } }); }
|
|
1748
|
+
catch (error) { if (dir) fs.rmSync(dir, { recursive: true, force: true }); return { refusal: `the runtime cargo wrapper cannot be installed (${error.message})` }; }
|
|
1749
|
+
const scan = shimModule.shimScanEnv(o.env, installed);
|
|
1750
|
+
const delta = Object.fromEntries(Object.entries(scan).filter(([k, v]) => o.env[k] !== v));
|
|
1751
|
+
return {
|
|
1752
|
+
env: delta, note: `runtime cargo wrapper ${installed.shim} (${installed.mode}): each cargo call a unit stage reaches binds its workspace's checkout target directory or is refused`,
|
|
1753
|
+
// Every call the wrapper decided is in the gate-owned ledger: a refused or unverified call fails the stage even when the test ignored cargo's exit (7.10, decision C).
|
|
1754
|
+
check: async () => { const { readLedger, UNSCOPED } = await import("../cargo-ledger.mjs"); return readLedger(installed.ledgerDir, UNSCOPED).problems ?? []; },
|
|
1755
|
+
cleanup: () => { fs.rmSync(installed.dir ?? dir, { recursive: true, force: true }); },
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1303
1759
|
/**
|
|
1304
|
-
*
|
|
1305
|
-
*
|
|
1306
|
-
*
|
|
1307
|
-
*
|
|
1760
|
+
* `--to <stage> --unit <selector> [--commit <rev>]` on the snapshot route (row 7.06a): select a commit (HEAD by default), take the
|
|
1761
|
+
* host's build ownership, sync its snapshot, run class A of the snapshot (`check --commit <sha> --full-a --snapshot <dir>`), and build
|
|
1762
|
+
* the unit from the snapshot (`--workspace <snapshot>`). Commit and tree come from the selection; no HEAD or status is read. The unit
|
|
1763
|
+
* manifest and the local-build grant are read from the selected commit, so one present only in the checkout is refused. Each stage is
|
|
1764
|
+
* bracketed by the ledger verify (7.08); Cargo's output is pinned to the checkout's target directory (7.09); `--to release` keeps its
|
|
1765
|
+
* publication path and takes only commit and tree from the selection.
|
|
1308
1766
|
*/
|
|
1309
|
-
async function
|
|
1767
|
+
async function runUnitSnapshot(o, args, root, git, io, option) {
|
|
1310
1768
|
const tag = "rightkit-gate:";
|
|
1311
|
-
const
|
|
1769
|
+
const selector = option("--unit");
|
|
1770
|
+
if (o.env.TAURI_CONFIG !== undefined) { o.err(`${tag} BLOCKED: RK_TAURI_CONFIG_OVERRIDE: TAURI_CONFIG is set in the environment; a commit-only build refuses it; nothing ran`); return 1; }
|
|
1771
|
+
const match = /^(?:npm:@rightkit\/([a-z0-9-]+)|cargo:(rightkit-[a-z0-9-]+))$/.exec(selector);
|
|
1772
|
+
const manifest = match && (match[1] ? `packages/${match[1]}/package.json` : `crates/${match[2]}/Cargo.toml`);
|
|
1773
|
+
if (!manifest) { o.err(`${tag} BLOCKED: unit needs npm:@rightkit/<name> or cargo:rightkit-<name>`); return 1; }
|
|
1774
|
+
const snap = await import("../snapshot.mjs");
|
|
1775
|
+
const { acquireBuildOwnership, ownershipFile } = await import("../build-ownership.mjs");
|
|
1776
|
+
let selected;
|
|
1777
|
+
try { selected = snap.resolveCommit(root, option("--commit") ?? "HEAD"); }
|
|
1778
|
+
catch (e) { o.err(`${tag} cannot resolve ${option("--commit") ?? "HEAD"} to a commit: ${e.message}`); return 2; }
|
|
1779
|
+
const commit = selected.commit;
|
|
1780
|
+
// The canonical manifest and the local-build grant must be tracked in the SELECTED COMMIT (read with the object database, never the checkout).
|
|
1781
|
+
for (const file of [".rightkit-local-build.json", manifest]) if (failed(git("cat-file", "-e", `${commit}:${file}`))) { o.err(`${tag} BLOCKED: unit and local grant must be tracked canonical files of ${commit.slice(0, 12)} (${file} is not)`); return 1; }
|
|
1782
|
+
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); };
|
|
1783
|
+
const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile }); applyExecutorEnv(o, executor);
|
|
1784
|
+
if (executor.kind === "refuse") { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
|
|
1785
|
+
if (executor.kind === "broker" && !o.snapshotBroker) { o.err(`${tag} BLOCKED: RK_SNAPSHOT_CARRIER_NOT_WIRED: a broker host builds a unit snapshot only through the snapshot carrier; nothing ran`); return 1; }
|
|
1786
|
+
const common = git("rev-parse", "--git-common-dir");
|
|
1787
|
+
if (failed(common) || !text(common).trim()) { o.err(`${tag} cannot read the git directory`); return 2; }
|
|
1788
|
+
const snapshotsAt = o.snapshotRoot ?? snap.snapshotRoot({ platform: o.platform, env: o.env });
|
|
1789
|
+
let own;
|
|
1790
|
+
try { own = (o.acquireOwnership ?? acquireBuildOwnership)({ file: ownershipFile({ platform: o.platform, beside: o.snapshotRoot }), stage: `door:unit:${option("--to")}`, snapshot: path.join(snapshotsAt, snap.repositoryOf(root).key), commit, platform: o.platform, env: o.env }); }
|
|
1791
|
+
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; }
|
|
1792
|
+
o.out(`${tag} build ownership ${own.joined ? "joined" : "held"} ${own.handle}`);
|
|
1793
|
+
try {
|
|
1794
|
+
let synced;
|
|
1795
|
+
try { synced = (o.syncSnapshot ?? snap.syncSnapshot)({ checkout: root, rev: commit, root: snapshotsAt }); }
|
|
1796
|
+
catch (e) { o.err(`${tag} ${e.code === "error" ? "RK_CHECK_ERROR" : "BLOCKED"}: snapshot ${e.message}; nothing ran`); return e.code === "error" ? 2 : 1; }
|
|
1797
|
+
o.out(`${tag} snapshot ${synced.path} is commit ${synced.commit} tree ${synced.tree} (written=${synced.written} removed=${synced.removed} unchanged=${synced.unchanged})`);
|
|
1798
|
+
const ds = { own, path: synced.path, commit: synced.commit, tree: synced.tree, root: snapshotsAt, ledger: { entries: [] } };
|
|
1799
|
+
// A unit's test or pack that reaches Cargo must never write `target/` into the snapshot (npm units included): see bindUnitCargo.
|
|
1800
|
+
const bound = await bindUnitCargo({ o, git, root, ds, executor, which });
|
|
1801
|
+
if (bound.refusal) { o.err(`${tag} BLOCKED: RK_SNAPSHOT_OUTPUT_UNPINNED: ${bound.refusal}; nothing ran`); return 1; }
|
|
1802
|
+
if (bound.note) o.out(`${tag} ${bound.note}`);
|
|
1803
|
+
const envExtra = bound.env;
|
|
1804
|
+
try {
|
|
1805
|
+
let checkout = root; try { checkout = fs.realpathSync.native(root); } catch { /* as given */ }
|
|
1806
|
+
const source = { checkout, commit: ds.commit, tree: ds.tree };
|
|
1807
|
+
const reportOut = option("--receipt-out");
|
|
1808
|
+
const { runUnitGate, resolveUnit } = await import("../unit-gate.mjs");
|
|
1809
|
+
const { localBuildGrantedAt } = await import("../local-publish.mjs");
|
|
1810
|
+
return await runUnitGate({ selector, to: option("--to"), root: ds.path, commonDir: path.resolve(root, text(common).trim()), commit: ds.commit, tree: ds.tree, executor, env: o.env, platform: o.platform, source, envExtra,
|
|
1811
|
+
resolve: (sel, r) => resolveUnit(sel, r, { granted: () => localBuildGrantedAt(source) }),
|
|
1812
|
+
...(o.loadUnitOwner ? { loadOwner: o.loadUnitOwner } : {}),
|
|
1813
|
+
stageGuard: { start: (stage) => openSnapshotStage({ o, ds, root, stage: `unit:${stage}`, tag }), end: async (stage) => {
|
|
1814
|
+
if (!(await closeSnapshotStage({ o, ds, root, stage: `unit:${stage}`, step: `unit:${stage}`, tag })).ok) return false;
|
|
1815
|
+
const calls = bound.check ? await bound.check() : [];
|
|
1816
|
+
for (const p of calls.slice(0, 10)) o.err(`${tag} RK_CHECK_ERROR: [unit:${stage}] a cargo call the unit stage made did not complete: ${typeof p === "string" ? p : JSON.stringify(p)}`);
|
|
1817
|
+
return calls.length === 0;
|
|
1818
|
+
} },
|
|
1819
|
+
check: (tier) => (o.runCheck ?? (() => main(["check", "--commit", ds.commit, "--full-a", "--snapshot", ds.path, "--rule-tier", tier], { ...io, cwd: root, buildOwnership: own })))(tier), out: o.out, err: o.err,
|
|
1820
|
+
...(reportOut ? { writeReport: (report) => o.writeFile(path.resolve(o.cwd, reportOut), `${JSON.stringify(report, null, 2)}\n`) } : {}),
|
|
1821
|
+
});
|
|
1822
|
+
} finally { bound.cleanup(); }
|
|
1823
|
+
} finally {
|
|
1824
|
+
const r = own.release();
|
|
1825
|
+
if (r?.released === false && !own.joined) o.err(`${tag} build ownership ${own.handle} kept: ${r.why}`);
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/**
|
|
1830
|
+
* Whether the door takes the snapshot route on this host. Linux always does (direct, no broker); a broker host (Mac, Dell) does only
|
|
1831
|
+
* when the snapshot carrier is wired (`deps.snapshotBroker`). Until the cutover commit wires it in production, that is tests only;
|
|
1832
|
+
* every other Mac or Dell run keeps the checkout route (decision 2 "Interim"). No flag or environment variable selects it.
|
|
1833
|
+
*/
|
|
1834
|
+
export function snapshotRouteSelected(o) {
|
|
1835
|
+
return directPlatform(o.platform) || Boolean(o.snapshotBroker);
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
/**
|
|
1839
|
+
* rightkit-gate --to dev|test [-- <right-release dev|test arguments>] on the CHECKOUT route (Mac and Dell until the cutover commit):
|
|
1840
|
+
* class A over the working tree first (nothing compiles while it is red), then `right-release dev|test` runs under a live door run,
|
|
1841
|
+
* 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
|
|
1842
|
+
* checks again), plain cargo on an unmanaged host, never around a managed host's broker. The cutover commit deletes this function.
|
|
1843
|
+
*/
|
|
1844
|
+
async function runWorktreeCompile(o, args, root, git, io, to, appRoot = root) {
|
|
1845
|
+
const tag = "rightkit-gate:";
|
|
1846
|
+
const separator = args.indexOf("--");
|
|
1847
|
+
const passthrough = separator >= 0 ? args.slice(separator + 1) : [];
|
|
1848
|
+
// `--to dev --platform mac` (the documented form) hands the platform to right-release as `--platform mac`; a platform given after
|
|
1849
|
+
// `--` wins (Pulse, 2026-10-27: the flag before `--` was dropped and right-release refused "requires --platform").
|
|
1850
|
+
const gateArgs = separator >= 0 ? args.slice(0, separator) : args;
|
|
1851
|
+
const platformAt = gateArgs.indexOf("--platform");
|
|
1852
|
+
if (platformAt >= 0 && gateArgs[platformAt + 1] && !passthrough.includes("--platform")) passthrough.unshift("--platform", gateArgs[platformAt + 1]);
|
|
1312
1853
|
const commit = text(git("rev-parse", "HEAD")).trim();
|
|
1313
1854
|
const tree = text(git("rev-parse", "HEAD^{tree}")).trim();
|
|
1314
1855
|
const common = text(git("rev-parse", "--git-common-dir")).trim();
|
|
1315
1856
|
if (!/^[0-9a-f]{40}$/.test(commit) || !common) { o.err(`${tag} cannot read HEAD and the git directory`); return 2; }
|
|
1316
1857
|
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 });
|
|
1858
|
+
const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile }); applyExecutorEnv(o, executor);
|
|
1318
1859
|
if (executor.kind === "refuse") { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
|
|
1319
1860
|
o.out(`${tag} --to ${to} on the working tree of ${commit.slice(0, 12)}: check (class A) -> right-release ${to}; executor ${executor.kind}`);
|
|
1320
|
-
|
|
1861
|
+
// Full class A on the working tree, the checks that execute repository code included, before right-release compiles anything.
|
|
1862
|
+
const checked = await (o.runCheck ?? (() => main(["check", "--worktree", "--full-a"], { ...io, cwd: root })))();
|
|
1321
1863
|
if (checked !== 0) { o.err(`${tag} --to ${to} stopped at check: nothing compiled`); return 1; }
|
|
1322
1864
|
const commonDir = path.resolve(root, common);
|
|
1323
1865
|
const run = startRun({ commonDir, commit, tree, stage: to });
|
|
1324
1866
|
try {
|
|
1325
1867
|
const cli = fileURLToPath(new URL("../cli/right-release.mjs", import.meta.url));
|
|
1326
|
-
|
|
1868
|
+
// right-release reads its config from its cwd: the resolved app root (appRootOf), not the directory the command was typed in
|
|
1869
|
+
// (apps/foo/src is apps/foo; Astra, third pass).
|
|
1870
|
+
// The door's own RightKit runs every sub-step: a dev target's `right-release web-build|stage-sidecar` steps resolve `right-release`
|
|
1871
|
+
// 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
|
|
1872
|
+
// door's 0.6.9 admitted). An installed gate (node_modules/@rightkit/release/bin) has its package's bin shims two levels up.
|
|
1873
|
+
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
1874
|
return r.status === 0 ? 0 : 1;
|
|
1328
1875
|
} finally { endRun(run); }
|
|
1329
1876
|
}
|
|
1330
1877
|
|
|
1878
|
+
/** The app's root inside the snapshot: `<snapshot>/<appDir>`, the snapshot itself for an app at the repository root. */
|
|
1879
|
+
export function snapshotAppRootOf(snapshotDir, appDir = ".") {
|
|
1880
|
+
return appDir === "." ? snapshotDir : path.join(snapshotDir, ...appDir.split("/"));
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/** What the door run records for a right-release child (source-split.mjs): the checkout it ran in and the snapshot it may read. */
|
|
1884
|
+
function sourceSplitOf({ root, ds, appDir }) {
|
|
1885
|
+
let checkout = root;
|
|
1886
|
+
try { checkout = fs.realpathSync.native(root); } catch { /* the path as given */ }
|
|
1887
|
+
return { checkout, snapshot: ds.path, commit: ds.commit, tree: ds.tree, appDir };
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
/**
|
|
1891
|
+
* Why the snapshot route cannot start on this host, or null. The route is the executor's: `direct` hosts (Linux always, any host
|
|
1892
|
+
* with no broker) take it as they are. A broker host compiles the snapshot only through the RustRight snapshot carrier, which the
|
|
1893
|
+
* gate reaches through `deps.snapshotBroker` (item 7 rows 7.20 to 7.24, wired in production by the carrier lane); without it the
|
|
1894
|
+
* host is refused (RK_SNAPSHOT_CARRIER_NOT_WIRED), and a broker that cannot be reached is refused too (RK_SNAPSHOT_BROKER_UNAVAILABLE).
|
|
1895
|
+
* Neither ever falls back to compiling the checkout.
|
|
1896
|
+
*/
|
|
1897
|
+
export function snapshotRouteRefusal(o, { rightRelease = false } = {}) {
|
|
1898
|
+
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); };
|
|
1899
|
+
const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile });
|
|
1900
|
+
if (executor.kind === "refuse") return { code: "RK_SNAPSHOT_BROKER_UNAVAILABLE", message: executor.problem };
|
|
1901
|
+
// right-release's own children (dev, test) reach Cargo with no carrier of their own on a broker host (row 7.24): refused even when the gate has one.
|
|
1902
|
+
if (executor.kind === "broker" && rightRelease) return { code: "RK_SNAPSHOT_CARRIER_NOT_WIRED", message: `right-release dev and test reach Cargo on a ${o.platform} broker host only through the snapshot carrier's per-call wrapper (item 7 row 7.24), which is not wired` };
|
|
1903
|
+
if (executor.kind === "broker" && !o.snapshotBroker) return { code: "RK_SNAPSHOT_CARRIER_NOT_WIRED", message: `a ${o.platform} host compiles a commit snapshot only through the RustRight snapshot carrier (item 7 rows 7.20 to 7.24), which this gate does not carry yet` };
|
|
1904
|
+
return null;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
/**
|
|
1908
|
+
* The checkout's Cargo output directory for the snapshot of `commit`: `{ dir }` (null when the commit has no Cargo manifest) or
|
|
1909
|
+
* `{ refusal }`. Each tracked Cargo.toml of the commit is asked, in the CHECKOUT with the stage's environment, for its
|
|
1910
|
+
* `target_directory` (which honours CARGO_TARGET_DIR and `.cargo/config.toml` build.target-dir) and its workspace root; one workspace
|
|
1911
|
+
* yields its directory, several are refused rather than pointed at one shared directory (GATE-CONTRACT: mixed workspaces never share a target).
|
|
1912
|
+
*/
|
|
1913
|
+
function cargoOutputPin({ o, git, root, commit, executor }) {
|
|
1914
|
+
const listed = git("ls-tree", "-r", "--name-only", "-z", commit);
|
|
1915
|
+
if (failed(listed)) return { refusal: `cannot list the files of ${commit.slice(0, 12)}: ${why(listed)}` };
|
|
1916
|
+
const manifests = text(listed).split("\0").filter((f) => f === "Cargo.toml" || f.endsWith("/Cargo.toml"));
|
|
1917
|
+
const cargo = executor.cargo ?? ["cargo"];
|
|
1918
|
+
const workspaces = new Map();
|
|
1919
|
+
for (const m of manifests) {
|
|
1920
|
+
const file = path.join(root, ...m.split("/"));
|
|
1921
|
+
if (!o.exists(file)) return { refusal: `the checkout has no ${m}, so its Cargo output directory cannot be resolved` };
|
|
1922
|
+
const r = o.spawn(cargo[0], [...cargo.slice(1), "metadata", "--offline", "--format-version", "1", "--no-deps", "--manifest-path", file], { cwd: path.dirname(file), env: o.env });
|
|
1923
|
+
let doc = null;
|
|
1924
|
+
try { doc = failed(r) ? null : JSON.parse(text(r)); } catch { doc = null; }
|
|
1925
|
+
if (!doc || typeof doc.target_directory !== "string" || !path.isAbsolute(doc.target_directory) || typeof doc.workspace_root !== "string") return { refusal: `cargo metadata in the checkout reported no absolute target_directory for ${m}${failed(r) ? ` (${why(r)})` : ""}` };
|
|
1926
|
+
workspaces.set(path.resolve(doc.workspace_root), path.resolve(doc.target_directory));
|
|
1927
|
+
}
|
|
1928
|
+
if (workspaces.size > 1) return { refusal: `the commit holds ${workspaces.size} Cargo workspaces (${[...workspaces.keys()].map((w) => posix(path.relative(root, w)) || ".").join(", ")}); one directory cannot serve them all, and right-release's own Cargo calls carry no per-workspace wrapper` };
|
|
1929
|
+
return { dir: workspaces.size ? [...workspaces.values()][0] : null };
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
/**
|
|
1933
|
+
* rightkit-gate --to dev|test [--commit <rev>] [-- <right-release dev|test arguments>]: the local compiles, on a commit like every other
|
|
1934
|
+
* stage (docs/GATE-CONTRACT.md "Commit-only build": `--to dev` and `--to test` build a commit; `--worktree` never admits a build). The
|
|
1935
|
+
* door has selected the commit and synced its snapshot under ownership (runStages); this runs full class A of that snapshot
|
|
1936
|
+
* (`check --commit <sha> --full-a --snapshot <dir>`, never `--worktree`), then right-release dev|test with the snapshot's app root as
|
|
1937
|
+
* its cwd under a live door run, which carries the source split. Hosted CI, whose runner checkout is the commit, keeps its own
|
|
1938
|
+
* clean checkout. The snapshot is verified before right-release starts and after it ends (E6): a result that covers no known bytes
|
|
1939
|
+
* is refused.
|
|
1940
|
+
*/
|
|
1941
|
+
async function runDevTest(o, args, root, git, io, to, appRoot, appDir) {
|
|
1942
|
+
const tag = "rightkit-gate:";
|
|
1943
|
+
const separator = args.indexOf("--");
|
|
1944
|
+
const passthrough = separator >= 0 ? args.slice(separator + 1) : [];
|
|
1945
|
+
// `--to dev --platform mac` (the documented form) hands the platform to right-release as `--platform mac`; a platform given after
|
|
1946
|
+
// `--` wins (Pulse, 2026-10-27: the flag before `--` was dropped and right-release refused "requires --platform").
|
|
1947
|
+
const gateArgs = separator >= 0 ? args.slice(0, separator) : args;
|
|
1948
|
+
const platformAt = gateArgs.indexOf("--platform");
|
|
1949
|
+
if (platformAt >= 0 && gateArgs[platformAt + 1] && !passthrough.includes("--platform")) passthrough.unshift("--platform", gateArgs[platformAt + 1]);
|
|
1950
|
+
const ds = o.doorSnapshot ?? null;
|
|
1951
|
+
const commit = ds ? ds.commit : text(git("rev-parse", "HEAD")).trim();
|
|
1952
|
+
const tree = ds ? ds.tree : text(git("rev-parse", "HEAD^{tree}")).trim();
|
|
1953
|
+
const common = text(git("rev-parse", "--git-common-dir")).trim();
|
|
1954
|
+
if (!/^[0-9a-f]{40}$/.test(commit) || !common) { o.err(`${tag} cannot read HEAD and the git directory`); return 2; }
|
|
1955
|
+
// Hosted CI's runner checkout is the commit; anywhere else the snapshot is, and the checkout may hold anything.
|
|
1956
|
+
if (!ds) {
|
|
1957
|
+
const dirty = git("status", "--porcelain", "--untracked-files=no");
|
|
1958
|
+
if (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; }
|
|
1959
|
+
}
|
|
1960
|
+
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); };
|
|
1961
|
+
const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile }); applyExecutorEnv(o, executor);
|
|
1962
|
+
if (executor.kind === "refuse") { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
|
|
1963
|
+
if (ds && executor.kind === "broker") { o.err(`${tag} BLOCKED: RK_SNAPSHOT_CARRIER_NOT_WIRED: right-release ${to} reaches Cargo on a broker host only through the snapshot carrier (item 7 row 7.24), which is not wired; nothing ran and the checkout is never built`); return 1; }
|
|
1964
|
+
o.out(`${tag} --to ${to} for ${commit.slice(0, 12)}${appDir === "." ? "" : ` (app ${appDir})`}: check (class A) -> right-release ${to}; executor ${executor.kind}${ds ? `; snapshot ${ds.path}` : ""}`);
|
|
1965
|
+
// Full class A of the commit before right-release compiles anything: of the snapshot, the checks that execute repository code included.
|
|
1966
|
+
const checked = await (o.runCheck ?? (() => main(["check", "--commit", ds ? commit : "HEAD", "--full-a", ...(ds ? ["--snapshot", ds.path] : [])], { ...io, cwd: root, ...(ds ? { buildOwnership: ds.own } : {}) })))();
|
|
1967
|
+
if (checked !== 0) { o.err(`${tag} --to ${to} stopped at check: nothing compiled`); return 1; }
|
|
1968
|
+
// The snapshot is exactly tree + dependencies + the ledger before right-release starts; what it wrote may remain only as ledger
|
|
1969
|
+
// entries (a generated `gen/schemas`, a dist), recorded by this gate's own walk (stage-ledger.mjs, decision 2 Q1).
|
|
1970
|
+
if (ds && !(await openSnapshotStage({ o, ds, root, stage: to, tag }))) { o.err(`${tag} --to ${to} stopped: the snapshot is not the commit's bytes plus the ledger; nothing compiled`); return 2; }
|
|
1971
|
+
// Cargo would resolve `target/` (or a relative build.target-dir) against the snapshot: its output directory is resolved in the checkout
|
|
1972
|
+
// and passed absolute, so the warm outputs stay the checkout's and the snapshot holds no target/ (docs/GATE-CONTRACT.md "Output
|
|
1973
|
+
// directory & cache"). One Cargo workspace is pinned as a whole; several would share one directory (refused, naming them).
|
|
1974
|
+
const pin = ds ? cargoOutputPin({ o, git, root, commit, executor }) : { dir: null };
|
|
1975
|
+
if (pin.refusal) { o.err(`${tag} BLOCKED: RK_SNAPSHOT_OUTPUT_UNPINNED: ${pin.refusal}; nothing compiled`); return 1; }
|
|
1976
|
+
if (pin.dir) o.out(`${tag} output directory for the Cargo workspace: ${pin.dir} (the checkout's)`);
|
|
1977
|
+
const commonDir = path.resolve(root, common);
|
|
1978
|
+
const source = ds ? sourceSplitOf({ root, ds, appDir }) : null;
|
|
1979
|
+
const run = startRun({ commonDir, commit, tree, stage: to, ...(source ? { source } : {}) });
|
|
1980
|
+
try {
|
|
1981
|
+
const cli = fileURLToPath(new URL("../cli/right-release.mjs", import.meta.url));
|
|
1982
|
+
// right-release reads its config from its cwd: the app root (appRootOf), inside the snapshot on the snapshot route, not the
|
|
1983
|
+
// directory the command was typed in (apps/foo/src is apps/foo; Astra, third pass).
|
|
1984
|
+
// The door's own RightKit runs every sub-step: a dev target's `right-release web-build|stage-sidecar` steps resolve `right-release`
|
|
1985
|
+
// 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
|
|
1986
|
+
// door's 0.6.9 admitted). An installed gate (node_modules/@rightkit/release/bin) has its package's bin shims two levels up.
|
|
1987
|
+
const r = (o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" })))(process.execPath, [cli, to, ...passthrough], { cwd: source ? snapshotAppRootOf(ds.path, appDir) : appRoot, env: { ...o.env, ...run.env, ...doorPath(o), ...(pin.dir ? { CARGO_TARGET_DIR: pin.dir } : {}) } });
|
|
1988
|
+
if (ds && !(await closeSnapshotStage({ o, ds, root, stage: to, step: to, tag })).ok) { o.err(`${tag} --to ${to} stopped: the result covers no known bytes`); return 2; }
|
|
1989
|
+
return r.status === 0 ? 0 : 1;
|
|
1990
|
+
} finally { endRun(run); }
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
/** A direct (Linux) executor's environment (PATH without RustRight shim directories) for every child this run starts. */
|
|
1994
|
+
function applyExecutorEnv(o, executor) {
|
|
1995
|
+
if (executor?.env && o.env) Object.assign(o.env, executor.env);
|
|
1996
|
+
if (executor?.note && o.out) o.out(`rightkit-gate: ${executor.note}`);
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
/** PATH with the door's own `right-release` shims first (`<node_modules>/.bin` beside an installed gate), or nothing for a checkout. */
|
|
2000
|
+
export function doorPath(o, gateFile = o.gateFile ?? fileURLToPath(import.meta.url), platform = o.platform ?? process.platform) {
|
|
2001
|
+
const bin = path.resolve(path.dirname(gateFile), "..", "..", "..", ".bin");
|
|
2002
|
+
const shim = path.join(bin, platform === "win32" ? "right-release.cmd" : "right-release");
|
|
2003
|
+
if (!(o.exists ?? fs.existsSync)(shim)) return {};
|
|
2004
|
+
const key = platform === "win32" ? Object.keys(o.env ?? {}).find((k) => k.toUpperCase() === "PATH") ?? "Path" : "PATH";
|
|
2005
|
+
return { [key]: `${bin}${path.delimiter}${(o.env ?? {})[key] ?? ""}` };
|
|
2006
|
+
}
|
|
2007
|
+
|
|
1331
2008
|
// ---- main ---------------------------------------------------------------------------------------------------
|
|
1332
2009
|
|
|
1333
2010
|
/**
|
|
@@ -1341,24 +2018,82 @@ export async function main(argv, io = {}) {
|
|
|
1341
2018
|
spawn: defaultSpawn, exists: fs.existsSync, readFile: (f) => fs.readFileSync(f, "utf8"), writeFile: fs.writeFileSync, mkdir: fs.mkdirSync, chmod: fs.chmodSync, readdir: fs.readdirSync,
|
|
1342
2019
|
readStdin: () => { try { return process.stdin.isTTY ? "" : fs.readFileSync(0, "utf8"); } catch { return ""; } },
|
|
1343
2020
|
out: (m) => console.log(m), err: (m) => console.error(m), cwd: process.cwd(), env: process.env, platform: process.platform,
|
|
1344
|
-
// Where this gate runs from (
|
|
2021
|
+
// Where this gate runs from (the door puts its own shims first on PATH when it is an installed package: doorPath).
|
|
1345
2022
|
gateFile: fileURLToPath(import.meta.url), ...io,
|
|
1346
2023
|
};
|
|
1347
2024
|
const args = [...argv];
|
|
1348
2025
|
const option = (name) => { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; };
|
|
2026
|
+
// `clippy`: the Clippy route (../clippy-route.mjs; docs/GATE-CONTRACT.md §1 "Clippy route & batching"). Dispatched before the global
|
|
2027
|
+
// flags below because everything after `clippy` is cargo's own argv, verbatim (`-v`, `-h` and `--version` are cargo's).
|
|
2028
|
+
if (args[0] === "clippy") return (await import("../clippy-route.mjs")).clippyCommand(o, args.slice(1), { main, io, absoluteEnv: SNAPSHOT_ABSOLUTE_ENV });
|
|
1349
2029
|
if (args.includes("--version") || args.includes("-v")) {
|
|
1350
2030
|
o.out(JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
|
|
1351
2031
|
return 0;
|
|
1352
2032
|
}
|
|
1353
2033
|
if (args.includes("--help") || args.includes("-h")) { o.out(USAGE); return 0; }
|
|
2034
|
+
// `--pre-push` (the plan mode of the retired pre-push hook) no longer exists: commit and push run no gate (docs/GATE-CONTRACT.md).
|
|
2035
|
+
if (args.includes("--pre-push")) { o.err(`rightkit-gate: unknown option "--pre-push"\n${USAGE}`); return 2; }
|
|
2036
|
+
// `snapshot`: sync the selected commit into the gate-owned source directory and verify it is exactly the commit's tree
|
|
2037
|
+
// (../snapshot.mjs; docs/GATE-CONTRACT.md §1). Reads the checkout's commits only, never its working files.
|
|
2038
|
+
// `--verify --env <digest>` also requires the installed dependency environment to still be the digest `env-digest --snapshot` recorded.
|
|
2039
|
+
if (args[0] === "snapshot") {
|
|
2040
|
+
const sc = await import("../snapshot-check.mjs");
|
|
2041
|
+
return (await import("../snapshot.mjs")).snapshotCommand(o, args.slice(1), { environmentDigest: ({ checkout, dir, commit }) => classAEnvironmentDigest({ root: dir, gatePackage: o.gatePackage, files: sc.commitFiles(checkout, commit, o.spawn).files, spawn: o.spawn }) });
|
|
2042
|
+
}
|
|
2043
|
+
// `live-scans`: re-run only the live remote scans (pnpm-audit) of a checked tree, answering the broker's verdict-reuse refresh
|
|
2044
|
+
// (../snapshot-check.mjs; GATE-CONTRACT §1, E18; rightkit#1257).
|
|
2045
|
+
if (args[0] === "live-scans") return (await import("../snapshot-check.mjs")).liveScansCommand(o, args.slice(1), { discoverPnpmRoots, discoverNpmUnits });
|
|
1354
2046
|
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; }
|
|
2047
|
+
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
2048
|
// `check` is the deterministic stage (Adrian, 2026-10-25: deterministic always first, one door): the app-check rules, then every
|
|
1357
2049
|
// class A scan (lockfiles, cargo-deny, cargo-audit, secrets, licences, manifests, workflow lint, syntax...) over the whole tree of the
|
|
1358
2050
|
// commit, never a diff. Nothing compiles. The RustRight broker runs exactly this before it admits any compile. `--list`,
|
|
1359
2051
|
// `--print-baseline` and `--only` stay app-check-only views.
|
|
2052
|
+
if ((sub === "check" || (!sub && args.includes("--all"))) && args.includes("--snapshot") && !o.buildOwnership) {
|
|
2053
|
+
// Host-wide build ownership (../build-ownership.mjs; GATE-CONTRACT §1): held from before the first snapshot read through the
|
|
2054
|
+
// end of the check, so no other request can sync the snapshot while class A runs in it. A nested gate whose
|
|
2055
|
+
// RIGHTKIT_BUILD_OWNERSHIP names the current owner joins it. Busy (or liveness unknown) runs nothing.
|
|
2056
|
+
const { acquireBuildOwnership, ownershipFile } = await import("../build-ownership.mjs");
|
|
2057
|
+
let own;
|
|
2058
|
+
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 }); }
|
|
2059
|
+
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; }
|
|
2060
|
+
o.out(`rightkit-gate: build ownership ${own.joined ? "joined" : "held"} ${own.handle}${own.reclaimed ? ` (reclaimed from ${own.reclaimed.from}: ${own.reclaimed.proofs.join("; ")})` : ""}`);
|
|
2061
|
+
try { return await main(argv, { ...io, buildOwnership: own }); }
|
|
2062
|
+
finally { const r = own.release(); if (r?.released === false && !own.joined) o.err(`rightkit-gate: build ownership ${own.handle} kept: ${r.why}`); }
|
|
2063
|
+
}
|
|
1360
2064
|
if (sub === "check") {
|
|
1361
|
-
|
|
2065
|
+
// `--snapshot <dir>`: full class A of the gate-owned snapshot of a selected commit (../snapshot-check.mjs). The checkout is only
|
|
2066
|
+
// where the commit comes from: its HEAD and status are never read, and the snapshot must be exactly the commit's tree before and
|
|
2067
|
+
// after the check, or the check is RK_CHECK_ERROR (exit 2).
|
|
2068
|
+
let snapshotDir = null; let snapshotCommit = null; let snapshotCheckout = null; let sc = null;
|
|
2069
|
+
if (args.includes("--snapshot")) {
|
|
2070
|
+
sc = await import("../snapshot-check.mjs");
|
|
2071
|
+
try {
|
|
2072
|
+
const at = option("--repo") ? path.resolve(o.cwd, option("--repo")) : o.cwd;
|
|
2073
|
+
const top0 = o.spawn("git", ["rev-parse", "--show-toplevel"], { cwd: at });
|
|
2074
|
+
if (failed(top0) || !text(top0).trim()) throw new sc.CheckError(`${at} is not a git checkout`);
|
|
2075
|
+
snapshotCheckout = fs.realpathSync.native(path.resolve(text(top0).trim()));
|
|
2076
|
+
snapshotDir = sc.snapshotOption(args, { checkout: snapshotCheckout });
|
|
2077
|
+
snapshotCommit = sc.commitOf(snapshotCheckout, option("--commit"), o.spawn);
|
|
2078
|
+
sc.verifySnapshotDir({ checkout: snapshotCheckout, dir: snapshotDir, ...snapshotCommit, when: "before the check" });
|
|
2079
|
+
o.out(`rightkit-gate: snapshot ${snapshotDir} is commit ${snapshotCommit.commit} tree ${snapshotCommit.tree} (verified before the check)`);
|
|
2080
|
+
} catch (e) { o.err(`rightkit-gate: ${sc.CHECK_ERROR}: ${e.message}`); return 2; }
|
|
2081
|
+
}
|
|
2082
|
+
if (args.filter((arg) => arg === "--rule-tier").length > 1 || (args.includes("--rule-tier") && option("--rule-tier") === undefined)) {
|
|
2083
|
+
o.err("rightkit-gate: check requires exactly one value for --rule-tier"); return 2;
|
|
2084
|
+
}
|
|
2085
|
+
const requestedTier = option("--rule-tier") ?? "compile";
|
|
2086
|
+
if (!["compile", "release"].includes(requestedTier)) { o.err(`rightkit-gate: check --rule-tier must be compile or release, not ${JSON.stringify(requestedTier)}`); return 2; }
|
|
2087
|
+
// Full admission cannot be downgraded by a caller's tier flag. Partial views retain their requested tier.
|
|
2088
|
+
const fullAdmission = args.includes("--full-a") && !args.includes("--list") && !args.includes("--print-baseline") && !args.includes("--only");
|
|
2089
|
+
const stripSnapshot = (list) => { const i = list.indexOf("--snapshot"); return i < 0 ? list : [...list.slice(0, i), ...list.slice(i + 2)]; };
|
|
2090
|
+
const appArgs = stripSnapshot(args.slice(1).filter((a) => a !== "--full-a"));
|
|
2091
|
+
if (fullAdmission) {
|
|
2092
|
+
const tierIndex = appArgs.indexOf("--rule-tier");
|
|
2093
|
+
if (tierIndex >= 0) appArgs.splice(tierIndex, 2);
|
|
2094
|
+
appArgs.push("--rule-tier", "release");
|
|
2095
|
+
}
|
|
2096
|
+
const appCheck = await (o.appCheck ?? (await import("../app-check.mjs")).main)(appArgs, { out: o.out, err: o.err, label: "check", cwd: o.cwd });
|
|
1362
2097
|
if (args.includes("--list") || args.includes("--print-baseline") || args.includes("--only") || o.classA === false) {
|
|
1363
2098
|
// 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
2099
|
// 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 +2101,17 @@ export async function main(argv, io = {}) {
|
|
|
1366
2101
|
o.err("rightkit-gate: PARTIAL: app rules only (--only); no class A scan ran, so this is not a class A result");
|
|
1367
2102
|
return appCheck === 0 ? PARTIAL_EXIT : appCheck;
|
|
1368
2103
|
}
|
|
2104
|
+
if (appCheck !== 0) {
|
|
2105
|
+
o.err("rightkit-gate: BLOCKED: app rules failed; no further scans ran");
|
|
2106
|
+
return appCheck;
|
|
2107
|
+
}
|
|
1369
2108
|
const repoArg = option("--repo");
|
|
1370
2109
|
const checkCwd = repoArg ? path.resolve(o.cwd, repoArg) : o.cwd;
|
|
1371
|
-
|
|
2110
|
+
// Admission uses the full tier; a partial diagnostic check may choose either tier.
|
|
2111
|
+
const ruleTier = fullAdmission ? "release" : requestedTier;
|
|
2112
|
+
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
2113
|
// 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.
|
|
2114
|
+
// pins, consumer locks, generated docs, temp paths: scripts/preflight.mjs) and its exports' import safety are class A for the RightKit repository.
|
|
1376
2115
|
let selfRules = 0;
|
|
1377
2116
|
const top = o.spawn("git", ["rev-parse", "--show-toplevel"], { cwd: checkCwd });
|
|
1378
2117
|
const root = failed(top) ? null : path.resolve(text(top).trim());
|
|
@@ -1381,7 +2120,30 @@ export async function main(argv, io = {}) {
|
|
|
1381
2120
|
// never after a failure, so a red tree executes nothing of its own.
|
|
1382
2121
|
// 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
2122
|
const canon = (p) => { let c; try { c = fs.realpathSync.native(p); } catch { c = path.resolve(p); } return process.platform === "win32" ? c.toLowerCase() : c; };
|
|
1384
|
-
|
|
2123
|
+
// In snapshot mode RightKit's own scripts are the snapshot's (the commit's code), never the live checkout's.
|
|
2124
|
+
const selfRoot = snapshotDir ?? root;
|
|
2125
|
+
const rightkitRepository = root && (() => {
|
|
2126
|
+
try { return JSON.parse(o.readFile(path.join(selfRoot, "packages", "release", "package.json"))).name === "@rightkit/release"; }
|
|
2127
|
+
catch { return false; }
|
|
2128
|
+
})();
|
|
2129
|
+
if (rightkitRepository && (!o.exists(path.join(selfRoot, "scripts", "preflight.mjs")) || !o.exists(path.join(selfRoot, "scripts", "ci", "exports-import-effects.mjs")))) {
|
|
2130
|
+
o.err("rightkit-gate: BLOCKED: RightKit's preflight or exports check is missing");
|
|
2131
|
+
selfRules = 1;
|
|
2132
|
+
}
|
|
2133
|
+
if (root && selfRules === 0 && snapshotDir && rightkitRepository && appCheck === 0 && classA === 0) {
|
|
2134
|
+
// The commit's own preflight reads the commit from the checkout's object database; its exports probe imports from the snapshot,
|
|
2135
|
+
// whose dependencies class A installed, and reports beside (never over) the commit-bound report publication reads.
|
|
2136
|
+
const launch = o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" }));
|
|
2137
|
+
const r = launch(process.execPath, [path.join(snapshotDir, "scripts", "preflight.mjs"), "--repo", root, "--commit", snapshotCommit.commit], { cwd: root, env: o.env });
|
|
2138
|
+
selfRules = r.status === 0 ? 0 : 1;
|
|
2139
|
+
const effects = path.join(snapshotDir, "scripts", "ci", "exports-import-effects.mjs");
|
|
2140
|
+
if (selfRules === 0 && o.exists(effects)) {
|
|
2141
|
+
const common = o.spawn("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: root });
|
|
2142
|
+
const report = path.join(failed(common) ? path.join(root, ".git") : text(common).trim(), "rightkit-gate", "import-effects", `snapshot-${snapshotCommit.commit}.json`);
|
|
2143
|
+
const e = launch(process.execPath, [effects, "--root", snapshotDir, "--no-export", "--report", report], { cwd: snapshotDir, env: o.env });
|
|
2144
|
+
if (e.status !== 0) selfRules = 1;
|
|
2145
|
+
}
|
|
2146
|
+
} 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
2147
|
const launch = o.spawnInherit ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, stdio: "inherit" }));
|
|
1386
2148
|
const selector = args.includes("--worktree") ? ["--worktree"] : ["--commit", option("--commit") ?? "HEAD"];
|
|
1387
2149
|
const r = launch(process.execPath, [path.join(root, "scripts", "preflight.mjs"), "--repo", root, ...selector], { cwd: root, env: o.env });
|
|
@@ -1399,15 +2161,15 @@ export async function main(argv, io = {}) {
|
|
|
1399
2161
|
if (requested !== null && at(requested) !== at("HEAD")) {
|
|
1400
2162
|
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
2163
|
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
2164
|
} else {
|
|
2165
|
+
// A working-tree check of a dirty checkout probes the working tree's own bytes (--worktree: staged into a throwaway index
|
|
2166
|
+
// and exported like a commit), never HEAD's, so local development is checked on what it actually is.
|
|
2167
|
+
const worktreeProbe = requested === null && dirty();
|
|
1406
2168
|
// Like CI's scans job: exports that resolve to build output are INCOMPLETE here and handed, through --report, to the
|
|
1407
2169
|
// sealed-byte check that publication runs; without --report the probe exits 3 and a green tree could never pass.
|
|
1408
2170
|
const common = o.spawn("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: root });
|
|
1409
2171
|
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 });
|
|
2172
|
+
const e = launch(process.execPath, [effects, "--report", report, ...(worktreeProbe ? ["--worktree"] : [])], { cwd: root, env: o.env });
|
|
1411
2173
|
if (e.status !== 0) selfRules = 1;
|
|
1412
2174
|
}
|
|
1413
2175
|
}
|
|
@@ -1424,9 +2186,37 @@ export async function main(argv, io = {}) {
|
|
|
1424
2186
|
if (!judged.inputs.length && !judged.findings.length) o.out("rightkit-gate: no declared compile inputs in the captured tree");
|
|
1425
2187
|
inputs = judged.findings.length ? 1 : 0;
|
|
1426
2188
|
}
|
|
2189
|
+
if (snapshotDir) {
|
|
2190
|
+
try { sc.verifySnapshotDir({ checkout: snapshotCheckout, dir: snapshotDir, ...snapshotCommit, when: "after the check" }); }
|
|
2191
|
+
catch (e) { o.err(`rightkit-gate: ${sc.CHECK_ERROR}: ${e.message}; the result covers no known bytes`); return 2; }
|
|
2192
|
+
o.out(`rightkit-gate: snapshot ${snapshotDir} is still tree ${snapshotCommit.tree} after the check`);
|
|
2193
|
+
}
|
|
1427
2194
|
return Math.max(appCheck, classA, selfRules, inputs);
|
|
1428
2195
|
}
|
|
1429
2196
|
const classAOnly = args.includes("--class-a");
|
|
2197
|
+
// --full-a: every class A check, the ones that execute the repository's code (tests, typecheck, lint, generated docs) included,
|
|
2198
|
+
// on the live working tree, before anything compiles. The door's dev/compile stages require it: a content-only check never
|
|
2199
|
+
// authorizes a build (Adrian: class A is everything mechanical and deterministic; reviewer audit 2026-10-28).
|
|
2200
|
+
const fullA = classAOnly && args.includes("--full-a");
|
|
2201
|
+
// Full class A of a gate-owned snapshot (`check --snapshot`, which verified it): the snapshot is the commit's bytes, so neither the
|
|
2202
|
+
// checkout's HEAD nor its cleanliness matters, and the repository's code runs in the snapshot, never in the checkout.
|
|
2203
|
+
const snapshotA = classAOnly ? (option("--snapshot") ?? null) : null;
|
|
2204
|
+
// The door's compile of a selected commit (`--all --commit <sha> --snapshot <dir>`, joint plan step 4c): every scan, class B's
|
|
2205
|
+
// compiles included, reads the verified gate-owned snapshot, never the checkout; outputs go to the checkout's own target
|
|
2206
|
+
// directory. A direct host only, until the broker's snapshot carrier lands (step 4d).
|
|
2207
|
+
const snapshotB = !classAOnly && args.includes("--all") ? (option("--snapshot") ?? null) : null;
|
|
2208
|
+
if (snapshotB && !option("--commit")) { o.err(`${tag} --all --snapshot <dir> needs --commit <sha>`); return 2; }
|
|
2209
|
+
if (fullA && !args.includes("--worktree") && !snapshotA) {
|
|
2210
|
+
// A commit is checked in full only where its bytes are the live checkout: it is HEAD and nothing tracked or untracked differs
|
|
2211
|
+
// (the unit and app stages already refuse a dirty tree). Then the working tree is that commit, and the checks run there.
|
|
2212
|
+
const at = (...a) => o.spawn("git", a, { cwd: o.cwd });
|
|
2213
|
+
const want = at("rev-parse", "--verify", `${option("--commit") ?? "HEAD"}^{commit}`), head = at("rev-parse", "--verify", "HEAD^{commit}");
|
|
2214
|
+
const dirty = at("status", "--porcelain", "--untracked-files=all");
|
|
2215
|
+
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; }
|
|
2216
|
+
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; }
|
|
2217
|
+
const i = args.indexOf("--commit"); if (i >= 0) args.splice(i, 2);
|
|
2218
|
+
args.push("--worktree");
|
|
2219
|
+
}
|
|
1430
2220
|
const classACommit = classAOnly && !args.includes("--worktree") ? (option("--commit") ?? "HEAD") : null;
|
|
1431
2221
|
if (args.includes("--base") && (!option("--base") || option("--base").startsWith("--"))) { o.err("rightkit-gate: --base needs a ref"); return 2; }
|
|
1432
2222
|
|
|
@@ -1434,7 +2224,63 @@ export async function main(argv, io = {}) {
|
|
|
1434
2224
|
if (failed(top) || !text(top).trim()) { o.err("rightkit-gate: not inside a git repository"); return 2; }
|
|
1435
2225
|
const root = path.resolve(text(top).trim());
|
|
1436
2226
|
const git = (...a) => o.spawn("git", a, { cwd: root });
|
|
1437
|
-
if (sub === "install-hook") return await installHook(o, root
|
|
2227
|
+
if (sub === "install-hook") return await installHook(o, root);
|
|
2228
|
+
// `env-digest`: only the environment identity a full class A verdict depends on (classAEnvironmentDigest), no scans. The broker
|
|
2229
|
+
// recomputes it when a queued compile starts and re-runs the check if it changed since the verdict.
|
|
2230
|
+
if (sub === "env-digest") {
|
|
2231
|
+
// `env-digest --snapshot <dir> --commit <sha>`: the same identity over a verified snapshot (the broker's START recheck of a
|
|
2232
|
+
// snapshot-aware verdict): the commit's files, the snapshot's installed dependencies.
|
|
2233
|
+
if (args.includes("--snapshot")) {
|
|
2234
|
+
const sc = await import("../snapshot-check.mjs");
|
|
2235
|
+
try {
|
|
2236
|
+
const dir = option("--snapshot"); const rev = option("--commit");
|
|
2237
|
+
if (!dir || !path.isAbsolute(dir) || !rev || rev.startsWith("--")) throw new sc.CheckError("env-digest --snapshot needs an absolute directory and --commit <sha>");
|
|
2238
|
+
const checkout = fs.realpathSync.native(root);
|
|
2239
|
+
const { commit, tree } = sc.commitOf(checkout, rev, o.spawn);
|
|
2240
|
+
const real = fs.realpathSync.native(dir);
|
|
2241
|
+
sc.verifySnapshotDir({ checkout, dir: real, commit, tree, when: "for env-digest" });
|
|
2242
|
+
o.out(`env=${classAEnvironmentDigest({ root: real, gatePackage: o.gatePackage, files: sc.commitFiles(checkout, commit, o.spawn).files, spawn: o.spawn })}`);
|
|
2243
|
+
return 0;
|
|
2244
|
+
} catch (e) { o.err(`rightkit-gate: ${e.code === sc.CHECK_ERROR ? `${sc.CHECK_ERROR}: ` : "env-digest: "}${e.message}`); return 2; }
|
|
2245
|
+
}
|
|
2246
|
+
const listed = git("ls-files", "-z", "--cached", "--others", "--exclude-standard");
|
|
2247
|
+
if (failed(listed)) { o.err(`rightkit-gate: env-digest cannot list the working tree: ${why(listed)}`); return 2; }
|
|
2248
|
+
try { const files = text(listed).split("\0").filter(Boolean).map(posix); o.out(`env=${classAEnvironmentDigest({ root, gatePackage: o.gatePackage, files, spawn: o.spawn })}`); }
|
|
2249
|
+
catch (error) { o.err(`rightkit-gate: env-digest: ${error.message}`); return 2; }
|
|
2250
|
+
return 0;
|
|
2251
|
+
}
|
|
2252
|
+
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; }
|
|
2253
|
+
// `--unit` is a gate flag only before a `--` separator: after it the arguments belong to right-release (an app's `--to dev -- …`).
|
|
2254
|
+
const separator = args.indexOf("--");
|
|
2255
|
+
const unitFlag = args.includes("--unit") && (separator < 0 || args.indexOf("--unit") < separator);
|
|
2256
|
+
if (option("--to") !== undefined && unitFlag) {
|
|
2257
|
+
const selector = option("--unit");
|
|
2258
|
+
const validArgs = args.length % 2 === 0 && args.every((a, i) => i % 2 ? !a.startsWith("--") : ["--to", "--unit", "--receipt-out", "--commit"].includes(a)) && ["--to", "--unit", "--receipt-out", "--commit"].every((flag) => args.filter((a) => a === flag).length <= 1);
|
|
2259
|
+
if (!validArgs || !selector) {
|
|
2260
|
+
o.err("rightkit-gate: unit stages take only --to, --unit and optional --receipt-out and --commit"); return 2;
|
|
2261
|
+
}
|
|
2262
|
+
// Row 7.06a: on the snapshot route (Linux now; Mac and Dell at the cutover) a unit stage builds the selected commit's snapshot.
|
|
2263
|
+
if (!o.doorSnapshot && o.env.GITHUB_ACTIONS !== "true" && snapshotRouteSelected(o)) return await runUnitSnapshot(o, args, root, git, io, option);
|
|
2264
|
+
if (option("--commit") !== undefined) { o.err("rightkit-gate: BLOCKED: --commit selects a commit for the snapshot route; on this host a unit stage takes HEAD of a clean checkout until the cutover"); return 2; }
|
|
2265
|
+
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");
|
|
2266
|
+
const commit = text(head).trim(), tree = text(treeResult).trim();
|
|
2267
|
+
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; }
|
|
2268
|
+
const match = /^(?:npm:@rightkit\/([a-z0-9-]+)|cargo:(rightkit-[a-z0-9-]+))$/.exec(selector);
|
|
2269
|
+
const manifest = match && (match[1] ? `packages/${match[1]}/package.json` : `crates/${match[2]}/Cargo.toml`);
|
|
2270
|
+
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; }
|
|
2271
|
+
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); };
|
|
2272
|
+
const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile }); applyExecutorEnv(o, executor);
|
|
2273
|
+
if (executor.kind === "refuse") { o.err(`rightkit-gate: BLOCKED: ${executor.problem}`); return 1; }
|
|
2274
|
+
const reportOut = option("--receipt-out");
|
|
2275
|
+
// Loaded here, not at the top: the unit path reads Cargo manifests (smol-toml), which an installed copy of this package without
|
|
2276
|
+
// node_modules does not carry; the app paths of this gate must keep loading there (app-check.test: the installed copy).
|
|
2277
|
+
const { runUnitGate } = await import("../unit-gate.mjs");
|
|
2278
|
+
return await runUnitGate({ selector, to: option("--to"), root, commonDir: path.resolve(root, text(common).trim()), commit, tree, executor, env: o.env, platform: o.platform,
|
|
2279
|
+
check: (tier) => main(["check", "--commit", "HEAD", "--full-a", "--rule-tier", tier], { ...io, cwd: root }), out: o.out, err: o.err,
|
|
2280
|
+
...(reportOut ? { writeReport: (report) => o.writeFile(path.resolve(o.cwd, reportOut), `${JSON.stringify(report, null, 2)}\n`) } : {}),
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2283
|
+
if (unitFlag) { o.err("rightkit-gate: --unit requires --to"); return 2; }
|
|
1438
2284
|
if (option("--to") !== undefined) return await runStages(o, args, root, git, io);
|
|
1439
2285
|
if (sub === "stage") return stageForPublisher(o, args, root, git);
|
|
1440
2286
|
|
|
@@ -1447,13 +2293,18 @@ export async function main(argv, io = {}) {
|
|
|
1447
2293
|
// (git hands the local field over verbatim, so a rev expression such as `HEAD@{1 minute ago}` or `HEAD^{/one thing}` holds spaces)
|
|
1448
2294
|
// refuses the push on every host and in every repository, never a line that disappears: its pushed commit could not be compared with
|
|
1449
2295
|
// HEAD and its remote ref could not be judged. `pushedRemoteShas` are the remote commits being replaced.
|
|
2296
|
+
// A push of ref deletions alone carries no content: nothing to check, nothing to plan (the hook skips its check the same way).
|
|
2297
|
+
let deletionsOnly = false;
|
|
1450
2298
|
if (!planOnly && !classAOnly) {
|
|
1451
2299
|
const head = text(git("rev-parse", "HEAD")).trim();
|
|
1452
2300
|
const parsed = parsePushLines(o.readStdin());
|
|
2301
|
+
// Every line is read before any shortcut: a malformed line hides what is pushed, so it blocks even beside deletions (Astra, third pass).
|
|
1453
2302
|
if (parsed.malformed.length) {
|
|
1454
2303
|
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
2304
|
return 1;
|
|
1456
2305
|
}
|
|
2306
|
+
deletionsOnly = parsed.lines.length > 0 && parsed.lines.every((l) => ZERO.test(l.localSha));
|
|
2307
|
+
if (deletionsOnly) { o.out(`${tag} only deletions are pushed; nothing to check, nothing planned`); return 0; }
|
|
1457
2308
|
for (const { localSha, remoteRef, remoteSha } of parsed.lines) {
|
|
1458
2309
|
if (ZERO.test(localSha)) continue; // a delete pushes no content
|
|
1459
2310
|
if (localSha !== head) {
|
|
@@ -1468,20 +2319,55 @@ export async function main(argv, io = {}) {
|
|
|
1468
2319
|
// The executor is chosen here, never by the caller (Adrian, 2026-10-25): the RustRight broker where this machine has it, a refusal
|
|
1469
2320
|
// on a broker-managed machine whose broker cannot be reached, direct cargo on any other host (a cloud container, a CI runner).
|
|
1470
2321
|
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
|
-
|
|
2322
|
+
const executor = (o.resolveExecutor ?? resolveExecutor)({ env: o.env, platform: o.platform, exists: o.exists, which, readFile: o.readFile }); applyExecutorEnv(o, executor);
|
|
2323
|
+
// A broker-managed host whose broker is unreachable refuses the moment a Cargo manifest is in the snapshot (below): a repository
|
|
2324
|
+
// 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.
|
|
2325
|
+
const cargoCommand = executor.cargo ?? ["cargo"];
|
|
2326
|
+
// A broker host (Mac, Dell) compiles a commit snapshot through the RustRight snapshot carrier: `deps.snapshotBroker`, built in the
|
|
2327
|
+
// snapshot-compile region below (item 7 rows 7.20 to 7.24). A broker-managed host whose broker cannot be reached is refused with
|
|
2328
|
+
// the executor's own problem, never compiled around.
|
|
2329
|
+
if (snapshotB && executor.kind === "refuse") { o.err(`${tag} BLOCKED: ${executor.problem}; nothing ran`); return 1; }
|
|
1474
2330
|
// The snapshot: the commit or tree being checked (check --commit), HEAD for the gate, or the working tree (check --worktree, --plan).
|
|
1475
2331
|
const allFilesMode = classAOnly || args.includes("--all");
|
|
1476
|
-
const snapshot = planOnly ? null : classAOnly ? classACommit : "HEAD";
|
|
2332
|
+
const snapshot = planOnly ? null : classAOnly ? classACommit : snapshotB ? option("--commit") : "HEAD";
|
|
2333
|
+
// Every effectful gate entry owes the app rules, not only `check` (Astra, 2026-10-26): a bare or --all run on HEAD runs them
|
|
2334
|
+
// first, in the tier asked for (compile by default), and plans nothing while they are red. --plan and --class-a (which runs them in
|
|
2335
|
+
// `check`) are untouched.
|
|
2336
|
+
if (snapshot && !classAOnly) {
|
|
2337
|
+
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 });
|
|
2338
|
+
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; }
|
|
2339
|
+
}
|
|
1477
2340
|
|
|
1478
2341
|
const ac = o.abort ?? new AbortController();
|
|
1479
2342
|
let exportRoot = null;
|
|
1480
|
-
|
|
2343
|
+
let snapshotGitConfig = null;
|
|
2344
|
+
let cargoShimDir = null;
|
|
2345
|
+
const cleanup = () => {
|
|
2346
|
+
if (exportRoot) fs.rmSync(exportRoot, { recursive: true, force: true });
|
|
2347
|
+
if (cargoShimDir) fs.rmSync(cargoShimDir, { recursive: true, force: true });
|
|
2348
|
+
if (snapshotGitConfig) { fs.rmSync(snapshotGitConfig, { force: true }); try { fs.rmdirSync(path.dirname(snapshotGitConfig)); } catch { /* not empty: leave it, the next exact sync prunes it */ } }
|
|
2349
|
+
};
|
|
1481
2350
|
const signals = [];
|
|
1482
2351
|
try {
|
|
1483
2352
|
let repo = root;
|
|
1484
|
-
|
|
2353
|
+
let snapshotBCommit = null;
|
|
2354
|
+
if (snapshot && snapshotB) {
|
|
2355
|
+
// Direct host: no broker grant to satisfy, so no config-only .git; the snapshot is scanned and compiled in place.
|
|
2356
|
+
repo = snapshotB;
|
|
2357
|
+
} else if (snapshot && snapshotA) {
|
|
2358
|
+
// The verified gate-owned snapshot is the commit's bytes: it is scanned in place, no export.
|
|
2359
|
+
repo = snapshotA;
|
|
2360
|
+
// As for an export (rightkit#1215): rustright binds the tracked .rightkit-local-build.json grant to the origin URL in .git/config,
|
|
2361
|
+
// so a locked cargo metadata in the snapshot needs a config-only .git naming exactly the checkout's origin. It is not a
|
|
2362
|
+
// repository (no HEAD, no objects), the seeded identity never reads .git, and it is removed when the check ends.
|
|
2363
|
+
const origin = o.spawn("git", ["config", "--get", "remote.origin.url"], { cwd: root });
|
|
2364
|
+
const originUrl = failed(origin) ? "" : text(origin).trim();
|
|
2365
|
+
if (originUrl && !o.exists(path.join(repo, ".git"))) {
|
|
2366
|
+
o.mkdir(path.join(repo, ".git"), { recursive: true });
|
|
2367
|
+
snapshotGitConfig = path.join(repo, ".git", "config");
|
|
2368
|
+
o.writeFile(snapshotGitConfig, `[remote "origin"]\n\turl = ${originUrl}\n`);
|
|
2369
|
+
}
|
|
2370
|
+
} else if (snapshot) {
|
|
1485
2371
|
exportRoot = fs.mkdtempSync(path.join(exportTmpRoot({ env: o.env, platform: o.platform, exists: o.exists }), "rk-gate-"));
|
|
1486
2372
|
repo = path.join(exportRoot, "repo");
|
|
1487
2373
|
// Every file of the commit or tree: read-tree into a private index, then checkout-index. `git archive` would leave out files
|
|
@@ -1516,6 +2402,7 @@ export async function main(argv, io = {}) {
|
|
|
1516
2402
|
let rust; let npm; let npmRoots = []; let manifests = [];
|
|
1517
2403
|
try {
|
|
1518
2404
|
manifests = tracked("Cargo.toml");
|
|
2405
|
+
if (executor.kind === "refuse" && manifests.length) { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
|
|
1519
2406
|
// ci/gate.config.json `fixtures` (optional): directories whose manifests are consumer or sample crates, gated through the
|
|
1520
2407
|
// consumer rows of the crates they exercise (CI treats them the same), never as units.
|
|
1521
2408
|
let fixtures = [];
|
|
@@ -1532,14 +2419,106 @@ export async function main(argv, io = {}) {
|
|
|
1532
2419
|
} catch (e) { o.err(`${tag} BLOCKED: cannot discover the repository's units: ${e.message}`); return 1; }
|
|
1533
2420
|
for (const k of rust.skipped) o.out(`${tag} note: ${k.manifest} is skipped, not scanned: ${k.why}`);
|
|
1534
2421
|
|
|
2422
|
+
// The snapshot compile: verified as the commit's tree before anything runs, dependencies installed into it offline from the
|
|
2423
|
+
// checkout's store, and every Cargo workspace's output pinned to the target directory cargo resolves for it IN THE CHECKOUT
|
|
2424
|
+
// (CARGO_TARGET_DIR or build.target-dir honoured), so the warm outputs are the checkout's and the snapshot holds no target/.
|
|
2425
|
+
let targetDirs = null; let cargoShim = null; let snapshotBroker = null;
|
|
2426
|
+
// The direct (Linux) compile's evidence (item 7 rows 7.09, 7.18, 7.19): how each output directory was resolved, the declared inputs
|
|
2427
|
+
// digested in the snapshot, and the stage's own timings.
|
|
2428
|
+
let outputResolution = {}; let directDeclared = []; let directDeclaredNotes = [];
|
|
2429
|
+
const directTiming = { bindStart: 0, bindEnd: 0, cargoStart: 0, cargoEnd: 0, afterStart: 0, afterEnd: 0, diagnostics: 0 };
|
|
2430
|
+
if (snapshotB) {
|
|
2431
|
+
const sc = await import("../snapshot-check.mjs");
|
|
2432
|
+
try {
|
|
2433
|
+
const checkout = fs.realpathSync.native(root);
|
|
2434
|
+
directTiming.bindStart = Date.now();
|
|
2435
|
+
snapshotBCommit = sc.commitOf(checkout, String(snapshot), o.spawn);
|
|
2436
|
+
// With a ledger the verify returns an object ({ tree, ledger }), without one the tree id: only the refusal (a throw) matters here.
|
|
2437
|
+
sc.verifySnapshotDir({ checkout, dir: snapshotB, ...snapshotBCommit, when: "before the compile", ...(o.snapshotLedger ? { ledger: o.snapshotLedger } : {}) });
|
|
2438
|
+
directTiming.bindEnd = Date.now();
|
|
2439
|
+
// A direct host digests the declared compile inputs in place (the snapshot's own .rightkit/compile-inputs.json, never the
|
|
2440
|
+
// checkout's); a malformed manifest refuses before anything compiles. The broker digests them for a broker host.
|
|
2441
|
+
if (executor.kind === "direct") {
|
|
2442
|
+
const inputsModule = await import("../compile-inputs.mjs");
|
|
2443
|
+
const judged = inputsModule.judgeCompileInputs(snapshotB);
|
|
2444
|
+
// Only a malformed manifest refuses here (the broker's request does the same); a declared path the snapshot does not hold, or a
|
|
2445
|
+
// finding in one, is recorded with the diagnostics and left to the check stage that judges the captured inputs.
|
|
2446
|
+
const malformed = judged.findings.filter((f) => f.startsWith(`${inputsModule.COMPILE_INPUTS_FILE}:`));
|
|
2447
|
+
if (malformed.length) throw Object.assign(new Error(`the snapshot's declared compile inputs are malformed: ${malformed.join("; ")}`), { code: "RK_COMPILE_INPUTS" });
|
|
2448
|
+
directDeclared = judged.inputs;
|
|
2449
|
+
directDeclaredNotes = judged.findings.map((f) => `declared compile input: ${f}`);
|
|
2450
|
+
}
|
|
2451
|
+
o.out(`${tag} snapshot ${snapshotB} is commit ${snapshotBCommit.commit} tree ${snapshotBCommit.tree} (verified before the compile)`);
|
|
2452
|
+
for (const r of sc.installSnapshotDependencies({ checkout, snapshot: snapshotB, roots: npmRoots, env: o.env, spawn: o.spawn })) o.out(`${tag} ${sc.describeInstall(r)}`);
|
|
2453
|
+
} catch (error) { o.err(`${tag} ${error.code === "RK_CHECK_ERROR" ? "RK_CHECK_ERROR" : "BLOCKED"}: ${error.message}`); return error.code === "RK_CHECK_ERROR" ? 2 : 1; }
|
|
2454
|
+
// A broker host (item 7 rows 7.20 to 7.24): the compile goes through the RustRight snapshot carrier. The broker pins the output
|
|
2455
|
+
// directory (its managed generation target), so no checkout target is resolved here; the gate's broker is built once, with the
|
|
2456
|
+
// host's enrolled pins, the declared inputs read from the snapshot and the build ownership; a missing piece refuses before
|
|
2457
|
+
// anything compiles (an app repository with no enrollment included), never a compile around the broker.
|
|
2458
|
+
if (executor.kind === "broker" && manifests.length) {
|
|
2459
|
+
try {
|
|
2460
|
+
const { createSnapshotBroker } = await import("../snapshot-broker.mjs");
|
|
2461
|
+
const client = which("rustright").find((c) => path.isAbsolute(c));
|
|
2462
|
+
snapshotBroker = createSnapshotBroker({ env: o.env, platform: o.platform, checkout: root, commit: { sha: snapshotBCommit.commit, tree: snapshotBCommit.tree }, snapshot: { path: snapshotB, expectedTree: snapshotBCommit.tree }, ownership: o.buildOwnership, client, gateFile: callingGatePath(), spawn: o.spawn });
|
|
2463
|
+
} catch (error) { o.err(`${tag} BLOCKED: ${error.code ?? "RK_SNAPSHOT_BROKER"}: ${error.message}; nothing compiled`); return 1; }
|
|
2464
|
+
o.out(`${tag} broker snapshot compile: pins ${snapshotBroker.pins.keyId.slice(0, 12)} from ${snapshotBroker.pinsSource}, ${snapshotBroker.declaredInputs.length} declared input(s) read from the snapshot, ownership ${snapshotBroker.handle}`);
|
|
2465
|
+
}
|
|
2466
|
+
targetDirs = {};
|
|
2467
|
+
for (const ws of snapshotBroker ? [] : rust.workspaces) {
|
|
2468
|
+
const manifest = path.join(root, ws.cargoDir, "Cargo.toml");
|
|
2469
|
+
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; }
|
|
2470
|
+
// Resolved in the PRIMARY CHECKOUT with the stage's environment, before the source location changes (row 7.09: CARGO_TARGET_DIR,
|
|
2471
|
+
// a relative or absolute build.target-dir, or the default target/ all resolve as for a developer's own build); the cwd and
|
|
2472
|
+
// environment used are recorded. A directory inside the snapshot is refused before anything compiles (a second output).
|
|
2473
|
+
let resolved;
|
|
2474
|
+
try { resolved = resolveTargetDirectory({ spawn: (c, a, opts) => o.spawn(c, a, opts), cargoCommand, manifest, env: o.env, checkoutRoot: root, snapshotRoot: snapshotB, workspace: ws.cargoDir }); }
|
|
2475
|
+
catch (error) { o.err(`${tag} BLOCKED: ${error.message}; nothing compiled`); return 1; }
|
|
2476
|
+
targetDirs[ws.cargoDir] = resolved.dir; outputResolution[ws.cargoDir] = resolved.inputs;
|
|
2477
|
+
o.out(`${tag} output directory for workspace ${ws.cargoDir}: ${targetDirs[ws.cargoDir]} (the checkout's)`);
|
|
2478
|
+
}
|
|
2479
|
+
// CARGO_HOME, sccache & temp are inherited as absolute values, never snapshot-relative (GATE-CONTRACT "Output directory & cache"):
|
|
2480
|
+
// a relative one would be resolved inside the snapshot, a second cache or output location. Refused before anything compiles.
|
|
2481
|
+
const relativeEnv = SNAPSHOT_ABSOLUTE_ENV.filter((name) => typeof o.env[name] === "string" && o.env[name] !== "" && !path.isAbsolute(o.env[name]));
|
|
2482
|
+
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; }
|
|
2483
|
+
// The runtime cargo wrapper (item 7): first on PATH and named in CARGO for every scan, it binds each real cargo invocation to its
|
|
2484
|
+
// own workspace's checkout target directory or refuses it (RK_CARGO_*), whatever path reaches cargo. Direct POSIX hosts only (a
|
|
2485
|
+
// broker host never reaches here; a direct Windows host keeps the fail-closed static walk). Removed when the gate ends (cleanup).
|
|
2486
|
+
if (o.platform !== "win32" && rust.workspaces.length) {
|
|
2487
|
+
const realCargo = which("cargo").find((c) => path.isAbsolute(c));
|
|
2488
|
+
if (!realCargo && !snapshotBroker) { o.err(`${tag} BLOCKED: no cargo on PATH to wrap for the snapshot compile; nothing compiled`); return 1; }
|
|
2489
|
+
try {
|
|
2490
|
+
const shimModule = await import("../cargo-target-shim.mjs");
|
|
2491
|
+
let broker = null;
|
|
2492
|
+
if (snapshotBroker) {
|
|
2493
|
+
// Broker mode (the Mac, decision C): every cargo a test reaches is its own carried request. The gate's own snapshot-to-checkout
|
|
2494
|
+
// map is the only source of a request's primaryCwd, and broker work may exist from here on: the ownership records it first.
|
|
2495
|
+
const checkoutRoot = fs.realpathSync.native(root);
|
|
2496
|
+
const checkoutDirs = Object.fromEntries(rust.workspaces.map((ws) => [ws.cargoDir, path.join(checkoutRoot, ...(ws.cargoDir === "." ? [] : ws.cargoDir.split("/")))]));
|
|
2497
|
+
const marked = snapshotBroker.markBrokerSubmit();
|
|
2498
|
+
if (marked?.broker?.submitted !== true || marked.handle !== snapshotBroker.handle) throw new Error(`the build ownership did not record the broker submit under ${snapshotBroker.handle}`);
|
|
2499
|
+
const { brokerSection } = await import("../snapshot-broker.mjs");
|
|
2500
|
+
broker = { checkoutDirs, section: brokerSection(snapshotBroker) };
|
|
2501
|
+
}
|
|
2502
|
+
cargoShim = shimModule.installCargoShim({ base: os.tmpdir(), snapshotRoot: snapshotB, targetDirs, broker, realCargo: realCargo ?? null, platform: o.platform, onDir: (dir) => { cargoShimDir = dir; } });
|
|
2503
|
+
cargoShim.scanEnv = shimModule.shimScanEnv(o.env, cargoShim);
|
|
2504
|
+
} catch (error) { o.err(`${tag} BLOCKED: the runtime cargo wrapper cannot be installed (${error.message}); nothing compiled`); return 1; }
|
|
2505
|
+
o.out(`${tag} runtime cargo wrapper ${cargoShim.shim} (identity ${cargoShim.identity.slice(0, 16)}, ${cargoShim.mode}) -> ${cargoShim.mode === "broker" ? "the RustRight broker, one carried request per call" : realCargo}: each cargo invocation ${cargoShim.mode === "broker" ? "is carried with its own receipt, verified by the gate," : "binds its workspace's checkout target directory"} or is refused`);
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
|
|
1535
2509
|
// 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
2510
|
// otherwise hide a committed failure. Class A reads only the snapshot.
|
|
1537
|
-
if (!planOnly && !classAOnly) {
|
|
2511
|
+
if (!planOnly && !classAOnly && !snapshotB) {
|
|
1538
2512
|
const npmRootFiles = ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc", ...ESLINT_CONFIG_FILES, ...BIOME_CONFIG_FILES];
|
|
1539
|
-
const inputs = [...new Set([...rust.units.map((u) => u.dir), ...npm.map((u) => u.dir), ...rust.workspaces.map((w) => w.cargoDir), "ci", "scripts", "vendor",
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
2513
|
+
const inputs = [...new Set([...rust.units.map((u) => u.dir), ...npm.map((u) => u.dir), ...rust.workspaces.map((w) => w.cargoDir), "ci", "scripts", "vendor",
|
|
2514
|
+
// The root manifest, lock and deny.toml only where a workspace is rooted there; a crate-per-workspace repository binds each crate's own
|
|
2515
|
+
// (its directory is an input above, which holds its Cargo.toml, Cargo.lock, deny.toml, .cargo and .rightkit).
|
|
2516
|
+
...(rust.workspaces.some((w) => w.cargoDir === ".") ? ["Cargo.toml", "Cargo.lock", "deny.toml"] : []),
|
|
2517
|
+
".cargo", "rust-toolchain.toml", "rust-toolchain", "clippy.toml", "rustfmt.toml", ...npmRootFiles, ...npmRoots.filter((r) => r !== ".").flatMap((r) => npmRootFiles.map((f) => `${r}/${f}`))])];
|
|
2518
|
+
// Batched like cargoInputsDiffer: one argv with every input path overflows Windows' command line in a large repository.
|
|
2519
|
+
const dirtyRuns = pathspecBatches(inputs).map((batch) => git("status", "--porcelain", "--untracked-files=all", "--", ...batch));
|
|
2520
|
+
const dirtyFailed = dirtyRuns.find((r) => failed(r));
|
|
2521
|
+
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
2522
|
if (dirtyFiles.length) {
|
|
1544
2523
|
o.err(`${tag} BLOCKED: the checkout differs from HEAD where compile scans read it; commit or stash first:`);
|
|
1545
2524
|
for (const l of dirtyFiles.slice(0, 20)) o.err(` ${l}`);
|
|
@@ -1573,25 +2552,88 @@ export async function main(argv, io = {}) {
|
|
|
1573
2552
|
}
|
|
1574
2553
|
const rustScope = restricted ? scopeNames : null;
|
|
1575
2554
|
|
|
1576
|
-
|
|
2555
|
+
// A broker host: rustright refuses every cargo command but a locked metadata outside the app's primary checkout
|
|
2556
|
+
// (RK_NOT_PRIMARY_CHECKOUT), so class A's cargo scans cannot read the export there. They run in the primary checkout, allowed only
|
|
2557
|
+
// 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
|
|
2558
|
+
// the check with the paths named. A direct host (cloud, CI) keeps reading the export. (rightkit#1215, third cause)
|
|
2559
|
+
let cargoLiveRoot = null;
|
|
2560
|
+
if (classAOnly && snapshot && executor.kind === "broker" && manifests.length) {
|
|
2561
|
+
const differing = cargoInputsDiffer({ root, snapshot, manifests, spawn: (c, a, opts) => o.spawn(c, a, opts) });
|
|
2562
|
+
if (differing.length) {
|
|
2563
|
+
const resolved = git("rev-parse", "--verify", String(snapshot));
|
|
2564
|
+
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`);
|
|
2565
|
+
return 1;
|
|
2566
|
+
}
|
|
2567
|
+
// A snapshot check's cargo scans then read the checkout's WORKSPACE, which can hold more than its tracked inputs: an untracked
|
|
2568
|
+
// member, or a stray directory a members glob (crates/*) matches. Cargo's own member list there must equal the commit's (from
|
|
2569
|
+
// the snapshot), or the verdict would not be the commit's (Oracle review of 79c4427b). A checkout cargo cannot read refuses too.
|
|
2570
|
+
if (snapshotA) {
|
|
2571
|
+
const memberDiff = [];
|
|
2572
|
+
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();
|
|
2573
|
+
for (const ws of rust.workspaces) {
|
|
2574
|
+
let inCommit; let inCheckout;
|
|
2575
|
+
try { inCommit = membersOf(repo, ws.cargoDir); } catch (e) { memberDiff.push(`${ws.cargoDir} (commit): ${e.message}`); continue; }
|
|
2576
|
+
try { inCheckout = membersOf(root, ws.cargoDir); } catch (e) { memberDiff.push(`${ws.cargoDir} (checkout): ${e.message}`); continue; }
|
|
2577
|
+
const extra = inCheckout.filter((m) => !inCommit.includes(m)); const missing = inCommit.filter((m) => !inCheckout.includes(m));
|
|
2578
|
+
if (extra.length || missing.length) memberDiff.push(`${ws.cargoDir}: checkout-only ${extra.join(", ") || "none"}; commit-only ${missing.join(", ") || "none"}`);
|
|
2579
|
+
}
|
|
2580
|
+
if (memberDiff.length) {
|
|
2581
|
+
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`);
|
|
2582
|
+
return 1;
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
cargoLiveRoot = root;
|
|
2586
|
+
}
|
|
2587
|
+
// One Cargo workspace and lock per crate (rightkit#1285): the reach between crates is the manifests' path dependencies, read from the
|
|
2588
|
+
// gated snapshot. A crate that is selected selects its transitive dependents (checked, not re-linted); a vendored source it patches
|
|
2589
|
+
// in selects the crate. Neither is derivable from a single lock, so a layout that cannot read them is refused, never planned narrower.
|
|
2590
|
+
let crateDependents = null; let externalInputs = null;
|
|
2591
|
+
const crateUnits = rust.units.filter((u) => u.cargoDir === `crates/${u.crate}` && u.name === u.crate);
|
|
2592
|
+
if (crateUnits.length) {
|
|
2593
|
+
try {
|
|
2594
|
+
const { crateWorkspaces, pathDependencies, reverseDependents } = await import("../crate-workspaces.mjs");
|
|
2595
|
+
const io = { readFile: (f) => o.readFile(f), exists: (f) => o.exists(f), readdir: (d) => o.readdir(d) };
|
|
2596
|
+
const known = new Set(crateUnits.map((u) => u.name));
|
|
2597
|
+
const graph = new Map();
|
|
2598
|
+
for (const [name, set] of pathDependencies(repo, io, crateWorkspaces(repo, io))) if (known.has(name)) graph.set(name, new Set([...set].filter((d) => known.has(d))));
|
|
2599
|
+
crateDependents = (names) => reverseDependents([...names].filter((n) => known.has(n)), graph);
|
|
2600
|
+
externalInputs = vendorOwners(crateUnits, (u) => o.readFile(path.join(repo, u.dir, "Cargo.toml")));
|
|
2601
|
+
} catch (e) { o.err(`${tag} BLOCKED: cannot read the path dependencies between the crate workspaces (${e.message}); their reverse dependents would go unchecked, so nothing is planned`); return 1; }
|
|
2602
|
+
}
|
|
2603
|
+
// A broker snapshot compile: its cargo scans are carried requests that name the commit's snapshot and run from the primary checkout
|
|
2604
|
+
// (rustright refuses cargo anywhere else); the checkout is the request's working directory, never its source.
|
|
2605
|
+
if (snapshotBroker && manifests.length) cargoLiveRoot = root;
|
|
2606
|
+
let fullPlan;
|
|
2607
|
+
fullPlan = planScans({
|
|
1577
2608
|
// 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
2609
|
// 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,
|
|
2610
|
+
changed, contentRoot: repo, liveRoot: (classAOnly && snapshot) || snapshotB ? repo : root, trustedRoot: snapshotA ?? snapshotB ?? root, includeTests: true, rustScope, cargoCommand, targetDirs, cargoShim: Boolean(cargoShim),
|
|
2611
|
+
tier: fullA ? "release" : (option("--rule-tier") ?? "compile"), cargoLiveRoot,
|
|
1580
2612
|
// The quiet tests (port, pipe, desktop and wall-clock bound) are proven by the CI shards, one crate per runner, unless
|
|
1581
2613
|
// RK_GATE_QUIET_TESTS=local asks for the serial quiet lane here (no CI, or a CI outage).
|
|
1582
|
-
|
|
1583
|
-
|
|
2614
|
+
// Full class A runs every test before a compile: the quiet suites run here in the quiet lane, never left to CI.
|
|
2615
|
+
// The door's snapshot compile already executes the repository's own code (its tests) in the verified snapshot, guarded like
|
|
2616
|
+
// full class A, so the docs generator runs there too instead of being deferred (step 4c review).
|
|
2617
|
+
quietTests: fullA || o.env.RK_GATE_QUIET_TESTS === "local" ? "local" : "ci", packageFeatures, liveExecution: (fullA && (args.includes("--worktree") || Boolean(snapshotA))) || Boolean(snapshotB),
|
|
2618
|
+
rustUnits: rust.units, npmUnits: npm, npmRoots, cargoDir: ".", rustLocks, crateFeatures, crateDependents, externalInputs,
|
|
1584
2619
|
// The broker (or, on a direct host, the gate itself) admits compiles; no separate grant file decides it here.
|
|
1585
2620
|
localCompile: true,
|
|
1586
2621
|
exists: o.exists, readFile: o.readFile, platform: o.platform,
|
|
1587
2622
|
});
|
|
1588
|
-
|
|
2623
|
+
// A broker-managed host whose broker is unreachable runs no class B scan: npm tests and scripts can compile native code the
|
|
2624
|
+
// snapshot lists no manifest for (Astra, second pass); the class A scans never compile and still run.
|
|
2625
|
+
// 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
|
|
2626
|
+
// broker is unreachable runs neither; the gate stops rather than skip them.
|
|
2627
|
+
if (executor.kind === "refuse" && fullPlan.scans.some((x) => x.class === "B" || (x.kind === "test" && (!classAOnly || fullA)))) { o.err(`${tag} BLOCKED: ${executor.problem}`); return 1; }
|
|
2628
|
+
let plan = fullPlan;
|
|
1589
2629
|
const jobs = Math.max(1, Number(o.env.RK_GATE_JOBS ?? 4) || 4);
|
|
1590
2630
|
const testJobs = Math.max(1, Number(o.env.RK_GATE_TEST_JOBS ?? jobs) || jobs);
|
|
2631
|
+
const classBJobs = Math.max(1, Number(o.env.RK_GATE_CLASS_B_JOBS ?? 3) || 3);
|
|
1591
2632
|
|
|
1592
2633
|
if (planOnly) {
|
|
1593
2634
|
o.out(`${tag} plan: ${plan.scans.length} scan(s) for ${changed.length} changed file(s) vs ${base.slice(0, 12)} (reads the working tree; nothing runs)`);
|
|
1594
|
-
|
|
2635
|
+
// A crate that is its own workspace is named by its unit (rust units below); only the other workspaces are listed.
|
|
2636
|
+
const wsList = rust.workspaces.filter((w) => !(w.units.length === 1 && w.units[0].name === w.units[0].crate && w.cargoDir === `crates/${w.units[0].crate}`)).map((w) => w.cargoDir);
|
|
1595
2637
|
const wsNote = wsList.length === 1 ? (wsList[0] === "." ? "" : ` (workspace ${wsList[0]})`) : wsList.length > 1 ? ` (workspaces ${wsList.join(", ")})` : "";
|
|
1596
2638
|
o.out(`${tag} rust units: ${rust.units.map((u) => `${u.name}=${u.dir}`).join(", ") || "none"}${wsNote}; npm units: ${npm.map((u) => `${u.name}=${u.dir}`).join(", ") || "none"}`);
|
|
1597
2639
|
for (const s of plan.scans) o.out(` [${s.class}] ${s.id} :: ${s.command}${plan.deferred.includes(s.id) ? " [deferred: no local compile grant]" : ""}`);
|
|
@@ -1602,7 +2644,7 @@ export async function main(argv, io = {}) {
|
|
|
1602
2644
|
for (const n of plan.notes) o.out(`${tag} note: ${n}`);
|
|
1603
2645
|
o.out(`${tag} executor: ${executor.kind}${executor.kind === "broker" ? ` (${cargoCommand.join(" ")})` : " (no broker on this host: cargo runs directly, through the same gates)"}`);
|
|
1604
2646
|
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)};
|
|
2647
|
+
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 ${classBJobs} component(s) at a time${quietCount ? `, ${quietCount} quiet test scan(s) last under the broker's quiet lease` : ""}`);
|
|
1606
2648
|
let signalCode = 0;
|
|
1607
2649
|
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
1608
2650
|
const handler = () => {
|
|
@@ -1613,11 +2655,84 @@ export async function main(argv, io = {}) {
|
|
|
1613
2655
|
};
|
|
1614
2656
|
process.on(sig, handler); signals.push([sig, handler]);
|
|
1615
2657
|
}
|
|
1616
|
-
// The result cache keys on the gated commit's object ids; the
|
|
2658
|
+
// The result cache keys on the gated commit's object ids; the stdin check above made HEAD the pushed commit.
|
|
1617
2659
|
let cache = null;
|
|
2660
|
+
// Full class A on the working tree (the door, and broker admission of the live checkout) caches on the LIVE bytes: every
|
|
2661
|
+
// tracked and untracked (not ignored) file is hashed raw, so the door's run and a repeat admission of the same bytes hit the
|
|
2662
|
+
// cache, and any edit misses it.
|
|
2663
|
+
const liveFullA = fullA && args.includes("--worktree");
|
|
2664
|
+
// 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
|
|
2665
|
+
// environment includes the dependencies installed into it below.
|
|
2666
|
+
const snapFullA = fullA && Boolean(snapshotA);
|
|
2667
|
+
const boundFullA = liveFullA || snapFullA;
|
|
2668
|
+
const liveBase = snapFullA ? snapshotA : root;
|
|
2669
|
+
// The live bytes full class A judges: every tracked and untracked (not ignored) file, hashed raw. Computed whether or not the
|
|
2670
|
+
// cache is used; a failure to compute it blocks (the verdict must name the bytes it covers).
|
|
2671
|
+
const liveTree = () => {
|
|
2672
|
+
const listed = git("ls-files", "-z", "--cached", "--others", "--exclude-standard");
|
|
2673
|
+
if (failed(listed)) throw new Error(`cannot list the working tree: ${why(listed)}`);
|
|
2674
|
+
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();
|
|
2675
|
+
const ids = new Map();
|
|
2676
|
+
for (const batch of pathspecBatches(files, 60000)) {
|
|
2677
|
+
const r = o.spawn("git", ["hash-object", "--no-filters", "--stdin-paths"], { cwd: root, input: `${batch.join("\n")}\n` });
|
|
2678
|
+
if (failed(r)) throw new Error(`git hash-object failed: ${why(r)}`);
|
|
2679
|
+
const out = text(r).split(/\r?\n/).filter(Boolean);
|
|
2680
|
+
if (out.length !== batch.length) throw new Error("git hash-object returned a short list");
|
|
2681
|
+
batch.forEach((f, i) => ids.set(f, out[i]));
|
|
2682
|
+
}
|
|
2683
|
+
const digestOf = (list) => createHash("sha256").update(list.map((f) => `${f}\t${ids.get(f)}\n`).join("")).digest("hex");
|
|
2684
|
+
return { files, ids, digestOf, digest: digestOf(files) };
|
|
2685
|
+
};
|
|
2686
|
+
let live = null;
|
|
2687
|
+
if (liveFullA) {
|
|
2688
|
+
try { live = liveTree(); } catch (error) { o.err(`${tag} BLOCKED: cannot bind full class A to the working tree's bytes (${error.message})`); return 1; }
|
|
2689
|
+
}
|
|
2690
|
+
let snapshotCommitId = null;
|
|
2691
|
+
if (snapFullA) {
|
|
2692
|
+
const sc = await import("../snapshot-check.mjs");
|
|
2693
|
+
try {
|
|
2694
|
+
const checkout = fs.realpathSync.native(root);
|
|
2695
|
+
const ct = sc.commitOf(checkout, String(snapshot), o.spawn);
|
|
2696
|
+
snapshotCommitId = ct;
|
|
2697
|
+
sc.verifySnapshotDir({ checkout, dir: snapshotA, ...ct, when: "before class A" });
|
|
2698
|
+
live = sc.commitFiles(checkout, ct.commit, o.spawn);
|
|
2699
|
+
// Dependencies for the checks that execute repository code: installed into the snapshot from the commit's lockfile and the
|
|
2700
|
+
// primary checkout's existing store (coordinator decision A, rightkit#1257). A store miss stops here, naming the package.
|
|
2701
|
+
const installed = sc.installSnapshotDependencies({ checkout, snapshot: snapshotA, roots: npmRoots, env: o.env, spawn: o.spawn });
|
|
2702
|
+
for (const r of installed) o.out(`${tag} ${sc.describeInstall(r)}`);
|
|
2703
|
+
} catch (error) { o.err(`${tag} ${error.code === "RK_CHECK_ERROR" ? "RK_CHECK_ERROR" : "BLOCKED"}: ${error.message}`); return error.code === "RK_CHECK_ERROR" ? 2 : 1; }
|
|
2704
|
+
}
|
|
2705
|
+
const liveSnapshot = live?.digest ?? null;
|
|
2706
|
+
// The environment, installed dependencies included, is bound at the start and compared at the end, so a dependency that
|
|
2707
|
+
// changes while class A runs voids the verdict.
|
|
2708
|
+
// envStartWithCaches records, for audit, the tool caches as the run found them (what its checks could read); the pass line
|
|
2709
|
+
// carries it next to the env the verdict binds.
|
|
2710
|
+
let envAtStart = null; let envStartWithCaches = null; let cachesAtStart = null;
|
|
2711
|
+
if (boundFullA) {
|
|
2712
|
+
const orphans = orphanedPnpmStores({ root: liveBase, files: live.files });
|
|
2713
|
+
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; }
|
|
2714
|
+
try { envAtStart = classAEnvironmentDigest({ root: liveBase, gatePackage: o.gatePackage, files: live.files, spawn: o.spawn, includeCaches: false }); envStartWithCaches = classAEnvironmentDigest({ root: liveBase, gatePackage: o.gatePackage, files: live.files, spawn: o.spawn }); cachesAtStart = toolCacheManifest({ root: liveBase, files: live.files }); }
|
|
2715
|
+
catch (error) { o.err(`${tag} BLOCKED: ${error.message}`); return 1; }
|
|
2716
|
+
}
|
|
2717
|
+
if (boundFullA && !args.includes("--no-cache") && o.env.RK_GATE_CACHE !== "0") {
|
|
2718
|
+
try {
|
|
2719
|
+
const common = git("rev-parse", "--git-common-dir");
|
|
2720
|
+
if (failed(common)) throw new Error("no git directory");
|
|
2721
|
+
const { files, ids, digestOf } = live;
|
|
2722
|
+
const under = (dir) => (dir === "." || dir === "" ? files : files.filter((f) => f === dir || f.startsWith(`${dir}/`)));
|
|
2723
|
+
const objectId = (p) => (ids.has(p) ? ids.get(p) : under(p).length ? digestOf(under(p)) : null);
|
|
2724
|
+
const store = fileScanCache(path.join(path.resolve(root, text(common).trim()), "rightkit-gate-cache"));
|
|
2725
|
+
const listFiles = (dir) => under(dir);
|
|
2726
|
+
const readBlob = (p) => fs.readFileSync(path.join(liveBase, p), "utf8");
|
|
2727
|
+
// 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
|
|
2728
|
+
// the checks, the installed dependencies (pnpm's install state) and the platform. Any change there is a cache miss.
|
|
2729
|
+
const environment = classAEnvironmentDigest({ root: liveBase, gatePackage: o.gatePackage, files, spawn: o.spawn });
|
|
2730
|
+
cache = { ...store, digest: `${buildCacheDigest({ objectId, headTree: digestOf(files), rustUnits: rust.units, npmUnits: npm, cargoDir: ".", rustLocks, crateFeatures, listFiles, readBlob })}:${environment}` };
|
|
2731
|
+
} catch { cache = null; }
|
|
2732
|
+
}
|
|
1618
2733
|
if (!classAOnly && !args.includes("--no-cache") && o.env.RK_GATE_CACHE !== "0") {
|
|
1619
2734
|
try {
|
|
1620
|
-
const headSha = text(git("rev-parse", "HEAD")).trim();
|
|
2735
|
+
const headSha = snapshotBCommit?.commit ?? text(git("rev-parse", "HEAD")).trim();
|
|
1621
2736
|
const treeId = text(git("rev-parse", `${headSha}^{tree}`)).trim();
|
|
1622
2737
|
const common = git("rev-parse", "--git-common-dir");
|
|
1623
2738
|
if (headSha && treeId && !failed(common) && text(common).trim()) {
|
|
@@ -1642,11 +2757,41 @@ export async function main(argv, io = {}) {
|
|
|
1642
2757
|
}
|
|
1643
2758
|
} catch { cache = null; }
|
|
1644
2759
|
}
|
|
2760
|
+
// The direct compile's freshness census (row 7.19): the fingerprint units before the scans, and the fingerprint log of each cargo
|
|
2761
|
+
// process that compiles (CARGO_LOG on that process only, read from its own standard error). A broker host's census is the broker's.
|
|
2762
|
+
const census = snapshotB && !snapshotBroker ? createFreshnessCensus({ targetDirs }) : null;
|
|
2763
|
+
directTiming.cargoStart = Date.now();
|
|
2764
|
+
census?.start();
|
|
1645
2765
|
const scanResult = await (o.runScans ?? runScans)(plan, {
|
|
1646
|
-
classes: classAOnly ? ["A"] : ["A", "B"], jobs, testJobs, signal: ac.signal, ...(cache ? { cache } : {}),
|
|
1647
|
-
|
|
2766
|
+
classes: classAOnly ? ["A"] : ["A", "B"], jobs, testJobs, classBJobs, signal: ac.signal, ...(cache ? { cache } : {}),
|
|
2767
|
+
...(census ? { spawn: census.wrapSpawn(defaultSpawnAsync) } : {}),
|
|
2768
|
+
// 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
|
|
2769
|
+
// wrapper's identity) is in every scan's cache key, so no result cached without the wrapper or with another map is reused.
|
|
2770
|
+
...(cargoShim ? { env: cargoShim.scanEnv, ...(cargoShim.sentinel ? { cargoSentinel: cargoShim.sentinel } : {}), cargoLedger: { dir: cargoShim.ledgerDir } } : {}),
|
|
2771
|
+
// The broker snapshot compile's seam (item 7 rows 7.20 to 7.24): the commit, snapshot, ownership, enrolled pins and receipt reader.
|
|
2772
|
+
...(snapshotBroker ? { snapshotBroker } : {}),
|
|
2773
|
+
// `check` (and the broker's compile admission, which runs it on a captured copy of the tree without node_modules) judges the
|
|
2774
|
+
// content: it never executes the repository's own code. The class A checks that do (tests, typecheck, lint, generated docs:
|
|
2775
|
+
// `executes`) run in the full gate (--all, the push hook, the door's compile stage), after the content checks and before any compile.
|
|
2776
|
+
...(classAOnly && !fullA ? { exclude: (s) => s.executes === true } : {}),
|
|
2777
|
+
// A snapshot check names its commit; it never reads the checkout's HEAD.
|
|
2778
|
+
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()}` }),
|
|
2779
|
+
// On a broker-managed host the quiet suites need the lease or they are refused (never run unleashed).
|
|
2780
|
+
requireQuietLease: executor.kind === "broker",
|
|
2781
|
+
// Failing scans' complete output: never inside the repository, on the host's scratch root (RIGHTKIT_SCRATCH_ROOT, else the
|
|
2782
|
+
// managed data root's runs/), in a folder unique to this run so evidence is never overwritten.
|
|
2783
|
+
scanLogDir: (() => {
|
|
2784
|
+
try {
|
|
2785
|
+
const base = o.env.RIGHTKIT_SCRATCH_ROOT || path.join(resolveManagedRoots({ env: o.env }).dataRoot, "runs");
|
|
2786
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2787
|
+
return path.join(base, "rightkit-gate-scan-logs", `${path.basename(root)}-${stamp}-${process.pid}`);
|
|
2788
|
+
} catch { return null; }
|
|
2789
|
+
})(),
|
|
1648
2790
|
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
2791
|
});
|
|
2792
|
+
directTiming.cargoEnd = Date.now();
|
|
2793
|
+
const freshness = census ? census.finish() : null;
|
|
2794
|
+
directTiming.diagnostics = Date.now() - directTiming.cargoEnd;
|
|
1650
2795
|
if (ac.signal.aborted) {
|
|
1651
2796
|
const notRun = scanResult.scans.filter((r) => r.status !== "pass" && r.status !== "fail").map((r) => r.id);
|
|
1652
2797
|
o.err(`${tag} CANCELLED (${signalCode || "aborted"}); ${notRun.length} scan(s) not run:`);
|
|
@@ -1658,14 +2803,122 @@ export async function main(argv, io = {}) {
|
|
|
1658
2803
|
const deferrals = scanDeferrals(scanResult);
|
|
1659
2804
|
for (const line of deferrals) failures.push(`DEFERRED ${line}`);
|
|
1660
2805
|
for (const note of scanResult.notes) o.out(`${tag} ${note}`);
|
|
2806
|
+
// What a full class A verdict binds, green or red: the bytes it judged, unchanged from start to end, and the environment it judged
|
|
2807
|
+
// in (gate, tools, installed dependencies, tool caches), unchanged too. A verdict whose binding moved covers nothing: a green one is
|
|
2808
|
+
// refused and a red one names no reusable deterministic cause (rightkit#1257). Returns { env, notes } or { prefix, problem, exit }.
|
|
2809
|
+
const judgedBinding = async () => {
|
|
2810
|
+
if (liveFullA) {
|
|
2811
|
+
let digestNow = null;
|
|
2812
|
+
try { digestNow = liveTree().digest; } catch { digestNow = null; }
|
|
2813
|
+
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." };
|
|
2814
|
+
}
|
|
2815
|
+
if (snapFullA) {
|
|
2816
|
+
try { (await import("../snapshot-check.mjs")).verifySnapshotDir({ checkout: fs.realpathSync.native(root), dir: snapshotA, ...snapshotCommitId, when: "after class A" }); }
|
|
2817
|
+
catch (error) { return { prefix: "RK_CHECK_ERROR", exit: 2, problem: `${error.message}; the verdict covers no known bytes` }; }
|
|
2818
|
+
}
|
|
2819
|
+
if (!live) return { env: null, notes: [] };
|
|
2820
|
+
let envDigest; let envNoCaches;
|
|
2821
|
+
try { envDigest = classAEnvironmentDigest({ root: liveBase, gatePackage: o.gatePackage, files: live.files, spawn: o.spawn }); envNoCaches = classAEnvironmentDigest({ root: liveBase, gatePackage: o.gatePackage, files: live.files, spawn: o.spawn, includeCaches: false }); }
|
|
2822
|
+
catch (error) { return { prefix: "BLOCKED", exit: 1, problem: error.message }; }
|
|
2823
|
+
const notes = [];
|
|
2824
|
+
if (cachesAtStart !== null) {
|
|
2825
|
+
let drift;
|
|
2826
|
+
try { drift = toolCacheDrift(cachesAtStart, toolCacheManifest({ root: liveBase, files: live.files })); } catch (error) { return { prefix: "BLOCKED", exit: 1, problem: error.message }; }
|
|
2827
|
+
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.` };
|
|
2828
|
+
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 ? ", …" : ""}`);
|
|
2829
|
+
}
|
|
2830
|
+
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." };
|
|
2831
|
+
return { env: envDigest, notes };
|
|
2832
|
+
};
|
|
2833
|
+
if (snapshotB) {
|
|
2834
|
+
// The compile covers the commit only if the snapshot still is its tree after every scan ran (E6).
|
|
2835
|
+
directTiming.afterStart = Date.now();
|
|
2836
|
+
try {
|
|
2837
|
+
// With the door's ledger the verify returns { tree, ledger: admitted } (item 5 row 5.09d, snapshot-check.mjs); the entries it admitted
|
|
2838
|
+
// are handed to the door, which holds them against its own walk at the stage's end (closeSnapshotStage). Without a ledger it returns the tree id.
|
|
2839
|
+
const verified = (await import("../snapshot-check.mjs")).verifySnapshotDir({ checkout: fs.realpathSync.native(root), dir: snapshotB, ...snapshotBCommit, when: "after the compile", ...(o.snapshotLedger ? { ledger: o.snapshotLedger, admitNew: true } : {}) });
|
|
2840
|
+
if (o.snapshotLedger) o.onCompileLedger?.(Array.isArray(verified?.ledger) ? verified.ledger : null);
|
|
2841
|
+
}
|
|
2842
|
+
catch (error) { o.err(`${tag} RK_CHECK_ERROR: ${error.message}; the compile covers no known bytes`); return 2; }
|
|
2843
|
+
directTiming.afterEnd = Date.now();
|
|
2844
|
+
o.out(`${tag} snapshot ${snapshotB} is still tree ${snapshotBCommit.tree} after the compile`);
|
|
2845
|
+
// A broker host's evidence is the broker's own signed receipts (each carried scan's row.snapshotEvidence, verified above); the
|
|
2846
|
+
// gate writes no receipt that says `executor: direct` for it.
|
|
2847
|
+
if (snapshotBroker) o.out(`${tag} broker compile: ${scanResult.scans.filter((r) => r.snapshotEvidence).length} carried request(s) with verified signed receipts; no direct receipt on a broker host`);
|
|
2848
|
+
else {
|
|
2849
|
+
// The direct host's own receipt (GATE-CONTRACT "Broker interface", row 7.18): the contract's returned-evidence names minus the
|
|
2850
|
+
// broker-only ones (admission, queue, reusedFrom): the run, the commit, the snapshot's tree at start and exit, the declared
|
|
2851
|
+
// inputs digested in place, the verdict, the freshness census (7.19), the timings, the exit status, the structured
|
|
2852
|
+
// diagnostics, the resolved output directory (7.09, with its inputs) and `refreshed` (nothing is reused on a direct host).
|
|
2853
|
+
// The gate refuses its own receipt unless it verifies, so a consumer never meets a receipt with a name missing.
|
|
2854
|
+
const requestId = randomUUID();
|
|
2855
|
+
const exitCode = failures.length ? 1 : 0;
|
|
2856
|
+
const diagnosticsDir = (() => {
|
|
2857
|
+
try { return path.join(o.env.RIGHTKIT_SCRATCH_ROOT || path.join(resolveManagedRoots({ env: o.env }).dataRoot, "runs"), "rightkit-gate-direct-diagnostics"); } catch { return path.join(os.tmpdir(), "rightkit-gate-direct-diagnostics"); }
|
|
2858
|
+
})();
|
|
2859
|
+
let directReceipt;
|
|
2860
|
+
try {
|
|
2861
|
+
const wrote = Date.now();
|
|
2862
|
+
const diagnosticsPath = writeDirectDiagnostics({ dir: diagnosticsDir, requestId, commit: { sha: snapshotBCommit.commit, tree: snapshotBCommit.tree }, exit: exitCode, scans: scanResult.scans, failures, deferrals, notes: [...scanResult.notes, ...directDeclaredNotes], freshness });
|
|
2863
|
+
directReceipt = buildDirectReceipt({
|
|
2864
|
+
requestId, issuedAt: new Date().toISOString(), commit: { sha: snapshotBCommit.commit, tree: snapshotBCommit.tree },
|
|
2865
|
+
snapshotPath: snapshotB, treeAtStart: snapshotBCommit.tree, treeAtExit: snapshotBCommit.tree, ownershipHandle: o.buildOwnership?.handle ?? null,
|
|
2866
|
+
declaredInputDigests: directDeclared, verdict: directVerdict({ scans: scanResult.scans, failures, deferrals }), freshness,
|
|
2867
|
+
timingsMs: { bind: directTiming.bindEnd - directTiming.bindStart, cargo: directTiming.cargoEnd - directTiming.cargoStart, afterExitBind: directTiming.afterEnd - directTiming.afterStart, diagnostics: directTiming.diagnostics + (Date.now() - wrote) },
|
|
2868
|
+
exit: exitCode, diagnosticsPath, outputDirs: targetDirs ?? {}, outputResolution,
|
|
2869
|
+
cargoWrapper: cargoShim ? { identity: cargoShim.identity, workspaces: cargoShim.workspaces } : null,
|
|
2870
|
+
});
|
|
2871
|
+
} catch (error) { o.err(`${tag} BLOCKED: RK_DIRECT_RECEIPT: the direct receipt cannot be written (${error.message}); the compile's evidence is incomplete`); return 1; }
|
|
2872
|
+
const receiptProblems = verifyDirectReceipt(directReceipt);
|
|
2873
|
+
if (receiptProblems.length) { o.err(`${tag} BLOCKED: RK_DIRECT_RECEIPT: the direct receipt does not verify: ${receiptProblems.join("; ")}`); return 1; }
|
|
2874
|
+
o.onDirectReceipt?.(directReceipt);
|
|
2875
|
+
o.out(`${tag} direct receipt ${JSON.stringify(directReceipt)}`);
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
1661
2878
|
if (failures.length) {
|
|
1662
2879
|
o.err(`${tag} BLOCKED (${failures.length})`);
|
|
1663
2880
|
for (const f of failures) o.err(` - ${f}`);
|
|
2881
|
+
// 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
|
|
2882
|
+
// environment it was judged in held from start to end, so the broker may reuse it (rightkit#1257); a live scan, a timeout, a
|
|
2883
|
+
// missing tool, anything undecided, or a binding that moved names none.
|
|
2884
|
+
if (boundFullA) {
|
|
2885
|
+
const binding = await judgedBinding();
|
|
2886
|
+
if (binding.problem) {
|
|
2887
|
+
o.err(`${tag} ${binding.prefix}: ${binding.problem} No deterministic verdict is named.`);
|
|
2888
|
+
if (binding.exit === 2) return 2;
|
|
2889
|
+
} else {
|
|
2890
|
+
for (const n of binding.notes) o.out(`${tag} ${n}`);
|
|
2891
|
+
const marker = (await import("../snapshot-check.mjs")).deterministicMarker(scanResult, binding.env);
|
|
2892
|
+
if (marker) o.out(marker);
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
1664
2895
|
o.err(classAOnly ? "Class A is red: nothing compiles until it is green." : "Fix and push again. Never bypass with --no-verify.");
|
|
1665
2896
|
return 1;
|
|
1666
2897
|
}
|
|
2898
|
+
if (fullA) {
|
|
2899
|
+
// The verdict is for the bytes that were checked, in the environment that was checked: either moving makes it a verdict for nothing.
|
|
2900
|
+
const binding = await judgedBinding();
|
|
2901
|
+
if (binding.problem) { o.err(`${tag} ${binding.prefix}: ${binding.problem}`); return binding.exit; }
|
|
2902
|
+
for (const n of binding.notes) o.out(`${tag} ${n}`);
|
|
2903
|
+
// The bytes this verdict covers, for the broker to compare with the snapshot it compiles: the tracked files only (what a
|
|
2904
|
+
// captured snapshot holds), and every file including untracked ones. Each is sha256 over sorted "<path>\t<git blob id of the
|
|
2905
|
+
// raw bytes>\n" lines (git hash-object --no-filters), so the broker can recompute it over its snapshot without this gate.
|
|
2906
|
+
if (live) {
|
|
2907
|
+
// A snapshot holds only the commit's files: every one is tracked.
|
|
2908
|
+
const trackedList = snapFullA ? null : git("ls-files", "-z", "--cached");
|
|
2909
|
+
if (trackedList && failed(trackedList)) { o.err(`${tag} BLOCKED: cannot list tracked files to name the checked bytes`); return 1; }
|
|
2910
|
+
const tracked = new Set(snapFullA ? live.files : text(trackedList).split("\0").filter(Boolean).map(posix));
|
|
2911
|
+
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}`);
|
|
2912
|
+
}
|
|
2913
|
+
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}`);
|
|
2914
|
+
return 0;
|
|
2915
|
+
}
|
|
1667
2916
|
if (classAOnly) {
|
|
1668
|
-
|
|
2917
|
+
// `check` judged the content only: the class A checks that execute the repository's code did not run here, so this is not a
|
|
2918
|
+
// full class A pass, and it says so (reviewer, 2026-10-27). The full gate runs them before any compile.
|
|
2919
|
+
const notRun = plan.scans.filter((s) => s.class === "A" && s.executes === true).map((s) => s.id);
|
|
2920
|
+
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 ? ", …" : ""}`);
|
|
2921
|
+
o.out(`${tag} class A content pass: ${scanResult.scans.length} deterministic scan(s) over ${changed.length} file(s) of ${base}`);
|
|
1669
2922
|
return 0;
|
|
1670
2923
|
}
|
|
1671
2924
|
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 +2929,19 @@ export async function main(argv, io = {}) {
|
|
|
1676
2929
|
}
|
|
1677
2930
|
}
|
|
1678
2931
|
|
|
2932
|
+
// The inherited locations a snapshot compile requires as absolute paths (GATE-CONTRACT "Output directory & cache").
|
|
2933
|
+
const SNAPSHOT_ABSOLUTE_ENV = ["CARGO_HOME", "RUSTUP_HOME", "SCCACHE_DIR", "TMPDIR", "TMP", "TEMP"];
|
|
2934
|
+
|
|
1679
2935
|
const invokedDirectly = (() => {
|
|
1680
2936
|
try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
|
|
1681
2937
|
})();
|
|
2938
|
+
/** This gate's own resolved entry file: the broker admits a compile only when the request names the pinned trusted gate by this
|
|
2939
|
+
* path (rustright reads it from RIGHTKIT_CALLING_GATE; a missing or different path is refused). */
|
|
2940
|
+
export const callingGatePath = () => fs.realpathSync(fileURLToPath(import.meta.url));
|
|
1682
2941
|
if (invokedDirectly) {
|
|
1683
|
-
|
|
2942
|
+
// Every child this run starts that can reach rustright (scans, unit stages, right-release dev/test, their builds) inherits it.
|
|
2943
|
+
process.env.RIGHTKIT_CALLING_GATE = callingGatePath();
|
|
2944
|
+
// Production carries the RustRight snapshot carrier (`o.snapshotBroker`, item 7 rows 7.20 to 7.24): the door's seam for a broker host.
|
|
2945
|
+
// The per-compile broker (pins, declared inputs, ownership, receipts) is built where the snapshot compile runs. Tests inject their own.
|
|
2946
|
+
import("../snapshot-broker.mjs").then(({ PRODUCTION_SNAPSHOT_CARRIER }) => main(process.argv.slice(2), { snapshotBroker: PRODUCTION_SNAPSHOT_CARRIER })).then((code) => process.exit(code), (e) => { console.error(`rightkit-gate: ${e?.stack ?? e}`); process.exit(2); });
|
|
1684
2947
|
}
|