@rightkit/release 0.6.12 → 0.6.14
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 +49 -1
- package/adapter-env.mjs +165 -0
- package/android-ndk.mjs +5 -1
- package/android-release.mjs +73 -8
- package/app-check.mjs +22 -8
- package/app-closure.mjs +214 -0
- package/assemble-app-command.mjs +24 -7
- package/atomic-write.mjs +57 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +1190 -335
- package/broker-client.mjs +106 -0
- package/broker-exception.mjs +211 -0
- package/broker-handshake.mjs +160 -0
- package/broker-pins.mjs +27 -8
- package/broker-wire.mjs +262 -0
- package/build-launch.mjs +364 -12
- package/build-ownership.mjs +117 -49
- package/build-release.mjs +3 -2
- package/cache-command.mjs +7 -2
- package/candidate-build-receipt.mjs +96 -0
- package/candidate-receipt-command.mjs +29 -0
- package/cargo-contract.mjs +4 -3
- package/cargo-ledger.mjs +1 -1
- package/cargo-license-notices.mjs +2 -2
- package/cargo-shapes.mjs +332 -0
- package/cargo-target-shim.mjs +44 -5
- package/cargo-target.mjs +5 -0
- package/carried-adapter-run.mjs +31 -0
- package/carried-cargo-run.mjs +9 -0
- package/carried-cargo.mjs +237 -0
- package/carrier-census.mjs +124 -0
- package/class-a-launch-guard.mjs +266 -33
- package/cli/right-release.mjs +2 -0
- package/clippy-route.mjs +23 -11
- package/compile-inputs.mjs +9 -12
- package/control-plane.mjs +18 -0
- package/deps-cargo-lock.mjs +208 -0
- package/deps.mjs +5 -0
- package/deterministic-scans.mjs +273 -44
- package/development-signing.mjs +14 -1
- package/development.mjs +109 -24
- package/direct-origin.mjs +107 -0
- package/disk-command.mjs +1 -0
- package/disk-policy.mjs +18 -3
- package/freshness-census.mjs +206 -58
- package/gate-executor.mjs +30 -12
- package/gate-stages.mjs +93 -16
- package/github-release.mjs +2 -1
- package/github-upload-gate.mjs +2 -1
- package/install-tauri-authenticode.mjs +346 -0
- package/ios-dev.mjs +77 -23
- package/ios-package-recipe.mjs +100 -41
- package/linux-artifacts.mjs +17 -2
- package/linux-qualifier.mjs +83 -0
- package/local-publish.mjs +36 -14
- package/local-runtime.mjs +2 -2
- package/lock-identity.mjs +138 -0
- package/lsclean.sh +0 -0
- package/managed-cargo.mjs +151 -44
- package/managed-roots.mjs +130 -12
- package/media-sidecar-catalog.mjs +1 -1
- package/native-adapters.mjs +411 -0
- package/native-helpers.mjs +26 -18
- package/native-tool-planning.mjs +94 -0
- package/ownership-mark.mjs +182 -0
- package/pack-digest.mjs +122 -0
- package/package.json +12 -12
- package/platform-dir.mjs +14 -0
- package/pnpm-adapter-grammar.mjs +226 -0
- package/progress-control.mjs +7 -2
- package/provenance-real-receipt.pins.json +7 -0
- package/provenance-verify.mjs +262 -1
- package/publication-outcomes.mjs +21 -0
- package/publish-update.mjs +2 -1
- package/qa-journeys-prebuilt-run.mjs +18 -0
- package/qa-journeys-prebuilt.mjs +148 -0
- package/qa-snapshot-run.mjs +53 -0
- package/qa-snapshot.mjs +164 -0
- package/qualification-verify.mjs +144 -0
- package/qualify-reuse.mjs +38 -0
- package/recipe-build.mjs +16 -3
- package/release-qa-audit.mjs +2 -2
- package/release-qa-command.mjs +2 -1
- package/release-qa-receipt.mjs +3 -2
- package/release-state.mjs +3 -2
- package/release.mjs +14 -3
- package/rightkit-drift.mjs +27 -5
- package/rightkit-versions.json +15 -10
- package/rustright-names.mjs +32 -0
- package/scan-tool-enrolment.mjs +167 -0
- package/sign-windows.mjs +212 -112
- package/signing-files.mjs +35 -0
- package/signing-identity.mjs +7 -0
- package/snapshot-broker.mjs +528 -57
- package/snapshot-check.mjs +95 -11
- package/snapshot-metadata.mjs +123 -0
- package/snapshot-request.mjs +389 -96
- package/stage-binary.mjs +22 -4
- package/stage-commands.mjs +56 -13
- package/stage-sidecar.mjs +93 -21
- package/suite-signing-policy.mjs +657 -0
- package/suite-signing.mjs +223 -0
- package/swift-scratch.mjs +25 -0
- package/tauri-locked.mjs +25 -0
- package/test-command.mjs +164 -29
- package/unit-gate.mjs +201 -31
- package/upload-large.mjs +0 -0
- package/web-build.mjs +2 -2
- package/win-package-recipe.mjs +26 -2
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// GB-79 (GATE test stage E1/E6/E11): `rightkit-qa journeys run` on a snapshot stage, as the gate's own build-once route (capability `journeys-prebuilt-input`).
|
|
2
|
+
//
|
|
3
|
+
// `journeys run` spawns its own `rustright cargo ... --no-run`, which a gate-carried stage cannot allow: a snapshot stage compiles once, in the carrier, and QA runs
|
|
4
|
+
// an already-built, already-signed product outside the broker. So the gate takes the build out of QA's hands:
|
|
5
|
+
// 1. ask QA what it would build, without building (`journeys run ... --print-build-plan`: the declared build command and each artifact role's marker policy);
|
|
6
|
+
// 2. carry that build (`cargo test ... --no-run --message-format=json`) through the carrier: one signed, ledgered broker request, its receipt verified;
|
|
7
|
+
// 3. have QA name the executable each role resolves to in the carried listing (`--print-build-plan --from-cargo-json <listing>`), and check each lies inside the
|
|
8
|
+
// receipt's verified output directory;
|
|
9
|
+
// 4. sign every product (a role whose marker is required) with the product's one identity (suite-signing.mjs runSignStep), outside the broker, from the receipt's
|
|
10
|
+
// output directory; a product that cannot be signed refuses and nothing launches;
|
|
11
|
+
// 5. write the attestation (`rightkit.journeys.prebuilt-input/1`: the broker request id and digest, the listing's sha256, the carried command, the sha256 of
|
|
12
|
+
// every executable QA will launch) and run QA directly with `--from-cargo-json <listing> --prebuilt-attestation <file>`, the signing evidence in its environment.
|
|
13
|
+
// QA verifies all of it again (crates/rightkit-qa/src/journeys/prebuilt.rs build_from_prebuilt) and never compiles. An empty journey set is never green: the plan
|
|
14
|
+
// step fails when nothing is selected, QA's own verdict needs executed journeys, and an empty listing is refused on both sides.
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import crypto from "node:crypto";
|
|
19
|
+
import { spawnSync } from "./background-process.mjs";
|
|
20
|
+
import { runCarriedCargo } from "./carried-cargo.mjs";
|
|
21
|
+
import { isInside, realish } from "./qa-snapshot.mjs";
|
|
22
|
+
import { runSignStep } from "./suite-signing.mjs";
|
|
23
|
+
import { QA_APP_BINARY_ENV, QA_SIGNED_FILES_ENV, QA_SIGNING_EVIDENCE_ENV } from "./suite-signing-policy.mjs";
|
|
24
|
+
|
|
25
|
+
export const JOURNEYS_PREBUILT_CAPABILITY = "journeys-prebuilt-input";
|
|
26
|
+
export const PREBUILT_SCHEMA = "rightkit.journeys.prebuilt-input/1";
|
|
27
|
+
export const BUILD_PLAN_SCHEMA = "rightkit.journeys.build-plan/1";
|
|
28
|
+
/** The suite name the sign step reads the carried receipt's output directory under. */
|
|
29
|
+
export const JOURNEYS_BUILD_SUITE = "journeys-build";
|
|
30
|
+
/** The exact key set of the attestation (QA reads it with unknown fields denied). */
|
|
31
|
+
export const ATTESTATION_KEYS = Object.freeze(["schema", "request_id", "request_sha256", "listing_sha256", "command", "outputs"]);
|
|
32
|
+
|
|
33
|
+
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
34
|
+
const words = (args) => args.map(String);
|
|
35
|
+
const OWN_FLAGS = ["--from-cargo-json", "--prebuilt-attestation", "--print-build-plan"];
|
|
36
|
+
const flagOf = (w) => { const at = w.indexOf("="); return at > 0 && w.startsWith("--") ? w.slice(0, at) : w; };
|
|
37
|
+
|
|
38
|
+
/** True when this QA argv is `journeys run ...`. */
|
|
39
|
+
export const isJourneysRun = (args) => { const w = words(args); return w[0] === "journeys" && w[1] === "run"; };
|
|
40
|
+
|
|
41
|
+
/** Why a `journeys run` argv cannot be carried, or null: the listing and attestation are the gate's to write, never the step's (an input the gate did not verify). */
|
|
42
|
+
export function journeysRunRefusal(args) {
|
|
43
|
+
const own = words(args).filter((w) => w.startsWith("-")).map(flagOf);
|
|
44
|
+
const named = OWN_FLAGS.find((flag) => own.includes(flag));
|
|
45
|
+
return named ? `${named} is the gate's own: a journeys step names no listing, attestation or plan flag (the gate carries the build and hands QA the verified input)` : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether the installed `rightkit-qa` implements the prebuilt journey input: its `journeys run --help` names the capability. A binary that cannot be asked, or does
|
|
50
|
+
* not name it, has no such mode.
|
|
51
|
+
*/
|
|
52
|
+
export function qaPrebuiltCapability(qa, { spawn = spawnSync, env = process.env } = {}) {
|
|
53
|
+
try {
|
|
54
|
+
const r = spawn(qa, ["journeys", "run", "--help"], { encoding: "utf8", env, windowsHide: true, timeout: 20000 });
|
|
55
|
+
return r?.status === 0 && String(r.stdout ?? "").includes(JOURNEYS_PREBUILT_CAPABILITY);
|
|
56
|
+
} catch { return false; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The refusal text of a planned journeys step on a snapshot stage whose QA lacks the capability, naming it. */
|
|
60
|
+
export const journeysCapabilityRefusal = (name) => `${name}: \`rightkit-qa journeys run\` needs capability "${JOURNEYS_PREBUILT_CAPABILITY}" and the installed rightkit-qa does not advertise it (\`rightkit-qa journeys run --help\`): without it QA would compile through the broker itself, and a gate-carried stage must build once, sign, then run prebuilt bytes. Nothing ran`;
|
|
61
|
+
|
|
62
|
+
const fail = (code, message, status = 101) => Object.assign(new Error(`${code}: ${message}`), { code, status });
|
|
63
|
+
|
|
64
|
+
function parsePlan(result, what) {
|
|
65
|
+
if (result?.error) throw fail("RK_QA_JOURNEYS_PLAN", `${what}: rightkit-qa did not start (${result.error.message})`);
|
|
66
|
+
if (result?.status !== 0) throw fail("RK_QA_JOURNEYS_PLAN", `${what}: rightkit-qa exited ${result?.status}: ${String(result?.stderr ?? "").trim().slice(0, 600) || "no message"}; nothing was built`);
|
|
67
|
+
let plan;
|
|
68
|
+
try { plan = JSON.parse(String(result.stdout).trim().split(/\r?\n/).filter(Boolean).pop() ?? ""); } catch { throw fail("RK_QA_JOURNEYS_PLAN", `${what}: rightkit-qa printed no build plan`); }
|
|
69
|
+
if (plan?.schema !== BUILD_PLAN_SCHEMA || !Array.isArray(plan.command) || !Array.isArray(plan.artifacts) || typeof plan.cwd !== "string") throw fail("RK_QA_JOURNEYS_PLAN", `${what}: the build plan is not a ${BUILD_PLAN_SCHEMA} document`);
|
|
70
|
+
return plan;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The gate's build-once run of one `journeys run` step. `args` is QA's argv (`journeys run ...`); `map` is the carrier map. Resolves `{ status }` (QA's own exit status)
|
|
75
|
+
* or throws an Error with a typed `code` (nothing ran, or QA was never started). Every effect is injectable: `spawn` (QA), `carry` (the carrier), `signStep`, `loadConfig`.
|
|
76
|
+
*/
|
|
77
|
+
export function runJourneysPrebuilt({ qa, args, cwd = process.cwd(), env = process.env, map, platform = process.platform, spawn = spawnSync, carry = runCarriedCargo, signStep = runSignStep, signDeps = {}, loadConfig = defaultConfig, log = (m) => process.stderr.write(`${m}\n`) }) {
|
|
78
|
+
const given = words(args);
|
|
79
|
+
if (!isJourneysRun(given)) throw fail("RK_QA_JOURNEYS_USAGE", `rightkit-qa ${given.join(" ")} is not a journeys run step`);
|
|
80
|
+
const own = journeysRunRefusal(given);
|
|
81
|
+
if (own) throw fail("RK_QA_JOURNEYS_FLAG", own);
|
|
82
|
+
const section = map.broker ?? {};
|
|
83
|
+
const snapshot = section.snapshot?.path;
|
|
84
|
+
const checkouts = [section.checkout, env.RK_GATE_CHECKOUT].filter((c) => typeof c === "string" && c);
|
|
85
|
+
const run = (extra, what) => spawn(qa, [...given, ...extra], { cwd, env, encoding: "utf8", windowsHide: true, maxBuffer: 1 << 26 });
|
|
86
|
+
// 1. what QA would build (nothing is created or built).
|
|
87
|
+
const plan = parsePlan(run(["--print-build-plan"]), "the build plan");
|
|
88
|
+
if (plan.command[0] !== "rustright" || plan.command[1] !== "cargo" || plan.command.length < 3) throw fail("RK_QA_JOURNEYS_PLAN", `the declared build ${JSON.stringify(plan.command)} is not a \`rustright cargo ...\` command; a snapshot stage carries Cargo builds only`);
|
|
89
|
+
if (plan.artifacts.length === 0) throw fail("RK_QA_JOURNEYS_EMPTY", "the journeys build declares no artifact: an empty journey set is never green");
|
|
90
|
+
if (!snapshot || !isInside(plan.cwd, snapshot) || checkouts.some((c) => isInside(plan.cwd, c))) throw fail("RK_QA_JOURNEYS_PLACE", `the journeys build directory ${plan.cwd} is not inside the commit's snapshot${snapshot ? ` (${snapshot})` : ""}`);
|
|
91
|
+
// 2. the carried build.
|
|
92
|
+
const dir = path.join(path.dirname(map.ledgerDir), "qa-listings");
|
|
93
|
+
if (isInside(dir, snapshot) || checkouts.some((c) => isInside(dir, c))) throw fail("RK_QA_JOURNEYS_PLACE", `the listing directory ${dir} is inside the snapshot or the primary checkout`);
|
|
94
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
95
|
+
const stem = path.join(realish(dir), `${process.pid}-${Date.now().toString(36)}-${crypto.randomBytes(4).toString("hex")}`);
|
|
96
|
+
const listingFile = `${stem}.jsonl`; const attestationFile = `${stem}.attestation.json`;
|
|
97
|
+
const cargoWords = plan.command.slice(2);
|
|
98
|
+
const built = carry(cargoWords, { cwd: plan.cwd, env, stdio: "capture" }, { map });
|
|
99
|
+
if (built.stderr) process.stderr.write(built.stderr);
|
|
100
|
+
if (built.error) throw fail("RK_QA_JOURNEYS_BUILD", built.error.message);
|
|
101
|
+
if (built.status !== 0 || built.refusal || built.outputDir === undefined || !built.requestId || !/^[0-9a-f]{64}$/.test(built.requestDigest ?? "")) {
|
|
102
|
+
throw Object.assign(fail("RK_QA_JOURNEYS_BUILD", `the carried build \`cargo ${cargoWords.join(" ")}\` did not complete with a verified receipt (exit ${built.status}${built.refusal ? `, ${built.refusal.code}` : ""}); QA did not run`), { status: built.status || 101 });
|
|
103
|
+
}
|
|
104
|
+
let signedDir = null;
|
|
105
|
+
try {
|
|
106
|
+
const listing = String(built.stdout ?? "");
|
|
107
|
+
fs.writeFileSync(listingFile, listing, { mode: 0o600 });
|
|
108
|
+
// 3. the executable of each role, as QA resolves it from this listing; each must be a file the receipt's output directory holds.
|
|
109
|
+
const resolved = parsePlan(run(["--print-build-plan", "--from-cargo-json", listingFile]), "the role to executable mapping");
|
|
110
|
+
const roles = resolved.artifacts;
|
|
111
|
+
const outputs = [];
|
|
112
|
+
for (const role of roles) {
|
|
113
|
+
if (typeof role.executable !== "string" || !role.executable) throw fail("RK_QA_JOURNEYS_PLAN", `role ${role.role} resolves to no executable in the carried listing`);
|
|
114
|
+
const real = realish(role.executable);
|
|
115
|
+
if (!isInside(real, built.outputDir)) throw fail("RK_QA_JOURNEYS_PLACE", `role ${role.role}: ${role.executable} is not inside the receipt's output directory ${built.outputDir}`);
|
|
116
|
+
let data;
|
|
117
|
+
try { if (!fs.statSync(real).isFile()) throw new Error("not a file"); data = fs.readFileSync(real); } catch (e) { throw fail("RK_QA_JOURNEYS_PLACE", `role ${role.role}: ${role.executable} cannot be read (${e.message})`); }
|
|
118
|
+
outputs.push({ role: role.role, marker: role.marker, path: role.executable, real, sha256: sha256(data) });
|
|
119
|
+
}
|
|
120
|
+
if (outputs.length === 0) throw fail("RK_QA_JOURNEYS_EMPTY", "the carried build resolved no executable: an empty journey set is never green");
|
|
121
|
+
// 4. sign every product before anything launches.
|
|
122
|
+
let signEnv = {};
|
|
123
|
+
const products = outputs.filter((o) => o.marker === "required");
|
|
124
|
+
if (products.length) {
|
|
125
|
+
let signed;
|
|
126
|
+
try {
|
|
127
|
+
const files = products.map((o) => ({ from: JOURNEYS_BUILD_SUITE, path: path.relative(realish(built.outputDir), o.real).split(path.sep).join("/") }));
|
|
128
|
+
signed = signStep({ name: "journeys-sign", sign: { files }, appRoot: cwd, config: loadConfig(cwd), env, platform, outputDirsOf: (suite) => (suite === JOURNEYS_BUILD_SUITE ? [built.outputDir] : null), deps: signDeps, log });
|
|
129
|
+
} catch (e) { throw fail(e.code ?? "RK_QA_JOURNEYS_UNSIGNED", `a product of the journeys build could not be signed: ${e.message}; an unsigned product is never launched, and QA did not run`); }
|
|
130
|
+
signedDir = signed.signedDir;
|
|
131
|
+
signEnv = { [QA_SIGNING_EVIDENCE_ENV]: signed.evidenceFile, [QA_SIGNED_FILES_ENV]: JSON.stringify(signed.signedFiles ?? {}), ...(signed.appBinary ? { [QA_APP_BINARY_ENV]: signed.appBinary } : {}) };
|
|
132
|
+
}
|
|
133
|
+
// 5. the attestation, then QA directly against the verified input.
|
|
134
|
+
const attestation = { schema: PREBUILT_SCHEMA, request_id: built.requestId, request_sha256: built.requestDigest, listing_sha256: sha256(Buffer.from(listing)), command: [...plan.command.slice(1)], outputs: outputs.map((o) => ({ path: o.path, sha256: o.sha256 })) };
|
|
135
|
+
fs.writeFileSync(attestationFile, JSON.stringify(attestation), { mode: 0o600 });
|
|
136
|
+
const ran = spawn(qa, [...given, "--from-cargo-json", listingFile, "--prebuilt-attestation", attestationFile], { cwd, env: { ...env, ...signEnv }, stdio: "inherit", windowsHide: true });
|
|
137
|
+
if (ran.error) throw fail("RK_QA_JOURNEYS_RUN", ran.error.message);
|
|
138
|
+
return { status: ran.status ?? 1, attestation };
|
|
139
|
+
} finally {
|
|
140
|
+
for (const f of [listingFile, attestationFile]) { try { fs.rmSync(f, { force: true }); } catch { /* pruned with the managed directory */ } }
|
|
141
|
+
if (signedDir) { try { fs.rmSync(signedDir, { recursive: true, force: true }); } catch { /* the managed root is pruned */ } }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The app's right-release config JSON beside the step's directory (the sign step derives the product identity from it); null when there is none. */
|
|
146
|
+
function defaultConfig(cwd) {
|
|
147
|
+
try { return JSON.parse(fs.readFileSync(path.join(cwd, "right-release.config.json"), "utf8")); } catch { return null; }
|
|
148
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `node qa-snapshot-run.mjs <rightkit-qa> -- <qa args>`: one `rightkit-qa run-tests` step of a snapshot stage that would compile, as the two
|
|
3
|
+
// parts the gate can carry (qa-snapshot.mjs). build-launch.mjs hands such a step to this entry instead of refusing it.
|
|
4
|
+
// (a) the build: the step's test build (`--no-run --message-format=json`) through the carried seam (carried-cargo.mjs runCarriedCargo: one
|
|
5
|
+
// signed, ledgered broker request from this directory), its stdout written to a listing file under the carrier's managed directory,
|
|
6
|
+
// which is outside the snapshot and the primary checkout;
|
|
7
|
+
// (b) the QA step itself, run directly (no broker job: it needs the GUI session and the unscrubbed environment) as the same argv with
|
|
8
|
+
// `--from-cargo-json <listing>` in place of the build arguments.
|
|
9
|
+
// The step's own environment is this process's environment (the gate's step spawn merged it), so the direct run gets it as declared.
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import { spawnSync } from "./background-process.mjs";
|
|
14
|
+
import { carriedCargoMapOf, runCarriedCargo } from "./carried-cargo.mjs";
|
|
15
|
+
import { isInside, qaSnapshotPlan, realish } from "./qa-snapshot.mjs";
|
|
16
|
+
|
|
17
|
+
const fail = (message, status = 101) => { process.stderr.write(`rightkit-gate: ${message}\n`); process.exit(status); };
|
|
18
|
+
|
|
19
|
+
const [qa, separator, ...args] = process.argv.slice(2);
|
|
20
|
+
if (!qa || separator !== "--") fail("RK_QA_SNAPSHOT_USAGE: usage: qa-snapshot-run.mjs <rightkit-qa> -- <args>");
|
|
21
|
+
const carried = carriedCargoMapOf(process.env);
|
|
22
|
+
if (!carried?.map) fail(`RK_QA_SNAPSHOT_CARRIER_MISSING: ${carried?.error ?? "no carried map in the environment"}; a snapshot stage's build is never run around its carrier`);
|
|
23
|
+
const plan = qaSnapshotPlan(args);
|
|
24
|
+
if (!plan) fail(`RK_QA_SNAPSHOT_NO_BUILD: rightkit-qa ${args.join(" ")} compiles nothing and is run directly, not through this entry`);
|
|
25
|
+
if (plan.refusal) fail(`RK_QA_SNAPSHOT_REFUSED: ${plan.refusal}`);
|
|
26
|
+
|
|
27
|
+
const { map } = carried;
|
|
28
|
+
const section = map.broker ?? {};
|
|
29
|
+
const snapshot = section.snapshot?.path;
|
|
30
|
+
const checkouts = [section.checkout, process.env.RK_GATE_CHECKOUT].filter((c) => typeof c === "string" && c);
|
|
31
|
+
const dir = path.join(path.dirname(map.ledgerDir), "qa-listings");
|
|
32
|
+
if ((snapshot && isInside(dir, snapshot)) || checkouts.some((c) => isInside(dir, c))) fail(`RK_QA_SNAPSHOT_LISTING_PLACE: the listing directory ${dir} is inside the snapshot or the primary checkout`);
|
|
33
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
34
|
+
const listing = path.join(realish(dir), `${process.pid}-${Date.now().toString(36)}-${crypto.randomBytes(4).toString("hex")}.jsonl`);
|
|
35
|
+
|
|
36
|
+
const built = runCarriedCargo(plan.cargoArgs, { cwd: process.cwd(), env: process.env, stdio: "capture" }, { map });
|
|
37
|
+
if (built.stderr) process.stderr.write(built.stderr);
|
|
38
|
+
if (built.error) fail(`RK_QA_SNAPSHOT_BUILD: ${built.error.message}`);
|
|
39
|
+
if (built.status !== 0 || built.refusal || built.outputDir === undefined) {
|
|
40
|
+
process.stderr.write(`rightkit-gate: RK_QA_SNAPSHOT_BUILD: the carried build \`${plan.cargoArgs.join(" ")}\` did not complete with a verified receipt (exit ${built.status}${built.refusal ? `, ${built.refusal.code}` : ""}); the QA step did not run\n`);
|
|
41
|
+
process.exit(built.status || 101);
|
|
42
|
+
}
|
|
43
|
+
// The listing is the build's output, not a result: it is removed when the direct QA step ends, whatever its exit code.
|
|
44
|
+
let status;
|
|
45
|
+
try {
|
|
46
|
+
fs.writeFileSync(listing, built.stdout, { mode: 0o600 });
|
|
47
|
+
const ran = spawnSync(qa, plan.qaArgs(listing), { cwd: process.cwd(), env: process.env, stdio: "inherit", windowsHide: true });
|
|
48
|
+
if (ran.error) process.stderr.write(`rightkit-gate: RK_QA_SNAPSHOT_RUN: ${ran.error.message}\n`);
|
|
49
|
+
status = ran.error ? 101 : ran.status ?? 1;
|
|
50
|
+
} finally {
|
|
51
|
+
try { fs.rmSync(listing, { force: true }); } catch { /* best effort: the managed directory is pruned with its run */ }
|
|
52
|
+
}
|
|
53
|
+
process.exit(status);
|
package/qa-snapshot.mjs
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// The QA step of a snapshot stage (rightkit#1301, rustright#190): what `rightkit-qa <args>` may do, and how a compiling `run-tests` step
|
|
2
|
+
// becomes two parts. QA runs an already-built artifact outside the broker (a GUI session, the unscrubbed environment), so nothing in the
|
|
3
|
+
// step may compile: the gate carries the build itself and then runs QA directly against its listing.
|
|
4
|
+
//
|
|
5
|
+
// rightkit-qa run-tests --app a -p crate [QA flags] -- <libtest args>
|
|
6
|
+
// (a) carried: cargo test -p crate --no-run --message-format=json stdout -> <listing> (qa-snapshot-run.mjs)
|
|
7
|
+
// (b) direct: rightkit-qa run-tests --app a [QA flags] --from-cargo-json <listing> -- <libtest args>
|
|
8
|
+
//
|
|
9
|
+
// `rightkit-qa journeys run` has no prebuilt form: it spawns `rustright cargo ... --no-run` itself (journeys/build_record.rs spawn_broker)
|
|
10
|
+
// and binds the build to the checkout's HEAD, so a snapshot stage cannot carry that build for it. It stays refused, naming the missing mode.
|
|
11
|
+
//
|
|
12
|
+
// The argument parser of `run-tests` is the Rust CLI's own (crates/rightkit-qa/src/run_tests/cli.rs `parse`): the flags below are its
|
|
13
|
+
// VALUE_FLAGS and BOOL_FLAGS, a flag's value is consumed whatever it looks like, and every other word is a cargo argument.
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
/** run-tests flags that take a value (cli.rs VALUE_FLAGS). */
|
|
18
|
+
export const RUN_TESTS_VALUE_FLAGS = Object.freeze(["--root", "--qa-config", "--app", "--managed-root", "--run-id", "--parallel", "--binary-timeout-s", "--total-timeout-s", "--compile-timeout-s", "--env", "--unset-env", "--cwd", "--from-cargo-json", "--build-with", "--receipt"]);
|
|
19
|
+
/** run-tests flags that take none (cli.rs BOOL_FLAGS). */
|
|
20
|
+
export const RUN_TESTS_BOOL_FLAGS = Object.freeze(["--require-broker", "--deny-ignored", "--allow-empty", "--isolate-tmp", "--no-isolate-tmp", "--show-output"]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Every rightkit-qa option that takes the next word as its value, across its subcommands (run-tests, run, journeys, sweep, ...): the
|
|
24
|
+
* run-tests list plus the cargo options run-tests forwards and the flags of the binary's own `Args::positionals`. A word that follows one of
|
|
25
|
+
* these is a value, never a flag.
|
|
26
|
+
*/
|
|
27
|
+
export const QA_VALUE_OPTIONS = new Set([
|
|
28
|
+
...RUN_TESTS_VALUE_FLAGS,
|
|
29
|
+
"--manifest-path", "-p", "--package", "--test", "--features",
|
|
30
|
+
"--config", "--app-root", "--tier", "--only", "--engine", "--ui-binary", "--backend", "--dir", "--max-gb", "--young-hours", "--kind", "--tree", "--manifest", "--results", "--evidence-root", "--id", "--platform", "--run-store-cap-bytes",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const splitInline = (word) => {
|
|
34
|
+
const at = word.indexOf("=");
|
|
35
|
+
return at > 0 && word.startsWith("--") ? [word.slice(0, at), word.slice(at + 1)] : [word, undefined];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `rightkit-qa run-tests`'s arguments, split exactly as the CLI splits them. `own` is the command's own words in order (flag, and its
|
|
40
|
+
* value when separate), `cargo` the words that go to `cargo test --no-run`, `testArgs` what follows the first `--` (null when there is none),
|
|
41
|
+
* `flags` every own flag's values by name. Throws on a value flag with no value (the CLI refuses it too).
|
|
42
|
+
*/
|
|
43
|
+
export function parseRunTests(words) {
|
|
44
|
+
const own = []; const cargo = []; const flags = new Map();
|
|
45
|
+
let testArgs = null;
|
|
46
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
47
|
+
const arg = words[i];
|
|
48
|
+
if (arg === "--") { testArgs = words.slice(i + 1); break; }
|
|
49
|
+
const [name, inline] = splitInline(arg);
|
|
50
|
+
if (RUN_TESTS_VALUE_FLAGS.includes(name)) {
|
|
51
|
+
let value = inline;
|
|
52
|
+
if (value === undefined) {
|
|
53
|
+
if (i + 1 >= words.length) throw new Error(`${name} needs a value`);
|
|
54
|
+
i += 1; value = words[i];
|
|
55
|
+
own.push(arg, value);
|
|
56
|
+
} else own.push(arg);
|
|
57
|
+
flags.set(name, [...(flags.get(name) ?? []), value]);
|
|
58
|
+
} else if (RUN_TESTS_BOOL_FLAGS.includes(name) && inline === undefined) { own.push(arg); flags.set(name, [...(flags.get(name) ?? []), ""]); }
|
|
59
|
+
else cargo.push(arg);
|
|
60
|
+
}
|
|
61
|
+
return { own, cargo, testArgs, flags };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The own flags of a non-run-tests QA command (`journeys`, `run`, ...) before `--`, by name, a flag's value consumed so it is never read as a flag. */
|
|
65
|
+
function genericFlags(words) {
|
|
66
|
+
const flags = new Map();
|
|
67
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
68
|
+
const arg = words[i];
|
|
69
|
+
if (arg === "--") break;
|
|
70
|
+
const [name, inline] = splitInline(arg);
|
|
71
|
+
if (QA_VALUE_OPTIONS.has(name)) {
|
|
72
|
+
let value = inline;
|
|
73
|
+
if (value === undefined) { i += 1; value = words[i]; }
|
|
74
|
+
if (value !== undefined) flags.set(name, [...(flags.get(name) ?? []), value]);
|
|
75
|
+
} else if (arg.startsWith("-")) flags.set(name, [...(flags.get(name) ?? []), ""]);
|
|
76
|
+
}
|
|
77
|
+
return flags;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Every value a flag takes anywhere in an argv, `--` included: the Rust `Args` of every command but run-tests reads the WHOLE argv (`has`
|
|
82
|
+
* is any word equal to the flag, `value` the word after the first one), so nothing is skipped as consumed or as after `--`, and a repeat
|
|
83
|
+
* (which Rust ignores) is checked too. A trailing flag with no value yields nothing.
|
|
84
|
+
*/
|
|
85
|
+
export function everyValue(words, flag) {
|
|
86
|
+
const out = [];
|
|
87
|
+
words.forEach((w, i) => { const [n, inline] = splitInline(w); if (n === flag) { const v = inline ?? words[i + 1]; if (v !== undefined) out.push(v); } });
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Whether a word is a flag that makes a QA run build (`--build`, `--build-ui`, `--build-with`, each also as `=value`). */
|
|
92
|
+
export const isBuildFlag = (w) => ["--build", "--build-ui", "--build-with"].includes(splitInline(String(w))[0]);
|
|
93
|
+
|
|
94
|
+
/** The words before the first `--` of an argv. */
|
|
95
|
+
export const ownWords = (words) => (words.includes("--") ? words.slice(0, words.indexOf("--")) : words);
|
|
96
|
+
|
|
97
|
+
/** Whether a QA argv hands `run-tests` an existing cargo listing, read in flag position only (a value that spells the flag is not the flag). */
|
|
98
|
+
export function hasListing(args) {
|
|
99
|
+
const words = args.map(String);
|
|
100
|
+
if (words[0] === "run-tests") { try { return parseRunTests(words.slice(1)).flags.has("--from-cargo-json"); } catch { return false; } }
|
|
101
|
+
return genericFlags(ownWords(words)).has("--from-cargo-json");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const isMessageFormat = (w) => w === "--message-format" || w.startsWith("--message-format=");
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* What the gate does with a snapshot stage's `rightkit-qa` step that compiles. `null` when it compiles nothing (or is not run-tests);
|
|
108
|
+
* `{ cargoArgs, qaArgs }` for a `run-tests` step with no listing: `cargoArgs` is the carried `cargo` argv (`test ... --no-run
|
|
109
|
+
* --message-format=json`), `qaArgs(listing)` the direct QA argv. `{ refusal }` for a build the gate cannot carry.
|
|
110
|
+
*/
|
|
111
|
+
export function qaSnapshotPlan(args) {
|
|
112
|
+
const words = args.map(String);
|
|
113
|
+
if (words[0] !== "run-tests") return null;
|
|
114
|
+
let parsed;
|
|
115
|
+
try { parsed = parseRunTests(words.slice(1)); } catch (error) { return { refusal: `rightkit-qa run-tests ${words.slice(1).join(" ")}: ${error.message}` }; }
|
|
116
|
+
if (parsed.flags.has("--from-cargo-json")) return null;
|
|
117
|
+
// --root moves the run's root, but the carried build runs from the step's directory: the two would disagree, and neither app uses it.
|
|
118
|
+
if (parsed.flags.has("--root")) return { code: "RK_QA_SNAPSHOT_ROOT_UNSUPPORTED", refusal: "RK_QA_SNAPSHOT_ROOT_UNSUPPORTED: a carried run-tests build runs from the step's directory, so --root is not supported on a snapshot stage; drop it" };
|
|
119
|
+
const cargo = parsed.cargo.filter((w) => w !== "--no-run");
|
|
120
|
+
const formats = cargo.map((w, i) => (w === "--message-format" ? cargo[i + 1] : w.startsWith("--message-format=") ? w.slice("--message-format=".length) : null)).filter((v) => v !== null);
|
|
121
|
+
if (formats.some((v) => v !== "json")) return { refusal: `rightkit-qa run-tests: a carried build lists its tests as --message-format=json, not ${JSON.stringify(formats.find((v) => v !== "json"))}` };
|
|
122
|
+
const cargoArgs = ["test", ...cargo, "--no-run", ...(cargo.some(isMessageFormat) ? [] : ["--message-format=json"])];
|
|
123
|
+
return { cargoArgs, qaArgs: (listing) => ["run-tests", ...parsed.own, "--from-cargo-json", listing, ...(parsed.testArgs === null ? [] : ["--", ...parsed.testArgs])] };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** `p` resolved as far as it exists (symlinks followed), the rest appended, so a path that does not exist yet still has a real location. */
|
|
127
|
+
export function realish(p) {
|
|
128
|
+
let current = path.resolve(p); const rest = [];
|
|
129
|
+
for (;;) {
|
|
130
|
+
try { return path.join(fs.realpathSync.native(current), ...rest); } catch { /* not there yet */ }
|
|
131
|
+
const parent = path.dirname(current);
|
|
132
|
+
if (parent === current) return path.join(current, ...rest);
|
|
133
|
+
rest.unshift(path.basename(current)); current = parent;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
export const isInside = (child, parent) => { const rel = path.relative(realish(parent), realish(child)); return rel === "" || !(rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)); };
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Why a QA step's paths are refused on a snapshot stage, or null. The step runs from the commit's snapshot: its working directory must be
|
|
140
|
+
* inside it, and no input it names (`--from-cargo-json`, `--root`, `--cwd <dir>`, `--manifest-path`) may resolve into the primary checkout
|
|
141
|
+
* (the gate never reads or runs a working tree). `cwd` is the step's directory; `snapshot` and `checkouts` come from the gate's carried map.
|
|
142
|
+
*/
|
|
143
|
+
export function qaPathReason(args, { cwd, snapshot, checkouts, env = {} }) {
|
|
144
|
+
const words = args.map(String);
|
|
145
|
+
const parsedTests = words[0] === "run-tests" ? (() => { try { return parseRunTests(words.slice(1)); } catch { return null; } })() : null;
|
|
146
|
+
// run-tests has its own parser (own flags before `--`); every other command's Args reads the whole argv, so every value counts.
|
|
147
|
+
const values = (flag) => (parsedTests ? parsedTests.flags.get(flag) ?? [] : words[0] === "run-tests" ? genericFlags(ownWords(words)).get(flag) ?? [] : everyValue(words, flag));
|
|
148
|
+
const cwdReal = realish(cwd);
|
|
149
|
+
if (!snapshot || !isInside(cwdReal, snapshot)) return `its working directory ${cwd} is outside the commit's snapshot${snapshot ? ` (${snapshot})` : ""}`;
|
|
150
|
+
const roots = values("--root");
|
|
151
|
+
const named = [];
|
|
152
|
+
for (const r of roots) named.push(["--root", path.resolve(cwdReal, r)]);
|
|
153
|
+
const root = path.resolve(cwdReal, roots[0] ?? ".");
|
|
154
|
+
for (const v of values("--from-cargo-json")) if (v !== "-") named.push(["--from-cargo-json", path.resolve(cwdReal, v)]);
|
|
155
|
+
for (const flag of ["--qa-config", ...(words[0] === "run-tests" ? [] : ["--app-root", "--config"])]) for (const v of values(flag)) named.push([flag, path.resolve(cwdReal, v)]);
|
|
156
|
+
if (typeof env.RIGHTKIT_QA_CONFIG === "string" && env.RIGHTKIT_QA_CONFIG) named.push(["RIGHTKIT_QA_CONFIG", path.resolve(cwdReal, env.RIGHTKIT_QA_CONFIG)]);
|
|
157
|
+
for (const mode of values("--cwd")) if (mode !== "package" && mode !== "workspace") named.push(["--cwd", path.resolve(root, mode)]);
|
|
158
|
+
const manifests = [];
|
|
159
|
+
if (parsedTests) parsedTests.cargo.forEach((w, i, all) => { const [name, inline] = splitInline(w); if (name === "--manifest-path") manifests.push(inline ?? all[i + 1]); });
|
|
160
|
+
else manifests.push(...values("--manifest-path"));
|
|
161
|
+
for (const m of manifests) if (typeof m === "string" && m) named.push(["--manifest-path", path.resolve(root, m)]);
|
|
162
|
+
for (const [flag, resolved] of named) for (const checkout of checkouts) if (isInside(resolved, checkout)) return `${flag} resolves to ${resolved}, inside the primary checkout ${checkout}; a snapshot stage's QA step reads only the snapshot and managed output`;
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// The verification of a unit qualification's signed broker receipts, in one place for its two callers: the hosted admission
|
|
2
|
+
// (scripts/ci/publish-admission.mjs, inside publish.yml) and the gate's own self-check before it dispatches anything (unit-gate.mjs).
|
|
3
|
+
// It lives in this package, not under scripts/, because unit-gate ships in the package and may import nothing outside it.
|
|
4
|
+
// Pure over the dispatched commit: `git(...args)` reads that commit's objects; nothing here touches the filesystem or the broker.
|
|
5
|
+
import { ProvenanceError, carriedCallNonce, qualificationNonce, verifyProvenance } from "./provenance-verify.mjs";
|
|
6
|
+
import { SnapshotRequestError, ownershipOfHandle, verifySnapshotEvidenceIdentity } from "./snapshot-request.mjs";
|
|
7
|
+
import { assignJobs, describeCommand, expectedStageCommands, rootOfWorkspace } from "./stage-commands.mjs";
|
|
8
|
+
|
|
9
|
+
export const ATTESTED_STAGES = ["compile", "package"];
|
|
10
|
+
export const MAX_VERIFIED_RECEIPTS = 64;
|
|
11
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
12
|
+
const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
13
|
+
const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
14
|
+
|
|
15
|
+
const refuse = (message) => { throw new Error(`publication admission refused: ${message}`); };
|
|
16
|
+
const requireThat = (condition, message) => { if (!condition) refuse(message); };
|
|
17
|
+
|
|
18
|
+
/** The enrolled host pins committed at `sha` (ci/rk-pipeline.json "attestation", one entry per platform) -- never taken from the request. */
|
|
19
|
+
export function enrolledPins(sha, git) {
|
|
20
|
+
let doc;
|
|
21
|
+
try { doc = JSON.parse(git("show", `${sha}:ci/rk-pipeline.json`)); } catch { refuse("ci/rk-pipeline.json is unreadable at the dispatched commit; no host is enrolled"); }
|
|
22
|
+
const pins = Object.entries(doc?.attestation ?? {})
|
|
23
|
+
.filter(([, p]) => p && typeof p === "object" && ["publicKey", "keyId", "host", "brokerDigest"].every((key) => typeof p[key] === "string" && p[key]))
|
|
24
|
+
.map(([platform, { publicKey, keyId, host, brokerDigest }]) => ({ publicKey, keyId, host, brokerDigest, platform }));
|
|
25
|
+
requireThat(pins.length > 0, "ci/rk-pipeline.json enrolls no attestation pins at the dispatched commit");
|
|
26
|
+
return pins;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The qualification's broker receipts, verified. For each of the compile and package stages: at least one receipt whose signature
|
|
31
|
+
* verifies against the pins enrolled at the dispatched commit for the receipt's host, whose job id is its own, and whose signed result is
|
|
32
|
+
* SUCCEEDED with exit code 0. The nonce binds the dispatched commit, its real git tree, the unit and the stage: a carried call (a cargo call, or a pnpm
|
|
33
|
+
* adapter job) carries the per-call nonce derived from it, the directory it ran in and its argv (carriedCallNonce, rightkit#1301 B8),
|
|
34
|
+
* checked against the stage row the job is assigned to. A receipt of another commit, tree, unit or stage, or of another
|
|
35
|
+
* command or directory, cannot carry the nonce. Every receipt also carries signed snapshotEvidence (B9): the commit and tree equal the
|
|
36
|
+
* dispatched commit's, the snapshot held that tree at start and at exit, and its request id is the job's; all receipts share one snapshot
|
|
37
|
+
* path and one ownership handle, and, with the stage row's directory removed, one primary root. A job id is used once across stages.
|
|
38
|
+
* The qualification's tree must equal the dispatched commit's tree. Returns the verified summary the admission receipt records.
|
|
39
|
+
*/
|
|
40
|
+
export function verifyQualificationReceipts(text, { sha, git }) {
|
|
41
|
+
let q;
|
|
42
|
+
try { q = JSON.parse(text); } catch { refuse("the qualification input is not JSON"); }
|
|
43
|
+
const tree = String(git("rev-parse", `${sha}^{tree}`)).trim();
|
|
44
|
+
requireThat(/^[0-9a-f]{40}$/.test(tree), `cannot read the tree of ${sha.slice(0, 12)}`);
|
|
45
|
+
requireThat(q?.tree === tree, `the qualification receipt is for tree ${String(q?.tree).slice(0, 12)}, the dispatched commit's tree is ${tree.slice(0, 12)}`);
|
|
46
|
+
requireThat(typeof q.unit?.kind === "string" && typeof q.unit?.name === "string", "the qualification receipt names no unit");
|
|
47
|
+
const pins = enrolledPins(sha, git);
|
|
48
|
+
const unit = `${q.unit.kind}:${q.unit.name}`;
|
|
49
|
+
// What each stage's signed requests must show, derived by the stages' own command builders from the dispatched commit's files.
|
|
50
|
+
const expectedByPlatform = new Map();
|
|
51
|
+
const expectedFor = (platform) => {
|
|
52
|
+
if (!expectedByPlatform.has(platform)) {
|
|
53
|
+
let expected;
|
|
54
|
+
try {
|
|
55
|
+
const config = JSON.parse(git("show", `${sha}:ci/gate.config.json`));
|
|
56
|
+
const manifest = q.unit.kind === "npm" ? JSON.parse(git("show", `${sha}:packages/${q.unit.name.slice("@rightkit/".length)}/package.json`)) : undefined;
|
|
57
|
+
expected = expectedStageCommands({ kind: q.unit.kind, unit: q.unit.name, config, manifest, platform });
|
|
58
|
+
} catch (error) { refuse(`cannot derive the ${platform} stage commands of ${unit} at ${sha.slice(0, 12)}: ${error.message}`); }
|
|
59
|
+
expectedByPlatform.set(platform, expected);
|
|
60
|
+
}
|
|
61
|
+
return expectedByPlatform.get(platform);
|
|
62
|
+
};
|
|
63
|
+
let platform = null;
|
|
64
|
+
let primaryRoot = null, snapshotPath = null, handle = null;
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
const verified = {};
|
|
67
|
+
for (const stage of ATTESTED_STAGES) {
|
|
68
|
+
const list = q.stages?.[stage]?.receipts;
|
|
69
|
+
requireThat(Array.isArray(list) && list.length >= 1, `the qualification receipt's ${stage} stage carries no signed broker receipts; an unauthenticated qualification admits nothing`);
|
|
70
|
+
requireThat(list.length <= MAX_VERIFIED_RECEIPTS, `the qualification receipt's ${stage} stage carries ${list.length} receipts (at most ${MAX_VERIFIED_RECEIPTS})`);
|
|
71
|
+
const stageNonce = qualificationNonce({ commit: sha, tree, unit, stage });
|
|
72
|
+
// The nonces a receipt may carry, before its row is known: every carried call (a Cargo call, and a pnpm adapter job, rightkit#1301 B8) the
|
|
73
|
+
// nonce derived for the directory of each of the stage's rows it could be and the argv it signed. The row it is assigned to (below) must
|
|
74
|
+
// then be the one that fits.
|
|
75
|
+
const nonceFor = (row, argv) => carriedCallNonce({ stageNonce, cwd: row.cwd ?? ".", argv });
|
|
76
|
+
// The stage nonce is always tried as well, so a job that is none of the stage's commands is refused for that (below), not for its nonce.
|
|
77
|
+
const candidateNonces = (host, request) => {
|
|
78
|
+
if ((request?.kind !== "cargo" && request?.kind !== "adapter") || !Array.isArray(request.requestedArgv)) return [stageNonce];
|
|
79
|
+
// A cargo row is told apart by its exact argv; an adapter row's argv can hold a scratch path (pack), so every adapter row is tried (a job
|
|
80
|
+
// of another adapter then reaches the command check below and is refused for that, not for its nonce).
|
|
81
|
+
const rows = expectedFor(host.platform)[stage].filter((row) => row.kind === request.kind && (row.kind === "adapter" || row.argvs.some((argv) => sameJson(argv, request.requestedArgv))));
|
|
82
|
+
return [...new Set([...rows.map((row) => nonceFor(row, request.requestedArgv)), stageNonce])];
|
|
83
|
+
};
|
|
84
|
+
const jobs = [];
|
|
85
|
+
const summaries = new Map();
|
|
86
|
+
list.forEach((receipt, index) => {
|
|
87
|
+
const where = `${stage} receipt ${index + 1}`;
|
|
88
|
+
requireThat(receipt && typeof receipt === "object" && typeof receipt.id === "string" && receipt.id, `${where} has no job id`);
|
|
89
|
+
requireThat(!seen.has(receipt.id), `${where}: job ${receipt.id} appears more than once in the qualification`);
|
|
90
|
+
seen.add(receipt.id);
|
|
91
|
+
const candidates = pins.filter((p) => p.host === receipt.provenance?.payload?.host);
|
|
92
|
+
requireThat(candidates.length > 0, `${where}: signed for host ${JSON.stringify(receipt.provenance?.payload?.host)}, which ci/rk-pipeline.json does not enroll`);
|
|
93
|
+
let payload, failure, pin;
|
|
94
|
+
for (const candidate of candidates) {
|
|
95
|
+
for (const nonce of candidateNonces(candidate, receipt.provenance?.payload?.request)) {
|
|
96
|
+
try { payload = verifyProvenance(receipt, candidate, { jobId: receipt.id, nonce }); pin = candidate; break; } catch (error) { if (!(error instanceof ProvenanceError)) throw error; failure = error; }
|
|
97
|
+
}
|
|
98
|
+
if (payload) break;
|
|
99
|
+
}
|
|
100
|
+
requireThat(payload, `${where} (job ${receipt.id}) does not verify against the enrolled pins at ${sha.slice(0, 12)}: ${failure?.code}: ${failure?.message}`);
|
|
101
|
+
requireThat(payload.result?.status === "SUCCEEDED" && payload.result.exitCode === 0, `${where} (job ${receipt.id}) did not succeed (${payload.result?.status}, exit ${payload.result?.exitCode})`);
|
|
102
|
+
requireThat(typeof payload.request?.workspace === "string" && payload.request.workspace !== "", `${where} (job ${receipt.id}) records no workspace`);
|
|
103
|
+
requireThat(SHA256_HEX.test(payload.requestDigest ?? ""), `${where} (job ${receipt.id}) carries no request digest`);
|
|
104
|
+
// The signed request must be one of this stage's own commands (kind, adapter and exact argv, as the stage's builders define them),
|
|
105
|
+
// run on the enrolled host: the same one for every receipt of the qualification.
|
|
106
|
+
platform ??= pin.platform;
|
|
107
|
+
requireThat(pin.platform === platform, `${where} (job ${receipt.id}) was signed by the ${pin.platform} host, another host than the qualification's other jobs (${platform})`);
|
|
108
|
+
// The signed snapshot evidence names the commit, tree and job and shows the snapshot held the tree throughout (the same pure
|
|
109
|
+
// function the gate applies to its own receipts), and one snapshot and one ownership handle carry every job of the qualification.
|
|
110
|
+
const evidence = payload.snapshotEvidence;
|
|
111
|
+
requireThat(isRecord(evidence), `${where} (job ${receipt.id}) carries no signed snapshotEvidence: it was not built as a snapshot request of the dispatched commit`);
|
|
112
|
+
try { verifySnapshotEvidenceIdentity(evidence, { commit: { sha, tree }, tree, jobId: receipt.id }); }
|
|
113
|
+
catch (error) { if (!(error instanceof SnapshotRequestError)) throw error; refuse(`${where} (job ${receipt.id}): ${error.message}`); }
|
|
114
|
+
// Each carried request signs its own sub-handle `<ownership-handle>#<n>`; the qualification's one ownership is the part before the `#`.
|
|
115
|
+
const path = evidence.snapshot?.path, owner = ownershipOfHandle(evidence.ownership?.handle);
|
|
116
|
+
requireThat(typeof path === "string" && path !== "" && typeof owner === "string" && owner !== "", `${where} (job ${receipt.id}) names no snapshot path or ownership handle`);
|
|
117
|
+
requireThat(snapshotPath === null || snapshotPath === path, `${where} (job ${receipt.id}) ran in the snapshot ${path}, another snapshot than the qualification's other jobs (${snapshotPath})`);
|
|
118
|
+
requireThat(handle === null || handle === owner, `${where} (job ${receipt.id}) was signed under ownership handle ${owner}, another than the qualification's other jobs (${handle})`);
|
|
119
|
+
snapshotPath = path; handle = owner;
|
|
120
|
+
jobs.push({ id: receipt.id, payload });
|
|
121
|
+
summaries.set(receipt.id, { jobId: receipt.id, host: payload.host, keyId: payload.keyId, requestDigest: payload.requestDigest, requestedArgv: payload.request.requestedArgv });
|
|
122
|
+
});
|
|
123
|
+
// EVERY command the stage runs must be covered by a verified receipt of its own job, and every receipt must be one of those commands.
|
|
124
|
+
const { assigned, unmatched, missing } = assignJobs(expectedFor(platform)[stage], jobs);
|
|
125
|
+
// Messages are formatted only on failure: on success there is no unmatched job or missing command to describe.
|
|
126
|
+
if (unmatched.length) requireThat(false, `${stage} job ${unmatched[0]} ran ${JSON.stringify(jobs.find((j) => j.id === unmatched[0])?.payload.request.requestedArgv)}, which is not (or no longer) a command of the ${stage} stage of ${unit}`);
|
|
127
|
+
if (missing.length) requireThat(false, `the qualification carries no signed receipt of the ${stage} stage's ${missing.length ? describeCommand(missing[0]) : ""} (${missing.length} of ${assigned.size + missing.length} required command(s) missing)`);
|
|
128
|
+
for (const job of jobs) {
|
|
129
|
+
const row = assigned.get(job.id);
|
|
130
|
+
const { workspace, workspaceRoot, requestedArgv } = job.payload.request;
|
|
131
|
+
// The nonce is the one this row's command owes: derived from the directory its row runs in and the argv it signed (a Cargo call or a pnpm job).
|
|
132
|
+
requireThat(job.payload.nonce === nonceFor(row, requestedArgv), `${stage} job ${job.id} carries a nonce that is not the one its command (${describeCommand(row)}) owes`);
|
|
133
|
+
// The primary root: the signed workspace without the directory of the row's command (a carried cargo call runs from its manifest's
|
|
134
|
+
// directory, a pnpm job at the root). Every job of the qualification must show the same one, and so must its workspaceRoot.
|
|
135
|
+
const root = rootOfWorkspace(workspace, row.cwd);
|
|
136
|
+
requireThat(root !== null, `${stage} job ${job.id} ran in ${workspace}, which is not the directory ${row.cwd} of a checkout`);
|
|
137
|
+
requireThat(workspaceRoot === undefined || rootOfWorkspace(workspaceRoot, row.cwd) === root, `${stage} job ${job.id} ran in ${workspace} with workspace root ${workspaceRoot}, not in one checkout`);
|
|
138
|
+
requireThat(primaryRoot === null || primaryRoot === root, `${stage} job ${job.id} ran in ${workspace}, another workspace than the qualification's other jobs (${primaryRoot})`);
|
|
139
|
+
primaryRoot = root;
|
|
140
|
+
}
|
|
141
|
+
verified[stage] = list.map((receipt) => summaries.get(receipt.id));
|
|
142
|
+
}
|
|
143
|
+
return verified;
|
|
144
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// GB-14 (GATE 37/E11): the gate's own qualify record is a journal of the protected installed-QA receipt, never a coarser claim beside it. The record
|
|
2
|
+
// carries the receipt's digest; reusing the record calls the EXISTING protected verifier (release-qa-receipt.mjs) over the exact current state, so a
|
|
3
|
+
// changed policy, floor, check coverage, source, artifact hash or sealed byte invalidates it at once. No parallel qualification framework exists here.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { QaAdmissionError, assertInstalledQaAdmission, qaReceiptPath, readQaReceipt } from "./release-qa-receipt.mjs";
|
|
6
|
+
|
|
7
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
8
|
+
|
|
9
|
+
/** The digest of the installed-QA receipt the qualify stage just produced for `releaseId`, or null when none is readable. */
|
|
10
|
+
export function installedQaReceiptDigest({ repoRoot, releaseId, platform, readReceipt = readQaReceipt }) {
|
|
11
|
+
const receipt = readReceipt(qaReceiptPath({ repoRoot, releaseId, platform, stage: "installed" }));
|
|
12
|
+
return receipt && HEX64.test(receipt.digest ?? "") ? receipt.digest : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Why a recorded qualify result no longer stands for the sealed bytes now, or null when it does. `verify` defaults to the existing protected admission
|
|
17
|
+
* (assertInstalledQaAdmission over the sealed manifest); it needs this host's receipt key, so a runner that has none (the trusted publisher, whose
|
|
18
|
+
* upload admission verifies the receipt itself) passes `verify: null` and is held to the digest equality and the manifest digest only.
|
|
19
|
+
*/
|
|
20
|
+
export function qualifyReuseProblem({ recorded, sealedNow, repoRoot, platform, configName, verify = defaultVerify, readFile = (f) => fs.readFileSync(f, "utf8") }) {
|
|
21
|
+
if (!recorded?.ok) return "no passed qualify result is recorded";
|
|
22
|
+
if (!sealedNow || recorded.manifestSha256 !== sealedNow.digest) return "the sealed bytes differ from the ones qualified";
|
|
23
|
+
if (!HEX64.test(recorded.qaReceiptDigest ?? "")) return "the qualify record carries no protected QA receipt digest";
|
|
24
|
+
if (verify === null) return null;
|
|
25
|
+
try {
|
|
26
|
+
const admitted = verify({ repoRoot, sealedNow, platform, configName, readFile });
|
|
27
|
+
if (admitted?.digest !== recorded.qaReceiptDigest) return "the protected QA receipt on disk is not the one the qualify record names";
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error instanceof QaAdmissionError) return `the protected installed-QA receipt no longer verifies: ${error.message}`;
|
|
30
|
+
return `the protected installed-QA receipt could not be verified: ${error.message}`;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function defaultVerify({ repoRoot, sealedNow, platform, configName, readFile }) {
|
|
36
|
+
const manifest = JSON.parse(readFile(sealedNow.manifest));
|
|
37
|
+
return assertInstalledQaAdmission({ repoRoot, manifest, platform, configName });
|
|
38
|
+
}
|
package/recipe-build.mjs
CHANGED
|
@@ -32,8 +32,10 @@ import { existsSync as nodeExistsSync, readFileSync, readdirSync as nodeReaddirS
|
|
|
32
32
|
import path from "node:path";
|
|
33
33
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
34
34
|
import { spawnSync as nodeSpawnSync } from "./background-process.mjs";
|
|
35
|
+
import { buildLaunch } from "./build-launch.mjs";
|
|
35
36
|
import { resolveTargetRoot as cargoResolveTargetRoot } from "./cargo-target.mjs";
|
|
36
37
|
import { buildAndEmbedNativeHelpers, validateNativeHelpers } from "./native-helpers.mjs";
|
|
38
|
+
import { lockedTauriArgs } from "./tauri-locked.mjs";
|
|
37
39
|
import { assertTargetDirUnchanged, removeFreshSources, stageArtifacts, verifySameContent } from "./managed-target-stage.mjs";
|
|
38
40
|
|
|
39
41
|
export const RECIPE_BUILD_PLAN_VERSION = 1;
|
|
@@ -382,7 +384,7 @@ export function waitForStableArtifacts(stability, { appRoot, log = () => {}, sta
|
|
|
382
384
|
* nothing after it runs.
|
|
383
385
|
* Options: appRoot (absolute), env, platform, log, spawnSync, fs, sleep, now, statSync (injected in tests).
|
|
384
386
|
*/
|
|
385
|
-
export function runRecipeBuild(rawPlan, { appRoot, env = process.env, platform = process.platform, log = () => {}, spawnSync = nodeSpawnSync, fs, sleep, now, statSync, resolveTargetRoot = cargoResolveTargetRoot } = {}) {
|
|
387
|
+
export function runRecipeBuild(rawPlan, { appRoot, env = process.env, platform = process.platform, log = () => {}, spawnSync = nodeSpawnSync, fs, sleep, now, statSync, resolveTargetRoot = cargoResolveTargetRoot, door = buildLaunch } = {}) {
|
|
386
388
|
if (typeof appRoot !== "string" || !path.isAbsolute(appRoot)) fail("RECIPE_BUILD_PLAN_INVALID", "appRoot must be an absolute path");
|
|
387
389
|
const plan = validateRecipePlan(rawPlan);
|
|
388
390
|
const done = [];
|
|
@@ -403,10 +405,21 @@ export function runRecipeBuild(rawPlan, { appRoot, env = process.env, platform =
|
|
|
403
405
|
verifyReal("after the copy-back, before the build step");
|
|
404
406
|
}
|
|
405
407
|
if (step.phase === "build" && plan.verifySidecars) verifyUniversalSidecars(plan.verifySidecars, { appRoot, spawnSync, env, log, ...(fs?.readFileSync ? { readFile: fs.readFileSync } : {}) });
|
|
406
|
-
const
|
|
408
|
+
const stepArgs = lockedTauriArgs(step.args, env);
|
|
409
|
+
const launch = resolveLaunch({ cmd: step.cmd, args: stepArgs, platform, env });
|
|
407
410
|
const cwd = path.resolve(appRoot, step.cwd ?? ".");
|
|
411
|
+
const stepEnv = { ...env, ...(step.env ?? {}) };
|
|
408
412
|
log(`recipe-build: [${step.phase}] ${step.cmd} ${step.args.join(" ")}`.trimEnd());
|
|
409
|
-
|
|
413
|
+
// The release stage goes through the same one door as dev and test (build-launch.mjs; rightkit#1301, one model): on a broker host a pnpm,
|
|
414
|
+
// node or cargo step is a broker job (in a snapshot stage a carried one, with the Tauri CLI's compiles nested under it), a snapshot stage's
|
|
415
|
+
// `install` step is skipped, and this package's own right-release runs as it is. Where the door adds nothing (a direct host, a step it
|
|
416
|
+
// passes through unchanged) the step runs as resolveLaunch decided.
|
|
417
|
+
const own = launch.command === process.execPath && launch.args[0] === OWN_RIGHT_RELEASE;
|
|
418
|
+
const routed = own ? null : door(step.cmd, stepArgs, { env: stepEnv, stepEnv: step.env, platform, cwd, exists: (fs?.existsSync ?? nodeExistsSync) });
|
|
419
|
+
if (routed?.signingEnvDropped?.length) log(`recipe-build: ${routed.signingEnvDropped.join(", ")} suppresses signing in a direct session and is not forwarded to the broker (GB-06: signing material never rides an adapter request); this carried step runs unsigned and the seal is made outside the broker`);
|
|
420
|
+
const passthrough = !routed || (routed.command === step.cmd && routed.args.length === stepArgs.length && routed.args.every((a, i) => a === stepArgs[i]));
|
|
421
|
+
const run = passthrough ? launch : { command: routed.command, args: routed.args, shell: false };
|
|
422
|
+
const result = spawnSync(run.command, run.args, { cwd, env: { ...stepEnv, ...(!passthrough && routed.env ? routed.env : {}) }, stdio: "inherit", shell: run.shell, windowsHide: true });
|
|
410
423
|
if (result?.error || result?.status !== 0) {
|
|
411
424
|
fail("RECIPE_BUILD_STEP_FAILED", `recipe-build ${step.phase} step ${index} (${step.cmd}) failed (${result?.error?.message ?? `exit ${result?.status ?? result?.signal ?? "unknown"}`}); later steps were not run`, { phase: step.phase, index, cmd: step.cmd, status: result?.status ?? null });
|
|
412
425
|
}
|