@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/test-command.mjs CHANGED
@@ -13,9 +13,6 @@
13
13
  // // Expands to journeys-1, journeys-2, ...: the listed journeys run `chunk` at a time in separate processes.
14
14
  // { name: "journeys", node: "scripts/qa-browser-functional.mjs", args: ["--keep-going"],
15
15
  // journeys: { file: "scripts/qa-e2e-journeys.json", key: "journeys", chunk: 12, flag: "--journey" } },
16
- // // A sign step (suite-signing.mjs, rightkit#1303): signs what an EARLIER suite built, in-process, with the product's one identity, then
17
- // // sets RIGHTKIT_QA_APP_BINARY to the signed copy of the main binary for the suites after it. It is never carried and has no cmd or node.
18
- // { name: "sign-app", sign: { files: [{ from: "build-app", path: "aarch64-apple-darwin/debug/heardright" }] } },
19
16
  // ],
20
17
  // }
21
18
  //
@@ -29,14 +26,10 @@ import { buildLaunch } from "./build-launch.mjs";
29
26
  import path from "node:path";
30
27
  import { pathToFileURL } from "node:url";
31
28
  import { spawn } from "./background-process.mjs";
32
- import { carriedCargoMapOf, carriedLedgerIds, carriedOutputDirsSince } from "./carried-cargo.mjs";
33
- import { GATE_TARGET_DIRS_ENV } from "./qa-layout.mjs";
34
29
  import { CommandSpecError, loadAppConfig, resolveInvocation, validateStep } from "./command-spec.mjs";
35
30
  import { defaultReleaseConfigFile } from "./release-config.mjs";
36
31
  import { fsListRustFiles } from "./signing-files.mjs";
37
- import { journeysCapabilityRefusal, journeysRunRefusal, qaPrebuiltCapability } from "./qa-journeys-prebuilt.mjs";
38
- import { SIGN_STEP_KEYS, QA_APP_BINARY_ENV, QA_SIGNING_EVIDENCE_ENV, QA_SIGNED_FILES_ENV, SuiteSigningError, assertPlanSigningPolicy, assertSuiteSigningPolicy, validateSignSpec } from "./suite-signing-policy.mjs";
39
- import { runSignStep } from "./suite-signing.mjs";
32
+ import { SuiteSigningError, assertPlanSigningPolicy, assertSuiteSigningPolicy } from "./suite-signing-policy.mjs";
40
33
 
41
34
  const fail = (message) => { throw new CommandSpecError(message); };
42
35
  const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
@@ -122,18 +115,6 @@ export function planTests(config, { suites = [], runner, passthrough = [], appRo
122
115
  const declared = new Set();
123
116
  tests.suites.forEach((entry, index) => {
124
117
  const label = `tests.suites[${index}]`;
125
- // A sign step is data, not a command: `{ name, sign: { files: [{ from, path }] } }`, judged by the policy and run in-process (runSuites).
126
- if (isObject(entry) && entry.sign !== undefined) {
127
- const extra = Object.keys(entry).filter((key) => !SIGN_STEP_KEYS.includes(key));
128
- if (extra.length) throw new SuiteSigningError("SIGN_STEP_SHAPE", `${label} is a sign step and has unknown key(s): ${extra.join(", ")} (allowed: ${SIGN_STEP_KEYS.join(", ")}); it has no cmd, node, args or env`);
129
- if (typeof entry.name !== "string" || !NAME.test(entry.name)) fail(`${label}.name must be set (letters, digits, . _ -)`);
130
- if (seen.has(entry.name) || declared.has(entry.name)) fail(`${label}: duplicate suite name ${entry.name}`);
131
- const sign = validateSignSpec(entry.sign, `${label}.sign`, declared);
132
- seen.add(entry.name); declared.add(entry.name);
133
- // A sign step needs the suites whose outputs it signs (GB-44): if one of them failed or was skipped, there is nothing to sign.
134
- planned.push({ name: entry.name, group: entry.name, sign, step: null, extraArgs: [], requires: [...new Set(sign.files.map((file) => file.from))] });
135
- return;
136
- }
137
118
  validateStep(entry, label, ["name", "runner", "journeys", "requires"]);
138
119
  // `requires` (GB-44): the earlier suites (by name or group) whose success this suite's inputs depend on; it is skipped, with the reason recorded,
139
120
  // when one of them failed or was skipped, while every independent suite still runs.
@@ -161,26 +142,11 @@ export function planTests(config, { suites = [], runner, passthrough = [], appRo
161
142
  }
162
143
  if (passthrough.length) {
163
144
  if (selected.length !== 1) fail("arguments after -- need exactly one selected suite (or use --runner)");
164
- if (selected[0].sign) fail("arguments after -- cannot go to a sign step");
165
145
  selected = [{ ...selected[0], extraArgs: [...passthrough] }];
166
146
  }
167
147
  return selected;
168
148
  }
169
149
 
170
- /** The refusal code of a planned `rightkit-qa journeys run` suite (GB-79). */
171
- /** RIGHTKIT_GATE_TARGET_DIRS: the absolute directories the gate pinned; none when unset or malformed. */
172
- function directOutputDirs(env) {
173
- try { const dirs = JSON.parse(env[GATE_TARGET_DIRS_ENV] ?? "[]"); return Array.isArray(dirs) ? dirs.filter((d) => typeof d === "string" && path.isAbsolute(d)) : []; } catch { return []; }
174
- }
175
-
176
- export const QA_JOURNEYS_UNSUPPORTED = "RK_QA_JOURNEYS_PREBUILT_UNSUPPORTED";
177
- /** True when a suite step is `rightkit-qa journeys run ...` (by command or by node script name, extra args included). */
178
- export function qaJourneysRunStep(step, extraArgs = []) {
179
- const words = [step.cmd ?? step.node, ...(step.args ?? []), ...extraArgs].filter((w) => typeof w === "string").map((w) => w.replaceAll("\\", "/"));
180
- const at = words.findIndex((w) => /(^|\/)rightkit-qa(\.exe)?$/.test(w));
181
- return at >= 0 && words[at + 1] === "journeys" && words[at + 2] === "run";
182
- }
183
-
184
150
  export function eventPrefix(config) {
185
151
  const prefix = config?.tests?.eventPrefix ?? "test";
186
152
  if (typeof prefix !== "string" || !PREFIX.test(prefix)) fail("tests.eventPrefix must be lower-case letters, digits and underscores");
@@ -191,12 +157,8 @@ export function eventPrefix(config) {
191
157
  * Run the planned suites in order, collecting every reachable failure (GB-44): a failed suite does not stop an independent one, a suite that requires a
192
158
  * failed or skipped one is skipped with its reason, and the run ends non-zero. `spawnFn` and `log` are injectable.
193
159
  * Resolves `{ exitCode, results: [{ suite, exitCode, durationMs, skipped?, reason?, error? }] }`; a signal (cancellation) rejects.
194
- * A `sign` step (suite-signing.mjs) runs IN THIS PROCESS, never through `door` (it is no command and never a broker job); it signs what an earlier
195
- * suite built, named by that suite's verified carried receipts, and only a success sets RIGHTKIT_QA_APP_BINARY for the suites after it.
196
- * On a direct host (no carrier) a suite's outputs are the Cargo output directories the gate pinned for the stage (RIGHTKIT_GATE_TARGET_DIRS).
197
- * `within({ outputs, env })`, when given, runs once every suite passed and before the signed copies are removed; its result is returned as `within`.
198
160
  */
199
- export async function runSuites(plan, { appRoot, prefix = "test", env = process.env, spawnFn = spawn, log = console.log, errLog = console.error, door = buildLaunch, config = null, platform = process.platform, signDeps = {}, signStep = runSignStep, within = null, snapshotStage = Boolean(carriedCargoMapOf(env)?.map), qaCapability = (qa) => qaPrebuiltCapability(qa, { env }) } = {}) {
161
+ export async function runSuites(plan, { appRoot, prefix = "test", env = process.env, spawnFn = spawn, log = console.log, errLog = console.error, door = buildLaunch, config = null, platform = process.platform } = {}) {
200
162
  const results = [];
201
163
  // One signing policy, enforced here whatever the config's format (the route probe reads JSON configs only; a legacy .mjs config is judged now): an
202
164
  // unsigned or ad hoc product build, or an identifier override, is refused before any suite runs (rightkit#1303). Mac and Dell only.
@@ -211,111 +173,49 @@ export async function runSuites(plan, { appRoot, prefix = "test", env = process.
211
173
  errLog(`right-release test: [${error.code}] ${error.message}`);
212
174
  return { exitCode: 1, results };
213
175
  }
214
- // GB-79 (GATE test stage/E1/E6/E11): `rightkit-qa journeys run` spawns its own broker build, which a gate-carried snapshot stage cannot allow. When the installed rightkit-qa
215
- // advertises `journeys-prebuilt-input` the door carries the build itself, signs the products and hands QA the verified listing and attestation (qa-journeys-prebuilt.mjs).
216
- // Without the capability it is a PLANNING failure naming it, before any suite runs; nothing is silently skipped and an empty journey set is never green. A step that names
217
- // its own listing, attestation or plan flag is refused too (an input the gate did not verify). Only a gate-carried snapshot stage (a carrier map in the environment) has the
218
- // one-build rule; a direct host starts it as it always did.
219
- const journeys = snapshotStage ? plan.filter((item) => !item.sign && item.step && qaJourneysRunStep(item.step, item.extraArgs)) : [];
220
- const unsupported = [];
221
- for (const item of journeys) {
222
- const argv = [item.step.cmd ?? item.step.node, ...(item.step.args ?? []), ...(item.extraArgs ?? [])].filter((w) => typeof w === "string");
223
- const at = argv.findIndex((w) => /(^|[\\/])rightkit-qa(\.exe)?$/.test(w));
224
- const own = journeysRunRefusal(argv.slice(at + 1));
225
- if (own) { errLog(`right-release test: [${QA_JOURNEYS_UNSUPPORTED}] ${item.name}: ${own}. Nothing ran`); unsupported.push(item); continue; }
226
- if (!qaCapability(argv[at], item.step)) { errLog(`right-release test: [${QA_JOURNEYS_UNSUPPORTED}] ${journeysCapabilityRefusal(item.name)}`); unsupported.push(item); }
227
- }
228
- if (unsupported.length) return { exitCode: 1, results };
229
- // The environment later suites get: the caller's, plus RIGHTKIT_QA_APP_BINARY and RIGHTKIT_QA_SIGNING_EVIDENCE once a sign step succeeded.
230
- let suiteEnv = env;
231
- // The verified carried receipts' output directories of each suite that passed, by name and by group (a sign step reads them).
232
- const outputs = new Map();
233
- const signedDirs = [];
234
176
  // GB-44 (BR29): every reachable failure is collected. A failed or errored suite does not stop its independent siblings; only a suite that
235
- // REQUIRES a failed or skipped one (a sign step needs the suites it signs; a suite after a sign step needs every earlier sign step unless it declares its own `requires`; an explicit
236
- // `requires`) is skipped, with the reason recorded. Cancellation (a signal) still stops the whole run, and nothing is signed or launched from a failed build.
177
+ // REQUIRES a failed or skipped one (its explicit `requires`) is skipped, with the reason recorded. Cancellation (a signal) still stops the whole run.
237
178
  const status = new Map(); // suite name and group -> "passed" | "failed" | "skipped"
238
- const signsSoFar = [];
239
- // What a suite waits for. A sign step waits for the suites it signs. Any other suite positioned after a sign step is by default a CONSUMER of its
240
- // signed output (a QA runner, a launcher: nothing in its command line shows what its script reads), so it waits for every earlier sign step too;
241
- // a suite that declares `requires` (even an empty list) states its own dependencies instead and so keeps running for its diagnostics.
242
- // Fail closed: nothing that may consume signed bytes runs after a sign step that failed or was skipped.
243
- const waitsFor = (item) => (item.sign || Array.isArray(item.requires) ? (item.requires ?? []) : signsSoFar);
179
+ const waitsFor = (item) => item.requires ?? [];
244
180
  const record = (item, outcome) => { for (const key of new Set([item.name, item.group])) status.set(key, outcome); };
245
181
  const firstFailure = () => results.find((r) => r.exitCode !== 0 && r.skipped !== true)?.exitCode ?? (results.some((r) => r.skipped) ? 1 : 0);
246
- try {
247
- for (const item of plan) {
248
- const unmet = waitsFor(item).filter((name) => status.has(name) && status.get(name) !== "passed");
249
- if (unmet.length) {
250
- const reason = `${[...new Set(unmet)].map((n) => JSON.stringify(n)).join(", ")} did not pass, and ${item.name} ${item.sign ? "signs what it builds" : "needs what it produces"}`;
251
- errLog(`right-release test: ${item.name}: skipped: ${reason}`);
252
- log(JSON.stringify({ event: `${prefix}_suite_skipped`, suite: item.name, reason }));
253
- results.push({ suite: item.name, exitCode: null, skipped: true, reason, durationMs: 0 });
254
- record(item, "skipped");
255
- if (item.sign) signsSoFar.push(item.name);
256
- continue;
257
- }
258
- log(JSON.stringify({ event: `${prefix}_suite_started`, suite: item.name }));
259
- const started = Date.now();
260
- if (item.sign) {
261
- let exitCode = 0;
262
- try {
263
- const signed = signStep({ name: item.name, sign: item.sign, appRoot, config, env: suiteEnv, platform, outputDirsOf: (suite) => outputs.get(suite) ?? null, deps: signDeps, log: errLog });
264
- signedDirs.push(signed.signedDir);
265
- // The variable is set only now, after every file was signed and read back.
266
- if (signed.appBinary) suiteEnv = { ...suiteEnv, [QA_APP_BINARY_ENV]: signed.appBinary };
267
- // The evidence file lives in the signed directory, kept until this run ends: a consumer checks what it was handed against it.
268
- suiteEnv = { ...suiteEnv, [QA_SIGNING_EVIDENCE_ENV]: signed.evidenceFile, [QA_SIGNED_FILES_ENV]: JSON.stringify(signed.signedFiles ?? {}) };
269
- log(JSON.stringify({ event: `${prefix}_suite_signed`, suite: item.name, appBinary: signed.appBinary, signing: signed.evidence }));
270
- } catch (error) {
271
- if (!(error instanceof SuiteSigningError)) throw error;
272
- errLog(`right-release test: ${item.name}: [${error.code}] ${error.message}`);
273
- exitCode = 1;
274
- }
275
- const durationMs = Date.now() - started;
276
- log(JSON.stringify({ event: `${prefix}_suite_completed`, suite: item.name, exitCode, durationMs }));
277
- results.push({ suite: item.name, exitCode, durationMs });
278
- record(item, exitCode === 0 ? "passed" : "failed");
279
- signsSoFar.push(item.name);
280
- continue;
281
- }
282
- const invocation = resolveInvocation(item.step, { appRoot, baseEnv: suiteEnv, extraArgs: item.extraArgs });
283
- const before = carriedLedgerIds(env);
284
- let exitCode; let failure = null;
285
- try {
286
- exitCode = await new Promise((resolve, reject) => {
287
- // A test suite goes through the one door (build-launch.mjs): a broker job on a managed host, which builds it under the app's claim.
288
- const target = door(invocation.cmd, invocation.args, { env: invocation.env, stepEnv: item.step.env, cwd: invocation.cwd, exists: existsSync });
289
- const child = spawnFn(target.command, target.args, { cwd: invocation.cwd, env: target.env ? { ...invocation.env, ...target.env } : invocation.env, stdio: "inherit" });
290
- child.once("error", (error) => reject(Object.assign(error, { spawnFailure: true })));
291
- child.once("exit", (code, signal) => {
292
- if (signal) reject(Object.assign(new Error(`${item.name} stopped by ${signal}`), { cancelled: true }));
293
- else resolve(code ?? 1);
294
- });
182
+ for (const item of plan) {
183
+ const unmet = waitsFor(item).filter((name) => status.has(name) && status.get(name) !== "passed");
184
+ if (unmet.length) {
185
+ const reason = `${[...new Set(unmet)].map((n) => JSON.stringify(n)).join(", ")} did not pass, and ${item.name} needs what it produces`;
186
+ errLog(`right-release test: ${item.name}: skipped: ${reason}`);
187
+ log(JSON.stringify({ event: `${prefix}_suite_skipped`, suite: item.name, reason }));
188
+ results.push({ suite: item.name, exitCode: null, skipped: true, reason, durationMs: 0 });
189
+ record(item, "skipped");
190
+ continue;
191
+ }
192
+ log(JSON.stringify({ event: `${prefix}_suite_started`, suite: item.name }));
193
+ const started = Date.now();
194
+ const invocation = resolveInvocation(item.step, { appRoot, baseEnv: env, extraArgs: item.extraArgs });
195
+ let exitCode; let failure = null;
196
+ try {
197
+ exitCode = await new Promise((resolve, reject) => {
198
+ // A test suite goes through the one door (build-launch.mjs): a broker job on a managed host, which builds it under the app's claim.
199
+ const target = door(invocation.cmd, invocation.args, { env: invocation.env, stepEnv: item.step.env, cwd: invocation.cwd, exists: existsSync });
200
+ const child = spawnFn(target.command, target.args, { cwd: invocation.cwd, env: target.env ? { ...invocation.env, ...target.env } : invocation.env, stdio: "inherit" });
201
+ child.once("error", (error) => reject(Object.assign(error, { spawnFailure: true })));
202
+ child.once("exit", (code, signal) => {
203
+ if (signal) reject(Object.assign(new Error(`${item.name} stopped by ${signal}`), { cancelled: true }));
204
+ else resolve(code ?? 1);
295
205
  });
296
- } catch (error) {
297
- // A suite that cannot start (a refused launch, a spawn error) is a failed suite; a signal is a cancellation and ends the run.
298
- if (error?.cancelled) throw error;
299
- failure = error; exitCode = 1;
300
- errLog(`right-release test: ${item.name}: ${error?.code ? `[${error.code}] ` : ""}${error?.message ?? error}`);
301
- }
302
- const durationMs = Date.now() - started;
303
- // Only a suite that passed hands its receipts to a sign step.
304
- if (exitCode === 0) {
305
- const dirs = carriedCargoMapOf(env)?.map ? carriedOutputDirsSince(env, before) : directOutputDirs(env);
306
- for (const key of new Set([item.name, item.group])) outputs.set(key, [...new Set([...(outputs.get(key) ?? []), ...dirs])]);
307
- }
308
- log(JSON.stringify({ event: `${prefix}_suite_completed`, suite: item.name, exitCode, durationMs }));
309
- results.push({ suite: item.name, exitCode, durationMs, ...(failure ? { error: String(failure.message ?? failure) } : {}) });
310
- record(item, exitCode === 0 ? "passed" : "failed");
206
+ });
207
+ } catch (error) {
208
+ // A suite that cannot start (a refused launch, a spawn error) is a failed suite; a signal is a cancellation and ends the run.
209
+ if (error?.cancelled) throw error;
210
+ failure = error; exitCode = 1;
211
+ errLog(`right-release test: ${item.name}: ${error?.code ? `[${error.code}] ` : ""}${error?.message ?? error}`);
311
212
  }
312
- const exitCode = results.some((r) => r.skipped !== true && r.exitCode !== 0) || results.some((r) => r.skipped) ? firstFailure() : 0;
313
- if (exitCode !== 0 || !within) return { exitCode, results };
314
- return { exitCode, results, within: await within({ outputs, env: suiteEnv }) };
315
- } finally {
316
- // The signed copies live only as long as the run that signed them.
317
- for (const dir of signedDirs) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* the managed root is pruned */ } }
213
+ const durationMs = Date.now() - started;
214
+ log(JSON.stringify({ event: `${prefix}_suite_completed`, suite: item.name, exitCode, durationMs }));
215
+ results.push({ suite: item.name, exitCode, durationMs, ...(failure ? { error: String(failure.message ?? failure) } : {}) });
216
+ record(item, exitCode === 0 ? "passed" : "failed");
318
217
  }
218
+ return { exitCode: results.some((r) => r.skipped !== true && r.exitCode !== 0) || results.some((r) => r.skipped) ? firstFailure() : 0, results };
319
219
  }
320
220
 
321
221
  export async function main(argv = process.argv.slice(2)) {
@@ -332,9 +232,9 @@ export async function main(argv = process.argv.slice(2)) {
332
232
  }
333
233
  const plan = planTests(config, { suites: opts.suites, runner: opts.runner, passthrough: opts.passthrough, appRoot });
334
234
  if (opts.list) {
335
- const view = plan.map((item) => (item.sign ? { suite: item.name, sign: item.sign } : { suite: item.name, ...resolveInvocation(item.step, { appRoot, baseEnv: {}, extraArgs: item.extraArgs }), env: undefined }));
235
+ const view = plan.map((item) => ({ suite: item.name, ...resolveInvocation(item.step, { appRoot, baseEnv: {}, extraArgs: item.extraArgs }), env: undefined }));
336
236
  if (opts.json) console.log(JSON.stringify(view, null, 2));
337
- else for (const item of view) console.log(item.sign ? `${item.suite}: sign ${item.sign.files.map((file) => `${file.from}:${file.path}`).join(", ")}` : `${item.suite}: ${[item.cmd, ...item.args].join(" ")}`);
237
+ else for (const item of view) console.log(`${item.suite}: ${[item.cmd, ...item.args].join(" ")}`);
338
238
  return 0;
339
239
  }
340
240
  const { exitCode } = await runSuites(plan, { appRoot, prefix: eventPrefix(config), config });
@@ -0,0 +1,26 @@
1
+ // rightkit#1443 (hrmac, 2026-11-02): the pre-gate R2 build verified the updater signature against the public key the app SHIPS (its
2
+ // tauri.conf plugins.updater.pubkey, the key every installed copy checks an update with); the gate only checked a requested key id.
3
+ // After the updater is signed, its `.sig` must verify (minisign, github-updater-release.mjs assertUpdaterSignature) over the archive's
4
+ // bytes with the shipped key, or the build stops: an update signed by any other key would be refused by every installed app.
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { assertUpdaterSignature } from "./github-updater-release.mjs";
8
+
9
+ /** The app's shipped updater public key: plugins.updater.pubkey of the first tauri.conf.json found (src-tauri/, then the app root). */
10
+ export function shippedUpdaterPublicKey(appRoot, { readFile = (f) => fs.readFileSync(f, "utf8"), exists = fs.existsSync } = {}) {
11
+ for (const rel of [["src-tauri", "tauri.conf.json"], ["tauri.conf.json"]]) {
12
+ const file = path.join(appRoot, ...rel);
13
+ if (!exists(file)) continue;
14
+ const key = JSON.parse(readFile(file))?.plugins?.updater?.pubkey;
15
+ if (typeof key === "string" && key.trim()) return { key: key.trim(), file };
16
+ return { key: null, file };
17
+ }
18
+ return { key: null, file: null };
19
+ }
20
+
21
+ /** Throws unless `<file>.sig` verifies over `file`'s bytes with the app's shipped updater key. */
22
+ export function assertUpdaterMatchesShippedKey({ appRoot, file, readFile = (f) => fs.readFileSync(f), readText = (f) => fs.readFileSync(f, "utf8"), exists = fs.existsSync }) {
23
+ const shipped = shippedUpdaterPublicKey(appRoot, { readFile: readText, exists });
24
+ if (!shipped.key) throw new Error(`the app ships no updater public key (plugins.updater.pubkey in ${shipped.file ?? "src-tauri/tauri.conf.json"}), so an updater artifact cannot be verified; nothing is shipped`);
25
+ assertUpdaterSignature(readFile(file), readText(`${file}.sig`), shipped.key, `updater ${path.basename(file)} against the shipped key in ${shipped.file}`);
26
+ }
package/web-build.mjs CHANGED
@@ -18,8 +18,7 @@
18
18
  import path from "node:path";
19
19
  import { pathToFileURL } from "node:url";
20
20
  import { spawnSync } from "./background-process.mjs";
21
- import { existsSync } from "node:fs";
22
- import { buildLaunch } from "./build-launch.mjs";
21
+ import { existsSync, readFileSync } from "node:fs";
23
22
  import { CommandSpecError, loadAppConfig, resolveInvocation, validateStep } from "./command-spec.mjs";
24
23
  import { defaultReleaseConfigFile } from "./release-config.mjs";
25
24
  import { requireDoorRun } from "./gate-stages.mjs";
@@ -61,20 +60,46 @@ export function webBuildDoorRefusal({ appRoot, env = process.env, spawnSyncFn =
61
60
  return requireDoor({ env, commonDir: context.commonDir, commit: context.commit, command: "right-release web-build" });
62
61
  }
63
62
 
63
+ /**
64
+ * Adrian, 2026-11-02 ("Yes send them", relayed by the broker owner and checked): tsc runs once, in Class A only, and vite (any web bundling)
65
+ * runs outside the broker. A `tsc` step that emits nothing (`--noEmit`, or `compilerOptions.noEmit` in the tsconfig it reads) only type-checks,
66
+ * which Class A already did for this commit, so it is skipped here. A tsc step that emits files still runs. An unreadable tsconfig keeps the step.
67
+ */
68
+ export function typecheckOnlyTsc(invocation, { readFile = (f) => readFileSync(f, "utf8") } = {}) {
69
+ const script = String(invocation.args?.find((a) => /(^|[\\/])typescript[\\/]bin[\\/]tsc$/.test(String(a))) ?? "");
70
+ if (!script) return false;
71
+ const args = invocation.args.slice(invocation.args.indexOf(script) + 1).map(String);
72
+ if (args.includes("--noEmit")) return true;
73
+ if (args.some((a) => a === "--build" || a === "-b")) return false;
74
+ const p = args.indexOf("-p") >= 0 ? args.indexOf("-p") : args.indexOf("--project");
75
+ let file = p >= 0 ? args[p + 1] : "tsconfig.json";
76
+ if (!file) return false;
77
+ file = path.resolve(invocation.cwd ?? ".", file);
78
+ if (!/\.json$/i.test(file)) file = path.join(file, "tsconfig.json");
79
+ try {
80
+ const text = readFile(file).replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"])\/\/.*$/gm, "$1").replace(/,(\s*[}\]])/g, "$1");
81
+ return JSON.parse(text)?.compilerOptions?.noEmit === true;
82
+ } catch { return false; }
83
+ }
84
+
85
+ /** The direct launch of a web-build step: never through the broker (Adrian, 2026-11-02: vite and web bundling run outside the broker). */
86
+ export const directWebLaunch = (cmd, args) => ({ command: cmd, args });
87
+
64
88
  function doorError(refusal) { const error = new Error(refusal); error.code = "RK_DOOR"; return error; }
65
89
 
66
90
  /**
67
91
  * Run the steps in order under a live door run; `spawnSyncFn`, `requireDoor` and `log` are injectable. Returns the exit code. The
68
92
  * door is checked here as well as in main: this is the only place a step is spawned.
69
93
  */
70
- export function runWebBuild(steps, { appRoot, env = process.env, dryRun = false, spawnSyncFn = spawnSync, requireDoor = requireDoorRun, log = console.log, door = buildLaunch } = {}) {
94
+ export function runWebBuild(steps, { appRoot, env = process.env, dryRun = false, spawnSyncFn = spawnSync, requireDoor = requireDoorRun, log = console.log, door = directWebLaunch, readFile } = {}) {
71
95
  const refusal = webBuildDoorRefusal({ appRoot, env, spawnSyncFn, requireDoor });
72
96
  if (refusal) throw doorError(refusal);
73
97
  for (const step of steps) {
74
98
  const invocation = resolveInvocation(step, { appRoot, baseEnv: env });
99
+ if (typecheckOnlyTsc(invocation, readFile ? { readFile } : {})) { log(`skipped (type-check only; Class A ran tsc for this commit): ${[invocation.cmd, ...invocation.args].join(" ")}`); continue; }
75
100
  log(`${dryRun ? "[dry-run] " : ""}${[invocation.cmd, ...invocation.args].join(" ")}`);
76
101
  if (dryRun) continue;
77
- // A web build is a build: through the one door (build-launch.mjs), a broker job on a managed host.
102
+ // The web build runs directly, never as a broker job: it is cheap and not cargo (Adrian, 2026-11-02).
78
103
  const target = door(invocation.cmd, invocation.args, { env: invocation.env, stepEnv: step.env, cwd: invocation.cwd, exists: existsSync });
79
104
  const result = spawnSyncFn(target.command, target.args, { cwd: invocation.cwd, env: target.env ? { ...invocation.env, ...target.env } : invocation.env, stdio: "inherit" });
80
105
  if (result.error) throw result.error;
@@ -1,18 +0,0 @@
1
- #!/usr/bin/env node
2
- // `node qa-journeys-prebuilt-run.mjs <rightkit-qa> -- journeys run <args>`: one `rightkit-qa journeys run` step of a snapshot stage (GB-79, capability
3
- // `journeys-prebuilt-input`) as the gate's build-once route (qa-journeys-prebuilt.mjs): carry the build, sign the products, hand QA the verified input.
4
- // build-launch.mjs hands such a step to this entry instead of refusing it.
5
- import { carriedCargoMapOf } from "./carried-cargo.mjs";
6
- import { runJourneysPrebuilt } from "./qa-journeys-prebuilt.mjs";
7
-
8
- const stop = (message, status = 101) => { process.stderr.write(`rightkit-gate: ${message}\n`); process.exit(status); };
9
- const [qa, separator, ...args] = process.argv.slice(2);
10
- if (!qa || separator !== "--") stop("RK_QA_JOURNEYS_USAGE: usage: qa-journeys-prebuilt-run.mjs <rightkit-qa> -- journeys run <args>");
11
- const carried = carriedCargoMapOf(process.env);
12
- if (!carried?.map) stop(`RK_QA_JOURNEYS_CARRIER_MISSING: ${carried?.error ?? "no carried map in the environment"}; a snapshot stage's build is never run around its carrier`);
13
- try {
14
- const { status } = runJourneysPrebuilt({ qa, args, map: carried.map });
15
- process.exit(status);
16
- } catch (error) {
17
- stop(String(error.message ?? error).startsWith("RK_") ? error.message : `${error.code ?? "RK_QA_JOURNEYS_FAILED"}: ${error.message}`, Number.isInteger(error.status) ? error.status : 101);
18
- }
@@ -1,148 +0,0 @@
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
- }