@rightkit/release 0.6.7 → 0.6.10

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 (53) hide show
  1. package/README.md +35 -10
  2. package/admission-files.mjs +1 -1
  3. package/app-check.mjs +94 -20
  4. package/assemble-app-command.mjs +151 -0
  5. package/assembled-app.mjs +445 -0
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/darwin/rightkit-swap.sign.json +6 -0
  8. package/bin/rightkit-gate.mjs +957 -83
  9. package/build-launch.mjs +88 -0
  10. package/build-ownership.mjs +1231 -0
  11. package/build-release.mjs +2 -1
  12. package/cargo-contract.mjs +3 -1
  13. package/cargo-target-shim.mjs +166 -0
  14. package/class-a-launch-guard.mjs +126 -0
  15. package/cli/right-release.mjs +3 -1
  16. package/codesign-shim.mjs +11 -1
  17. package/deterministic-scans.mjs +807 -56
  18. package/development-signing.mjs +4 -1
  19. package/development.mjs +110 -20
  20. package/door-policy.mjs +61 -6
  21. package/door.mjs +23 -0
  22. package/gate-executor.mjs +14 -2
  23. package/gate-stages.mjs +15 -5
  24. package/heavy-command.mjs +3 -2
  25. package/install-app.mjs +11 -2
  26. package/install-assembled-app.mjs +423 -0
  27. package/ios-dev.mjs +70 -3
  28. package/linux-artifacts.mjs +93 -0
  29. package/local-publish.mjs +161 -26
  30. package/managed-cargo.mjs +47 -5
  31. package/managed-root-guard.mjs +34 -0
  32. package/native-helpers.mjs +355 -0
  33. package/package-recipe-targets.mjs +52 -7
  34. package/package.json +5 -3
  35. package/pipeline-receipts.mjs +302 -302
  36. package/project-type.mjs +2 -2
  37. package/provenance-verify.mjs +115 -0
  38. package/publish-cargo.mjs +8 -6
  39. package/qa-admission-policy.json +10 -1
  40. package/recipe-build.mjs +73 -5
  41. package/release-config.mjs +1 -1
  42. package/release.mjs +15 -5
  43. package/rightkit-versions.json +13 -7
  44. package/snapshot-check.mjs +306 -0
  45. package/snapshot-git.mjs +166 -0
  46. package/snapshot-request.mjs +161 -0
  47. package/snapshot-root-config.mjs +76 -0
  48. package/snapshot.mjs +856 -0
  49. package/stage-commands.mjs +60 -0
  50. package/stage-sidecar.mjs +57 -7
  51. package/test-command.mjs +17 -4
  52. package/unit-gate.mjs +259 -0
  53. package/web-build.mjs +38 -6
@@ -0,0 +1,60 @@
1
+ // What a unit's compile and package broker jobs must have run, derived from the code that builds those commands (local-publish.mjs
2
+ // localTestPlan and the pack argv builders), so the gate (which chooses the receipts to carry) and the hosted admission (which checks
3
+ // the signed requestedArgv of each) share one definition and cannot drift from the stages. EVERY command of a stage must be covered by
4
+ // a signed receipt of its own job; a missing command is a refusal that names it.
5
+ import path from "node:path";
6
+ import { CARGO_METADATA_ARGS, cargoPackageArgs, localTestPlan, npmPackArgs, qualifyFmtArgs } from "./local-publish.mjs";
7
+ import { SWAP_HELPER_CARGO_ARGS, SWAP_HELPER_PACKAGE } from "./swap-helper-bundle.mjs";
8
+
9
+ /** Stands for the one argument that is a fresh absolute scratch path (`pnpm pack --pack-destination <mkdtemp>`). */
10
+ export const ANY_ABSOLUTE_PATH = Object.freeze({ anyAbsolutePath: true });
11
+ const PNPM = (...argvs) => ({ kind: "adapter", adapter: "rightkit-pnpm", argvs });
12
+ const CARGO = (...argvs) => ({ kind: "cargo", argvs });
13
+
14
+ /** Readable name of an expected command (scratch paths shown as <scratch>). */
15
+ export const describeCommand = (row) => `${row.kind === "adapter" ? "pnpm" : "cargo"} ${row.argvs[0].map((a) => (typeof a === "string" ? a : "<scratch>")).join(" ")}`;
16
+
17
+ /**
18
+ * The commands of the compile and package stages of one unit, one { kind, adapter?, argvs } row per job the stage launches
19
+ * (`argvs` are the accepted spellings of that one command: cargo metadata retries without --offline). Inputs are what the dispatched
20
+ * commit holds: `config` = ci/gate.config.json, `manifest` = the npm package's package.json.
21
+ */
22
+ export function expectedStageCommands({ kind, unit, config, manifest, platform }) {
23
+ const dir = kind === "npm" ? `packages/${unit.slice("@rightkit/".length)}` : `crates/${unit}`;
24
+ const plan = localTestPlan({ kind, unit, dir, config, manifest, batch: false, platform });
25
+ const compile = plan.steps.map((step) => (step.command === "pnpm" ? PNPM(step.args) : CARGO(step.args)));
26
+ const pack = [];
27
+ if (kind === "npm") {
28
+ if (unit === SWAP_HELPER_PACKAGE) {
29
+ // The swap helper builds and signs a binary, then packs from a scratch copy of its directory.
30
+ pack.push(CARGO(SWAP_HELPER_CARGO_ARGS), CARGO(["metadata", "--format-version", "1", "--no-deps"]), PNPM(npmPackArgs(ANY_ABSOLUTE_PATH, ANY_ABSOLUTE_PATH)));
31
+ } else pack.push(PNPM(npmPackArgs(dir, ANY_ABSOLUTE_PATH)));
32
+ } else {
33
+ compile.unshift(CARGO(qualifyFmtArgs(unit)));
34
+ pack.push(CARGO(cargoPackageArgs(unit, true)), CARGO([...CARGO_METADATA_ARGS], CARGO_METADATA_ARGS.filter((arg) => arg !== "--offline")));
35
+ }
36
+ return { compile, package: pack };
37
+ }
38
+
39
+ const argvMatches = (expected, actual) => Array.isArray(actual) && expected.length === actual.length
40
+ && expected.every((want, i) => (want === ANY_ABSOLUTE_PATH ? typeof actual[i] === "string" && (path.posix.isAbsolute(actual[i]) || /^[A-Za-z]:[\\/]/.test(actual[i])) : want === actual[i]));
41
+
42
+ /** Whether a signed payload's request is this command: request.kind, adapter (when any) and requestedArgv. */
43
+ export function requestIs(payload, row) {
44
+ const request = payload?.request;
45
+ return Boolean(request && typeof request === "object" && request.kind === row.kind && (row.adapter === undefined || request.adapter === row.adapter) && row.argvs.some((argv) => argvMatches(argv, request.requestedArgv)));
46
+ }
47
+
48
+ /**
49
+ * Assigns each job (`{ id, payload }`) to a distinct command of the stage. Returns { assigned: Map(id -> row), unmatched: [id...],
50
+ * missing: [row...] }: a job that is none of the commands is unmatched; a command no job covers is missing.
51
+ */
52
+ export function assignJobs(rows, jobs) {
53
+ const free = rows.map((row) => ({ row, taken: false }));
54
+ const assigned = new Map(), unmatched = [];
55
+ for (const job of jobs) {
56
+ const slot = free.find((s) => !s.taken && requestIs(job.payload, s.row));
57
+ if (slot) { slot.taken = true; assigned.set(job.id, slot.row); } else unmatched.push(job.id);
58
+ }
59
+ return { assigned, unmatched, missing: free.filter((s) => !s.taken).map((s) => s.row) };
60
+ }
package/stage-sidecar.mjs CHANGED
@@ -38,7 +38,7 @@ import os from "node:os";
38
38
  import path from "node:path";
39
39
  import { spawnSync as nodeSpawnSync } from "./background-process.mjs";
40
40
  import { resolveCommand } from "./development.mjs";
41
- import { managedHost } from "./managed-cargo.mjs";
41
+ import { directPlatform, managedHost } from "./managed-cargo.mjs";
42
42
  import { APPROVAL_MACHO_STRIP_LINKEDIT_V1, APPROVAL_RAW_SHA256, approvalDigest } from "./macho-approval-hash.mjs";
43
43
  import {
44
44
  bundledFileName, planMediaSidecar, sourceOfferText, stagedFileName, validateMediaSidecarConfig, verifyStagedSidecars, writeMediaSidecarNotices,
@@ -111,7 +111,7 @@ const KEYS = Object.freeze({
111
111
  "native-library": ["kind", "library", "version", "catalog", "resourceDir", "licenseDir", "cacheDir", "as", "files", "companions", "source", "platforms"],
112
112
  resource: ["kind", "from", "to", "sha256", "machO", "clean", "platforms"],
113
113
  command: ["kind", "name", "cmd", "args", "cwd", "output", "externalBin", "dest", "clean", "platforms"],
114
- remote: ["kind", "name", "url", "sha256", "dest", "to", "size", "extract", "memberSha256", "executable", "clean", "platforms"],
114
+ remote: ["kind", "name", "url", "sha256", "dest", "to", "size", "extract", "extractKind", "memberSha256", "executable", "clean", "platforms"],
115
115
  cmake: ["kind", "name", "repo", "commit", "target", "output", "cmakeArgs", "platformArgs", "generator", "platformGenerators", "architecture", "cmakeBin", "smoke", "sha256", "dest", "clean", "platforms"],
116
116
  prebuilt: ["kind", "name", "from", "manifest", "dest", "platforms"],
117
117
  });
@@ -476,10 +476,15 @@ export function validateSidecarManifest(raw, { target, mode = "release" } = {})
476
476
  if (item.size !== undefined && (!Number.isSafeInteger(item.size) || item.size <= 0)) errors.push(`${where}: size must be a positive integer (bytes)`);
477
477
  if (item.extract !== undefined) { const member = normalizeArchiveMember(item.extract); if (member.error || (!member.byPath && !NAME.test(member.value))) errors.push(`${where}: extract must be the bare file name of one archive member or its archive path (posix, no leading /, no .. segment): ${member.error ?? "invalid file name"}`); }
478
478
  if (item.memberSha256 !== undefined && (item.extract === undefined || !SHA256.test(item.memberSha256 ?? ""))) errors.push(`${where}: memberSha256 must be a lowercase 64-hex SHA-256 and needs extract`);
479
+ if (item.extractKind !== undefined && item.extractKind !== "nsis") errors.push(`${where}: extractKind must be "nsis" (omit it for tar-readable zip/tgz/nupkg archives)`);
480
+ if (item.extractKind === "nsis") {
481
+ if (item.extract === undefined) errors.push(`${where}: extractKind "nsis" needs extract (the installer member path, e.g. bin/smartctl.exe)`);
482
+ if (typeof item.memberSha256 !== "string" || !SHA256.test(item.memberSha256)) errors.push(`${where}: extractKind "nsis" requires memberSha256 (the extracted member's lowercase 64-hex SHA-256); an NSIS member is never staged unpinned`);
483
+ }
479
484
  if (item.executable !== undefined && typeof item.executable !== "boolean") errors.push(`${where}: executable must be a boolean`);
480
485
  const clean = cleanOf();
481
486
  if (dest && !skipped) claim(dest, where);
482
- Object.assign(entry, { name, url: item.url, sha256: item.sha256, dest, ...(item.size ? { size: item.size } : {}), ...(item.extract ? { extract: item.extract } : {}), ...(item.memberSha256 ? { memberSha256: item.memberSha256 } : {}), ...(item.executable ? { executable: true } : {}), ...(clean ? { clean } : {}) });
487
+ Object.assign(entry, { name, url: item.url, sha256: item.sha256, dest, ...(item.extractKind ? { extractKind: item.extractKind } : {}), ...(item.size ? { size: item.size } : {}), ...(item.extract ? { extract: item.extract } : {}), ...(item.memberSha256 ? { memberSha256: item.memberSha256 } : {}), ...(item.executable ? { executable: true } : {}), ...(clean ? { clean } : {}) });
483
488
  } else if (item.kind === "cmake") {
484
489
  const name = item.name;
485
490
  if (typeof name !== "string" || !NAME.test(name) || /\.exe$/i.test(name) || name !== path.basename(name)) errors.push(`${where}: name must be a bare sidecar name (no path, no .exe, no target triple)`);
@@ -611,6 +616,45 @@ export function defaultExtractMember({ archive, fileName, destDir, run = default
611
616
  return out;
612
617
  }
613
618
 
619
+ /** 7-Zip candidates: an explicit path (deps.sevenZip or RIGHTKIT_SEVEN_ZIP), then `7z` on PATH, then 7z.exe under Program Files on win32. */
620
+ export function sevenZipCandidates({ explicit, env = process.env, platform = process.platform } = {}) {
621
+ const list = [];
622
+ if (explicit) list.push(explicit);
623
+ else if (env.RIGHTKIT_SEVEN_ZIP) list.push(env.RIGHTKIT_SEVEN_ZIP);
624
+ list.push("7z");
625
+ if (platform === "win32") for (const base of [env.ProgramFiles ?? "C:\\Program Files", env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"]) list.push(path.win32.join(base, "7-Zip", "7z.exe"));
626
+ return [...new Set(list)];
627
+ }
628
+
629
+ /**
630
+ * Extract one member of an NSIS installer with 7-Zip (`x -y -o<dir> <installer> <member>`; nothing in the installer is run), then
631
+ * require its SHA-256 to equal the pinned one. Fails closed, naming the installer digest, member, expected and actual digests, on a
632
+ * missing tool, a failed extraction, a missing member or any mismatch. Returns the extracted file path under destDir.
633
+ */
634
+ export function defaultExtractNsisMember({ archive, fileName, destDir, expectedSha256, installerSha256, run = defaultRun, sevenZip, env = process.env, platform = process.platform, readFile = nodeFs.readFileSync, exists = nodeFs.existsSync }) {
635
+ const wanted = normalizeArchiveMember(fileName);
636
+ const who = `NSIS installer ${installerSha256 ?? archive} member ${JSON.stringify(fileName)}`;
637
+ if (wanted.error) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${who} ${wanted.error}`);
638
+ if (!SHA256.test(expectedSha256 ?? "")) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${who}: a pinned member sha256 is required`);
639
+ let tool;
640
+ let lastError = "not found";
641
+ for (const candidate of sevenZipCandidates({ explicit: sevenZip, env, platform })) {
642
+ const result = run(candidate, ["x", "-y", `-o${destDir}`, archive, wanted.value], { cwd: path.dirname(archive) });
643
+ if (result.error || result.status === null || result.status === undefined) { lastError = result.error?.message ?? "could not start"; continue; }
644
+ tool = candidate;
645
+ if (result.status !== 0) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${who}: ${candidate} exited ${result.status}; nothing staged`);
646
+ break;
647
+ }
648
+ if (!tool) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${who}: 7-Zip is required and was not found (${lastError}); install 7-Zip or set RIGHTKIT_SEVEN_ZIP; nothing staged`);
649
+ const out = path.join(destDir, ...wanted.value.split("/"));
650
+ const inside = path.relative(path.resolve(destDir), path.resolve(out));
651
+ if (!inside || inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${who} resolves outside the extraction directory`);
652
+ if (!exists(out)) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${who}: member not found after extraction; nothing staged`);
653
+ const actual = sha256Of(readFile(out));
654
+ if (actual !== expectedSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${who}: sha256 is ${actual}, expected ${expectedSha256}; nothing staged`, { expected: expectedSha256, actual });
655
+ return out;
656
+ }
657
+
614
658
  async function defaultFetchBytes(url) {
615
659
  if (!url.startsWith("https://")) fail("STAGE_SIDECAR_CONFIG_INVALID", `refusing a non-https archive URL: ${url}`);
616
660
  const response = await fetch(url);
@@ -1453,7 +1497,9 @@ async function stageRemote(entry, ctx) {
1453
1497
  let memberSha256;
1454
1498
  if (entry.extract) {
1455
1499
  scratch = ctx.makeTempDir();
1456
- source = await ctx.extractMember({ archive: cached, fileName: entry.extract, destDir: scratch });
1500
+ source = entry.extractKind === "nsis"
1501
+ ? await ctx.extractNsisMember({ archive: cached, fileName: entry.extract, destDir: scratch, expectedSha256: entry.memberSha256, installerSha256: entry.sha256 })
1502
+ : await ctx.extractMember({ archive: cached, fileName: entry.extract, destDir: scratch });
1457
1503
  memberSha256 = sha256Of(fs.readFileSync(source));
1458
1504
  if (entry.memberSha256 && memberSha256 !== entry.memberSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.extract} from ${entry.url} is ${memberSha256}, expected ${entry.memberSha256}; nothing staged`, { expected: entry.memberSha256, actual: memberSha256 });
1459
1505
  }
@@ -1462,7 +1508,7 @@ async function stageRemote(entry, ctx) {
1462
1508
  const copied = copyInto(ctx, source, dest, { executable: entry.executable === true });
1463
1509
  const expected = entry.extract ? entry.memberSha256 : entry.sha256;
1464
1510
  if (expected && copied.sha256 !== expected) fail("STAGE_SIDECAR_HASH_MISMATCH", `staged ${entry.dest} is ${copied.sha256}, expected ${expected}`, { expected, actual: copied.sha256 });
1465
- return [{ kind: "remote", name: entry.name, path: rel(ctx, dest), ...copied, sourceSha256: memberSha256 ?? entry.sha256, url: entry.url, cached: fromCache, externalBin: false, ...(entry.extract ? { archiveSha256: entry.sha256, member: entry.extract } : {}) }];
1511
+ return [{ kind: "remote", name: entry.name, path: rel(ctx, dest), ...copied, sourceSha256: memberSha256 ?? entry.sha256, url: entry.url, cached: fromCache, externalBin: false, ...(entry.extract ? { archiveSha256: entry.sha256, member: entry.extract, memberSha256 } : {}), ...(entry.extractKind ? { extractKind: entry.extractKind } : {}) }];
1466
1512
  } finally {
1467
1513
  if (scratch) ctx.removeDir(scratch);
1468
1514
  }
@@ -1687,10 +1733,13 @@ function wipeStaleStageDir(ctx, plan, produced) {
1687
1733
  * call can reuse (acquireManagedRunLease records a per-process lease file; no RK_* variable carries it to a child), so
1688
1734
  * the only safe behaviour is to refuse early. RIGHTKIT_STAGE_SIDECAR_ALLOW_NESTED=1 opts out.
1689
1735
  */
1690
- export function isNestedBrokerContext(env = process.env) {
1736
+ export function isNestedBrokerContext(env = process.env, platform = process.platform) {
1737
+ // A direct (Linux) host has no broker, so no outer broker build to nest in, whatever flag or socket the env carries (heavy-command sets
1738
+ // RIGHTSUITE_HEAVY_WORK_OWNER on every platform).
1739
+ if (directPlatform(platform)) return false;
1691
1740
  if (env?.RIGHTKIT_STAGE_SIDECAR_ALLOW_NESTED === "1") return false;
1692
1741
  const insideTauri = Boolean(env?.TAURI_ENV_PLATFORM || env?.TAURI_ENV_TARGET_TRIPLE);
1693
- return insideTauri && (managedHost(env ?? {}) || env?.RIGHTSUITE_HEAVY_WORK_OWNER === "1");
1742
+ return insideTauri && (managedHost(env ?? {}, platform) || env?.RIGHTSUITE_HEAVY_WORK_OWNER === "1");
1694
1743
  }
1695
1744
 
1696
1745
  /** True when staging would call `rustright cargo` for a non-skipped entry. */
@@ -1754,6 +1803,7 @@ export async function stageSidecars({ appRoot, manifest, target, deps = {} } = {
1754
1803
  fetchBytes: deps.fetchBytes ?? defaultFetchBytes,
1755
1804
  fetch: deps.fetch ?? ((...args) => globalThis.fetch(...args)),
1756
1805
  extractMember: deps.extractMember ?? ((args) => defaultExtractMember({ ...args, run })),
1806
+ extractNsisMember: deps.extractNsisMember ?? ((args) => defaultExtractNsisMember({ ...args, run, env, sevenZip: deps.sevenZip, readFile: fs.readFileSync, exists: fs.existsSync })),
1757
1807
  loadMediaCatalog: deps.loadMediaCatalog ?? (() => undefined),
1758
1808
  loadNativeCatalog: deps.loadNativeCatalog ?? (() => undefined),
1759
1809
  makeTempDir: deps.makeTempDir ?? (() => nodeFs.mkdtempSync(path.join(os.tmpdir(), "rightkit-stage-"))),
package/test-command.mjs CHANGED
@@ -21,7 +21,8 @@
21
21
  // from the app root. `--suite journeys` selects every chunk of that group. `--runner vitest -- run --project unit` runs
22
22
  // the runner once with the arguments after `--` (the watch or one-off case); `-- <args>` after `--suite` is allowed when
23
23
  // exactly one suite is selected. Exit code: 0 when every selected suite passed, else the failing suite's exit code.
24
- import { readFileSync } from "node:fs";
24
+ import { existsSync, readFileSync } from "node:fs";
25
+ import { buildLaunch } from "./build-launch.mjs";
25
26
  import path from "node:path";
26
27
  import { pathToFileURL } from "node:url";
27
28
  import { spawn } from "./background-process.mjs";
@@ -39,10 +40,11 @@ export function parseTestArgs(argv) {
39
40
  for (let i = 0; i < argv.length; i += 1) {
40
41
  const arg = argv[i];
41
42
  if (arg === "--") { opts.passthrough = argv.slice(i + 1); break; }
42
- if (arg === "--config" || arg === "--suite" || arg === "--runner") {
43
+ if (arg === "--config" || arg === "--suite" || arg === "--runner" || arg === "--platform") {
43
44
  const value = argv[i + 1];
44
45
  if (!value || value.startsWith("--")) throw new Error(`${arg} needs a value`);
45
46
  if (arg === "--suite") opts.suites.push(...value.split(",").filter(Boolean));
47
+ else if (arg === "--platform") opts.platform = value;
46
48
  else opts[arg.slice(2)] = value;
47
49
  i += 1;
48
50
  } else if (arg === "--list") opts.list = true;
@@ -147,14 +149,16 @@ export function eventPrefix(config) {
147
149
  * Run the planned suites in order; stop at the first non-zero exit. `spawnFn` and `log` are injectable.
148
150
  * Resolves `{ exitCode, results: [{ suite, exitCode, durationMs }] }`; a spawn error or a signal rejects.
149
151
  */
150
- export async function runSuites(plan, { appRoot, prefix = "test", env = process.env, spawnFn = spawn, log = console.log } = {}) {
152
+ export async function runSuites(plan, { appRoot, prefix = "test", env = process.env, spawnFn = spawn, log = console.log, door = buildLaunch } = {}) {
151
153
  const results = [];
152
154
  for (const item of plan) {
153
155
  const invocation = resolveInvocation(item.step, { appRoot, baseEnv: env, extraArgs: item.extraArgs });
154
156
  log(JSON.stringify({ event: `${prefix}_suite_started`, suite: item.name }));
155
157
  const started = Date.now();
156
158
  const exitCode = await new Promise((resolve, reject) => {
157
- const child = spawnFn(invocation.cmd, invocation.args, { cwd: invocation.cwd, env: invocation.env, stdio: "inherit" });
159
+ // 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.
160
+ const target = door(invocation.cmd, invocation.args, { env: invocation.env, cwd: invocation.cwd, exists: existsSync });
161
+ const child = spawnFn(target.command, target.args, { cwd: invocation.cwd, env: invocation.env, stdio: "inherit" });
158
162
  child.once("error", reject);
159
163
  child.once("exit", (code, signal) => {
160
164
  if (signal) reject(new Error(`${item.name} stopped by ${signal}`));
@@ -172,6 +176,15 @@ export async function runSuites(plan, { appRoot, prefix = "test", env = process.
172
176
  export async function main(argv = process.argv.slice(2)) {
173
177
  const opts = parseTestArgs(argv);
174
178
  const { config, appRoot } = await loadAppConfig(opts.config);
179
+ // `--platform ios`: the app's declared XCUITest journeys (development.targets.ios.uiTests) on its dev device or simulator.
180
+ if (opts.platform !== undefined) {
181
+ if (opts.platform !== "ios") throw new Error(`--platform ${opts.platform}: only ios has platform UI test journeys; other suites are declared in tests.suites`);
182
+ if (opts.suites.length || opts.runner || opts.passthrough.length) throw new Error("--platform ios runs the declared journeys; it takes no --suite, --runner or arguments");
183
+ const { runDevelopment } = await import("./development.mjs");
184
+ const result = runDevelopment({ root: appRoot, platform: "ios", config, uiTests: true, dryRun: opts.list });
185
+ if (opts.list) console.log(JSON.stringify(result.plan ?? result, null, 2));
186
+ return 0;
187
+ }
175
188
  const plan = planTests(config, { suites: opts.suites, runner: opts.runner, passthrough: opts.passthrough, appRoot });
176
189
  if (opts.list) {
177
190
  const view = plan.map((item) => ({ suite: item.name, ...resolveInvocation(item.step, { appRoot, baseEnv: {}, extraArgs: item.extraArgs }), env: undefined }));
package/unit-gate.mjs ADDED
@@ -0,0 +1,259 @@
1
+ // RightKit's own units use the same door, but never reuse mutable unit receipts as evidence.
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { localBuildGranted } from "./execution-policy.mjs";
7
+ import { readCargoPackage } from "./crate-graph.mjs";
8
+ import { spawnSync as nodeSpawnSync } from "./background-process.mjs";
9
+ import { endRun, startRun, stagesTo } from "./gate-stages.mjs";
10
+ import { qualificationNonce } from "./provenance-verify.mjs";
11
+ import { assignJobs, describeCommand, expectedStageCommands } from "./stage-commands.mjs";
12
+
13
+ const GH = process.platform === "win32" ? "C:\\Program Files\\GitHub CLI\\gh.exe" : "/opt/homebrew/bin/gh";
14
+ const defaultRun = (cmd, args, options = {}) => nodeSpawnSync(cmd, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, ...options });
15
+ const defaultReadJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
16
+
17
+ /**
18
+ * The gate-owned release of one RightKit unit. Runs only on a broker-managed host and only after this process's check, compile,
19
+ * package and qualify stages: (1) the unit's local pre-flight through the broker (rk-pipeline preflight: the compile rows the dispatch
20
+ * adapter admits), (2) the broker-admitted dispatch of publish.yml (rk-pipeline dispatch -> rustright run rightkit-dispatch; the
21
+ * broker's request id is the run's correlation token), (3) rk-pipeline watch --wait: the exact run (sha, attempt, event, workflow,
22
+ * token), then its publish-direct upload run, then the registry, (4) the upload run's publication receipt (the sealed bytes the
23
+ * registry serves: published && registryVerified) read back against the registry itself. A queued dispatch, a missing upload run,
24
+ * an ambiguous receipt or differing registry bytes is a failure; nothing is retried or re-dispatched here (reconcile rk-pipeline's
25
+ * state by hand). The local bundle's digest is reported beside the hosted one, never equated with it.
26
+ */
27
+ export const QUALIFICATION_SCHEMA = "rightkit.unit-qualification/1";
28
+ /** The stages whose broker jobs the hosted admission authenticates (their signed receipts ride in the qualification), and how many
29
+ * receipts one stage may carry (every command of the stage needs its own; the dispatch input is capped at 65535 characters in all and
30
+ * each signed envelope is ~3 KB, which writeQualification checks). */
31
+ export const ATTESTED_STAGES = ["compile", "package"];
32
+ export const MAX_STAGE_RECEIPTS = 64;
33
+ const MAX_QUALIFICATION_BYTES = 58_000;
34
+
35
+ /** `rustright status <id>`: the broker's own record of one job, reduced to what a verifier needs (id + signed envelope). A job the
36
+ * broker cannot report, or one without a signed envelope, fails the stage: an unauthenticated qualification is worth nothing. */
37
+ /** The commands each stage must have run for this unit, from the same builders the stages use, over the checkout's own config. */
38
+ export function defaultStageCommands({ unit, root, platform, readJson = defaultReadJson }) {
39
+ const manifest = unit.kind === "npm" ? readJson(path.join(root, "packages", unit.name.slice("@rightkit/".length), "package.json")) : undefined;
40
+ return expectedStageCommands({ kind: unit.kind, unit: unit.name, config: readJson(path.join(root, "ci", "gate.config.json")), manifest, platform });
41
+ }
42
+
43
+ export function readBrokerReceipt(id, { run = defaultRun } = {}) {
44
+ const r = run("rustright", ["status", id], { encoding: "utf8", timeout: 60_000 });
45
+ if (r.error || r.status !== 0) throw new Error(`the broker cannot report job ${id} (rustright status failed)`);
46
+ let doc;
47
+ try { doc = JSON.parse(r.stdout); } catch { throw new Error(`the broker's status of job ${id} is not JSON`); }
48
+ const receipt = doc?.receipt ?? doc;
49
+ if (receipt?.id !== id || !receipt.provenance?.payload || !receipt.provenance.signature) throw new Error(`job ${id} has no signed provenance receipt`);
50
+ return { id, provenance: receipt.provenance };
51
+ }
52
+ /** `<bundle>/qualification.json`: the local stages that passed and the one artifact sha256 they bound, for the dispatcher. */
53
+ export function writeQualification({ report, artifact, unit, commit, tree, runtime, executor, write = fs.writeFileSync }) {
54
+ for (const stage of ["check", "compile", "package", "qualify"]) if (report.stages?.[stage]?.ok !== true) throw new Error(`release needs stage ${stage} ok before it runs`);
55
+ if (!artifact?.artifactFile || !/^[0-9a-f]{64}$/.test(artifact.artifactSha256 ?? "")) throw new Error("release needs the qualified artifact");
56
+ const receipt = { schema: QUALIFICATION_SCHEMA, unit: { kind: unit.kind, name: unit.name, version: unit.version }, commit, tree, runtime, executor: executor?.kind ?? null,
57
+ stages: Object.fromEntries(["check", "compile", "package", "qualify"].map((s) => [s, { ...report.stages[s] }])), artifactSha256: artifact.artifactSha256, writtenAt: new Date().toISOString() };
58
+ // The broker's signed receipts for the compile and package stages are what the hosted admission verifies; without them the
59
+ // booleans above are only this process's say-so.
60
+ for (const stage of ATTESTED_STAGES) {
61
+ const list = report.stages[stage].receipts;
62
+ if (!Array.isArray(list) || list.length < 1 || list.length > MAX_STAGE_RECEIPTS) throw new Error(`release needs 1 to ${MAX_STAGE_RECEIPTS} signed broker receipts of the ${stage} stage (found ${Array.isArray(list) ? list.length : "none"})`);
63
+ }
64
+ if (JSON.stringify(receipt).length > MAX_QUALIFICATION_BYTES) throw new Error("the qualification receipt with its broker receipts exceeds the workflow dispatch input limit");
65
+ const file = path.join(path.dirname(artifact.artifactFile), "qualification.json");
66
+ write(file, `${JSON.stringify(receipt, null, 2)}\n`);
67
+ return file;
68
+ }
69
+
70
+ const requireBrokerHost = (executor) => { if (executor?.kind !== "broker") throw new Error("unit release runs only on a broker-managed host (the hosted qualification is dispatched by the RustRight rightkit-dispatch adapter); a direct host has no gate-owned release route, so no upload was started"); };
71
+
72
+ export async function releaseUnit({ unit, root, commit, executor, env, artifact, qualification, platform = process.platform, run = defaultRun, readJson = defaultReadJson, out = console.log, scratch = platform === "win32" ? "D:\\scratch" : "/Volumes/D/scratch", registryBytes = defaultRegistryBytes }) {
73
+ requireBrokerHost(executor);
74
+ if (typeof qualification !== "string" || !fs.existsSync(qualification)) throw new Error("release needs the unit qualification receipt (written by the gate after the qualify stage); nothing is dispatched without it");
75
+ const registry = unit.kind === "npm" ? "npm" : "crates";
76
+ const pipeline = path.join(root, "scripts", "ci", "rk-pipeline.mjs");
77
+ const stateFile = platform === "win32" ? "D:\\scratch\\rk-pipeline\\state.json" : "/Volumes/D/scratch/rk-pipeline/state.json";
78
+ const step = (label, args, { accept = [0], acceptWhen = null } = {}) => {
79
+ out(`rightkit-gate: [release] ${label}`);
80
+ const r = run(process.execPath, [pipeline, ...args], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] });
81
+ const accepted = r.status === 0 || (accept.includes(r.status) && (!acceptWhen || acceptWhen(r)));
82
+ if (!accepted) {
83
+ const said = `${String(r.stdout ?? "")}\n${String(r.stderr ?? "")}`.split("\n").map((l) => l.trim()).filter((l) => l && !l.includes("note:")).slice(-4).join(" | ");
84
+ throw new Error(`${label} failed (exit ${r.status}): ${said.slice(0, 600)}`);
85
+ }
86
+ return r;
87
+ };
88
+ const findDispatch = () => {
89
+ const found = Object.values(readJson(stateFile).dispatches ?? {}).filter((d) => d.unit === unit.name && d.sha === commit && d.registry === registry);
90
+ if (found.length > 1) throw new Error(`${found.length} dispatch records for ${unit.name} at ${commit.slice(0, 12)} in rk-pipeline state; reconcile before any retry`);
91
+ return found[0];
92
+ };
93
+ // The pipeline canary (rk-release-canary: the candidate's `right-release doctor` against every app on both hosts, versus the
94
+ // registry's version) runs before the pre-flight. rk-pipeline exits 2 both for "no pipeline canary for this unit" and for any
95
+ // internal error, so 2 is accepted only when it says the former (reviewer, 2026-10-27); a canary error never reads as no canary.
96
+ step(`pipeline canary of ${unit.name} (doctor on every app, both hosts, candidate vs registry)`, ["canary", unit.name],
97
+ { accept: [2], acceptWhen: (r) => /no pipeline canary for this unit/.test(`${r.stdout ?? ""}\n${r.stderr ?? ""}`) });
98
+ step(`local pre-flight of ${unit.name} through the broker`, ["preflight", unit.name, "--registry", registry]);
99
+ step(`broker-admitted hosted qualification dispatch of ${unit.name} at ${commit.slice(0, 12)}`, ["dispatch", unit.name, "--registry", registry, "--qualification", qualification]);
100
+ const dispatched = findDispatch();
101
+ if (!dispatched?.runId || !dispatched.runAttempt) throw new Error(`no dispatch record with a run for ${unit.name} at ${commit.slice(0, 12)}; nothing is claimed (reconcile rk-pipeline state before any retry)`);
102
+ step(`waiting for publish.yml run ${dispatched.runId}, its publish-direct upload run and the registry`, ["watch", "--wait"]);
103
+ const done = findDispatch();
104
+ if (done?.status !== "published" || done.version !== unit.version || !done.uploadRunId) throw new Error(`${unit.name}: the watch ended without a published record (${done?.status ?? "none"}${done?.conclusion ? `: ${done.conclusion}` : ""}); nothing is claimed`);
105
+ const receipt = readPublicationReceipt({ uploadRunId: done.uploadRunId, runId: done.runId, runAttempt: done.runAttempt, run, env, scratch });
106
+ // The receipt is written by the publish-direct UPLOAD run (its runId/runAttempt are that run's: upload.mjs records GITHUB_RUN_ID);
107
+ // the qualification run it consumed is its `candidate` (reviewed-candidate.mjs prepareQualified: id qualified-<run>-<attempt>,
108
+ // runId, runAttempt, sourceSha). Both are bound to what rk-pipeline dispatched and watched, plus commit, registry and repository.
109
+ const candidate = receipt.candidate ?? {};
110
+ const bound = String(receipt.runId) === String(done.uploadRunId)
111
+ && receipt.commit === commit && receipt.registry === registry && receipt.repository === "bogusyogi/rightkit" && receipt.dryRun !== true
112
+ && candidate.source === "qualified" && String(candidate.runId) === String(done.runId) && String(candidate.runAttempt) === String(done.runAttempt) && candidate.sourceSha === commit;
113
+ if (!bound) {
114
+ throw new Error(`${unit.name}@${unit.version}: the publication receipt of upload run ${done.uploadRunId} is not that run's receipt for qualification run ${done.runId} attempt ${done.runAttempt} of ${commit.slice(0, 12)} (${registry}); nothing is claimed`);
115
+ }
116
+ const rows = (receipt.receipts ?? []).filter((r) => r.unit === unit.name && r.version === unit.version);
117
+ if (rows.length !== 1) throw new Error(`${unit.name}@${unit.version}: the publication receipt holds ${rows.length} row(s) for this unit; exactly one is required`);
118
+ const [row] = rows;
119
+ if (row.published !== true || row.registryVerified !== true || row.outcome !== "published" || !/^[0-9a-f]{64}$/.test(row.sha256 ?? "")) throw new Error(`${unit.name}@${unit.version}: the publication receipt of upload run ${done.uploadRunId} records no verified publication (${row.outcome})`);
120
+ // Read the registry back independently of the upload run: npm's dist.shasum (sha1) and the served tarball's sha256 (the receipt
121
+ // row carries sha256 + sha1, not the sha512 integrity); crates: the sparse index cksum (sha256).
122
+ const served = await registryBytes({ unit, registry, run, env, expected: row, scratch });
123
+ const same = registry === "npm" ? served.shasum === row.sha1 && served.sha256 === row.sha256 : served.cksum === row.sha256;
124
+ if (!same) throw new Error(`${unit.name}@${unit.version}: the registry serves different bytes from the upload receipt (served sha256 ${served.sha256 ?? served.cksum ?? "?"}, receipt ${row.sha256})`);
125
+ return {
126
+ registry, runId: done.runId, runAttempt: done.runAttempt, requestId: done.requestId ?? null, uploadRunId: done.uploadRunId,
127
+ hostedSha256: row.sha256, hostedSha1: row.sha1 ?? null, registryVerified: true,
128
+ localArtifactSha256: artifact?.artifactSha256 ?? null, hostedEqualsLocal: artifact ? artifact.artifactSha256 === row.sha256 : null,
129
+ };
130
+ }
131
+
132
+ /** The one `publication-receipt-*` artifact of the upload run, downloaded and parsed. More or fewer than one is ambiguous. */
133
+ export function readPublicationReceipt({ uploadRunId, runId, runAttempt, run, env, scratch }) {
134
+ const listed = run(GH, ["api", `repos/bogusyogi/rightkit/actions/runs/${uploadRunId}/artifacts`], { env, encoding: "utf8" });
135
+ if (listed.status !== 0) throw new Error(`cannot list the artifacts of upload run ${uploadRunId}`);
136
+ // publish-direct names its receipt `publication-receipt-<qualification run id>-<its attempt>-<upload attempt>`.
137
+ const pattern = new RegExp(`^publication-receipt-${runId}-${runAttempt}-\\d+$`);
138
+ const names = (JSON.parse(listed.stdout).artifacts ?? []).map((a) => a.name).filter((n) => pattern.test(n));
139
+ if (names.length !== 1) throw new Error(`upload run ${uploadRunId} has ${names.length} publication receipt artifact(s) for run ${runId} attempt ${runAttempt}; exactly one is required`);
140
+ const dir = path.join(scratch, "rk-unit-receipts", `${uploadRunId}-${Date.now()}`);
141
+ fs.rmSync(dir, { recursive: true, force: true });
142
+ fs.mkdirSync(dir, { recursive: true });
143
+ const fetched = run(GH, ["run", "download", String(uploadRunId), "-R", "bogusyogi/rightkit", "--name", names[0], "--dir", dir], { env, encoding: "utf8" });
144
+ if (fetched.status !== 0) throw new Error(`cannot download ${names[0]} of upload run ${uploadRunId}`);
145
+ const file = path.join(dir, "publication-receipt.json");
146
+ if (!fs.existsSync(file)) throw new Error(`${names[0]} holds no publication-receipt.json`);
147
+ return JSON.parse(fs.readFileSync(file, "utf8"));
148
+ }
149
+
150
+ /** What the registry serves for the unit's version: npm dist.integrity + dist.shasum; crates the sparse index cksum. */
151
+ export async function defaultRegistryBytes({ unit, registry, run, env, expected, scratch }) {
152
+ if (registry === "npm") {
153
+ const r = run("npm", ["view", `${unit.name}@${unit.version}`, "dist.shasum", "--json", "--prefer-online"], { env, encoding: "utf8" });
154
+ if (r.status !== 0) throw new Error(`npm view ${unit.name}@${unit.version} failed`);
155
+ const shasum = JSON.parse(r.stdout);
156
+ // The served tarball itself, hashed here: `npm pack` downloads exactly what the registry serves for that version.
157
+ const dir = path.join(scratch, "rk-unit-readback", `${unit.name.replace("/", "__")}-${unit.version}-${Date.now()}`);
158
+ fs.mkdirSync(dir, { recursive: true });
159
+ const packed = run("npm", ["pack", `${unit.name}@${unit.version}`, "--pack-destination", dir, "--ignore-scripts"], { env, encoding: "utf8", cwd: dir });
160
+ if (packed.status !== 0) throw new Error(`npm pack ${unit.name}@${unit.version} failed`);
161
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith(".tgz"));
162
+ if (files.length !== 1) throw new Error(`npm pack of ${unit.name}@${unit.version} produced ${files.length} tarball(s)`);
163
+ return { shasum: typeof shasum === "string" ? shasum : shasum?.["dist.shasum"], sha256: hash(fs.readFileSync(path.join(dir, files[0]))) };
164
+ }
165
+ // The sparse index must serve exactly the receipt's sha256 (verifyCargoRegistry throws PUBLICATION_REGISTRY_MISMATCH otherwise).
166
+ const { verifyCargoRegistry } = await import("./cargo-registry-readback.mjs");
167
+ await verifyCargoRegistry({ name: unit.name, version: unit.version, sha256: expected.sha256 });
168
+ return { cksum: expected.sha256 };
169
+ }
170
+
171
+
172
+ const hash = (data) => createHash("sha256").update(data).digest("hex");
173
+
174
+ export function resolveUnit(selector, root, { granted = localBuildGranted, readJson = (p) => JSON.parse(fs.readFileSync(p, "utf8")), readCargo = readCargoPackage } = {}) {
175
+ const match = /^(npm):(@rightkit\/[a-z0-9-]+)$|^(cargo):(rightkit-[a-z0-9-]+)$/.exec(selector ?? "");
176
+ if (!match || !granted(root)) throw new Error("unit needs npm:@rightkit/<name> or cargo:rightkit-<name> in the locally granted RightKit repository");
177
+ const kind = match[1] ?? match[3], name = match[2] ?? match[4];
178
+ const dir = path.join(root, kind === "npm" ? "packages" : "crates", kind === "npm" ? name.slice("@rightkit/".length) : name);
179
+ const manifestFile = path.join(dir, kind === "npm" ? "package.json" : "Cargo.toml");
180
+ if (!fs.realpathSync(manifestFile).startsWith(`${fs.realpathSync(root)}${path.sep}`)) throw new Error("unit manifest escapes the repository");
181
+ const manifest = kind === "npm" ? readJson(path.join(dir, "package.json")) : readCargo(dir);
182
+ if (manifest.name !== name || manifest.private === true || manifest.manifest?.package?.publish === false || typeof manifest.version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(manifest.version)) throw new Error(`invalid canonical manifest for ${selector}`);
183
+ return { kind, name, version: manifest.version };
184
+ }
185
+
186
+ /** One invocation reruns all owed stages; reports are never read as admission evidence. */
187
+ export async function runUnitGate({ selector, to, root, commonDir, commit, tree, executor, env, platform, check, loadOwner = () => import("./local-publish.mjs"), out = console.log, err = console.error, releaser = null, resolve = resolveUnit, writeReport, fetchReceipt = readBrokerReceipt, stageCommands = defaultStageCommands }) {
188
+ const stages = stagesTo(to);
189
+ if (!stages) { err(`rightkit-gate: invalid unit stage ${to}`); return 2; }
190
+ let unit;
191
+ try { unit = resolve(selector, root); } catch (error) { err(`rightkit-gate: BLOCKED: ${error.message}`); return 1; }
192
+ const scratch = platform === "win32" ? "D:\\scratch" : "/Volumes/D/scratch";
193
+ const bundle = path.join(scratch, `rk-unit-${randomBytes(12).toString("hex")}`);
194
+ const argv = ["--local", unit.kind === "npm" ? "--package" : "--crate", unit.name, "--workspace", root, "--qualify-only", "--bundle", bundle];
195
+ const runtimeDir = path.dirname(fileURLToPath(import.meta.url));
196
+ const runtimeFiles = [...fs.readdirSync(runtimeDir).filter((name) => name.endsWith(".mjs")).sort().map((name) => path.join(runtimeDir, name)), path.join(runtimeDir, "bin", "rightkit-gate.mjs")];
197
+ const runtime = hash(runtimeFiles.map((file) => `${path.relative(runtimeDir, file)}\0${hash(fs.readFileSync(file))}`).join("\n"));
198
+ const report = { schema: "rightkit.unit-gate-report/1", unit, commit, tree, runtime, executor: executor.kind, stages: {} };
199
+ const save = () => { if (writeReport) writeReport(report); };
200
+ let state, owner, artifact, currentStage, checkTier = null;
201
+ // A run headed to release has its compile and package broker jobs sent with a nonce bound to this commit, tree, unit and stage
202
+ // (RIGHTKIT_ATTEST_NONCE), so each job's signed receipt can be verified hosted for exactly that and nothing else.
203
+ const attested = stages.includes("release") && executor?.kind === "broker";
204
+ const stageEnv = async (stage, action) => {
205
+ const run = startRun({ commonDir, commit, tree, stage });
206
+ const nonce = attested && ATTESTED_STAGES.includes(stage) ? { RIGHTKIT_ATTEST_NONCE: qualificationNonce({ commit, tree, unit: `${unit.kind}:${unit.name}`, stage }) } : {};
207
+ try { return await action({ ...env, ...nonce, ...run.env }); } finally { endRun(run); }
208
+ };
209
+ const receipts = {};
210
+ const collectReceipts = (stage, ids) => {
211
+ if (!attested || !ATTESTED_STAGES.includes(stage)) return;
212
+ // Every job of the stage is fetched; the stage owes a signed receipt for EVERY command it runs (hosted admission requires the same),
213
+ // so a missing one fails here, before any dispatch, naming it. Jobs that are none of the stage's commands are not carried.
214
+ const fetched = [...new Set(ids ?? [])].map((id) => fetchReceipt(id));
215
+ const { assigned, missing } = assignJobs(stageCommands({ unit, root, platform })[stage], fetched.map((r) => ({ id: r.id, payload: r.provenance.payload })));
216
+ if (missing.length) throw new Error(`the ${stage} stage's ${describeCommand(missing[0])} has no broker job receipt (${missing.length} required command(s) missing)`);
217
+ receipts[stage] = fetched.filter((r) => assigned.has(r.id));
218
+ };
219
+ const verifyArtifact = (result) => {
220
+ if (!result || result.kind !== unit.kind || result.name !== unit.name || result.version !== unit.version || result.commit !== commit || !/^[0-9a-f]{64}$/.test(result.artifactSha256 ?? "") || hash(fs.readFileSync(result.artifactFile)) !== result.artifactSha256) throw new Error("unit artifact identity or digest differs from the completed stage");
221
+ };
222
+ try {
223
+ for (const stage of stages) {
224
+ currentStage = stage;
225
+ // A unit run headed to qualify or release owes the release tier: it runs here, before the compile, and qualify does not run it again.
226
+ if (stage === "check") { checkTier = stages.includes("qualify") ? "release" : "compile"; if (await check(checkTier) !== 0) throw new Error(`class A check (${checkTier} tier) failed`); }
227
+ else if (stage === "compile") {
228
+ owner = await loadOwner();
229
+ state = await stageEnv(stage, (runEnv) => owner.compileLocalUnit(unit.kind, argv, { env: runEnv }));
230
+ collectReceipts(stage, owner.localUnitCompileRequests?.(state));
231
+ } else if (stage === "package") {
232
+ artifact = await stageEnv(stage, (runEnv) => owner.packageLocalUnit(state, { env: runEnv }));
233
+ verifyArtifact(artifact);
234
+ collectReceipts(stage, artifact.brokerRequests);
235
+ } else if (stage === "qualify") {
236
+ if (checkTier !== "release" && await check("release") !== 0) throw new Error("release-tier check failed");
237
+ const qualified = await stageEnv(stage, (runEnv) => owner.qualifyLocalUnit(state, { env: runEnv }));
238
+ verifyArtifact(qualified);
239
+ if (!qualified.qualifiedOnly || !qualified.evidencePath || qualified.artifactSha256 !== artifact.artifactSha256) throw new Error("qualification did not bind the packaged artifact");
240
+ artifact = qualified;
241
+ }
242
+ let released = null;
243
+ if (stage === "release") {
244
+ // The qualification receipt the dispatcher carries to publish.yml: the hosted run is bound to these bytes and stages.
245
+ requireBrokerHost(executor); // a direct host has no broker receipts to carry: refuse for that reason, not for the missing receipts
246
+ const qualification = writeQualification({ report, artifact, unit, commit, tree, runtime, executor });
247
+ released = await stageEnv(stage, (runEnv) => (releaser ?? releaseUnit)({ unit, root, commit, executor, env: runEnv, artifact, qualification, platform, out }));
248
+ }
249
+ report.stages[stage] = { ok: true, ...(stage === "package" || stage === "qualify" ? { artifactFile: artifact.artifactFile, artifactSha256: artifact.artifactSha256 } : {}), ...(receipts[stage] ? { receipts: receipts[stage] } : {}), ...(released ?? {}) };
250
+ save();
251
+ out(`rightkit-gate: [${stage}] ${unit.kind}:${unit.name} passed`);
252
+ }
253
+ return 0;
254
+ } catch (error) {
255
+ if (currentStage) { report.stages[currentStage] = { ok: false }; save(); }
256
+ err(`rightkit-gate: unit ${selector} stopped: ${error.message}`);
257
+ return 1;
258
+ } finally { if (state && owner) await owner.closeLocalUnit(state); }
259
+ }
package/web-build.mjs CHANGED
@@ -12,10 +12,17 @@
12
12
  //
13
13
  // A step is `{ cmd | node, args?, cwd?, env?, nodeFlags?, nodeOptions? }` (see command-spec.mjs). `--dry-run` prints the
14
14
  // resolved commands and runs nothing. Exit code: 0, or the failing step's exit code.
15
+ //
16
+ // The door (gate-stages.mjs requireDoorRun) is checked before the config is imported: a .mjs config is code, so even a dry run
17
+ // runs only under a live door run. The exported runner checks it again, so no in-process caller spawns a step around it.
18
+ import path from "node:path";
15
19
  import { pathToFileURL } from "node:url";
16
20
  import { spawnSync } from "./background-process.mjs";
21
+ import { existsSync } from "node:fs";
22
+ import { buildLaunch } from "./build-launch.mjs";
17
23
  import { CommandSpecError, loadAppConfig, resolveInvocation, validateStep } from "./command-spec.mjs";
18
24
  import { defaultReleaseConfigFile } from "./release-config.mjs";
25
+ import { requireDoorRun } from "./gate-stages.mjs";
19
26
 
20
27
  export function parseWebBuildArgs(argv) {
21
28
  const opts = { config: defaultReleaseConfigFile(), dryRun: false };
@@ -41,23 +48,48 @@ export function planWebBuild(config) {
41
48
  return steps.map((step, index) => validateStep(step, `web.build[${index}]`));
42
49
  }
43
50
 
44
- /** Run the steps in order; `spawnSyncFn` and `log` are injectable. Returns the exit code. */
45
- export function runWebBuild(steps, { appRoot, env = process.env, dryRun = false, spawnSyncFn = spawnSync, log = console.log } = {}) {
51
+ /**
52
+ * The door refusal for a web build of the app at `appRoot`, or null under a live door run. Outside a git repository nothing can be
53
+ * bound to a commit: refused. `spawnSyncFn` runs git; `requireDoor` is gate-stages.mjs requireDoorRun.
54
+ */
55
+ export function webBuildDoorRefusal({ appRoot, env = process.env, spawnSyncFn = spawnSync, requireDoor = requireDoorRun }) {
56
+ const gitOut = (a) => { const r = spawnSyncFn("git", a, { cwd: appRoot, encoding: "utf8" }); return r.status === 0 ? String(r.stdout).trim() : ""; };
57
+ const common = gitOut(["rev-parse", "--git-common-dir"]);
58
+ if (!common) return "right-release web-build runs only inside a git repository, under a door run";
59
+ return requireDoor({ env, commonDir: path.resolve(appRoot, common), commit: gitOut(["rev-parse", "HEAD"]), command: "right-release web-build" });
60
+ }
61
+
62
+ function doorError(refusal) { const error = new Error(refusal); error.code = "RK_DOOR"; return error; }
63
+
64
+ /**
65
+ * Run the steps in order under a live door run; `spawnSyncFn`, `requireDoor` and `log` are injectable. Returns the exit code. The
66
+ * door is checked here as well as in main: this is the only place a step is spawned.
67
+ */
68
+ export function runWebBuild(steps, { appRoot, env = process.env, dryRun = false, spawnSyncFn = spawnSync, requireDoor = requireDoorRun, log = console.log, door = buildLaunch } = {}) {
69
+ const refusal = webBuildDoorRefusal({ appRoot, env, spawnSyncFn, requireDoor });
70
+ if (refusal) throw doorError(refusal);
46
71
  for (const step of steps) {
47
72
  const invocation = resolveInvocation(step, { appRoot, baseEnv: env });
48
73
  log(`${dryRun ? "[dry-run] " : ""}${[invocation.cmd, ...invocation.args].join(" ")}`);
49
74
  if (dryRun) continue;
50
- const result = spawnSyncFn(invocation.cmd, invocation.args, { cwd: invocation.cwd, env: invocation.env, stdio: "inherit" });
75
+ // A web build is a build: through the one door (build-launch.mjs), a broker job on a managed host.
76
+ const target = door(invocation.cmd, invocation.args, { env: invocation.env, cwd: invocation.cwd, exists: existsSync });
77
+ const result = spawnSyncFn(target.command, target.args, { cwd: invocation.cwd, env: invocation.env, stdio: "inherit" });
51
78
  if (result.error) throw result.error;
52
79
  if (result.status !== 0) return result.status ?? 1;
53
80
  }
54
81
  return 0;
55
82
  }
56
83
 
57
- export async function main(argv = process.argv.slice(2)) {
84
+ export async function main(argv = process.argv.slice(2), { env = process.env, requireDoor = requireDoorRun, spawnSyncFn = spawnSync, loadConfig = loadAppConfig, log = console.log } = {}) {
58
85
  const opts = parseWebBuildArgs(argv);
59
- const { config, appRoot } = await loadAppConfig(opts.config);
60
- return runWebBuild(planWebBuild(config), { appRoot, dryRun: opts.dryRun });
86
+ // A web build is a compile: like right-release build, the direct entry runs only under a door run (rightkit-gate --to compile runs
87
+ // class A first). The door is checked before the config is imported, since a .mjs config is code; a dry run is no exception.
88
+ const appRoot = path.dirname(path.resolve(opts.config));
89
+ const refusal = webBuildDoorRefusal({ appRoot, env, spawnSyncFn, requireDoor });
90
+ if (refusal) throw doorError(refusal);
91
+ const { config } = await loadConfig(opts.config);
92
+ return runWebBuild(planWebBuild(config), { appRoot, env, dryRun: opts.dryRun, spawnSyncFn, requireDoor, log });
61
93
  }
62
94
 
63
95
  if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {