@rightkit/release 0.6.16 → 0.6.20

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.
Files changed (64) hide show
  1. package/README.md +7 -3
  2. package/admission-host.mjs +9 -5
  3. package/app-closure.mjs +9 -3
  4. package/bin/darwin/rightkit-swap +0 -0
  5. package/bin/rightkit-gate.mjs +469 -102
  6. package/bin/rightkit-linux-setup.mjs +15 -0
  7. package/broker-admission.mjs +184 -0
  8. package/broker-wire.mjs +15 -3
  9. package/build-launch.mjs +4 -1
  10. package/build-ownership.mjs +125 -36
  11. package/build-release.mjs +39 -7
  12. package/cargo-contract.mjs +4 -1
  13. package/cargo-guard.mjs +25 -5
  14. package/cargo-target-shim.mjs +56 -7
  15. package/clippy-route.mjs +8 -7
  16. package/crate-graph.mjs +5 -1
  17. package/credential-provider.mjs +174 -0
  18. package/deterministic-scans.mjs +247 -40
  19. package/development.mjs +8 -0
  20. package/disk-policy.mjs +2 -2
  21. package/gate-executor.mjs +5 -3
  22. package/gate-stages.mjs +34 -3
  23. package/gradle-lock.mjs +206 -0
  24. package/heavy-command.mjs +33 -2
  25. package/linux-artifacts.mjs +2 -1
  26. package/linux-setup.mjs +106 -0
  27. package/local-publish.mjs +21 -10
  28. package/local-qualify.mjs +9 -3
  29. package/managed-cargo.mjs +54 -18
  30. package/ownership-lockfiles.mjs +14 -3
  31. package/ownership.mjs +5 -1
  32. package/package.json +17 -11
  33. package/protected-credentials.mjs +13 -5
  34. package/publication-gate.mjs +9 -4
  35. package/python-lock.mjs +209 -0
  36. package/qa-admission-policy.json +32 -0
  37. package/qa-layout.mjs +229 -0
  38. package/qa-runner.mjs +156 -0
  39. package/release-artifact-path.mjs +29 -3
  40. package/release-config.mjs +1 -1
  41. package/release-qa-command.mjs +58 -8
  42. package/release-qa-policy.mjs +5 -3
  43. package/release-source-identity.mjs +1 -0
  44. package/release.mjs +10 -2
  45. package/rightkit-ownership.json +1465 -79
  46. package/rightkit-registry-resolver.mjs +7 -3
  47. package/rightkit-versions.json +173 -36
  48. package/runtime-budget-classes.json +4 -0
  49. package/runtime-budgets.mjs +5 -1
  50. package/shell-contract.mjs +1 -1
  51. package/snapshot-broker.mjs +107 -9
  52. package/snapshot-check.mjs +2 -0
  53. package/snapshot-request.mjs +20 -3
  54. package/snapshot.mjs +11 -4
  55. package/stage-commands.mjs +2 -1
  56. package/stage-ledger.mjs +15 -0
  57. package/stage-sidecar.mjs +17 -3
  58. package/suite-signing.mjs +40 -4
  59. package/swift-lock.mjs +334 -0
  60. package/test-command.mjs +13 -3
  61. package/tool-build.mjs +156 -0
  62. package/unit-gate.mjs +9 -2
  63. package/windows-identity-plan.mjs +119 -0
  64. package/windows-identity.mjs +136 -0
package/qa-layout.mjs ADDED
@@ -0,0 +1,229 @@
1
+ // Source QA against the gate-built QA shell, in the layout an install has (rightkit#1309 gap 2).
2
+ //
3
+ // A TOML-scenario launch of a Tauri app with a sidecar needs the test-mode engine and `resources/` BESIDE the UI binary, and the snapshot a package
4
+ // stage reads holds no Cargo target (and may not be written: the snapshot is exactly the commit's tree, GATE-CONTRACT §1). So `right-release qa --stage
5
+ // source` does not look for a binary in the source. The gate hands it the output directories its own compile recorded (RIGHT_RELEASE_QA_COMPILED_DIRS,
6
+ // set only after the recorded compile still proved intact), and this module builds a RUN-SCOPED directory, never inside the snapshot:
7
+ //
8
+ // <run dir>/app/ the overlay app root: every top-level entry of the snapshot's app root as a symlink (read-only view), the
9
+ // qa config as a real COPY at the same relative path (its `app_root` resolves to the overlay, so the runner's
10
+ // own writes, `.rightkit-qa-bin` included, land here), `.rightkit-qa-bin/<triple|host>/<bin>` the QA shell
11
+ // <run dir>/app/.rightkit-qa-bin/<triple|host>/ the layout beside the shell: the sidecars and trees the app declared
12
+ //
13
+ // and the runner is pointed at the staged shell with RIGHTKIT_QA_APP_BINARY (already inside the stash, so it is launched as is, never copied again).
14
+ // The shell is the newest `[<triple>/]<debug|dev|release-iterate>/<bin>` in the compile outputs that carries the QA control marker; a `release` profile
15
+ // directory is never read. The layout is declared by the app in its release config:
16
+ //
17
+ // qa.layout = {
18
+ // binary: "heardright_next_bin", // cargo bin target of the QA shell
19
+ // build: ["qa-build-shell", "qa-build-engine", "qa-sign"], // tests.suites that build (and sign) the QA shell and what sits beside it
20
+ // beside: [ // staged beside the shell, in order
21
+ // { built: "heardright-engine" }, // another executable of the same compile output (same profile directory)
22
+ // { built: "heardright-engine", optional: true }, // ... absent is allowed
23
+ // { from: "src-tauri/resources", to: "resources" }, // a file or tree of the snapshot (a list names fallbacks: the first that exists)
24
+ // { from: "src-tauri/fonts", to: "fonts", resource: true, platforms: ["mac"] }, // `platforms` (mac, win) limits an entry to those hosts
25
+ // { from: "src-tauri/resources", to: ".", resource: true }, // ... into the app's RESOURCE directory, as an install has it: beside the exe on
26
+ // // Windows, `Contents/Resources` of the bundle rightkit-qa makes on macOS
27
+ // { from: ["src-tauri/binaries/heardright-engine-aarch64-apple-darwin"], to: "heardright-engine" },
28
+ // ],
29
+ // }
30
+ //
31
+ // `build` names suites of the app's own `tests.suites` (the steps `--to test` runs): source QA runs them first, through the same door and signing policy
32
+ // (test-command.mjs runSuites), and the shell and `built` entries are then found in THOSE suites' output directories, never the release compile's. When a
33
+ // sign step is among them, the shell and every `built` entry are staged from its signed copies (rightkit#1303), and one it did not sign is refused.
34
+ // Without `build` the shell is looked for in the compile outputs the gate hands over.
35
+ // On macOS rightkit-qa wraps the stashed shell in a bundle and carries the staged layout into it (control.rs make_bundle): every entry beside the
36
+ // shell goes to `Contents/MacOS` (where an install keeps its externalBin sidecars), and the `Resources/` directory a `resource: true` entry is
37
+ // staged into goes to `Contents/Resources` (where Tauri's resource_dir() points in an install). On Windows the shell runs in place, its
38
+ // resource directory is its own directory, so a `resource: true` entry is staged beside it.
39
+ // `from` stays inside the snapshot app root (links resolved), `to` stays beside the shell. Scenario state (a seeded settings.json) is the scenario's
40
+ // own `write_file` step inside the runner's isolated data directory, not this layout. Every file staged is digested into the receipt.
41
+ import crypto from "node:crypto";
42
+ import fs from "node:fs";
43
+ import path from "node:path";
44
+ import { QA_CONTROL_MARKER } from "./qa-control-scan.mjs";
45
+ import { realish } from "./qa-snapshot.mjs";
46
+
47
+ export const QA_COMPILED_DIRS_ENV = "RIGHT_RELEASE_QA_COMPILED_DIRS";
48
+ /** A direct host's Cargo output directories the gate pinned for this stage (JSON list): what a suite built there lands in (test-command.mjs). */
49
+ export const GATE_TARGET_DIRS_ENV = "RIGHTKIT_GATE_TARGET_DIRS";
50
+ export const QA_APP_BINARY_ENV = "RIGHTKIT_QA_APP_BINARY";
51
+ export const STASH_DIR = ".rightkit-qa-bin";
52
+ /** The profile directories the runner reads for a dev build (binary.rs DEV_PROFILES); `release` is never one. */
53
+ export const DEV_PROFILES = Object.freeze(["release-iterate", "debug", "dev"]);
54
+
55
+ const LAYOUT_KEYS = new Set(["binary", "beside", "build"]);
56
+ const BESIDE_KEYS = new Set(["built", "from", "to", "optional", "resource", "platforms"]);
57
+ const LAYOUT_PLATFORMS = new Set(["mac", "win"]);
58
+ /** The directory beside a macOS shell that rightkit-qa's bundle maps to `Contents/Resources` (rightkit-qa control.rs MAC_RESOURCES_DIR). */
59
+ export const MAC_RESOURCES_DIR = "Resources";
60
+ const BARE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
61
+
62
+ const fail = (code, message) => Object.assign(new Error(`${code}: ${message}`), { code });
63
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
64
+ const sha256Of = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
65
+ const insideOrSame = (child, parent) => { const rel = path.relative(parent, child); return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); };
66
+ const cleanRel = (value) => (typeof value === "string" && value && !path.isAbsolute(value) && !/^[A-Za-z]:/.test(value) && !value.replaceAll("\\", "/").split("/").includes("..") ? value.replaceAll("\\", "/").replace(/\/+$/, "") || "." : null);
67
+
68
+ /** Problems with a `qa.layout` declaration, all at once; empty when it is well formed. */
69
+ export function validateQaLayout(layout) {
70
+ if (!isObject(layout)) return ["qa.layout must be an object { binary, beside? }"];
71
+ const problems = [];
72
+ for (const key of Object.keys(layout)) if (!LAYOUT_KEYS.has(key)) problems.push(`qa.layout has unknown key ${JSON.stringify(key)} (allowed: ${[...LAYOUT_KEYS].join(", ")})`);
73
+ if (typeof layout.binary !== "string" || !BARE_NAME.test(layout.binary) || /\.exe$/i.test(layout.binary)) problems.push("qa.layout.binary must be a bare cargo bin target name (no path, no .exe)");
74
+ if (layout.beside !== undefined && !Array.isArray(layout.beside)) problems.push("qa.layout.beside must be a list");
75
+ if (layout.build !== undefined && (!Array.isArray(layout.build) || !layout.build.length || layout.build.some((name) => typeof name !== "string" || !name) || new Set(layout.build).size !== layout.build.length)) problems.push("qa.layout.build must be a non-empty list of distinct tests.suites names");
76
+ const tos = new Set();
77
+ (Array.isArray(layout.beside) ? layout.beside : []).forEach((item, index) => {
78
+ const at = `qa.layout.beside[${index}]`;
79
+ if (!isObject(item)) { problems.push(`${at} must be an object`); return; }
80
+ for (const key of Object.keys(item)) if (!BESIDE_KEYS.has(key)) problems.push(`${at} has unknown key ${JSON.stringify(key)} (allowed: ${[...BESIDE_KEYS].join(", ")})`);
81
+ const hasBuilt = item.built !== undefined; const hasFrom = item.from !== undefined;
82
+ if (hasBuilt === hasFrom) problems.push(`${at} needs exactly one of built or from`);
83
+ if (hasBuilt && (typeof item.built !== "string" || !BARE_NAME.test(item.built) || /\.exe$/i.test(item.built))) problems.push(`${at}.built must be a bare cargo bin target name`);
84
+ if (hasFrom) {
85
+ const list = Array.isArray(item.from) ? item.from : [item.from];
86
+ if (!list.length || list.some((from) => cleanRel(from) === null)) problems.push(`${at}.from must be a relative path (or a non-empty list of them) inside the app, without ..`);
87
+ if (item.to === undefined) problems.push(`${at}.to is required with from`);
88
+ }
89
+ if (item.to !== undefined && cleanRel(item.to) === null) problems.push(`${at}.to must be a relative path beside the binary, without ..`);
90
+ if (item.optional !== undefined && typeof item.optional !== "boolean") problems.push(`${at}.optional must be a boolean`);
91
+ if (item.resource !== undefined && (item.resource !== true || !hasFrom)) problems.push(`${at}.resource must be true and only with from`);
92
+ if (item.platforms !== undefined && (!Array.isArray(item.platforms) || !item.platforms.length || item.platforms.some((p) => !LAYOUT_PLATFORMS.has(p)))) problems.push(`${at}.platforms must be a non-empty list of ${[...LAYOUT_PLATFORMS].join(", ")}`);
93
+ const to = hasBuilt ? (cleanRel(item.to) ?? item.built) : item.resource ? `${MAC_RESOURCES_DIR}/${cleanRel(item.to)}` : cleanRel(item.to);
94
+ // Several `from` entries may fill one directory (a file both would write is refused when staged); a built executable is staged once.
95
+ if (to && hasBuilt) { if (tos.has(to)) problems.push(`${at} stages ${to} a second time`); tos.add(to); }
96
+ });
97
+ return problems;
98
+ }
99
+
100
+ /** `RIGHT_RELEASE_QA_COMPILED_DIRS`: a JSON list of absolute directories; null when unset, a typed error when malformed. */
101
+ export function compiledDirsFromEnv(env = process.env) {
102
+ const raw = env?.[QA_COMPILED_DIRS_ENV];
103
+ if (raw === undefined || raw === "") return null;
104
+ let dirs;
105
+ try { dirs = JSON.parse(raw); } catch { throw fail("RK_QA_LAYOUT_DIRS", `${QA_COMPILED_DIRS_ENV} is not a JSON list`); }
106
+ if (!Array.isArray(dirs) || !dirs.length || dirs.some((dir) => typeof dir !== "string" || !path.isAbsolute(dir))) throw fail("RK_QA_LAYOUT_DIRS", `${QA_COMPILED_DIRS_ENV} must be a non-empty JSON list of absolute directories`);
107
+ return dirs;
108
+ }
109
+
110
+ const exeName = (name, platform) => (platform === "win" ? `${name}.exe` : name);
111
+ const hasMarker = (file) => {
112
+ const bytes = fs.readFileSync(file);
113
+ return bytes.includes(Buffer.from(QA_CONTROL_MARKER, "latin1")) || bytes.includes(Buffer.from(QA_CONTROL_MARKER, "utf16le"));
114
+ };
115
+ const listDirs = (dir) => { try { return fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name); } catch { return []; } };
116
+
117
+ /** Every `<dir>/[<triple>/]<profile>/<name>` regular file under the compile output directories, newest first, with the profile directory and triple. */
118
+ export function findBuiltExecutables(dirs, name, platform) {
119
+ const found = [];
120
+ for (const dir of dirs) {
121
+ const profileRoots = [{ root: dir, triple: "host" }, ...listDirs(dir).filter((n) => n.includes("-") && !DEV_PROFILES.includes(n)).map((n) => ({ root: path.join(dir, n), triple: n }))];
122
+ for (const { root, triple } of profileRoots) {
123
+ for (const profile of DEV_PROFILES) {
124
+ const file = path.join(root, profile, exeName(name, platform));
125
+ try { const meta = fs.statSync(file); if (meta.isFile()) found.push({ file, dir, profileDir: path.dirname(file), triple, mtimeMs: meta.mtimeMs }); } catch { /* not built here */ }
126
+ }
127
+ }
128
+ }
129
+ return found.sort((a, b) => b.mtimeMs - a.mtimeMs);
130
+ }
131
+
132
+ function copyTree(from, to, entries, relBase) {
133
+ const meta = fs.lstatSync(from);
134
+ if (meta.isSymbolicLink()) throw fail("RK_QA_LAYOUT_LINK", `${from} is a symbolic link; a staged layout copies only regular files`);
135
+ if (meta.isDirectory()) {
136
+ fs.mkdirSync(to, { recursive: true });
137
+ for (const name of fs.readdirSync(from).sort()) copyTree(path.join(from, name), path.join(to, name), entries, `${relBase}/${name}`);
138
+ return;
139
+ }
140
+ if (!meta.isFile()) throw fail("RK_QA_LAYOUT_KIND", `${from} is neither a file nor a directory`);
141
+ fs.mkdirSync(path.dirname(to), { recursive: true });
142
+ // Two entries filling one directory may not write the same file with other bytes.
143
+ if (fs.existsSync(to)) {
144
+ if (sha256Of(fs.readFileSync(to)) !== sha256Of(fs.readFileSync(from))) throw fail("RK_QA_LAYOUT_COLLISION", `${relBase} is staged twice with different bytes (${from})`);
145
+ return; // the same bytes, already staged and recorded
146
+ }
147
+ fs.copyFileSync(from, to);
148
+ fs.chmodSync(to, meta.mode & 0o777);
149
+ entries.push({ path: relBase, sha256: sha256Of(fs.readFileSync(to)), bytes: meta.size });
150
+ }
151
+
152
+ /** A directory that mirrors `src` with every entry a symlink, except the directories on the way to `relFile`, which are real, and `relFile` itself (a copy). */
153
+ function mirror(src, dst, relParts, copyFile) {
154
+ fs.mkdirSync(dst, { recursive: true });
155
+ const [head, ...tail] = relParts;
156
+ for (const name of fs.readdirSync(src)) {
157
+ if (name === ".git" || name === STASH_DIR) continue;
158
+ const from = path.join(src, name); const to = path.join(dst, name);
159
+ if (name === head && tail.length) mirror(from, to, tail, copyFile);
160
+ else if (name === head) copyFile(from, to);
161
+ else fs.symlinkSync(from, to);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Stage the run-scoped overlay app root, the QA shell and its declared layout. Returns
167
+ * `{ runDir, appRoot, configFile, binary, evidence, cleanup }`; `cleanup()` removes `runDir`. Nothing is written under `appRoot` of the snapshot.
168
+ * `snapshotAppRoot` is the app root the release config lives in, `qaConfigFile` the absolute qa config inside it, `runsRoot` the gate's runs root
169
+ * (outside every repository and snapshot), `dirs` the compile output directories. `signed` (a sign step's `{ "<path in its output dir>": "<signed copy>" }`)
170
+ * makes the shell and every `built` entry come from their signed copies; a file it does not name is refused.
171
+ */
172
+ export function stageQaLayout({ layout, snapshotAppRoot, qaConfigFile, runsRoot, dirs, platform, signed = null, now = () => Date.now() }) {
173
+ const problems = validateQaLayout(layout);
174
+ if (problems.length) throw fail("RK_QA_LAYOUT_INVALID", problems.join("; "));
175
+ const sourceRoot = fs.realpathSync.native(snapshotAppRoot);
176
+ const configReal = fs.realpathSync.native(qaConfigFile);
177
+ if (!insideOrSame(configReal, sourceRoot)) throw fail("RK_QA_LAYOUT_CONFIG", `the qa config ${qaConfigFile} is outside the app root ${snapshotAppRoot}`);
178
+ if (!path.isAbsolute(runsRoot) || insideOrSame(realish(runsRoot), sourceRoot)) throw fail("RK_QA_LAYOUT_PLACE", `the runs root ${runsRoot} must be an absolute directory outside the snapshot`);
179
+
180
+ const candidates = findBuiltExecutables(dirs, layout.binary, platform);
181
+ if (!candidates.length) throw fail("RK_QA_LAYOUT_NO_BINARY", `no ${exeName(layout.binary, platform)} in a ${DEV_PROFILES.join("/")} directory of the compile outputs (${dirs.join(", ")}); the compile did not build the QA shell`);
182
+ // The bytes staged for a built file: its signed copy when a sign step ran, keyed by its path inside the output directory it was found in.
183
+ const bytesOf = (file, dir) => {
184
+ if (!signed) return file;
185
+ const rel = path.relative(dir, file).split(path.sep).join("/");
186
+ const copy = signed[rel];
187
+ if (typeof copy !== "string" || !fs.existsSync(copy)) throw fail("RK_QA_LAYOUT_UNSIGNED", `${file} (${rel}) has no signed copy; the declared sign step signed ${Object.keys(signed).join(", ") || "nothing"}`);
188
+ return copy;
189
+ };
190
+ const shell = candidates.find((candidate) => hasMarker(candidate.file));
191
+ if (!shell) throw fail("RK_QA_LAYOUT_NOT_QA", `${candidates.map((c) => c.file).join(", ")} carry no QA control marker; the compile built a plain app, not the QA shell`);
192
+
193
+ fs.mkdirSync(runsRoot, { recursive: true, mode: 0o700 });
194
+ const runDir = fs.mkdtempSync(path.join(runsRoot, `qa-layout-${now().toString(36)}-`));
195
+ const cleanup = () => { try { fs.rmSync(runDir, { recursive: true, force: true }); } catch { /* the runs root is pruned */ } };
196
+ try {
197
+ const appRoot = path.join(runDir, "app");
198
+ const relConfig = path.relative(sourceRoot, configReal).split(path.sep);
199
+ const configFile = path.join(appRoot, ...relConfig);
200
+ mirror(sourceRoot, appRoot, relConfig, (from, to) => fs.copyFileSync(from, to));
201
+ const besideDir = path.join(appRoot, STASH_DIR, shell.triple);
202
+ fs.mkdirSync(besideDir, { recursive: true });
203
+ const binary = path.join(besideDir, exeName(layout.binary, platform));
204
+ fs.copyFileSync(bytesOf(shell.file, shell.dir), binary);
205
+ fs.chmodSync(binary, 0o755);
206
+ const evidence = { source: shell.file, ...(signed ? { signed: true } : {}), binary: { name: path.basename(binary), sha256: sha256Of(fs.readFileSync(binary)) }, qaConfig: { sha256: sha256Of(fs.readFileSync(configFile)) }, beside: [] };
207
+ for (const item of layout.beside ?? []) {
208
+ if (Array.isArray(item.platforms) && !item.platforms.includes(platform)) continue;
209
+ const optional = item.optional === true;
210
+ if (item.built !== undefined) {
211
+ const from = path.join(shell.profileDir, exeName(item.built, platform));
212
+ const toName = cleanRel(item.to) ?? exeName(item.built, platform);
213
+ if (!fs.existsSync(from)) { if (optional) continue; throw fail("RK_QA_LAYOUT_MISSING", `layout names built executable ${item.built}, but ${from} is not in the QA shell's output directory`); }
214
+ copyTree(bytesOf(from, shell.dir), path.join(besideDir, toName), evidence.beside, toName);
215
+ continue;
216
+ }
217
+ const options = (Array.isArray(item.from) ? item.from : [item.from]).map(cleanRel);
218
+ const pick = options.map((rel) => path.join(sourceRoot, rel)).find((file) => fs.existsSync(file));
219
+ if (!pick) { if (optional) continue; throw fail("RK_QA_LAYOUT_MISSING", `layout source ${options.join(" or ")} does not exist in the snapshot`); }
220
+ if (!insideOrSame(fs.realpathSync.native(pick), sourceRoot)) throw fail("RK_QA_LAYOUT_ESCAPE", `layout source ${pick} resolves outside the app root`);
221
+ const rel = cleanRel(item.to);
222
+ // A resource goes to the app's resource directory: `Resources/` (Contents/Resources of the macOS bundle) on macOS, beside the shell on Windows.
223
+ const toName = item.resource && platform === "mac" ? path.posix.join(MAC_RESOURCES_DIR, rel) : rel;
224
+ copyTree(pick, path.join(besideDir, toName), evidence.beside, toName);
225
+ }
226
+ evidence.beside.sort((a, b) => a.path.localeCompare(b.path));
227
+ return { runDir, appRoot, configFile, binary, evidence, cleanup };
228
+ } catch (error) { cleanup(); throw error; }
229
+ }
package/qa-runner.mjs ADDED
@@ -0,0 +1,156 @@
1
+ // The rightkit-qa runner an app's `qa.version` names, obtained the one sanctioned way (rightkit#1309 pins are routine; never a PATH binary).
2
+ //
3
+ // `right-release qa` used to take the runner from `qa.runner` (a file inside the source) or the first `rightkit-qa` on PATH. A source snapshot holds no
4
+ // runner and PATH is not a trust root, so a package stage's source QA had nothing admissible to run. The runner is now a managed tool, the pattern of
5
+ // scan-tool-enrolment.mjs:
6
+ //
7
+ // record `<managed data root>/qa-runners/<mac|win>/record.json`, `{schema, platform, runners: {<version>: {path, sha256, version}}, enrolledAt}`, and the
8
+ // binary itself at `<managed data root>/qa-runners/<platform>/<version>/rightkit-qa[.exe]`: a COPY the enrol operation made, so a later
9
+ // `cargo clean` or target rotation cannot change what a receipt digests. Outside every repository and snapshot.
10
+ // enrol `enrollQaRunner` (also `right-release qa runner enroll`): the operator names a built file (RightKit's own gate-built `crates/rightkit-qa`
11
+ // output) and the version; the file must be a regular executable that answers `--version` with exactly `rightkit-qa <version>`; its copy is
12
+ // digested and recorded. A second enrolment of the same version is a rotation and needs `rotate`.
13
+ // use `resolveManagedQaRunner`: the record names the version the app declared; the managed file must still be a real file inside the managed
14
+ // directory whose sha256 IS the recorded one and which still answers `--version` as that version. Anything else refuses, naming it.
15
+ // admit unchanged and separate: the sealed receipt carries the runner's sha256, and qa-admission-policy.json `approvedRunners` (version + platform +
16
+ // sha256, release-qa-policy.mjs approve-runner) decides whether the receipt is admitted. This module trusts nothing on its own.
17
+ import crypto from "node:crypto";
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { spawnSync } from "./background-process.mjs";
22
+ import { resolveManagedRoots } from "./managed-roots.mjs";
23
+
24
+ export const QA_RUNNERS_SCHEMA = "rightkit.qa-runners/1";
25
+ export const QA_RUNNERS_DIR = "qa-runners";
26
+ export const QA_RUNNER_PLATFORMS = Object.freeze(["mac", "win"]);
27
+ const HOST_PLATFORM = { darwin: "mac", win32: "win" };
28
+ const SEMVER = /^\d+\.\d+\.\d+$/;
29
+ const SHA256 = /^[0-9a-f]{64}$/;
30
+
31
+ export class QaRunnerError extends Error {
32
+ constructor(code, message) { super(message); this.name = "QaRunnerError"; this.code = code; }
33
+ }
34
+ const fail = (code, message) => { throw new QaRunnerError(code, message); };
35
+
36
+ /** True when `qa.version` names a rightkit-qa release (`0.10.0`), as opposed to the legacy QA plan version (`"1"`). */
37
+ export const isRunnerVersion = (version) => SEMVER.test(String(version ?? ""));
38
+
39
+ export const runnerExecutableName = (platform) => (platform === "win" ? "rightkit-qa.exe" : "rightkit-qa");
40
+
41
+ const checkPlatform = (platform) => { if (!QA_RUNNER_PLATFORMS.includes(platform)) fail("RK_QA_RUNNER_PLATFORM", `platform must be ${QA_RUNNER_PLATFORMS.join(" or ")}, not ${JSON.stringify(platform)}`); };
42
+ const checkDataRoot = (dataRoot) => { if (!dataRoot || !path.isAbsolute(dataRoot)) fail("RK_QA_RUNNER_ROOT", "the managed data root must be an absolute path"); };
43
+
44
+ export const qaRunnersDir = ({ dataRoot, platform }) => { checkDataRoot(dataRoot); checkPlatform(platform); return path.join(dataRoot, QA_RUNNERS_DIR, platform); };
45
+ export const qaRunnersRecordFile = (args) => path.join(qaRunnersDir(args), "record.json");
46
+ export const qaRunnerFile = ({ dataRoot, platform, version }) => {
47
+ if (!isRunnerVersion(version)) fail("RK_QA_RUNNER_VERSION", `${JSON.stringify(version)} is not a rightkit-qa version (major.minor.patch)`);
48
+ return path.join(qaRunnersDir({ dataRoot, platform }), version, runnerExecutableName(platform));
49
+ };
50
+
51
+ const sha256OfFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
52
+ const isInside = (child, parent) => { const rel = path.relative(parent, child); return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel); };
53
+
54
+ /** The runner's own answer to `--version` must be exactly `rightkit-qa <version>`; the file that answers is the file that is digested. */
55
+ function assertReportsVersion(file, version, spawn) {
56
+ const run = spawn(file, ["--version"], { encoding: "utf8", windowsHide: true, timeout: 30000 });
57
+ const output = String(run?.stdout ?? "").trim();
58
+ if (run?.status !== 0 || output !== `rightkit-qa ${version}`) {
59
+ fail("RK_QA_RUNNER_VERSION_MISMATCH", `${file} --version reported ${JSON.stringify(output.slice(0, 80))} (exit ${run?.status ?? run?.error?.message ?? "none"}), expected "rightkit-qa ${version}"`);
60
+ }
61
+ }
62
+
63
+ function readRecord({ dataRoot, platform }) {
64
+ const file = qaRunnersRecordFile({ dataRoot, platform });
65
+ let meta;
66
+ try { meta = fs.lstatSync(file); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
67
+ if (meta.isSymbolicLink() || !meta.isFile()) fail("RK_QA_RUNNER_RECORD", `${file} is not a regular file`);
68
+ if (process.platform !== "win32" && (meta.mode & 0o022)) fail("RK_QA_RUNNER_RECORD", `${file} is writable by group or others`);
69
+ let record;
70
+ try { record = JSON.parse(fs.readFileSync(file, "utf8")); } catch (error) { fail("RK_QA_RUNNER_RECORD", `${file} is not JSON (${error.message})`); }
71
+ if (record?.schema !== QA_RUNNERS_SCHEMA || record.platform !== platform || record.runners === null || typeof record.runners !== "object" || Array.isArray(record.runners)) {
72
+ fail("RK_QA_RUNNER_RECORD", `${file} is not a ${QA_RUNNERS_SCHEMA} record for ${platform}`);
73
+ }
74
+ return { file, record };
75
+ }
76
+
77
+ /**
78
+ * The explicit enrol operation. `runner` is a built rightkit-qa file (an absolute path); its copy under the managed directory is what is recorded.
79
+ * Enrols on the host whose platform it is (the bytes are executed here to read their version). Returns `{ file, runnerFile, entry, rotated }`.
80
+ */
81
+ export function enrollQaRunner({ dataRoot, platform, runner, version, rotate = false, spawn = spawnSync, hostPlatform = process.platform, now = () => new Date() }) {
82
+ checkPlatform(platform);
83
+ if (HOST_PLATFORM[hostPlatform] !== platform) fail("RK_QA_RUNNER_PLATFORM", `a ${platform} runner is enrolled on a ${platform} host (this is ${hostPlatform}): its bytes are executed to read their version`);
84
+ const target = qaRunnerFile({ dataRoot, platform, version });
85
+ if (!runner || !path.isAbsolute(String(runner))) fail("RK_QA_RUNNER_PATH", "the runner must be named by an absolute path");
86
+ let source; let meta;
87
+ try { source = fs.realpathSync.native(runner); meta = fs.statSync(source); } catch (error) { fail("RK_QA_RUNNER_PATH", `the runner ${runner} cannot be resolved (${error.code ?? error.message})`); }
88
+ if (!meta.isFile()) fail("RK_QA_RUNNER_PATH", `${source} is not a regular file`);
89
+ if (platform !== "win" && (meta.mode & 0o111) === 0) fail("RK_QA_RUNNER_PATH", `${source} is not executable`);
90
+ assertReportsVersion(source, version, spawn);
91
+ const existing = readRecord({ dataRoot, platform });
92
+ const had = existing?.record.runners?.[version] ?? null;
93
+ if (had && !rotate) fail("RK_QA_RUNNER_EXISTS", `${existing.file} already enrols rightkit-qa ${version} (sha256 ${String(had.sha256).slice(0, 12)}); replacing it is a rotation (pass rotate)`);
94
+ const dir = path.dirname(target);
95
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
96
+ const staging = `${target}.${crypto.randomUUID()}.tmp`;
97
+ try {
98
+ fs.copyFileSync(source, staging, fs.constants.COPYFILE_EXCL);
99
+ fs.chmodSync(staging, 0o755);
100
+ assertReportsVersion(staging, version, spawn);
101
+ fs.renameSync(staging, target);
102
+ } finally { fs.rmSync(staging, { force: true }); }
103
+ const entry = { path: target, sha256: sha256OfFile(target), version };
104
+ const record = { schema: QA_RUNNERS_SCHEMA, platform, runners: { ...(existing?.record.runners ?? {}), [version]: entry }, enrolledAt: now().toISOString() };
105
+ const file = qaRunnersRecordFile({ dataRoot, platform });
106
+ const tmp = `${file}.${crypto.randomUUID()}.tmp`;
107
+ try { fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { flag: "wx", mode: 0o600 }); fs.renameSync(tmp, file); } finally { fs.rmSync(tmp, { force: true }); }
108
+ return { file, runnerFile: target, entry, rotated: Boolean(had) };
109
+ }
110
+
111
+ /**
112
+ * The managed runner for `version` on `platform`: `{ file, sha256, version, source: "managed" }`, or a typed refusal. The record is read, the file it names
113
+ * must be the managed copy (inside the managed directory, not a link), its bytes must still hash to the recorded sha256, and it must still answer
114
+ * `--version` with that version. There is no fallback: a missing enrolment is a refusal that names the operation that makes one.
115
+ */
116
+ export function resolveManagedQaRunner({ dataRoot, platform, version, spawn = spawnSync }) {
117
+ checkPlatform(platform);
118
+ if (!isRunnerVersion(version)) fail("RK_QA_RUNNER_VERSION", `qa.version ${JSON.stringify(version)} is not a rightkit-qa version (major.minor.patch)`);
119
+ const expected = qaRunnerFile({ dataRoot, platform, version });
120
+ const read = readRecord({ dataRoot, platform });
121
+ const entry = read?.record.runners?.[version];
122
+ if (!entry) fail("RK_QA_RUNNER_MISSING", `rightkit-qa ${version} is not enrolled for ${platform} (${qaRunnersRecordFile({ dataRoot, platform })}); build crates/rightkit-qa ${version} through the gate and run \`right-release qa runner enroll --runner <built rightkit-qa> --version ${version} --platform ${platform}\``);
123
+ if (entry.version !== version || !SHA256.test(String(entry.sha256)) || entry.path !== expected) fail("RK_QA_RUNNER_RECORD", `${read.file}: the entry for ${version} is malformed or names a file other than ${expected}`);
124
+ let meta;
125
+ try { meta = fs.lstatSync(expected); } catch (error) { fail("RK_QA_RUNNER_MISSING", `the enrolled rightkit-qa ${version} is gone (${expected}: ${error.code ?? error.message}); enrol it again`); }
126
+ if (meta.isSymbolicLink() || !meta.isFile()) fail("RK_QA_RUNNER_RECORD", `${expected} is not a regular file`);
127
+ if (!isInside(fs.realpathSync.native(expected), fs.realpathSync.native(path.join(dataRoot, QA_RUNNERS_DIR)))) fail("RK_QA_RUNNER_RECORD", `${expected} resolves outside ${path.join(dataRoot, QA_RUNNERS_DIR)}`);
128
+ const actual = sha256OfFile(expected);
129
+ if (actual !== entry.sha256) fail("RK_QA_RUNNER_DIGEST", `the enrolled rightkit-qa ${version} at ${expected} has sha256 ${actual}, not the recorded ${entry.sha256}; it changed after enrolment`);
130
+ assertReportsVersion(expected, version, spawn);
131
+ return { file: expected, sha256: actual, version, source: "managed" };
132
+ }
133
+
134
+ /** `node qa-runner.mjs enroll --data-root <dir> --platform mac|win --runner <file> --version <v> [--rotate]` or `show --data-root <dir> --platform mac|win`. */
135
+ export function main(argv, { out = (m) => process.stdout.write(`${m}\n`), err = (m) => process.stderr.write(`${m}\n`), env = process.env, spawn, program = "qa-runner.mjs" } = {}) {
136
+ const [command, ...rest] = argv;
137
+ const opt = (name) => { const i = rest.indexOf(name); return i < 0 ? undefined : rest[i + 1]; };
138
+ try {
139
+ const platform = opt("--platform") ?? HOST_PLATFORM[process.platform];
140
+ const dataRoot = opt("--data-root") ?? resolveManagedRoots({ env }).dataRoot;
141
+ if (command === "enroll") {
142
+ const r = enrollQaRunner({ dataRoot, platform, runner: opt("--runner") && path.resolve(opt("--runner")), version: opt("--version"), rotate: rest.includes("--rotate"), ...(spawn ? { spawn } : {}) });
143
+ out(`enrolled rightkit-qa ${r.entry.version} for ${platform} (sha256 ${r.entry.sha256}) at ${r.runnerFile}${r.rotated ? " (rotation)" : ""}; approve it for admission with \`right-release qa policy approve-runner --runner ${r.runnerFile} --version ${r.entry.version} --platform ${platform} --set-by <name>\``);
144
+ return 0;
145
+ }
146
+ if (command === "show") {
147
+ const read = readRecord({ dataRoot, platform });
148
+ out(read ? JSON.stringify(read.record) : "no enrolment");
149
+ return read ? 0 : 1;
150
+ }
151
+ err(`usage: ${program} enroll --platform mac|win --runner <built rightkit-qa> --version <v> [--data-root <dir>] [--rotate]\n ${program} show [--platform mac|win] [--data-root <dir>]`);
152
+ return 2;
153
+ } catch (error) { err(`${error.code ?? "error"}: ${error.message}`); return 1; }
154
+ }
155
+
156
+ if (process.argv[1] && (() => { try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })()) process.exit(main(process.argv.slice(2)));
@@ -26,6 +26,7 @@
26
26
  import { existsSync, realpathSync } from "node:fs";
27
27
  import path from "node:path";
28
28
  import { fileURLToPath } from "node:url";
29
+ import { shimMapOfEnv } from "./cargo-target-shim.mjs";
29
30
  import { resolveTargetRoot as cargoResolveTargetRoot } from "./cargo-target.mjs";
30
31
  import { expandRecipeBuildCommands } from "./recipe-build.mjs";
31
32
  import { SourceSplitError, readSourceSplit } from "./source-split.mjs";
@@ -123,10 +124,17 @@ export function splitArtifactContext({
123
124
  if (!root) { try { root = readSourceSplit({ env, cwd: appRoot })?.snapshot ?? null; } catch (error) { if (!(error instanceof SourceSplitError)) throw error; } }
124
125
  const snapshot = canonical(root ?? appRoot);
125
126
  const pinned = typeof env.CARGO_TARGET_DIR === "string" && path.isAbsolute(env.CARGO_TARGET_DIR) ? canonical(env.CARGO_TARGET_DIR) : null;
127
+ // Under the per-call wrapper of SEVERAL workspaces (cargo-target-shim.mjs) CARGO_TARGET_DIR is no binding: the wrapper binds each call's
128
+ // workspace to its own checkout target, and any CARGO_TARGET_DIR in this process is ambient or a scan sentinel. The binding of a manifest
129
+ // is then the wrapper's map entry for the workspace that holds it. One workspace (or no wrapper) keeps the process-wide value.
130
+ const wrapperMap = resolve ? multiWorkspaceWrapperMap(env) : null;
126
131
  const cache = new Map();
127
132
  const boundTargets = declared.map(({ prefix, manifest }) => {
128
133
  if (!resolve) return Object.freeze({ prefix, manifest, dir: null });
129
- if (!cache.has(manifest)) cache.set(manifest, bindManifest({ manifest, appRoot, snapshot, pinned, resolveTargetRoot, exists }));
134
+ if (!cache.has(manifest)) {
135
+ const mapped = wrapperMap ? wrapperBindingOf(wrapperMap, manifest) : null;
136
+ cache.set(manifest, bindManifest({ manifest, appRoot, snapshot, pinned: wrapperMap ? (mapped?.target ?? null) : pinned, workspace: mapped?.workspace ?? null, resolveTargetRoot, exists }));
137
+ }
130
138
  return Object.freeze({ prefix, manifest, dir: cache.get(manifest) });
131
139
  });
132
140
  return Object.freeze({
@@ -135,7 +143,25 @@ export function splitArtifactContext({
135
143
  });
136
144
  }
137
145
 
138
- function bindManifest({ manifest, appRoot, snapshot, pinned, resolveTargetRoot, exists }) {
146
+ /** The wrapper map of this process when it binds more than one workspace; null for no wrapper or a single workspace (CARGO_TARGET_DIR binds that). */
147
+ function multiWorkspaceWrapperMap(env) {
148
+ const map = shimMapOfEnv(env);
149
+ return map && Object.keys(map.workspaces).length > 1 ? map : null;
150
+ }
151
+
152
+ /** The wrapper's binding for the workspace holding `manifest`: the deepest mapped workspace that contains it, as `{ workspace, target }`; null when none does. */
153
+ function wrapperBindingOf(map, manifest) {
154
+ const dir = canonical(path.dirname(manifest));
155
+ let best = null;
156
+ for (const [workspace, target] of Object.entries(map.workspaces)) {
157
+ if (typeof target !== "string" || !path.isAbsolute(target)) continue;
158
+ const root = canonical(workspace);
159
+ if (insideCanonical(dir, root) && (!best || root.length > best.root.length)) best = { root, workspace, target: canonical(target) };
160
+ }
161
+ return best ? { workspace: best.workspace, target: best.target } : null;
162
+ }
163
+
164
+ function bindManifest({ manifest, appRoot, snapshot, pinned, workspace = null, resolveTargetRoot, exists }) {
139
165
  if (!exists(manifest)) throw new ArtifactPathError(SPLIT_TARGET_UNRESOLVED_CODE, `the Cargo manifest ${manifest} is not in the snapshot, so its target directory cannot be bound; a split never falls back to a snapshot-relative target/`);
140
166
  let dir;
141
167
  try { dir = resolveTargetRoot(manifest); } catch (error) {
@@ -146,7 +172,7 @@ function bindManifest({ manifest, appRoot, snapshot, pinned, resolveTargetRoot,
146
172
  if (insideCanonical(real, snapshot) || insideCanonical(real, canonical(appRoot))) {
147
173
  throw new ArtifactPathError(SPLIT_TARGET_UNRESOLVED_CODE, `the Cargo target of ${manifest} resolves inside the snapshot (${dir}); the gate binds a split's target outside it (CARGO_TARGET_DIR, row 7.09) and never to a snapshot-relative target/`);
148
174
  }
149
- if (pinned && real !== pinned) throw new ArtifactPathError(SPLIT_TARGET_DRIFT_CODE, `the Cargo target of ${manifest} is ${dir}, not the CARGO_TARGET_DIR the gate bound for this stage`);
175
+ if (pinned && real !== pinned) throw new ArtifactPathError(SPLIT_TARGET_DRIFT_CODE, `the Cargo target of ${manifest} is ${dir}, not the CARGO_TARGET_DIR the gate bound for this stage (expected ${pinned}${workspace ? `, the cargo wrapper's target for workspace ${workspace}` : ""})`);
150
176
  return path.resolve(dir);
151
177
  }
152
178
 
@@ -41,7 +41,7 @@ export const LEGACY_EXECUTABLE_EXTENSIONS = Object.freeze([".mjs", ".js", ".cjs"
41
41
  export const TOP_LEVEL_KEYS = Object.freeze([
42
42
  "$schema", "$comment", "schema", "app", "version", "channel", "distribution", "packageManager", "workdir", "targets", "development",
43
43
  "deps", "checks", "buildInputs", "hostedWorkflows", "devtools", "releaseSwitches", "qa", "nativeAssembly", "legal", "web", "tests",
44
- "sidecars", "assembly", "releaseDependencies", "appStoreConnect", "storage", "cargoProfileExempt", "runtimeArtifacts",
44
+ "sidecars", "assembly", "releaseDependencies", "appStoreConnect", "storage", "cargoProfileExempt", "runtimeArtifacts", "windows",
45
45
  ]);
46
46
  export const TEMPLATE_TOKENS = Object.freeze(["app", "version", "home", "localAppData"]);
47
47
 
@@ -6,13 +6,17 @@
6
6
  // right-release qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>] (release-qa-audit.mjs; exit 1 when an uploaded build lacks passing QA)
7
7
  // right-release qa policy show | set --app <app> --mode warn|refuse --set-by <name> [--stage source|installed]
8
8
  // | approve-runner --runner <file> --version <v> --platform mac|win --set-by <name> [--note <text>] (release-qa-policy.mjs)
9
+ // right-release qa runner enroll --runner <built rightkit-qa> --version <v> [--platform mac|win] [--rotate] | show (qa-runner.mjs)
9
10
  //
10
11
  // The release config declares the app's QA plan:
11
12
  // qa: { version: "1", config: "rightkit-qa.toml", runner?: "<path to rightkit-qa>",
12
13
  // args?: { source?: [..], installed?: [..] }, // {sealedDir} {releaseId} expand; installed also {installedApp} {installedDir} {qaRoot}
13
14
  // installed?: { installDir?: "<abs dir>" }, // opt in: mount the sealed DMG (mac) / run the sealed NSIS installer (win, needs RIGHT_RELEASE_QA_ALLOW_INSTALL=1 off CI), see release-qa-installed.mjs
14
15
  // journeys?: { manifest?: "journeys.toml", receipt?: "<path to a rightkit.journeys.receipt/1>", args?: { source?: [..], installed?: [..] } },
16
+ // layout?: { binary: "<bin target of the QA shell>", beside?: [..], build?: [<tests.suites names>] }, // source stage: run against the gate-built QA shell in the layout an install has, see qa-layout.mjs
15
17
  // checks: { "smoke.launch": ["app.launch"], journey: ["journey.*"] } } // floor id -> evidence check names
18
+ // `qa.version` that is a rightkit-qa version (0.10.0) names the runner: the managed, digest-recorded one enrolled by `right-release qa runner enroll`
19
+ // (qa-runner.mjs), never a PATH binary. A legacy plan version ("1") with no `qa.runner` still takes the first `rightkit-qa` on PATH.
16
20
  // Every id in release-qa-floor.json must be mapped; skipped/failed/missing required checks fail the receipt.
17
21
  // `qa.journeys` maps the floor's `journey` id to "every release-critical journey required on this platform passed":
18
22
  // `rightkit-qa journeys run` is run (or `receipt` is read), the receipt is validated through `rightkit-qa journeys
@@ -37,6 +41,11 @@ import {
37
41
  qaReceiptPath, QA_STAGES, sealQaReceipt, sourceIdentity, validateAppQaConfig, writeQaReceipt,
38
42
  } from "./release-qa-receipt.mjs";
39
43
  import { platformDirOf } from "./platform-dir.mjs";
44
+ import { isRunnerVersion, main as runnerMain, resolveManagedQaRunner } from "./qa-runner.mjs";
45
+ import { compiledDirsFromEnv, QA_APP_BINARY_ENV, stageQaLayout, validateQaLayout } from "./qa-layout.mjs";
46
+ import { gateRunsRoot, resolveManagedRoots } from "./managed-roots.mjs";
47
+ import { planTests, runSuites } from "./test-command.mjs";
48
+ import { QA_SIGNED_FILES_ENV } from "./suite-signing-policy.mjs";
40
49
 
41
50
  const sha256File = (file) => createHash("sha256").update(readFileSync(file)).digest("hex");
42
51
 
@@ -119,6 +128,8 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
119
128
  const { floor, digest: floorDigest } = loadQaFloor(deps.floorDir);
120
129
  const problems = validateAppQaConfig(config.qa, floor, stage);
121
130
  if (problems.length) throw new Error(`release config QA plan does not meet the protected QA floor:\n- ${problems.join("\n- ")}`);
131
+ const layoutProblems = config.qa.layout === undefined ? [] : validateQaLayout(config.qa.layout);
132
+ if (layoutProblems.length) throw new Error(`release config qa.layout is invalid:\n- ${layoutProblems.join("\n- ")}`);
122
133
 
123
134
  let releaseId = releaseIdArg;
124
135
  let sealed = null;
@@ -145,8 +156,17 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
145
156
  if (journeysDeclared.receipt) assertInsideSource(release, path.resolve(appRoot, String(journeysDeclared.receipt)), "qa.journeys.receipt");
146
157
  }
147
158
  if (config.qa.runner && !deps.runnerFile) assertInsideSource(release, path.resolve(appRoot, config.qa.runner), "qa.runner");
148
- const runnerFile = deps.runnerFile ?? resolveRunner({ qa: config.qa, appRoot });
159
+ // A runner the config names is read from the source; else the managed runner of the version `qa.version` names (qa-runner.mjs); else, for a legacy plan version only, PATH.
160
+ let managedRunner = null;
161
+ let runnerFile = deps.runnerFile;
162
+ if (!runnerFile) {
163
+ if (!config.qa.runner && isRunnerVersion(config.qa.version)) {
164
+ managedRunner = (deps.resolveManagedRunner ?? resolveManagedQaRunner)({ dataRoot: resolveManagedRoots({ env: deps.env ?? process.env }).dataRoot, platform, version: String(config.qa.version) });
165
+ runnerFile = managedRunner.file;
166
+ } else runnerFile = resolveRunner({ qa: config.qa, appRoot });
167
+ }
149
168
  const runnerSha = sha256File(runnerFile);
169
+ if (managedRunner && managedRunner.sha256 !== runnerSha) throw new Error(`the managed rightkit-qa ${managedRunner.version} changed between its verification and its digest; refusing to seal`);
150
170
  const journeysConfig = config.qa.journeys ?? null;
151
171
  const qaConfig = path.resolve(appRoot, config.qa.config);
152
172
  if (!existsSync(qaConfig)) throw new Error(`qa.config does not exist: ${qaConfig}`);
@@ -161,25 +181,50 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
161
181
  });
162
182
  }
163
183
  let run, journeysRun = null;
184
+ let layoutStage = null;
164
185
  try {
186
+ // Source stage with a declared layout: the QA shell and its layout are staged in a run-scoped directory from the gate's compile outputs (qa-layout.mjs); the snapshot is never written.
187
+ if (stage === "source" && config.qa.layout?.build) {
188
+ // The QA shell is built here, by the app's own build (and sign) suites, never by the release compile (qa-layout.mjs `build`).
189
+ const env = deps.env ?? process.env;
190
+ const plan = planTests(config, { suites: config.qa.layout.build, appRoot });
191
+ const stageFrom = ({ outputs, env: suiteEnv }) => {
192
+ const dirs = [...new Set(plan.filter((item) => !item.sign).flatMap((item) => outputs.get(item.name) ?? []))];
193
+ if (!dirs.length) throw new Error(`qa.layout.build: ${plan.map((item) => item.name).join(", ")} passed but named no output directory (no carried receipt, and no RIGHTKIT_GATE_TARGET_DIRS from the gate)`);
194
+ const signed = plan.some((item) => item.sign) ? JSON.parse(suiteEnv[QA_SIGNED_FILES_ENV] ?? "{}") : null;
195
+ return (deps.stageLayout ?? stageQaLayout)({ layout: config.qa.layout, snapshotAppRoot: appRoot, qaConfigFile: qaConfig, runsRoot: deps.runsRoot ?? gateRunsRoot({ env }), dirs, platform, signed });
196
+ };
197
+ const built = await (deps.runSuites ?? runSuites)(plan, { appRoot, prefix: "qa_build", env, config, log: (line) => console.error(line), errLog: (line) => console.error(line), within: stageFrom });
198
+ if (built.exitCode !== 0) throw new Error(`qa.layout.build: the QA shell build failed (${built.results.filter((r) => r.exitCode !== 0).map((r) => r.suite).join(", ")}); no QA ran`);
199
+ layoutStage = built.within;
200
+ } else if (stage === "source" && config.qa.layout) {
201
+ const dirs = deps.compiledDirs ?? compiledDirsFromEnv(deps.env ?? process.env);
202
+ if (!dirs) throw new Error("qa.layout needs the gate's compile outputs (RIGHT_RELEASE_QA_COMPILED_DIRS names none): run source QA through `rightkit-gate --to package`, after its compile");
203
+ layoutStage = (deps.stageLayout ?? stageQaLayout)({ layout: config.qa.layout, snapshotAppRoot: appRoot, qaConfigFile: qaConfig, runsRoot: deps.runsRoot ?? gateRunsRoot({ env: deps.env ?? process.env }), dirs, platform });
204
+ }
165
205
  const expand = (value) => String(value).replaceAll("{sealedDir}", sealed?.sealedDir ?? "").replaceAll("{releaseId}", releaseId)
166
206
  .replaceAll("{installedApp}", installedQa?.installedApp ?? "").replaceAll("{installedDir}", installedQa?.installedDir ?? "").replaceAll("{qaRoot}", installedQa?.qaRoot ?? "");
167
207
  const installedDirs = installedDirNames(config, deps.env ?? process.env);
168
208
  const runEnv = {
169
209
  ...process.env, RIGHT_RELEASE_QA_STAGE: stage, RIGHT_RELEASE_QA_COMMIT: source.commit, RIGHT_RELEASE_QA_RELEASE_ID: releaseId,
170
210
  ...(sealed ? { RIGHT_RELEASE_QA_SEALED_DIR: sealed.sealedDir } : {}), ...(installedDirs.length ? { RIGHTKIT_QA_INSTALLED_DIRS: installedDirs.join(";") } : {}), ...(installedQa?.env ?? {}),
211
+ ...(layoutStage ? { [QA_APP_BINARY_ENV]: layoutStage.binary } : {}),
171
212
  };
172
- const args = ["run", "--config", qaConfig, ...((config.qa.args?.[stage] ?? []).map(expand))];
213
+ const runCwd = layoutStage?.appRoot ?? appRoot;
214
+ const args = ["run", "--config", layoutStage?.configFile ?? qaConfig, ...((config.qa.args?.[stage] ?? []).map(expand))];
173
215
  run = (deps.spawnRunner ?? spawnSync)(runnerFile, args, {
174
- cwd: appRoot, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024,
216
+ cwd: runCwd, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024,
175
217
  env: runEnv,
176
218
  });
177
219
  // Journeys lane: the one release proof (ADR-0001 item 10). A receipt named in the config is read as is.
178
220
  if (journeysConfig && !journeysConfig.receipt) {
179
- journeysRun = (deps.spawnJourneys ?? deps.spawnRunner ?? spawnSync)(runnerFile, ["journeys", "run", "--root", appRoot, ...((journeysConfig.args?.[stage] ?? []).map(expand))], {
180
- cwd: appRoot, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024, env: runEnv,
221
+ journeysRun = (deps.spawnJourneys ?? deps.spawnRunner ?? spawnSync)(runnerFile, ["journeys", "run", "--root", runCwd, ...((journeysConfig.args?.[stage] ?? []).map(expand))], {
222
+ cwd: runCwd, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024, env: runEnv,
181
223
  });
182
224
  }
225
+ } catch (error) {
226
+ layoutStage?.cleanup();
227
+ throw error;
183
228
  } finally {
184
229
  installedQa?.cleanup();
185
230
  }
@@ -207,19 +252,23 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
207
252
  required = required.map((item) => (item.id === floor.journeys.floorId ? journeyRequiredItem({ floorId: item.id, validation }) : item));
208
253
  }
209
254
  const executionFinishedAt = new Date();
255
+ // The runner's outputs (evidence, journeys receipt) have been read; the run-scoped layout is not evidence and goes.
256
+ const layoutEvidence = layoutStage?.evidence ?? null;
257
+ layoutStage?.cleanup();
210
258
  // A passing receipt needs the runner to exit cleanly AND an evidence bundle that itself passed.
211
259
  if (run.status !== 0 && evidence) evidence = { ...evidence, status: "failed" };
212
- const receipt = sealQaReceipt(buildQaReceipt({
260
+ // The layout evidence rides beside the standard body (release-qa-receipt.mjs is a policy-bundle entrypoint and stays untouched); the seal covers it.
261
+ const receipt = sealQaReceipt({ ...buildQaReceipt({
213
262
  stage, app: config.app, platform, releaseId, source, closure: dependencyClosure(repoRoot, source.commit, gitRunner),
214
263
  releaseConfigSha256: sha256File(configPath), qa: config.qa, floor, floorDigest,
215
264
  policyBundleDigest: deps.policyBundleDigest ?? currentPolicyBundleDigest(deps.floorDir),
216
- runner: { basename: path.basename(runnerFile).replace(/\.exe$/i, ""), path: runnerFile, sha256: runnerSha },
265
+ runner: { basename: path.basename(runnerFile).replace(/\.exe$/i, ""), path: runnerFile, sha256: runnerSha, ...(managedRunner ? { version: managedRunner.version, source: "managed" } : {}) },
217
266
  evidence, evidenceSha256, required, artifacts, sourceReceiptDigest, journeys,
218
267
  execution: {
219
268
  startedAt: executionStartedAt.toISOString(), finishedAt: executionFinishedAt.toISOString(),
220
269
  runnerPids: [run.pid, journeysRun?.pid].filter((pid) => Number.isInteger(pid) && pid > 0), sealerPid: process.pid,
221
270
  },
222
- }), key);
271
+ }), ...(layoutEvidence ? { layout: layoutEvidence } : {}) }, key);
223
272
  const file = qaReceiptPath({ repoRoot, releaseId, platform, stage });
224
273
  writeQaReceipt(file, receipt);
225
274
  // #183: copy the sealed receipt next to the candidate handoff so the signing job (a different runner) can read it.
@@ -239,6 +288,7 @@ function usage(code) {
239
288
  export async function main(args) {
240
289
  if (args[0] === "audit") process.exit(auditMain(args.slice(1)));
241
290
  if (args[0] === "policy") process.exit(policyMain(args.slice(1), { program: "right-release qa policy" }));
291
+ if (args[0] === "runner") process.exit(runnerMain(args.slice(1), { program: "right-release qa runner" }));
242
292
  let stage = "", platform = process.platform === "win32" ? "win" : "mac", releaseId = "", configName = defaultReleaseConfigFile(), carryTo = null;
243
293
  for (let index = 0; index < args.length; index += 1) {
244
294
  const arg = args[index];