@rightkit/release 0.6.24 → 0.6.26

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 (59) hide show
  1. package/README.md +11 -35
  2. package/android-dev.mjs +2 -1
  3. package/app-check.mjs +1 -2
  4. package/app-closure.mjs +3 -2
  5. package/assembled-app.mjs +11 -3
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/darwin/rightkit-swap.sign.json +6 -0
  8. package/bin/rightkit-gate.mjs +83 -26
  9. package/build-helper-trace.mjs +264 -0
  10. package/build-helpers-command.mjs +184 -0
  11. package/build-launch.mjs +11 -75
  12. package/build-release.mjs +10 -11
  13. package/cargo-profile-contract.mjs +19 -29
  14. package/cargo-target-shim.mjs +31 -0
  15. package/cli/right-release.mjs +5 -7
  16. package/create-mac-updater.mjs +4 -0
  17. package/cross-targets.mjs +94 -0
  18. package/deterministic-scans.mjs +310 -20
  19. package/development.mjs +8 -8
  20. package/freshness-census.mjs +1 -1
  21. package/gate-stages.mjs +1 -1
  22. package/install-assembled-app.mjs +1 -1
  23. package/install-tauri-authenticode.mjs +3 -3
  24. package/local-publish.mjs +27 -5
  25. package/mac-package-recipe.mjs +55 -2
  26. package/managed-cargo.mjs +27 -1
  27. package/managed-roots.mjs +12 -4
  28. package/package.json +1 -5
  29. package/pipeline-admission.mjs +1 -1
  30. package/pipeline-receipts.mjs +302 -302
  31. package/provenance-verify.mjs +3 -1
  32. package/qa-build-features.mjs +11 -2
  33. package/release-qa-audit.mjs +9 -27
  34. package/release-qa-command.mjs +23 -64
  35. package/release-qa-fixtures.mjs +9 -5
  36. package/release-qa-floor.json +0 -1
  37. package/release-qa-policy.mjs +9 -10
  38. package/release-qa-receipt.mjs +6 -44
  39. package/release-source-identity.mjs +1 -12
  40. package/release-switch-registry.json +4 -1
  41. package/rightkit-registry-resolver.mjs +35 -3
  42. package/rightkit-versions.json +2 -2
  43. package/sign-windows.mjs +1 -1
  44. package/snapshot-broker.mjs +33 -8
  45. package/snapshot-metadata.mjs +32 -0
  46. package/snapshot-request.mjs +6 -3
  47. package/stage-sidecar.mjs +28 -11
  48. package/suite-signing-policy.mjs +8 -58
  49. package/templates/cargo-profiles.toml +6 -25
  50. package/test-command.mjs +40 -140
  51. package/updater-shipped-key.mjs +26 -0
  52. package/web-build.mjs +29 -4
  53. package/qa-journeys-prebuilt-run.mjs +0 -18
  54. package/qa-journeys-prebuilt.mjs +0 -148
  55. package/qa-layout.mjs +0 -229
  56. package/qa-snapshot-run.mjs +0 -53
  57. package/qa-snapshot.mjs +0 -164
  58. package/sign-qa-bundle.mjs +0 -210
  59. package/suite-signing.mjs +0 -259
package/qa-layout.mjs DELETED
@@ -1,229 +0,0 @@
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
- }
@@ -1,53 +0,0 @@
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 DELETED
@@ -1,164 +0,0 @@
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
- }