@rightkit/release 0.6.24 → 0.6.25
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 +11 -35
- package/android-dev.mjs +2 -1
- package/app-check.mjs +1 -2
- package/app-closure.mjs +3 -2
- package/assembled-app.mjs +11 -3
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/bin/rightkit-gate.mjs +83 -26
- package/build-helper-trace.mjs +264 -0
- package/build-helpers-command.mjs +184 -0
- package/build-launch.mjs +11 -75
- package/build-release.mjs +10 -11
- package/cargo-profile-contract.mjs +19 -29
- package/cargo-target-shim.mjs +31 -0
- package/cli/right-release.mjs +5 -7
- package/create-mac-updater.mjs +4 -0
- package/cross-targets.mjs +94 -0
- package/deterministic-scans.mjs +310 -20
- package/development.mjs +8 -8
- package/freshness-census.mjs +1 -1
- package/gate-stages.mjs +1 -1
- package/install-assembled-app.mjs +1 -1
- package/install-tauri-authenticode.mjs +3 -3
- package/local-publish.mjs +27 -5
- package/mac-package-recipe.mjs +55 -2
- package/managed-roots.mjs +12 -4
- package/package.json +1 -5
- package/pipeline-admission.mjs +1 -1
- package/pipeline-receipts.mjs +302 -302
- package/provenance-verify.mjs +3 -1
- package/qa-build-features.mjs +11 -2
- package/release-qa-audit.mjs +9 -27
- package/release-qa-command.mjs +23 -64
- package/release-qa-fixtures.mjs +9 -5
- package/release-qa-floor.json +0 -1
- package/release-qa-policy.mjs +9 -10
- package/release-qa-receipt.mjs +6 -44
- package/release-source-identity.mjs +1 -12
- package/release-switch-registry.json +4 -1
- package/rightkit-registry-resolver.mjs +35 -3
- package/rightkit-versions.json +2 -2
- package/sign-windows.mjs +1 -1
- package/snapshot-broker.mjs +33 -8
- package/snapshot-metadata.mjs +32 -0
- package/snapshot-request.mjs +6 -3
- package/stage-sidecar.mjs +28 -11
- package/suite-signing-policy.mjs +8 -58
- package/templates/cargo-profiles.toml +6 -25
- package/test-command.mjs +40 -140
- package/updater-shipped-key.mjs +26 -0
- package/web-build.mjs +29 -4
- package/qa-journeys-prebuilt-run.mjs +0 -18
- package/qa-journeys-prebuilt.mjs +0 -148
- package/qa-layout.mjs +0 -229
- package/qa-snapshot-run.mjs +0 -53
- package/qa-snapshot.mjs +0 -164
- package/sign-qa-bundle.mjs +0 -210
- package/suite-signing.mjs +0 -259
package/deterministic-scans.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
25
25
|
import { spawn as spawnChild, spawnSync } from "./background-process.mjs";
|
|
26
26
|
import { spawnUnderOwnership } from "./build-ownership.mjs";
|
|
27
27
|
import { checkAdvisoryExceptions } from "./advisory-exceptions.mjs";
|
|
28
|
+
import { crossCompileEnv, crossTargetsFor, oneOutput } from "./cross-targets.mjs";
|
|
28
29
|
import { CLIPPY_UNIVERSAL_FEATURE_FLAGS, canonicalJson, clippyBatchPlan, clippySelectionOf, digestScanSet, requiredScanIds, rustCratePlan, rustScanId } from "./scan-digest.mjs";
|
|
29
30
|
import { reach } from "./lock-graph.mjs";
|
|
30
31
|
import { cargoManifestState } from "./cargo-manifest-text.mjs";
|
|
@@ -33,7 +34,7 @@ import { PYTHON_LOCK_FILE, pythonLockProblems } from "./python-lock.mjs";
|
|
|
33
34
|
import { lockOwnerOf, packageLockProblems, xcodeProjectLockProblems } from "./swift-lock.mjs";
|
|
34
35
|
import { LAUNCH_GUARD_IMPORT } from "./class-a-launch-guard.mjs";
|
|
35
36
|
import { advisoryCacheRoot, ensureAdvisoryDb, prepareDenyOverlay, readAdvisoryPin } from "./advisory-db.mjs";
|
|
36
|
-
import { observedEnvOf, readRequestRef, verifySnapshotReceipt, writeSnapshotRequest } from "./snapshot-request.mjs";
|
|
37
|
+
import { isInsideGenerationTarget, observedEnvOf, readRequestRef, verifySnapshotReceipt, writeSnapshotRequest } from "./snapshot-request.mjs";
|
|
37
38
|
import { SCAN_TOKEN_ENV, appendLedger, readLedger, scanTokenOf, unclaimedLedgers } from "./cargo-ledger.mjs";
|
|
38
39
|
import { brokerSection, carryBrokerAdapterAsync, verifyBrokerLedger } from "./snapshot-broker.mjs";
|
|
39
40
|
import { ADMISSION_POLL_MS, CARRIED_CANCEL_GRACE_MS, QUEUED_CEILING_FACTOR, createBrokerLive, createRunClock, createStatusPoller, watchBrokerAdmission } from "./broker-admission.mjs";
|
|
@@ -63,13 +64,14 @@ export const SCAN_CLASS = Object.freeze({ A: "A", B: "B" });
|
|
|
63
64
|
export const UNOBSERVED_NESTED_CARGO_NOTE = "cargo calls that a build script makes through $CARGO are unobserved: cargo hands build scripts its own path, not the gate's wrapper, so they are not in the cargo ledger; they inherit the parent's bound CARGO_TARGET_DIR, and the per-scan sentinel and the after-compile snapshot verify are the backstops";
|
|
64
65
|
|
|
65
66
|
/** The ledger token of the gate's own tool version probes (they reach the cargo wrapper like any scan does). */
|
|
67
|
+
const BUILD_HELPERS_COMMAND = fileURLToPath(new URL("./build-helpers-command.mjs", import.meta.url));
|
|
66
68
|
const PROBE_LEDGER_TOKEN = scanTokenOf("tool-probe");
|
|
67
69
|
|
|
68
70
|
/** Scan id prefixes (the text before the first ":") that belong to each class. */
|
|
69
71
|
export const CLASS_A_SCAN_PREFIXES = Object.freeze([
|
|
70
72
|
"rustfmt", "node-check", "json-parse", "toml-parse", "yaml-parse", "ownership-catalog",
|
|
71
73
|
"actionlint", "shellcheck", "pnpm-lockfile", "swift-lock", "gradle-lock", "python-lock", "pnpm-audit", "cargo-metadata", "cargo-deny", "cargo-audit", "cargo-package-list",
|
|
72
|
-
"changelog-version", "license", "secret-scan", "path-policy", "advisory-exceptions", "suite-api",
|
|
74
|
+
"changelog-version", "license", "secret-scan", "path-policy", "advisory-exceptions", "suite-api", "build-helpers",
|
|
73
75
|
// Everything mechanical and deterministic that does not compile is class A (Adrian, 2026-10-27): typecheck, lint, generated docs.
|
|
74
76
|
"tsc", "eslint", "biome", "generated-docs",
|
|
75
77
|
]);
|
|
@@ -154,7 +156,13 @@ const BUILD_ENV_NAMES = new Set(["CI", "GITHUB_ACTIONS", "CC", "CXX", "AR", "LD"
|
|
|
154
156
|
"VCINSTALLDIR", "LIB", "INCLUDE", "LIBPATH", "CFLAGS", "CXXFLAGS", "LDFLAGS", "CPPFLAGS", "PKG_CONFIG_PATH", "PKG_CONFIG_LIBDIR", "OPENSSL_DIR", "OPENSSL_LIB_DIR", "OPENSSL_INCLUDE_DIR",
|
|
155
157
|
"LIBCLANG_PATH", "LLVM_CONFIG_PATH", "ORT_LIB_LOCATION", "ORT_STRATEGY", "PROTOC", "WEBVIEW2_", "TAURI_"]);
|
|
156
158
|
const BUILD_ENV_PREFIXES = ["CARGO_", "RUST", "SCCACHE_", "RK_", "RIGHTKIT_", "RUSTRIGHT_", "PNPM_", "NPM_CONFIG_", "CMAKE_", "CFLAGS_", "CXXFLAGS_", "OPENSSL_", "ORT_", "ONNX", "TAURI_", "WEBVIEW2_", "WINDOWS_SDK", "MSVC"];
|
|
157
|
-
|
|
159
|
+
// Per-run identities never key a result (they change on every run, so the cache could never hit; measured 2026-11-02: HR Class A wrote
|
|
160
|
+
// 3,809 entries and hit none across identical runs): the run's ownership handle, the attest nonce, the request-id file, the carried call's
|
|
161
|
+
// snapshot request and cargo map, the direct receipt path.
|
|
162
|
+
const BUILD_ENV_EXCLUDED = /^(RK_GATE_|RUSTRIGHT_QUIET_LEASE$|RIGHTKIT_BUILD_OWNERSHIP$|RIGHTKIT_CARRIED_CARGO_MAP$|RUSTRIGHT_ATTEST_NONCE$|RUSTRIGHT_REQUEST_ID_FILE$|RUSTRIGHT_SNAPSHOT_REQUEST$|RK_DIRECT_RECEIPT$)/;
|
|
163
|
+
// A per-run temporary directory inside a value (the runtime cargo wrapper lives under a fresh `rk-gate-shim-XXXXXX`) is normalised: the
|
|
164
|
+
// wrapper's own identity (`rk-cargo-shim-<identity>`) still keys the result, the random parent does not.
|
|
165
|
+
const normalizeRunTemp = (value) => String(value).replace(/rk-gate-shim-[A-Za-z0-9]+/g, "rk-gate-shim-*");
|
|
158
166
|
export const buildEnvName = (name) => {
|
|
159
167
|
const upper = String(name).toUpperCase();
|
|
160
168
|
if (BUILD_ENV_EXCLUDED.test(upper)) return false;
|
|
@@ -163,7 +171,7 @@ export const buildEnvName = (name) => {
|
|
|
163
171
|
|
|
164
172
|
/** sha256 of the build-relevant part (buildEnvName) of the environment a scan's child receives. Pass the merged `{ ...deps.env, ...scan.env }`. */
|
|
165
173
|
export function inheritedEnvDigest(env = {}) {
|
|
166
|
-
const rows = Object.entries(env).filter(([name, value]) => value !== undefined && value !== null && buildEnvName(name)).map(([name, value]) => [String(name).toUpperCase(),
|
|
174
|
+
const rows = Object.entries(env).filter(([name, value]) => value !== undefined && value !== null && buildEnvName(name)).map(([name, value]) => [String(name).toUpperCase(), normalizeRunTemp(value)]);
|
|
167
175
|
rows.sort((x, y) => (x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : 0));
|
|
168
176
|
return sha256(canonicalJson(rows));
|
|
169
177
|
}
|
|
@@ -202,9 +210,9 @@ export function fileScanCache(dir, { now = () => new Date(), fsApi = fs } = {})
|
|
|
202
210
|
|
|
203
211
|
const packageFlags = (units) => [].concat(units).flatMap((unit) => ["-p", unit]);
|
|
204
212
|
|
|
205
|
-
/** `cargo clippy -p <units> [--message-format=...] --no-deps --all-targets <features> [--locked] -- -D warnings` (argv after `cargo`). */
|
|
206
|
-
export function clippyArgs(units, { features = [], messageFormat, noDeps = true, locked = false } = {}) {
|
|
207
|
-
return ["clippy", ...packageFlags(units), ...(messageFormat ? [`--message-format=${messageFormat}`] : []), ...(noDeps ? ["--no-deps"] : []), "--all-targets", ...features, ...(locked ? ["--locked"] : []), "--", "-D", "warnings"];
|
|
213
|
+
/** `cargo clippy -p <units> [--message-format=...] --no-deps --all-targets [--target <t>] <features> [--locked] -- -D warnings` (argv after `cargo`). */
|
|
214
|
+
export function clippyArgs(units, { features = [], messageFormat, noDeps = true, locked = false, target = null } = {}) {
|
|
215
|
+
return ["clippy", ...packageFlags(units), ...(messageFormat ? [`--message-format=${messageFormat}`] : []), ...(noDeps ? ["--no-deps"] : []), "--all-targets", ...(target ? ["--target", target] : []), ...features, ...(locked ? ["--locked"] : []), "--", "-D", "warnings"];
|
|
208
216
|
}
|
|
209
217
|
|
|
210
218
|
/** `cargo check -p <unit> [--message-format=...] --all-targets <features> [--locked]`. */
|
|
@@ -849,6 +857,8 @@ export function declaredBrokerCall(unit, value) {
|
|
|
849
857
|
export function planScans({
|
|
850
858
|
changed = [], contentRoot, liveRoot = contentRoot, trustedRoot = null, exists = fs.existsSync, readFile = (f) => fs.readFileSync(f, "utf8"),
|
|
851
859
|
platform = process.platform, includeTests = true, localCompile = true,
|
|
860
|
+
// The host's CPU architecture for its own Rust target (oneOutput, cross-targets.mjs).
|
|
861
|
+
nativeArch = process.arch,
|
|
852
862
|
// Where the quiet test scans (ci/gate.config.json `quietTests`) run: "local" plans them for the quiet lane; "ci" leaves them to the
|
|
853
863
|
// CI shards (one crate per runner, where the port, pipe, desktop and wall-clock conflicts that force the serial lane do not exist)
|
|
854
864
|
// and records a note naming them (Adrian 2026-10-22: compile locally on the warm cache, qualify the conflicting tests on VMs).
|
|
@@ -981,9 +991,18 @@ export function planScans({
|
|
|
981
991
|
const plainDir = (d) => !ABSOLUTE_PATH.test(d) && !d.split("/").includes("..") && SAFE_UNIT_DIR.test(d);
|
|
982
992
|
const cargoWs = cleanDir(cargoDir);
|
|
983
993
|
if (!plainDir(cargoWs)) throw new TypeError("planScans: cargoDir must be a plain repository-relative directory");
|
|
984
|
-
|
|
994
|
+
// Every compile lands in the one output directory (oneOutput, cross-targets.mjs): release profile, the host's target unless another is named.
|
|
995
|
+
// A refused argv (RK_PROFILE_REFUSED, RK_TEST_MODE_REFUSED) becomes a scan that fails with the refusal: in scope it fails the run, out of
|
|
996
|
+
// scope it is dropped with the rest, and it never aborts planning for the whole repository.
|
|
997
|
+
const refusedScan = (error) => ({ tool: "node", args: ["-e", `console.error(${JSON.stringify(String(error.message))}); process.exit(1)`] });
|
|
998
|
+
const cargoBase = (ws, args, { raw = false } = {}) => { let argv; try { argv = oneOutput(raw ? args : everyFailure(args), { platform, arch: nativeArch }); } catch (error) { return { ...refusedScan(error), ...(ws !== "." ? { cwd: ws } : {}) }; } return { tool: cargoCommand[0], args: [...cargoCommand.slice(1), ...argv], ...(ws !== "." ? { cwd: ws } : {}), ...(targetDirs?.[ws] ? { env: { CARGO_TARGET_DIR: targetDirs[ws] } } : {}) }; };
|
|
985
999
|
// Class B (compiles): started from the primary checkout on a broker host, the snapshot carrier names the source.
|
|
986
1000
|
const cargoIn = (ws, ...args) => ({ ...cargoBase(ws, args), ...(cargoLiveRoot ? { root: "cargoLive" } : {}) });
|
|
1001
|
+
// A Rust test scan on a BROKER host (cargoLiveRoot set; GATE-CONTRACT GC-A08): the broker compiles with the plain test command plus --locked and
|
|
1002
|
+
// --message-format=json (the broker adds --no-run itself and never executes a test), and the gate runs the listed executables outside it. --no-fail-fast and
|
|
1003
|
+
// --test-threads=1 are RUN arguments (runOutside.args), never part of the compile. A direct host (no broker, cargoLiveRoot unset) has nothing to keep tests
|
|
1004
|
+
// out of: its test scan stays the one-step `test ... --locked --no-fail-fast`, compile and run together, with no runOutside.
|
|
1005
|
+
const cargoTestIn = (ws, ...args) => ({ ...cargoBase(ws, [...args, "--message-format=json"], { raw: true }), root: "cargoLive" });
|
|
987
1006
|
// Class A (reads Cargo inputs, compiles nothing): always the content root, the snapshot or the commit's own tree. These still execute
|
|
988
1007
|
// cargo (through the broker on a broker host), so a unit-scoped check follows the Rust closure for them (app-closure.mjs scopeScans reads
|
|
989
1008
|
// the ids `cargo-metadata|cargo-deny|cargo-audit[:<label>]` and `cargo-package-list:<unit>` and the scan's `cwd`; rename one there too).
|
|
@@ -1228,6 +1247,11 @@ export function planScans({
|
|
|
1228
1247
|
}
|
|
1229
1248
|
if (rustTouched) {
|
|
1230
1249
|
for (const ws of touchedWs) add({ id: `cargo-metadata${wsIdSuffix(ws)}`, class: A, ...cargoReadIn(ws, ...metadataArgs()) });
|
|
1250
|
+
// rightkit#1450 (Adrian, 2026-11-02): every build script and proc macro of the workspace must have been traced once on this host, writing only
|
|
1251
|
+
// inside its build directories; an untraced or out-of-bounds unit refuses Class A, naming it. Runs the gate's own command, directly (GC-A11).
|
|
1252
|
+
// It runs in its workspace (cwd), so a unit's scope keeps it only for the Cargo workspaces of its own closure (app-closure CARGO_WORKSPACE_SCAN),
|
|
1253
|
+
// like metadata, deny and audit: an npm-only unit traces nothing.
|
|
1254
|
+
for (const ws of touchedWs) add({ id: `build-helpers${wsIdSuffix(ws)}`, class: A, tool: "node", args: [BUILD_HELPERS_COMMAND, "check", "--manifest-path", "Cargo.toml", ...(platform === "linux" ? ["--trace-missing"] : [])], ...(ws !== "." ? { cwd: ws } : {}), root: "content" });
|
|
1231
1255
|
// Locked package listings are observations in RustRight (#1216). They run through the selected executor
|
|
1232
1256
|
// from the checked Cargo inputs, just like metadata; an executor refusal remains a failed scan.
|
|
1233
1257
|
for (const name of crateNames.filter((n) => rustMap?.get(n).publish !== false)) add({ id: rustScanId.packageList(name), class: A, ...cargoReadIn(wsOf(name), "package", "-p", crateOf(name), "--list", "--allow-dirty", "--locked") });
|
|
@@ -1309,6 +1333,18 @@ export function planScans({
|
|
|
1309
1333
|
checkSets.forEach((set, i) => add({ id: rustScanId.checkFeatures(name, i), class: B, ...cargoIn(ws, ...checkArgs(crate, { features: set, locked: true })) }));
|
|
1310
1334
|
if (clippyBatching) lintSets.forEach((set) => clippyEntries.push({ name, crate, ws, set, count: lintSets.length, env: targetDirs?.[ws] ?? null }));
|
|
1311
1335
|
else lintSets.forEach((set, i) => add({ id: rustScanId.clippy(name, lintSets.length, i), class: B, ...cargoIn(ws, ...clippyArgs(crate, { features: set, locked: true })) }));
|
|
1336
|
+
// The other two OS targets, every lint set, -D warnings, on every host (cross-targets.mjs; Adrian 2026-11-02/03, no skip). The zig C
|
|
1337
|
+
// toolchain must be provisioned (crossCcDir); without it the scan fails naming the fix, never skipped. On a broker host the broker must
|
|
1338
|
+
// admit the cross target and its CC_/CXX_/AR_ variables; a refusal there fails the scan.
|
|
1339
|
+
for (const target of crossTargetsFor(own, platform)) {
|
|
1340
|
+
const crossEnv = crossCompileEnv(target);
|
|
1341
|
+
lintSets.forEach((set, i) => {
|
|
1342
|
+
const id = `${rustScanId.clippy(name, lintSets.length, i)}@${target}`;
|
|
1343
|
+
if (!crossEnv) { add({ id, class: B, tool: "node", args: ["-e", `console.error("RK_CROSS_CC_UNPROVISIONED: the zig C toolchain for ${target} is not provisioned; run node scripts/ci/provision-zig.mjs <managed data root>/zig (or set RIGHTKIT_CROSS_CC_DIR)"); process.exit(1)`] }); return; }
|
|
1344
|
+
const scan = cargoIn(ws, ...clippyArgs(crate, { features: set, locked: true, target }));
|
|
1345
|
+
add({ id, class: B, ...scan, env: { ...(scan.env ?? {}), ...crossEnv } });
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1312
1348
|
// rustdoc flags go after `--` for this crate's library only: the broker owns RUSTDOCFLAGS and refuses
|
|
1313
1349
|
// a request that sets it. A crate with no library has no API docs to check.
|
|
1314
1350
|
if (exists(path.join(contentRoot, rustDir(name), "src", "lib.rs"))) {
|
|
@@ -1432,8 +1468,8 @@ export function planScans({
|
|
|
1432
1468
|
const e2e = e2eCrateDirs instanceof Set && e2eCrateDirs.has(rustDir(name));
|
|
1433
1469
|
if (e2e) { qualifyOwed.push({ id: rustScanId.testRust(name, sets.length, 0), kind: "e2e-crate", dir: rustDir(name), crate: crateOf(name), reason: "end-to-end crate: its tests drive a built app and never run as class B unit tests (P7)" }); notes.push(`${crateOf(name)} is an end-to-end crate: its test scan compiles with --no-run here; its tests run in the qualify stage against the built app (qa.journeys), never as class B unit tests`); }
|
|
1434
1470
|
sets.forEach((set, i) => {
|
|
1435
|
-
add({ id: rustScanId.testRust(name, sets.length, i), class: B, ...quiet, ...(e2e ? { e2e: true } : {}), ...cargoIn(wsOf(name), "test", "-p", crateOf(name), ...set, "--locked", ...(
|
|
1436
|
-
if (own.alsoSerial) add({ id: rustScanId.testRustSerial(name, sets.length, i), class: B, ...quiet, ...(e2e ? { e2e: true } : {}), ...cargoIn(wsOf(name), "test", "-p", crateOf(name), ...set, "--locked", ...(
|
|
1471
|
+
add({ id: rustScanId.testRust(name, sets.length, i), class: B, ...quiet, ...(e2e ? { e2e: true } : {}), ...(e2e ? cargoIn(wsOf(name), "test", "-p", crateOf(name), ...set, "--locked", "--no-run") : cargoLiveRoot ? { ...cargoTestIn(wsOf(name), "test", "-p", crateOf(name), ...set, "--locked"), runOutside: { args: [] } } : cargoIn(wsOf(name), "test", "-p", crateOf(name), ...set, "--locked", "--no-fail-fast")) });
|
|
1472
|
+
if (own.alsoSerial) add({ id: rustScanId.testRustSerial(name, sets.length, i), class: B, ...quiet, ...(e2e ? { e2e: true } : {}), ...(e2e ? cargoIn(wsOf(name), "test", "-p", crateOf(name), ...set, "--locked", "--no-run") : cargoLiveRoot ? { ...cargoTestIn(wsOf(name), "test", "-p", crateOf(name), ...set, "--locked"), runOutside: { args: ["--test-threads=1"] } } : cargoIn(wsOf(name), "test", "-p", crateOf(name), ...set, "--locked", "--no-fail-fast", "--", "--test-threads=1")) });
|
|
1437
1473
|
});
|
|
1438
1474
|
}
|
|
1439
1475
|
for (const name of pkgNames) {
|
|
@@ -1763,6 +1799,200 @@ export function parseTestSummary(text) {
|
|
|
1763
1799
|
return null;
|
|
1764
1800
|
}
|
|
1765
1801
|
|
|
1802
|
+
// ---- Rust tests run outside the broker (GATE-CONTRACT GC-A08) -------------------------------------------------------------------------
|
|
1803
|
+
// On a broker host a Rust test scan compiles through the broker with the plain test command and --message-format=json (the broker adds
|
|
1804
|
+
// --no-run itself) and the gate then runs the listed test executables itself, never through the broker. A direct host (no broker) keeps the
|
|
1805
|
+
// one-step test command and uses none of this. Doctests
|
|
1806
|
+
// are not planned: the broker refuses `test --doc`, and a --no-run listing has no doctest executable. These are the pure
|
|
1807
|
+
// parts of that second phase; they mirror crates/rightkit-qa/src/run_tests (artifacts.rs, exec.rs, libtest.rs).
|
|
1808
|
+
|
|
1809
|
+
/** The package name and version of a cargo package id: `path+file:///w/fx#fx@0.1.0`, `path+file:///w/fx#0.1.0` (the name is the last
|
|
1810
|
+
* path segment) or the old `fx 0.1.0 (path+file:///w/fx)`. Null parts when the id says nothing usable. */
|
|
1811
|
+
export function packageOfId(id) {
|
|
1812
|
+
const text = String(id ?? "");
|
|
1813
|
+
const old = /^([^\s#@]+) (\d[^\s]*) \(/.exec(text);
|
|
1814
|
+
if (old) return { name: old[1], version: old[2] };
|
|
1815
|
+
const hash = text.indexOf("#");
|
|
1816
|
+
if (hash === -1) return { name: null, version: null };
|
|
1817
|
+
const fragment = text.slice(hash + 1);
|
|
1818
|
+
const at = fragment.indexOf("@");
|
|
1819
|
+
if (at > 0) return { name: fragment.slice(0, at), version: fragment.slice(at + 1) || null };
|
|
1820
|
+
if (/^\d/.test(fragment)) {
|
|
1821
|
+
const segments = text.slice(0, hash).replace(/\/+$/, "").split("/");
|
|
1822
|
+
return { name: segments.at(-1) || null, version: fragment };
|
|
1823
|
+
}
|
|
1824
|
+
return { name: fragment || null, version: null };
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/**
|
|
1828
|
+
* What a `cargo test --no-run --message-format=json` listing says. Only `compiler-artifact` records of the test profile that name an
|
|
1829
|
+
* executable are test executables (every unit-test binary and integration test; never a build script, library or plain binary). Lines
|
|
1830
|
+
* that are not JSON objects (a broker banner) are ignored. `buildFinished` is true when every `build-finished` record said success,
|
|
1831
|
+
* false when one said otherwise, null when the listing has none (a compile cut short).
|
|
1832
|
+
*/
|
|
1833
|
+
export function parseTestListing(text) {
|
|
1834
|
+
const out = { binaries: [], buildFinished: null, artifacts: 0 };
|
|
1835
|
+
const seen = new Set();
|
|
1836
|
+
for (const raw of String(text ?? "").split(/\r?\n/)) {
|
|
1837
|
+
const line = raw.trimStart();
|
|
1838
|
+
if (!line.startsWith("{")) continue;
|
|
1839
|
+
let v; try { v = JSON.parse(line); } catch { continue; }
|
|
1840
|
+
if (v?.reason === "build-finished") out.buildFinished = (out.buildFinished ?? true) && v.success === true;
|
|
1841
|
+
else if (v?.reason === "compiler-artifact") {
|
|
1842
|
+
out.artifacts += 1;
|
|
1843
|
+
if (v.profile?.test !== true || typeof v.executable !== "string" || v.executable === "" || seen.has(v.executable)) continue;
|
|
1844
|
+
seen.add(v.executable);
|
|
1845
|
+
const pkg = packageOfId(v.package_id);
|
|
1846
|
+
const manifest = typeof v.manifest_path === "string" ? v.manifest_path : "";
|
|
1847
|
+
const target = typeof v.target?.name === "string" ? v.target.name : "";
|
|
1848
|
+
out.binaries.push({ package: pkg.name ?? target, version: pkg.version, target, kind: Array.isArray(v.target?.kind) ? v.target.kind.filter((k) => typeof k === "string") : [], executable: v.executable, manifestDir: manifest ? path.dirname(manifest) : "" });
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
out.binaries.sort((a, b) => (a.executable < b.executable ? -1 : a.executable > b.executable ? 1 : 0));
|
|
1852
|
+
return out;
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
/** The text a failed compile leaves for a reader: the rendered error diagnostics of the JSON listing plus every non-JSON line (a broker refusal). */
|
|
1856
|
+
export function listingFailureText(text, limit = 8000) {
|
|
1857
|
+
const out = [];
|
|
1858
|
+
for (const raw of String(text ?? "").split(/\r?\n/)) {
|
|
1859
|
+
const line = raw.trimEnd();
|
|
1860
|
+
if (!line.trim()) continue;
|
|
1861
|
+
if (!line.trimStart().startsWith("{")) { out.push(line); continue; }
|
|
1862
|
+
try { const v = JSON.parse(line); if (v?.reason === "compiler-message" && v.message?.level === "error" && typeof v.message.rendered === "string") out.push(v.message.rendered.trimEnd()); } catch { out.push(line.slice(0, 300)); }
|
|
1863
|
+
}
|
|
1864
|
+
return out.join("\n").slice(-limit);
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
/** Sum of every `test result: ok. N passed; M failed; ...` line a libtest binary printed; null when it printed none (a custom harness, a crash). A line counts only when it starts the line and carries both a passed and a failed count. */
|
|
1868
|
+
export function parseLibtestSummary(text) {
|
|
1869
|
+
const sum = { results: 0, ok: true, passed: 0, failed: 0, ignored: 0, measured: 0, filteredOut: 0 };
|
|
1870
|
+
for (const line of String(text ?? "").split(/\r?\n/)) {
|
|
1871
|
+
const m = /^test result: (ok|FAILED)\. (.*)$/.exec(line.trimEnd());
|
|
1872
|
+
if (!m) continue;
|
|
1873
|
+
const counts = {}; for (const c of m[2].matchAll(/(\d+) (passed|failed|ignored|measured|filtered out)/g)) counts[c[2]] = Number(c[1]);
|
|
1874
|
+
if (counts.passed === undefined || counts.failed === undefined) continue;
|
|
1875
|
+
sum.results += 1; sum.ok = sum.ok && m[1] === "ok";
|
|
1876
|
+
sum.passed += counts.passed; sum.failed += counts.failed; sum.ignored += counts.ignored ?? 0; sum.measured += counts.measured ?? 0; sum.filteredOut += counts["filtered out"] ?? 0;
|
|
1877
|
+
}
|
|
1878
|
+
return sum.results > 0 ? sum : null;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
const TEST_RUN_OPS = Object.freeze({
|
|
1882
|
+
realpath: (p) => fs.realpathSync.native(p),
|
|
1883
|
+
isFile: (p) => fs.statSync(p).isFile(),
|
|
1884
|
+
isDir: (p) => fs.statSync(p).isDirectory(),
|
|
1885
|
+
digestFile: (p) => sha256(fs.readFileSync(p)),
|
|
1886
|
+
});
|
|
1887
|
+
|
|
1888
|
+
const insideDir = (real, dirReal, platform) => {
|
|
1889
|
+
const mod = platform === "win32" ? path.win32 : path;
|
|
1890
|
+
const fold = (p) => (platform === "win32" ? p.toLowerCase() : p);
|
|
1891
|
+
const rel = mod.relative(fold(dirReal), fold(real));
|
|
1892
|
+
return rel !== "" && !mod.isAbsolute(rel) && rel.split(/[\\/]/)[0] !== "..";
|
|
1893
|
+
};
|
|
1894
|
+
|
|
1895
|
+
/**
|
|
1896
|
+
* Every test executable must be a regular file whose real path is inside the verified output directory (`boundDirs`: the receipt's
|
|
1897
|
+
* outputDir on a broker host, the scan's bound target directory otherwise); with `generationRoots` its lexical directory must also be a
|
|
1898
|
+
* managed generation target. An executable anywhere else is refused: the gate never runs a binary the verified compile did not write.
|
|
1899
|
+
* Returns { accepted: [binary + real + sha256], problems: [text] }.
|
|
1900
|
+
*/
|
|
1901
|
+
export function containTestExecutables(binaries, { boundDirs, generationRoots = null, platform = process.platform, ops = TEST_RUN_OPS } = {}) {
|
|
1902
|
+
const mod = platform === "win32" ? path.win32 : path;
|
|
1903
|
+
const problems = []; const accepted = [];
|
|
1904
|
+
const dirs = [];
|
|
1905
|
+
for (const d of boundDirs ?? []) { try { dirs.push(ops.realpath(d)); } catch { /* an output directory that does not exist contains nothing */ } }
|
|
1906
|
+
if (dirs.length === 0) return { accepted, problems: [`no verified output directory exists (${(boundDirs ?? []).join(", ") || "none bound"}); no test executable can be shown to come from the compile`] };
|
|
1907
|
+
for (const b of binaries) {
|
|
1908
|
+
const exe = b.executable;
|
|
1909
|
+
if (!mod.isAbsolute(exe)) { problems.push(`${exe} is not an absolute path`); continue; }
|
|
1910
|
+
if (generationRoots && !isInsideGenerationTarget(mod.dirname(exe), generationRoots, platform)) { problems.push(`${exe} is not inside a managed generation target`); continue; }
|
|
1911
|
+
let real;
|
|
1912
|
+
try { real = ops.realpath(exe); if (!ops.isFile(real)) throw new Error("not a regular file"); } catch (e) { problems.push(`${exe} is not a readable file (${e.message})`); continue; }
|
|
1913
|
+
if (!dirs.some((d) => insideDir(real, d, platform))) { problems.push(`${exe} resolves to ${real}, outside the verified output directory ${dirs.join(", ")}`); continue; }
|
|
1914
|
+
let sha = null; try { sha = ops.digestFile(real); } catch (e) { problems.push(`${exe} cannot be read (${e.message})`); continue; }
|
|
1915
|
+
accepted.push({ ...b, real, sha256: sha });
|
|
1916
|
+
}
|
|
1917
|
+
return { accepted, problems };
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
/**
|
|
1921
|
+
* Run the accepted test executables one after another, outside the broker, each from its package directory with the CARGO_MANIFEST_DIR,
|
|
1922
|
+
* CARGO_PKG_NAME and CARGO_PKG_VERSION cargo would set, with `scan.runOutside.args`, under one deadline (`limitMs`) that kills the
|
|
1923
|
+
* process tree. Every executable runs (no-fail-fast); the row fails if any failed. The judgment is per crate (the scan), not per
|
|
1924
|
+
* executable: the libtest summaries are summed and the same rules as a node:test row apply (testResultsProblem), because many test
|
|
1925
|
+
* binaries legitimately run 0 tests. Returns { status, code?, detail?, results, executables }.
|
|
1926
|
+
*/
|
|
1927
|
+
/**
|
|
1928
|
+
* Every accepted test executable must be in the broker's signed list (receipt.testExecutables: [{path, sha256, ...}]) with the same sha256.
|
|
1929
|
+
* A null list (the broker could not enumerate them, e.g. no JSON output) refuses everything, naming the broker's reason.
|
|
1930
|
+
*/
|
|
1931
|
+
export function signedTestExecutableProblems(accepted, signed, reason = null, platform = process.platform) {
|
|
1932
|
+
if (!Array.isArray(signed)) return [`the broker signed no test executable list (${reason ?? "none given"})`];
|
|
1933
|
+
const norm = (p) => { const r = (platform === "win32" ? path.win32 : path.posix).normalize(String(p)); return platform === "win32" ? r.toLowerCase() : r; };
|
|
1934
|
+
const byPath = new Map(signed.map((e) => [norm(e?.path), e]));
|
|
1935
|
+
const problems = [];
|
|
1936
|
+
for (const b of accepted) {
|
|
1937
|
+
const e = byPath.get(norm(b.executable)) ?? byPath.get(norm(b.real));
|
|
1938
|
+
if (!e) problems.push(`${b.executable} is not in the broker's signed test executables`);
|
|
1939
|
+
else if (e.sha256 !== b.sha256) problems.push(`${b.executable} has sha256 ${b.sha256}, the broker signed ${e.sha256}`);
|
|
1940
|
+
}
|
|
1941
|
+
return problems;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
/** Test executables run at once (ADR-001 C2, rightkit#1454): the host's physical cores minus 2, at least 1. */
|
|
1945
|
+
export function testRunConcurrency({ cpus = os.cpus().length } = {}) {
|
|
1946
|
+
return Math.max(1, cpus - 2);
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
export async function runTestBinaries({ scan, binaries, spawn, env, limitMs, signal = null, ops = TEST_RUN_OPS, concurrency = testRunConcurrency() }) {
|
|
1950
|
+
const args = [...(scan.runOutside?.args ?? [])];
|
|
1951
|
+
const ctl = new AbortController();
|
|
1952
|
+
const startedAt = Date.now();
|
|
1953
|
+
const clock = setTimeout(() => ctl.abort(), limitMs);
|
|
1954
|
+
const runSignal = signal ? AbortSignal.any([signal, ctl.signal]) : ctl.signal;
|
|
1955
|
+
const executables = []; const failures = []; const total = { tests: 0, pass: 0, fail: 0, skipped: 0, todo: 0 };
|
|
1956
|
+
let stopped = null;
|
|
1957
|
+
// ADR-001 C2 (kept by Adrian 2026-11-02, rightkit#1454): the executables run in parallel, `concurrency` at a time (physical cores minus 2), each
|
|
1958
|
+
// recording into its own slot so the report keeps the listing order.
|
|
1959
|
+
const slots = new Array(binaries.length);
|
|
1960
|
+
const runOne = async (b, index) => {
|
|
1961
|
+
const executables = { push: (e) => { slots[index] = e; } };
|
|
1962
|
+
{
|
|
1963
|
+
const label = `${b.package}::${b.target}`;
|
|
1964
|
+
if (runSignal.aborted) { stopped = signal?.aborted ? "CANCELLED" : "TIMEOUT"; executables.push({ label, sha256: b.sha256, status: "not-run" }); return; }
|
|
1965
|
+
try { if (!b.manifestDir || !ops.isDir(b.manifestDir)) { failures.push(`${label}: its package directory ${b.manifestDir || "(none)"} does not exist`); executables.push({ label, sha256: b.sha256, status: "fail" }); return; } } catch (e) { failures.push(`${label}: its package directory ${b.manifestDir} is unreadable (${e.message})`); executables.push({ label, sha256: b.sha256, status: "fail" }); return; }
|
|
1966
|
+
const childEnv = { ...env, CARGO_MANIFEST_DIR: b.manifestDir, CARGO_PKG_NAME: b.package, ...(b.version ? { CARGO_PKG_VERSION: b.version } : {}) };
|
|
1967
|
+
const remaining = Math.max(1, limitMs - (Date.now() - startedAt));
|
|
1968
|
+
let r;
|
|
1969
|
+
try { r = await spawn(b.real, args, { cwd: b.manifestDir, env: childEnv, signal: runSignal, boundedMs: remaining }); } catch (e) { r = { error: e }; }
|
|
1970
|
+
if (runSignal.aborted && (!r || r.error || r.status !== 0)) { stopped = signal?.aborted ? "CANCELLED" : "TIMEOUT"; executables.push({ label, sha256: b.sha256, status: "not-run" }); return; }
|
|
1971
|
+
if (r?.error?.code === "ETIMEDOUT" || r?.timedOut === true) { stopped = "TIMEOUT"; executables.push({ label, sha256: b.sha256, status: "fail" }); failures.push(`${label}: killed with its process tree at the run limit`); return; }
|
|
1972
|
+
const summary = parseLibtestSummary(`${r?.stdout ?? ""}\n${r?.stderr ?? ""}`);
|
|
1973
|
+
if (summary) { total.tests += summary.passed + summary.failed + summary.ignored; total.pass += summary.passed; total.fail += summary.failed; total.skipped += summary.ignored; }
|
|
1974
|
+
const entry = { label, sha256: b.sha256, status: "pass", ...(summary ? { passed: summary.passed, failed: summary.failed, ignored: summary.ignored } : {}) };
|
|
1975
|
+
if (!r || r.error) { entry.status = "fail"; failures.push(`${label}: ${r?.error?.message ?? "no result"}`); }
|
|
1976
|
+
else if (r.status !== 0) { entry.status = "fail"; failures.push(`${label}: ${failureDetail(r) || `exit ${r.status}`}`); }
|
|
1977
|
+
else if (summary && (!summary.ok || summary.failed > 0)) { entry.status = "fail"; failures.push(`${label}: exited 0 but its summary reports ${summary.failed} failed test(s)`); }
|
|
1978
|
+
executables.push(entry);
|
|
1979
|
+
}
|
|
1980
|
+
};
|
|
1981
|
+
try {
|
|
1982
|
+
let next = 0;
|
|
1983
|
+
const worker = async () => { while (next < binaries.length) { const i = next; next += 1; await runOne(binaries[i], i); } };
|
|
1984
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, binaries.length)) }, () => worker()));
|
|
1985
|
+
for (const e of slots) if (e) executables.push(e);
|
|
1986
|
+
} finally { clearTimeout(clock); }
|
|
1987
|
+
const results = { runner: "libtest", ...total, binaries: executables.length };
|
|
1988
|
+
if (stopped === "CANCELLED") return { status: "not-run", code: "CANCELLED", detail: "cancelled while running", results, executables };
|
|
1989
|
+
if (stopped === "TIMEOUT") return { status: "fail", code: "TIMEOUT", detail: `${scan.id}: its test executables ran longer than the ${Math.round(limitMs / 1000)} s run limit and were killed with their process tree${failures.length ? `; ${failures.slice(0, 3).join(" | ")}` : ""}`, results, executables };
|
|
1990
|
+
if (failures.length) return { status: "fail", code: "TEST_FAILED", detail: `${failures.length} of ${executables.length} test executable(s) failed: ${failures.slice(0, 5).join(" | ")}`.slice(0, 4000), results, executables };
|
|
1991
|
+
const problem = testResultsProblem(results);
|
|
1992
|
+
if (problem) return { status: "fail", code: problem.code, detail: `${problem.detail} (summed over ${executables.length} test executable(s) of ${scan.id})`, results, executables };
|
|
1993
|
+
return { status: "pass", results, executables };
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1766
1996
|
/** The largest inline `run:` script a workflow may carry while the pinned actionlint is 1.7.12 (rhysd/actionlint#702). */
|
|
1767
1997
|
export const RUN_SCRIPT_LIMIT_BYTES = 3800;
|
|
1768
1998
|
/** Every block `run:` script (`run: |` or `run: >`) of a workflow text larger than `limit` bytes, as "<file>:<line> <bytes> bytes".
|
|
@@ -2218,7 +2448,9 @@ export async function runScans(plan, options = {}) {
|
|
|
2218
2448
|
const selected = plan.scans.filter((s) => classes.includes(s.class) && !exclude(s));
|
|
2219
2449
|
const ordered = [...selected.filter((s) => s.class === SCAN_CLASS.A), ...selected.filter((s) => s.class === SCAN_CLASS.B)];
|
|
2220
2450
|
const ctx = { deps, contentRoot, read: (f) => deps.readFile(path.join(contentRoot, f)) };
|
|
2221
|
-
|
|
2451
|
+
// GATE-CONTRACT GC-A11 (Adrian, 2026-11-02): Class A never runs in the broker. Class A cargo reads are never carried, in any stage; the gate
|
|
2452
|
+
// runs them directly (snapshot-metadata.mjs directReadSpawn), and cargo-deny / cargo-audit are its own enrolled tools.
|
|
2453
|
+
const cargoReadsAreCarried = false;
|
|
2222
2454
|
// A broker host: the gate says so (options.brokerHost, from its executor) and a snapshot carrier implies it. One predicate (isBrokerCarriedScan) names the scans the broker carries.
|
|
2223
2455
|
const brokerHost = options.brokerHost === true || Boolean(deps.snapshotBroker);
|
|
2224
2456
|
const carriedCtx = { brokerHost, snapshotBroker: deps.snapshotBroker ?? null };
|
|
@@ -2320,7 +2552,14 @@ export async function runScans(plan, options = {}) {
|
|
|
2320
2552
|
async function runOneBody(s, extraEnv = null) {
|
|
2321
2553
|
if (signal?.aborted) return { id: s.id, class: s.class, command: s.command, tool: s.tool ?? null, toolVersion: null, status: "not-run", code: "CANCELLED", detail: "cancelled before it started" };
|
|
2322
2554
|
const started = Date.now();
|
|
2323
|
-
|
|
2555
|
+
let row = await scanRow(s, extraEnv);
|
|
2556
|
+
// rightkit#1459: a class A scan that timed out is run once more before it fails Class A (a hung tool, e.g. actionlint on 2026-11-02, ran
|
|
2557
|
+
// 27 min and failed a run whose rerun passed in 1 s). Class B and carried scans are not retried: a compile that times out is a real failure.
|
|
2558
|
+
if (row.status === "fail" && row.code === "TIMEOUT" && s.class === SCAN_CLASS.A && !signal?.aborted) {
|
|
2559
|
+
const first = row;
|
|
2560
|
+
row = await scanRow(s, extraEnv);
|
|
2561
|
+
row = { ...row, retried: { code: first.code, detail: first.detail } };
|
|
2562
|
+
}
|
|
2324
2563
|
done += 1;
|
|
2325
2564
|
try { onResult(row, { done, total, seconds: Math.round((Date.now() - started) / 1000) }); } catch { /* reporting never fails a scan */ }
|
|
2326
2565
|
return row;
|
|
@@ -2459,6 +2698,7 @@ export async function runScans(plan, options = {}) {
|
|
|
2459
2698
|
}
|
|
2460
2699
|
async function scanRowIn(s, extraEnv, owned) {
|
|
2461
2700
|
const row = { id: s.id, class: s.class, command: s.command, tool: s.tool ?? null, toolVersion: null, status: "fail" };
|
|
2701
|
+
const scanStartedAt = Date.now();
|
|
2462
2702
|
if (s.class === SCAN_CLASS.B && classBBlocked) {
|
|
2463
2703
|
return { ...row, status: "not-run", code: "CLASS_A_REQUIRED", detail: "class B refused: class A did not run in this call and no valid prior class A evidence for the same scan set was supplied" };
|
|
2464
2704
|
}
|
|
@@ -2511,22 +2751,25 @@ export async function runScans(plan, options = {}) {
|
|
|
2511
2751
|
else if (depVersionProblem) { status = "fail"; code = "TOOL_VERSION"; detail = `TOOL_VERSION: ${s.tool} runs ${depVersionProblem}`; }
|
|
2512
2752
|
else {
|
|
2513
2753
|
let cacheKey = null;
|
|
2514
|
-
//
|
|
2515
|
-
//
|
|
2754
|
+
// GATE-CONTRACT GC-T14: every unchanged scan result is reused by its full input key, a broker-carried compile included (measured
|
|
2755
|
+
// 2026-11-02: HR class B re-ran 92 broker jobs, most with no dirty unit, because carried compiles were never cached). The key covers the
|
|
2756
|
+
// crate's closure, its lock entries, the toolchain files, the tool version and the build environment; a miss runs the compile and
|
|
2757
|
+
// verifies the broker's signed evidence as before, and only a verified pass is written.
|
|
2516
2758
|
const carriedScan = isBrokerCarriedScan(s, carriedCtx);
|
|
2517
2759
|
const viaSnapshotBroker = carriedScan && Boolean(deps.snapshotBroker) && s.root === "cargoLive" && (s.tool === "rustright" || s.tool === "cargo");
|
|
2518
|
-
const carriedRead = cargoReadsAreCarried && s.class === SCAN_CLASS.A && (s.tool === "rustright" || s.tool === "cargo");
|
|
2519
|
-
if (cache && !
|
|
2760
|
+
const carriedRead = cargoReadsAreCarried && s.class === SCAN_CLASS.A && (s.tool === "rustright" || s.tool === "cargo");
|
|
2761
|
+
if (cache && !carriedRead) {
|
|
2520
2762
|
try {
|
|
2521
2763
|
const digest = cache.digest(s);
|
|
2522
2764
|
// The analyzers a scan runs through (actionlint's shellcheck) judge it too, so their versions key it as well.
|
|
2523
2765
|
const toolVersion = [p.identity ? `${p.version}@${p.identity.sha256}` : p.version, ...depVersions].join(" + ");
|
|
2524
|
-
if (digest) cacheKey = scanCacheKey({ id: s.id, command: s.command, toolVersion, env: s.env ?? {}, inheritedEnv: inheritedEnvDigest({ ...deps.env, ...s.env }), digest });
|
|
2766
|
+
if (digest) cacheKey = scanCacheKey({ id: s.id, command: s.runOutside ? `${s.command} ;; run ${canonicalJson(s.runOutside)}` : s.command, toolVersion, env: s.env ?? {}, inheritedEnv: inheritedEnvDigest({ ...deps.env, ...s.env }), digest });
|
|
2525
2767
|
} catch { cacheKey = null; }
|
|
2526
2768
|
const hit = cacheKey ? cache.read(cacheKey) : null;
|
|
2527
2769
|
// A cached test pass counts only with the counts it recorded: without them its coverage is unknown, so it runs again.
|
|
2528
2770
|
// ...and only if it passed at least one test: a cached all-skipped "pass" (written before D17) runs again (rightkit#1236).
|
|
2529
|
-
|
|
2771
|
+
// A Rust test scan (runOutside) is judged the same way: the executables are unknown until the compile, so its key is the source digest and the cached row must carry the summed libtest counts.
|
|
2772
|
+
const testEvidence = (s.kind !== "test" && !s.runOutside) || testResultsProblem(hit?.row?.results) === null;
|
|
2530
2773
|
if (hit && hit.key === cacheKey && hit.row?.status === "pass" && hit.row.id === s.id && hit.row.command === s.command && testEvidence) {
|
|
2531
2774
|
cacheHits.push(s.id);
|
|
2532
2775
|
return { ...row, status: "pass", cached: true, detail: `cached ${String(hit.at ?? "").slice(0, 10) || "earlier"}`, ...(s.kind === "test" ? { results: hit.row.results } : {}) };
|
|
@@ -2663,6 +2906,9 @@ export async function runScans(plan, options = {}) {
|
|
|
2663
2906
|
fs.mkdirSync(sentinel, { recursive: true });
|
|
2664
2907
|
}
|
|
2665
2908
|
const sentinelEnv = sentinel ? { CARGO_TARGET_DIR: sentinel, CARGO_BUILD_TARGET_DIR: sentinel } : {};
|
|
2909
|
+
// A Rust test scan's JSON listing (every compiler-artifact record) streams whole to a file of its own: the kept tail of a spawn would lose the early artifact records of a noisy compile.
|
|
2910
|
+
let listingDir = null; let listingFile = null; let listingText = ""; let listingCut = false;
|
|
2911
|
+
if (s.runOutside) { listingDir = fs.mkdtempSync(path.join(tmpRoot, "rk-testlist-")); listingFile = path.join(listingDir, "listing.jsonl"); }
|
|
2666
2912
|
try {
|
|
2667
2913
|
// The ownership records this request's sub-handle immediately before it is sent (the reclaim proof reads broker.submitted): a
|
|
2668
2914
|
// missing hook or a mark that did not land sends nothing.
|
|
@@ -2716,7 +2962,14 @@ export async function runScans(plan, options = {}) {
|
|
|
2716
2962
|
admission = watchBrokerAdmission({ clock: runClock, refFiles, live: brokerLive, poller: statusPoller });
|
|
2717
2963
|
admission.start();
|
|
2718
2964
|
}
|
|
2719
|
-
try { r = await deps.spawn(p.command, spawnArgs, { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...sentinelEnv, ...readTargetEnv, ...s.env, ...(extraEnv ?? {}), ...guardEnv, ...ledgerEnv, ...(carrier ? carrier.made.env : {}), ...refEnv, ...(carriedScan && !watched ? scanOperationEnv({ ...deps.env, ...s.env }, limitMs) : {}), ...trustEnv }, signal: scanSignal, ...(watched ? {} : { timeout: limitMs }) }); } finally { stopClock(); advisory?.cleanup?.(); }
|
|
2965
|
+
try { r = await deps.spawn(p.command, spawnArgs, { cwd: path.join(rootDir(s.root), s.cwd ?? ""), env: { ...deps.env, ...sentinelEnv, ...readTargetEnv, ...s.env, ...(extraEnv ?? {}), ...guardEnv, ...ledgerEnv, ...(carrier ? carrier.made.env : {}), ...refEnv, ...(carriedScan && !watched ? scanOperationEnv({ ...deps.env, ...s.env }, limitMs) : {}), ...trustEnv }, signal: scanSignal, ...(listingFile ? { stdoutFile: listingFile } : {}), ...(watched ? {} : { timeout: limitMs }) }); } finally { stopClock(); advisory?.cleanup?.(); }
|
|
2966
|
+
if (s.runOutside && r && !r.error) {
|
|
2967
|
+
// The whole listing: the file the spawn streamed, else (a spawn that does not know stdoutFile) its stdout. What a reader sees of a failed compile is the rendered errors.
|
|
2968
|
+
let fromFile = false;
|
|
2969
|
+
try { if (fs.existsSync(listingFile) && fs.statSync(listingFile).size > 0) { listingText = fs.readFileSync(listingFile, "utf8"); fromFile = true; } } catch { /* falls back to stdout */ }
|
|
2970
|
+
if (!fromFile) { listingText = String(r.stdout ?? ""); listingCut = r.truncated === true; }
|
|
2971
|
+
r = { ...r, stdout: listingFailureText(listingText) };
|
|
2972
|
+
}
|
|
2720
2973
|
}
|
|
2721
2974
|
if (signal?.aborted && (!r || r.error || r.status !== 0)) return { ...row, status: "not-run", code: "CANCELLED", detail: "cancelled while running" };
|
|
2722
2975
|
if ((clockExpired || timer.signal.aborted || r?.error?.code === "ETIMEDOUT") && (!r || r.error || r.status !== 0)) {
|
|
@@ -2767,7 +3020,7 @@ export async function runScans(plan, options = {}) {
|
|
|
2767
3020
|
const ref = readRequestRef(path.join(carrier.refDir, "request.json"));
|
|
2768
3021
|
const receipt = await deps.snapshotBroker.receiptOf(ref.requestId);
|
|
2769
3022
|
const { evidence } = verifySnapshotReceipt(receipt, { pins: deps.snapshotBroker.pins, jobId: ref.requestId, carrier: carrier.made.carrier, argv: carrier.cargoArgv, workspace: ref.workspace, observedEnv: observedEnvOf({ ...deps.env, ...s.env, ...(extraEnv ?? {}) }, deps.platform ?? process.platform), checkout: deps.snapshotBroker.checkout, outputRoots: deps.snapshotBroker.outputRoots, platform: deps.snapshotBroker.platform ?? deps.platform ?? process.platform });
|
|
2770
|
-
row.snapshotEvidence = { requestId: ref.requestId, outputDir: evidence.outputDir, cargoDir: s.cwd ?? ".", targetTriple: typeof receipt?.targetTriple === "string" && /^[A-Za-z0-9_.]+(?:-[A-Za-z0-9_.]+){1,4}$/.test(receipt.targetTriple) ? receipt.targetTriple : null, treeAtStart: evidence.snapshot.treeAtStart, treeAtExit: evidence.snapshot.treeAtExit };
|
|
3023
|
+
row.snapshotEvidence = { requestId: ref.requestId, outputDir: evidence.outputDir, cargoDir: s.cwd ?? ".", targetTriple: typeof receipt?.targetTriple === "string" && /^[A-Za-z0-9_.]+(?:-[A-Za-z0-9_.]+){1,4}$/.test(receipt.targetTriple) ? receipt.targetTriple : null, treeAtStart: evidence.snapshot.treeAtStart, treeAtExit: evidence.snapshot.treeAtExit, ...(receipt && Object.hasOwn(receipt, "testExecutables") ? { testExecutables: receipt.testExecutables, testExecutablesReason: receipt.testExecutablesReason ?? null } : {}) };
|
|
2771
3024
|
} catch (e) {
|
|
2772
3025
|
status = "fail"; code = "SNAPSHOT_EVIDENCE"; detail = `${s.id}: the broker's snapshot evidence did not verify (${e.code ?? "error"}: ${e.message})`;
|
|
2773
3026
|
}
|
|
@@ -2775,6 +3028,43 @@ export async function runScans(plan, options = {}) {
|
|
|
2775
3028
|
} finally {
|
|
2776
3029
|
if (carrier) { removeCarrier(carrier.made.file); fs.rmSync(carrier.refDir, { recursive: true, force: true }); }
|
|
2777
3030
|
if (tscProject) fs.rmSync(tscProject.dir, { recursive: true, force: true });
|
|
3031
|
+
if (listingDir) fs.rmSync(listingDir, { recursive: true, force: true });
|
|
3032
|
+
}
|
|
3033
|
+
// GATE-CONTRACT GC-A08: the compile above never ran a test. The gate runs the listed test executables here, outside the broker.
|
|
3034
|
+
// Quiet lane: a quiet suite keeps the broker's quiet lease (taken by runScans around this whole scan) through this run phase. The lease exists so nothing else
|
|
3035
|
+
// runs beside a conflict-prone suite, and the suite's tests now execute here, so releasing it after the compile would defeat it; the run phase is bounded
|
|
3036
|
+
// to what is left of QUIET_SCAN_MAX_MS so compile + run stay under the broker's lease ceiling.
|
|
3037
|
+
if (s.runOutside && status === "pass" && r && !r.error) {
|
|
3038
|
+
const fail = (c, d) => { status = "fail"; code = c; detail = d; };
|
|
3039
|
+
const listing = parseTestListing(listingText);
|
|
3040
|
+
const platform = deps.snapshotBroker?.platform ?? deps.platform ?? process.platform;
|
|
3041
|
+
if (listingCut) fail("TEST_LISTING", `${s.id}: the compile listing was cut at the kept output tail, so the test executables cannot be enumerated`);
|
|
3042
|
+
else if (listing.buildFinished !== true) fail("TEST_LISTING", `${s.id}: the compile exited 0 but its listing has no successful build-finished record (${listing.buildFinished === false ? "a build-finished record said failure" : "none found"})`);
|
|
3043
|
+
else if (listing.binaries.length === 0) {
|
|
3044
|
+
// A test scan that lists no test executable proves nothing; it is never a pass (rightkit-qa run_tests: "the compile listed no test executable").
|
|
3045
|
+
fail("NO_TEST_EXECUTABLES", `${s.id}: the compile listed no test executable (${listing.artifacts} artifact record(s), none of the test profile with an executable); a test scan that runs nothing is not a pass`);
|
|
3046
|
+
} else {
|
|
3047
|
+
let boundDirs; let generationRoots = null;
|
|
3048
|
+
if (viaSnapshotBroker) { boundDirs = row.snapshotEvidence?.outputDir ? [row.snapshotEvidence.outputDir] : []; generationRoots = deps.snapshotBroker.outputRoots ?? []; }
|
|
3049
|
+
else {
|
|
3050
|
+
const scanDir = path.join(rootDir(s.root), s.cwd ?? "");
|
|
3051
|
+
const target = s.env?.CARGO_TARGET_DIR ?? deps.env?.CARGO_TARGET_DIR ?? "target";
|
|
3052
|
+
boundDirs = [path.resolve(scanDir, target)];
|
|
3053
|
+
}
|
|
3054
|
+
const ops = { ...TEST_RUN_OPS, ...(options.testRunOps ?? {}) };
|
|
3055
|
+
const contained = containTestExecutables(listing.binaries, { boundDirs, generationRoots, platform, ops });
|
|
3056
|
+
// ADR-001 C2 (rightkit#1454): a broker that signs the compile's test executables (receipt.testExecutables, rustright) binds each one;
|
|
3057
|
+
// an executable whose path or sha256 is not in that signed list never runs. An older broker that signs no list leaves containment only.
|
|
3058
|
+
const signedProblems = viaSnapshotBroker && row.snapshotEvidence && Object.hasOwn(row.snapshotEvidence, "testExecutables") ? signedTestExecutableProblems(contained.accepted, row.snapshotEvidence.testExecutables, row.snapshotEvidence.testExecutablesReason, platform) : [];
|
|
3059
|
+
if (contained.problems.length) fail("TEST_EXECUTABLE_OUTSIDE_OUTPUT", `${s.id}: ${contained.problems.slice(0, 5).join("; ")}; the row fails and nothing was run`);
|
|
3060
|
+
else if (signedProblems.length) fail("TEST_EXECUTABLE_UNSIGNED", `${s.id}: ${signedProblems.slice(0, 5).join("; ")}; the row fails and nothing was run`);
|
|
3061
|
+
else {
|
|
3062
|
+
const budget = s.quiet === true ? Math.max(1, Math.min(limitMs, QUIET_SCAN_MAX_MS - (Date.now() - scanStartedAt))) : limitMs;
|
|
3063
|
+
const ran = await runTestBinaries({ scan: s, binaries: contained.accepted, spawn: deps.spawn, env: { ...deps.env, ...s.env, ...(extraEnv ?? {}) }, limitMs: budget, signal, ops });
|
|
3064
|
+
row.results = ran.results; row.testRun = { executables: ran.executables };
|
|
3065
|
+
if (ran.status !== "pass") { status = ran.status; code = ran.code; detail = ran.detail; }
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
2778
3068
|
}
|
|
2779
3069
|
if (status === "fail" && options.scanLogDir && r) {
|
|
2780
3070
|
try { fs.mkdirSync(options.scanLogDir, { recursive: true }); const file = path.join(options.scanLogDir, `${s.id.replace(/[^A-Za-z0-9._-]+/g, "_")}.log`); fs.writeFileSync(file, `$ ${s.command ?? s.id}\nexit ${r.status}\n--- stdout\n${r.stdout ?? ""}\n--- stderr\n${r.stderr ?? ""}\n`); row.log = file; detail = `${detail} (full output: ${file})`; } catch { /* the tail in the row remains */ }
|
package/development.mjs
CHANGED
|
@@ -29,10 +29,10 @@ import { packLinuxArtifacts, validateLinuxArchive } from "./linux-artifacts.mjs"
|
|
|
29
29
|
import { buildAndEmbedNativeHelpers, nativeHelpersFromConfig } from "./native-helpers.mjs";
|
|
30
30
|
import { androidDevelopmentNdkCommand, androidDevelopmentSerial, installAndroidDevelopment, validateAndroidDevelopment } from "./android-dev.mjs";
|
|
31
31
|
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
export const DEVELOPMENT_PROFILE = "
|
|
35
|
-
// Cargo writes the `dev` (and `test`) profile to target/<triple>/debug, never .../dev; `bench`
|
|
32
|
+
// GATE-CONTRACT GC-A07: the dev installer is the release Cargo profile plus the `qa-native` feature
|
|
33
|
+
// (`tauri build --features qa-native`, no `--debug`). One unit set, one cache, one output directory (GC-A15): no dev profile anywhere.
|
|
34
|
+
export const DEVELOPMENT_PROFILE = "release";
|
|
35
|
+
// Cargo writes the `dev` (and `test`) profile to target/<triple>/debug, never .../dev; `release` and `bench` go to release.
|
|
36
36
|
// Every path built from a profile name must go through this.
|
|
37
37
|
export function cargoProfileDirectory(profile) {
|
|
38
38
|
if (profile === "dev" || profile === "test") return "debug";
|
|
@@ -52,7 +52,7 @@ export function developmentEnvironment(env = process.env, platform = process.pla
|
|
|
52
52
|
};
|
|
53
53
|
for (const key of RELEASE_ONLY_APPLE_ENV) delete result[key];
|
|
54
54
|
delete result.CARGO_INCREMENTAL;
|
|
55
|
-
// A dev build
|
|
55
|
+
// A dev build uses the dev signing identity: a release signing-properties path never reaches it.
|
|
56
56
|
delete result.RIGHTKIT_ANDROID_SIGNING_PROPERTIES;
|
|
57
57
|
// A direct (Linux) host never gets the managed agent-bin on PATH or a discovered socket, even when it DECLARES a broker route: managedRuntimeClient then returns the declared,
|
|
58
58
|
// pinned client, but the agent-bin directory and socket here come from environment-derived managed roots that nothing validated. Whatever the build needs from the broker
|
|
@@ -154,7 +154,7 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
|
|
|
154
154
|
let releaseLease = true;
|
|
155
155
|
try {
|
|
156
156
|
const startedMs = Date.now();
|
|
157
|
-
// Android: cargo-ndk (
|
|
157
|
+
// Android: cargo-ndk (the release profile) fills the generated jniLibs, then the Gradle debug build packages it.
|
|
158
158
|
if (target.ndk) { rmSync(target.ndk.jniLibs, { recursive: true, force: true }); execute(androidDevelopmentNdkCommand(target.ndk), { root, env: childEnv, dryRun, run, door, label: "native build" }); }
|
|
159
159
|
// RA-09: the iOS compile steps (xcodegen, xcodebuild build / build-for-testing / test-without-building) are broker adapter jobs of the production native runner, planned together before the first
|
|
160
160
|
// launch; there is no execute() of this file's own for them. The app's own non-native `prepare` commands are the only direct steps and keep going through the door. The built app and the
|
|
@@ -195,7 +195,7 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
|
|
|
195
195
|
if (helpers) {
|
|
196
196
|
let version;
|
|
197
197
|
try { version = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version; } catch { version = undefined; }
|
|
198
|
-
buildAndEmbedNativeHelpers({ appRoot: root, app: built, helpers, version, forceArch: String(target.install.targetTriple).startsWith("x86_64") ? "x86_64" : "arm64", configuration: "
|
|
198
|
+
buildAndEmbedNativeHelpers({ appRoot: root, app: built, helpers, version, forceArch: String(target.install.targetTriple).startsWith("x86_64") ? "x86_64" : "arm64", configuration: "release", replaceExisting: true, env: childEnv });
|
|
199
199
|
}
|
|
200
200
|
releaseLease = false;
|
|
201
201
|
return installTauriDevId({ appRoot: root, config: target.install, releaseConfig: config, built, env: childEnv })
|
|
@@ -368,7 +368,7 @@ export function receiptRelativeOutputPath({ declared, root, manifestDir = ".", m
|
|
|
368
368
|
const manifestParts = String(manifestDir).split(/[\\/]/).filter((part) => part && part !== ".");
|
|
369
369
|
const at = segments.findIndex((segment, index) => segment === "target" && segments.slice(0, index).every((part, i) => part === manifestParts[i]));
|
|
370
370
|
if (at < 0) {
|
|
371
|
-
const error = new Error(`${label} ${JSON.stringify(declared)} is a source-relative path, but this build is receipt-managed: its outputs are under ${managedOutputDir}, not the source tree or the snapshot. Declare it relative to the Cargo target directory (target/<triple>/
|
|
371
|
+
const error = new Error(`${label} ${JSON.stringify(declared)} is a source-relative path, but this build is receipt-managed: its outputs are under ${managedOutputDir}, not the source tree or the snapshot. Declare it relative to the Cargo target directory (target/<triple>/release/bundle/...), or omit it so the receipt's directory is used`);
|
|
372
372
|
error.code = "DEV_OUTPUT_SOURCE_RELATIVE";
|
|
373
373
|
throw error;
|
|
374
374
|
}
|
package/freshness-census.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export const FINGERPRINT_LOG_FILTER = "cargo::core::compiler::fingerprint=info";
|
|
|
29
29
|
const FINGERPRINT_TARGET = "cargo::core::compiler::fingerprint";
|
|
30
30
|
export const FRESHNESS_SOURCE = "cargo-fingerprint-log+fingerprint-mtime";
|
|
31
31
|
/** Reasons kept in the receipt; the rest are counted. */
|
|
32
|
-
export const REASONS_CAP =
|
|
32
|
+
export const REASONS_CAP = 10_000;
|
|
33
33
|
/** Characters kept of one reason (Cargo prints it with `Debug`, paths and mtimes included). */
|
|
34
34
|
export const REASON_TEXT_CAP = 512;
|
|
35
35
|
/** Unit directories and files visited before the census is declared unavailable rather than partial. */
|
package/gate-stages.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The stages behind the one door (Adrian, 2026-10-25): rightkit-gate --to <stage> runs every stage up to <stage>, in order, for the
|
|
2
2
|
// commit at HEAD, and stops at the first that fails. Class A always runs first. The stages reuse the existing owners instead of
|
|
3
|
-
// reimplementing them: the gate's own scans for check/compile, `right-release` for package (
|
|
3
|
+
// reimplementing them: the gate's own scans for check/compile, `right-release` for package (the build that seals the
|
|
4
4
|
// exact artifact set), installed QA on that sealed set, and upload of that same sealed set. Those right-release commands refuse to
|
|
5
5
|
// run unless a live door run launched them (requireDoorRun), so there is no side door into a stage.
|
|
6
6
|
//
|
|
@@ -145,7 +145,7 @@ export function buildOuterApp({ root, spec, storage, profile, env = process.env,
|
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
export function resolveAssemblyStorage({ root, profile, env, run }) {
|
|
148
|
-
return resolveXcodeStorage({ env, run, cwd: root, fallbackRoot: path.join(root, ".right-release", "assembled-app", profile
|
|
148
|
+
return resolveXcodeStorage({ env, run, cwd: root, fallbackRoot: path.join(root, ".right-release", "assembled-app") }); // one Xcode build folder, never one per profile (GC-A15)
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
// ---- macOS --------------------------------------------------------------------------------------------------------
|