@rightkit/release 0.6.8 → 0.6.11

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 (96) hide show
  1. package/README.md +30 -12
  2. package/admission-files.mjs +4 -1
  3. package/admission.mjs +2 -2
  4. package/advisory-exceptions.mjs +41 -5
  5. package/android-play-upload.mjs +16 -15
  6. package/app-check.mjs +105 -27
  7. package/assemble-app-command.mjs +151 -0
  8. package/assembled-app.mjs +445 -0
  9. package/bin/darwin/rightkit-swap +0 -0
  10. package/bin/darwin/rightkit-swap.sign.json +6 -0
  11. package/bin/rightkit-gate.mjs +1608 -345
  12. package/broker-pins.mjs +152 -0
  13. package/build-launch.mjs +88 -0
  14. package/build-ownership.mjs +1231 -0
  15. package/build-release.mjs +82 -28
  16. package/cargo-contract.mjs +3 -1
  17. package/cargo-ledger.mjs +62 -0
  18. package/cargo-target-shim.mjs +238 -0
  19. package/class-a-launch-guard.mjs +126 -0
  20. package/cli/right-release.mjs +31 -5
  21. package/clippy-equivalence.mjs +202 -0
  22. package/clippy-route.mjs +543 -0
  23. package/codesign-shim.mjs +11 -1
  24. package/committed-source.mjs +39 -5
  25. package/crate-workspaces.mjs +87 -0
  26. package/create-mac-updater.mjs +14 -2
  27. package/deterministic-scans.mjs +997 -71
  28. package/development-signing.mjs +4 -1
  29. package/development.mjs +120 -21
  30. package/direct-receipt.mjs +135 -0
  31. package/door-policy.mjs +71 -6
  32. package/door.mjs +6 -6
  33. package/freshness-census.mjs +257 -0
  34. package/gate-executor.mjs +14 -2
  35. package/gate-stages.mjs +91 -15
  36. package/heavy-command.mjs +3 -2
  37. package/hook-retirement.mjs +65 -0
  38. package/init-command.mjs +1 -1
  39. package/install-app.mjs +11 -2
  40. package/install-assembled-app.mjs +423 -0
  41. package/inventory-check.mjs +11 -8
  42. package/ios-dev.mjs +70 -3
  43. package/ios-upload.mjs +20 -15
  44. package/linux-artifacts.mjs +93 -0
  45. package/local-publish.mjs +224 -53
  46. package/local-qualify.mjs +5 -3
  47. package/local-runtime.mjs +5 -4
  48. package/mac-package-recipe.mjs +4 -1
  49. package/managed-cargo.mjs +47 -5
  50. package/managed-root-guard.mjs +34 -0
  51. package/mirror-root-artifact.mjs +18 -5
  52. package/native-helpers.mjs +355 -0
  53. package/ownership-class-rules.mjs +1 -1
  54. package/ownership-evidence.mjs +37 -13
  55. package/ownership-hooks.mjs +20 -59
  56. package/ownership-lockfiles.mjs +14 -1
  57. package/ownership-policy-bundle.mjs +3 -2
  58. package/ownership-staged.mjs +2 -1
  59. package/ownership.mjs +24 -0
  60. package/package-recipe-targets.mjs +84 -10
  61. package/package.json +4 -3
  62. package/pipeline-admission.mjs +4 -4
  63. package/pipeline-receipts.mjs +302 -302
  64. package/preflight.mjs +27 -4
  65. package/project-type.mjs +2 -2
  66. package/provenance-verify.mjs +115 -0
  67. package/publish-cargo.mjs +12 -9
  68. package/qa-admission-policy.json +10 -1
  69. package/recipe-build.mjs +106 -14
  70. package/release-artifact-path.mjs +341 -0
  71. package/release-config.mjs +1 -1
  72. package/release-invocation.mjs +8 -1
  73. package/release-qa-command.mjs +25 -9
  74. package/release-source-brand.mjs +17 -0
  75. package/release-source-identity.mjs +171 -0
  76. package/release.mjs +59 -22
  77. package/rightkit-versions.json +73 -47
  78. package/runtime-budgets.mjs +6 -2
  79. package/scan-digest.mjs +71 -5
  80. package/sign-updater.mjs +6 -3
  81. package/snapshot-broker.mjs +172 -0
  82. package/snapshot-check.mjs +312 -0
  83. package/snapshot-git.mjs +166 -0
  84. package/snapshot-request.mjs +363 -0
  85. package/snapshot-root-config.mjs +76 -0
  86. package/snapshot.mjs +969 -0
  87. package/source-split.mjs +73 -0
  88. package/stage-commands.mjs +60 -0
  89. package/stage-ledger.mjs +204 -0
  90. package/stage-sidecar-command.mjs +5 -1
  91. package/stage-sidecar.mjs +157 -27
  92. package/swap-helper-bundle.mjs +5 -2
  93. package/test-command.mjs +17 -4
  94. package/unit-gate.mjs +301 -0
  95. package/web-build.mjs +40 -6
  96. package/win-package-recipe.mjs +20 -7
package/unit-gate.mjs ADDED
@@ -0,0 +1,301 @@
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
+ /** `ci/known-red.json` hosts by `process.platform`; any other platform (Linux) has no known-red table. */
187
+ const KNOWN_RED_HOST = { darwin: "macos", win32: "windows" };
188
+
189
+ /**
190
+ * The ci/known-red.json entry that bars this unit from a compile, or null. A unit listed for the host platform is not compiled,
191
+ * packaged or qualified there; one that is headed to release is barred when it is listed for either host (a known-red unit is never
192
+ * published, AGENTS.md; scripts/ci/publish.mjs refuses it the same way). An entry is keyed by the crate name or `rust:<crate>`, the
193
+ * unit's directory or `npm:<directory>` (the ids scripts/ci/gate.mjs and publish.mjs use). A missing file lists nothing; an unreadable
194
+ * one refuses (a table that cannot be read is not an empty table).
195
+ */
196
+ export function knownRedEntry({ unit, root, platform, toRelease = false, readText = (file) => fs.readFileSync(file, "utf8") }) {
197
+ let table;
198
+ try { table = JSON.parse(readText(path.join(root, "ci", "known-red.json"))); }
199
+ catch (error) { if (error?.code === "ENOENT") return null; throw new Error(`ci/known-red.json cannot be read (${error?.message}); a known-red table that cannot be read is not an empty one`); }
200
+ const dir = unit.kind === "npm" ? `packages/${unit.name.slice("@rightkit/".length)}` : `crates/${unit.name}`;
201
+ const keys = [unit.name, `rust:${unit.name}`, `${unit.kind}:${unit.name}`, dir, `npm:${dir}`];
202
+ const hosts = toRelease ? Object.keys(table ?? {}) : [KNOWN_RED_HOST[platform]].filter(Boolean);
203
+ for (const host of hosts) {
204
+ const key = keys.find((k) => Object.hasOwn(table?.[host] ?? {}, k));
205
+ if (key !== undefined) {
206
+ const reason = table[host][key];
207
+ return { host, key, reason: typeof reason === "string" ? reason : JSON.stringify(reason) };
208
+ }
209
+ }
210
+ return null;
211
+ }
212
+
213
+ /** One invocation reruns all owed stages; reports are never read as admission evidence. */
214
+ /**
215
+ * `source` ({ checkout, commit, tree }) marks a run built from the door's snapshot of that commit (row 7.06a): `root` is then the
216
+ * snapshot, `commonDir` the checkout's, and the stages take their identity from the selection (local-publish.mjs sourceGit). `stageGuard`
217
+ * ({ start(stage), end(stage) } returning true or false) is the door's ledger verify around each stage (7.08); `envExtra` carries the
218
+ * Cargo output pin. Without `source` every behavior is the checkout route's, unchanged.
219
+ */
220
+ export async function runUnitGate({ selector, to, root, commonDir, commit, tree, executor, env, platform, check, source = null, stageGuard = null, envExtra = {}, loadOwner = () => import("./local-publish.mjs"), out = console.log, err = console.error, releaser = null, resolve = resolveUnit, writeReport, fetchReceipt = readBrokerReceipt, stageCommands = defaultStageCommands, readKnownRed }) {
221
+ const stages = stagesTo(to);
222
+ if (!stages) { err(`rightkit-gate: invalid unit stage ${to}`); return 2; }
223
+ let unit;
224
+ try { unit = resolve(selector, root); } catch (error) { err(`rightkit-gate: BLOCKED: ${error.message}`); return 1; }
225
+ // Before any compile (so --to compile|package|qualify|release; --to check compiles nothing): a unit recorded red is refused, not built.
226
+ if (stages.includes("compile")) {
227
+ try {
228
+ const red = knownRedEntry({ unit, root, platform, toRelease: stages.includes("release"), ...(readKnownRed ? { readText: readKnownRed } : {}) });
229
+ if (red) { err(`rightkit-gate: BLOCKED: ${selector} is listed in ci/known-red.json for ${red.host} (${red.key}: ${red.reason}); a known-red unit is not compiled, packaged, qualified or published. Fix the defect and remove the entry first`); return 1; }
230
+ } catch (error) { err(`rightkit-gate: BLOCKED: ${error.message}`); return 1; }
231
+ }
232
+ const scratch = platform === "win32" ? "D:\\scratch" : "/Volumes/D/scratch";
233
+ const bundle = path.join(scratch, `rk-unit-${randomBytes(12).toString("hex")}`);
234
+ const argv = ["--local", unit.kind === "npm" ? "--package" : "--crate", unit.name, "--workspace", root, "--qualify-only", "--bundle", bundle];
235
+ const runtimeDir = path.dirname(fileURLToPath(import.meta.url));
236
+ const runtimeFiles = [...fs.readdirSync(runtimeDir).filter((name) => name.endsWith(".mjs")).sort().map((name) => path.join(runtimeDir, name)), path.join(runtimeDir, "bin", "rightkit-gate.mjs")];
237
+ const runtime = hash(runtimeFiles.map((file) => `${path.relative(runtimeDir, file)}\0${hash(fs.readFileSync(file))}`).join("\n"));
238
+ const report = { schema: "rightkit.unit-gate-report/1", unit, commit, tree, runtime, executor: executor.kind, stages: {} };
239
+ const save = () => { if (writeReport) writeReport(report); };
240
+ let state, owner, artifact, currentStage, checkTier = null;
241
+ // A run headed to release has its compile and package broker jobs sent with a nonce bound to this commit, tree, unit and stage
242
+ // (RIGHTKIT_ATTEST_NONCE), so each job's signed receipt can be verified hosted for exactly that and nothing else.
243
+ const attested = stages.includes("release") && executor?.kind === "broker";
244
+ const stageEnv = async (stage, action) => {
245
+ const run = startRun({ commonDir, commit, tree, stage });
246
+ const nonce = attested && ATTESTED_STAGES.includes(stage) ? { RIGHTKIT_ATTEST_NONCE: qualificationNonce({ commit, tree, unit: `${unit.kind}:${unit.name}`, stage }) } : {};
247
+ try { return await action({ ...env, ...envExtra, ...nonce, ...run.env }); } finally { endRun(run); }
248
+ };
249
+ const receipts = {};
250
+ const collectReceipts = (stage, ids) => {
251
+ if (!attested || !ATTESTED_STAGES.includes(stage)) return;
252
+ // Every job of the stage is fetched; the stage owes a signed receipt for EVERY command it runs (hosted admission requires the same),
253
+ // so a missing one fails here, before any dispatch, naming it. Jobs that are none of the stage's commands are not carried.
254
+ const fetched = [...new Set(ids ?? [])].map((id) => fetchReceipt(id));
255
+ const { assigned, missing } = assignJobs(stageCommands({ unit, root, platform })[stage], fetched.map((r) => ({ id: r.id, payload: r.provenance.payload })));
256
+ if (missing.length) throw new Error(`the ${stage} stage's ${describeCommand(missing[0])} has no broker job receipt (${missing.length} required command(s) missing)`);
257
+ receipts[stage] = fetched.filter((r) => assigned.has(r.id));
258
+ };
259
+ const verifyArtifact = (result) => {
260
+ 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");
261
+ };
262
+ try {
263
+ for (const stage of stages) {
264
+ currentStage = stage;
265
+ if (stageGuard && !(await stageGuard.start(stage))) throw new Error(`RK_CHECK_ERROR: the snapshot is not the commit's bytes plus the ledger before the ${stage} stage`);
266
+ // 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.
267
+ if (stage === "check") { checkTier = stages.includes("qualify") ? "release" : "compile"; if (await check(checkTier) !== 0) throw new Error(`class A check (${checkTier} tier) failed`); }
268
+ else if (stage === "compile") {
269
+ owner = await loadOwner();
270
+ state = await stageEnv(stage, (runEnv) => owner.compileLocalUnit(unit.kind, argv, { env: runEnv, ...(source ? { source } : {}) }));
271
+ collectReceipts(stage, owner.localUnitCompileRequests?.(state));
272
+ } else if (stage === "package") {
273
+ artifact = await stageEnv(stage, (runEnv) => owner.packageLocalUnit(state, { env: runEnv }));
274
+ verifyArtifact(artifact);
275
+ collectReceipts(stage, artifact.brokerRequests);
276
+ } else if (stage === "qualify") {
277
+ if (checkTier !== "release" && await check("release") !== 0) throw new Error("release-tier check failed");
278
+ const qualified = await stageEnv(stage, (runEnv) => owner.qualifyLocalUnit(state, { env: runEnv }));
279
+ verifyArtifact(qualified);
280
+ if (!qualified.qualifiedOnly || !qualified.evidencePath || qualified.artifactSha256 !== artifact.artifactSha256) throw new Error("qualification did not bind the packaged artifact");
281
+ artifact = qualified;
282
+ }
283
+ let released = null;
284
+ if (stage === "release") {
285
+ // The qualification receipt the dispatcher carries to publish.yml: the hosted run is bound to these bytes and stages.
286
+ requireBrokerHost(executor); // a direct host has no broker receipts to carry: refuse for that reason, not for the missing receipts
287
+ const qualification = writeQualification({ report, artifact, unit, commit, tree, runtime, executor });
288
+ released = await stageEnv(stage, (runEnv) => (releaser ?? releaseUnit)({ unit, root, commit, executor, env: runEnv, artifact, qualification, platform, out }));
289
+ }
290
+ if (stageGuard && !(await stageGuard.end(stage))) throw new Error(`RK_CHECK_ERROR: the ${stage} stage's result covers no known bytes (a snapshot byte changed, or a write is outside the ledger)`);
291
+ report.stages[stage] = { ok: true, ...(stage === "package" || stage === "qualify" ? { artifactFile: artifact.artifactFile, artifactSha256: artifact.artifactSha256 } : {}), ...(receipts[stage] ? { receipts: receipts[stage] } : {}), ...(released ?? {}) };
292
+ save();
293
+ out(`rightkit-gate: [${stage}] ${unit.kind}:${unit.name} passed`);
294
+ }
295
+ return 0;
296
+ } catch (error) {
297
+ if (currentStage) { report.stages[currentStage] = { ok: false }; save(); }
298
+ err(`rightkit-gate: unit ${selector} stopped: ${error.message}`);
299
+ return 1;
300
+ } finally { if (state && owner) await owner.closeLocalUnit(state); }
301
+ }
package/web-build.mjs CHANGED
@@ -12,10 +12,18 @@
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";
26
+ import { doorContextFor } from "./source-split.mjs";
19
27
 
20
28
  export function parseWebBuildArgs(argv) {
21
29
  const opts = { config: defaultReleaseConfigFile(), dryRun: false };
@@ -41,23 +49,49 @@ export function planWebBuild(config) {
41
49
  return steps.map((step, index) => validateStep(step, `web.build[${index}]`));
42
50
  }
43
51
 
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 } = {}) {
52
+ /**
53
+ * 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
54
+ * bound to a commit: refused. `spawnSyncFn` runs git; `requireDoor` is gate-stages.mjs requireDoorRun.
55
+ */
56
+ export function webBuildDoorRefusal({ appRoot, env = process.env, spawnSyncFn = spawnSync, requireDoor = requireDoorRun }) {
57
+ // Inside a source split (the door's snapshot of a commit) the git context is the door run's checkout and commit, not the cwd's.
58
+ let context;
59
+ try { context = doorContextFor({ cwd: appRoot, env, run: spawnSyncFn }); } catch (error) { return `right-release web-build: ${error.code ?? "RK_SOURCE_SPLIT"}: ${error.message}`; }
60
+ if (!context) return "right-release web-build runs only inside a git repository, under a door run";
61
+ return requireDoor({ env, commonDir: context.commonDir, commit: context.commit, command: "right-release web-build" });
62
+ }
63
+
64
+ function doorError(refusal) { const error = new Error(refusal); error.code = "RK_DOOR"; return error; }
65
+
66
+ /**
67
+ * Run the steps in order under a live door run; `spawnSyncFn`, `requireDoor` and `log` are injectable. Returns the exit code. The
68
+ * door is checked here as well as in main: this is the only place a step is spawned.
69
+ */
70
+ export function runWebBuild(steps, { appRoot, env = process.env, dryRun = false, spawnSyncFn = spawnSync, requireDoor = requireDoorRun, log = console.log, door = buildLaunch } = {}) {
71
+ const refusal = webBuildDoorRefusal({ appRoot, env, spawnSyncFn, requireDoor });
72
+ if (refusal) throw doorError(refusal);
46
73
  for (const step of steps) {
47
74
  const invocation = resolveInvocation(step, { appRoot, baseEnv: env });
48
75
  log(`${dryRun ? "[dry-run] " : ""}${[invocation.cmd, ...invocation.args].join(" ")}`);
49
76
  if (dryRun) continue;
50
- const result = spawnSyncFn(invocation.cmd, invocation.args, { cwd: invocation.cwd, env: invocation.env, stdio: "inherit" });
77
+ // A web build is a build: through the one door (build-launch.mjs), a broker job on a managed host.
78
+ const target = door(invocation.cmd, invocation.args, { env: invocation.env, cwd: invocation.cwd, exists: existsSync });
79
+ const result = spawnSyncFn(target.command, target.args, { cwd: invocation.cwd, env: invocation.env, stdio: "inherit" });
51
80
  if (result.error) throw result.error;
52
81
  if (result.status !== 0) return result.status ?? 1;
53
82
  }
54
83
  return 0;
55
84
  }
56
85
 
57
- export async function main(argv = process.argv.slice(2)) {
86
+ export async function main(argv = process.argv.slice(2), { env = process.env, requireDoor = requireDoorRun, spawnSyncFn = spawnSync, loadConfig = loadAppConfig, log = console.log } = {}) {
58
87
  const opts = parseWebBuildArgs(argv);
59
- const { config, appRoot } = await loadAppConfig(opts.config);
60
- return runWebBuild(planWebBuild(config), { appRoot, dryRun: opts.dryRun });
88
+ // A web build is a compile: like right-release build, the direct entry runs only under a door run (rightkit-gate --to compile runs
89
+ // class A first). The door is checked before the config is imported, since a .mjs config is code; a dry run is no exception.
90
+ const appRoot = path.dirname(path.resolve(opts.config));
91
+ const refusal = webBuildDoorRefusal({ appRoot, env, spawnSyncFn, requireDoor });
92
+ if (refusal) throw doorError(refusal);
93
+ const { config } = await loadConfig(opts.config);
94
+ return runWebBuild(planWebBuild(config), { appRoot, env, dryRun: opts.dryRun, spawnSyncFn, requireDoor, log });
61
95
  }
62
96
 
63
97
  if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
@@ -20,6 +20,7 @@ import path from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { spawnSync } from "./background-process.mjs";
22
22
  import { verifyNsisEmbeddedBinary } from "./nsis-payload.mjs";
23
+ import { resolveArtifactPath, splitArtifactContext } from "./release-artifact-path.mjs";
23
24
  import { signingReceiptPath } from "./signing-receipts.mjs";
24
25
  import { patchTauriBundleType } from "./tauri-bundle-marker.mjs";
25
26
 
@@ -198,7 +199,9 @@ export function defaultWinPackageDeps({ appRoot, target, env }) {
198
199
  }
199
200
 
200
201
  /** Where a produced artifact really is: the declared path, else the managed artifact directory (same rule as release.mjs). */
201
- function locate(declared, { env, fs }) {
202
+ function locate(declared, { env, fs, split = false }) {
203
+ // A door split (row 7.04c, R-T2): the declared path is already the bound target's; there is no artifact-directory fallback for it.
204
+ if (split) return declared;
202
205
  if (fs.existsSync(declared)) return declared;
203
206
  const managed = env.RIGHT_RELEASE_ARTIFACT_DIR;
204
207
  if (managed) {
@@ -219,6 +222,15 @@ export function runWinPackageRecipe({ appRoot, config = {}, target, deps = {} }
219
222
  const base = defaultWinPackageDeps({ appRoot: plan.appRoot, target, env });
220
223
  const d = { ...base, ...deps, env, fs: { ...base.fs, ...(deps.fs ?? {}) } };
221
224
  const { fs } = d;
225
+ // A door split has no target directory in its snapshot: the plan's `<app>/src-tauri/target/...` paths mean the bound target (R-T1, R-T3), where the
226
+ // raw EXE is deleted before the build, signed in place, bundled and verified. Outside a split this is the identity.
227
+ const artifactPaths = d.artifactPaths ?? splitArtifactContext({ cwd: plan.appRoot, env, target });
228
+ const split = artifactPaths.mode === "split";
229
+ const placed = (declared) => {
230
+ if (!split) return declared;
231
+ const relative = path.relative(plan.appRoot, declared).replaceAll("\\", "/");
232
+ return resolveArtifactPath(relative.startsWith("..") || path.isAbsolute(relative) ? declared : relative, artifactPaths);
233
+ };
222
234
  const dry = d.dryRun === true;
223
235
  const phases = [];
224
236
  const phase = (name, work) => {
@@ -244,15 +256,16 @@ export function runWinPackageRecipe({ appRoot, config = {}, target, deps = {} }
244
256
 
245
257
  if (d.platform !== "win32" && !dry) throw new WinPackageRecipeError("build", `the Windows package recipe must run on Windows, not ${d.platform}`);
246
258
 
247
- let raw = plan.rawExe;
259
+ const rawExe = placed(plan.rawExe);
260
+ let raw = rawExe;
248
261
  const sidecars = [...plan.sidecars];
249
262
 
250
263
  phase("build", () => {
251
- if (!dry) fs.rmSync(plan.rawExe, { force: true });
264
+ if (!dry) fs.rmSync(rawExe, { force: true });
252
265
  tauri(["build", "--no-bundle", "--target", plan.triple], quiet);
253
266
  if (dry) return;
254
- raw = locate(plan.rawExe, { env, fs });
255
- if (!fs.existsSync(raw)) throw new Error(`tauri build did not produce the raw EXE: ${plan.rawExe}`);
267
+ raw = locate(rawExe, { env, fs, split });
268
+ if (!fs.existsSync(raw)) throw new Error(`tauri build did not produce the raw EXE: ${rawExe}`);
256
269
  for (const sidecar of sidecars) if (!fs.existsSync(sidecar)) throw new Error(`declared sidecar does not exist: ${sidecar}`);
257
270
  });
258
271
 
@@ -270,14 +283,14 @@ export function runWinPackageRecipe({ appRoot, config = {}, target, deps = {} }
270
283
  return { sha256: byFile.get(path.resolve(raw).toLowerCase()), sidecars: Object.fromEntries(sidecars.map((file) => [file, byFile.get(path.resolve(file).toLowerCase())])) };
271
284
  });
272
285
 
273
- const installerBase = plan.installerFile;
286
+ const installerBase = placed(plan.installerFile);
274
287
  let installer = installerBase;
275
288
  phase("bundle", () => {
276
289
  if (!dry) for (const stale of [installerBase, `${installerBase}.sig`, `${installerBase}.zip`, `${installerBase}.zip.sig`]) fs.rmSync(stale, { force: true });
277
290
  const override = { bundle: { createUpdaterArtifacts: false, windows: { nsis: { installMode: plan.perMachine ? "perMachine" : "currentUser" } } } };
278
291
  tauri(["bundle", "--bundles", "nsis", "--target", plan.triple, "--config", JSON.stringify(override)], quiet);
279
292
  if (dry) return;
280
- installer = locate(installerBase, { env, fs });
293
+ installer = locate(installerBase, { env, fs, split });
281
294
  if (!fs.existsSync(installer)) throw new Error(`tauri bundle did not produce the installer: ${installerBase}`);
282
295
  });
283
296