@rightkit/release 0.6.10 → 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 (69) hide show
  1. package/README.md +7 -5
  2. package/admission-files.mjs +3 -0
  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 +13 -9
  7. package/bin/rightkit-gate.mjs +762 -345
  8. package/broker-pins.mjs +152 -0
  9. package/build-release.mjs +80 -27
  10. package/cargo-ledger.mjs +62 -0
  11. package/cargo-target-shim.mjs +98 -26
  12. package/cli/right-release.mjs +28 -4
  13. package/clippy-equivalence.mjs +202 -0
  14. package/clippy-route.mjs +543 -0
  15. package/committed-source.mjs +39 -5
  16. package/crate-workspaces.mjs +87 -0
  17. package/create-mac-updater.mjs +14 -2
  18. package/deterministic-scans.mjs +196 -21
  19. package/development.mjs +13 -4
  20. package/direct-receipt.mjs +135 -0
  21. package/door-policy.mjs +10 -0
  22. package/door.mjs +6 -6
  23. package/freshness-census.mjs +257 -0
  24. package/gate-stages.mjs +76 -10
  25. package/hook-retirement.mjs +65 -0
  26. package/init-command.mjs +1 -1
  27. package/inventory-check.mjs +11 -8
  28. package/ios-upload.mjs +20 -15
  29. package/local-publish.mjs +77 -41
  30. package/local-qualify.mjs +5 -3
  31. package/local-runtime.mjs +5 -4
  32. package/mac-package-recipe.mjs +4 -1
  33. package/mirror-root-artifact.mjs +18 -5
  34. package/ownership-class-rules.mjs +1 -1
  35. package/ownership-evidence.mjs +37 -13
  36. package/ownership-hooks.mjs +20 -59
  37. package/ownership-lockfiles.mjs +14 -1
  38. package/ownership-policy-bundle.mjs +3 -2
  39. package/ownership-staged.mjs +2 -1
  40. package/ownership.mjs +24 -0
  41. package/package-recipe-targets.mjs +35 -6
  42. package/package.json +1 -1
  43. package/pipeline-admission.mjs +4 -4
  44. package/preflight.mjs +27 -4
  45. package/publish-cargo.mjs +4 -3
  46. package/recipe-build.mjs +38 -14
  47. package/release-artifact-path.mjs +341 -0
  48. package/release-invocation.mjs +8 -1
  49. package/release-qa-command.mjs +25 -9
  50. package/release-source-brand.mjs +17 -0
  51. package/release-source-identity.mjs +171 -0
  52. package/release.mjs +44 -17
  53. package/rightkit-versions.json +62 -42
  54. package/runtime-budgets.mjs +6 -2
  55. package/scan-digest.mjs +71 -5
  56. package/sign-updater.mjs +6 -3
  57. package/snapshot-broker.mjs +172 -0
  58. package/snapshot-check.mjs +12 -6
  59. package/snapshot-request.mjs +207 -5
  60. package/snapshot.mjs +123 -10
  61. package/source-split.mjs +73 -0
  62. package/stage-commands.mjs +2 -2
  63. package/stage-ledger.mjs +204 -0
  64. package/stage-sidecar-command.mjs +5 -1
  65. package/stage-sidecar.mjs +100 -20
  66. package/swap-helper-bundle.mjs +5 -2
  67. package/unit-gate.mjs +45 -3
  68. package/web-build.mjs +6 -4
  69. package/win-package-recipe.mjs +20 -7
@@ -0,0 +1,152 @@
1
+ // The gate's own record of the broker it trusts on this host (Sage decision A, item 7 row 7.21; AR 256-258: pins come from the
2
+ // consumer's own enrollment, "never from the receipt, request or project").
3
+ //
4
+ // record `<managed data root>/pins/broker-<platform>.json`, `{schema, platform, host, keyId, publicKey, brokerDigest, enrolledAt}`,
5
+ // outside every repository and snapshot, written only by `enrollBrokerPins` (an explicit operation; a second enrollment
6
+ // for the same platform is a rotation and says so).
7
+ // fallback RightKit's own checkout only: `ci/rk-pipeline.json` `attestation.<platform>`, read from the revision of the RUNNING gate
8
+ // (`git show <gate repository HEAD>:ci/rk-pipeline.json`), never from the selected commit, the snapshot or the working tree.
9
+ //
10
+ // Never read here: the broker's control root, its runtime manifest, `attestation/key.json` (it holds the SECRET key), an environment
11
+ // variable, a flag, the snapshot or a receipt. A missing record in any repository but RightKit refuses the snapshot compile; nothing
12
+ // is enrolled automatically.
13
+ import crypto from "node:crypto";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { spawnSync } from "./background-process.mjs";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ export const PINS_SCHEMA = "rightkit.broker-pins/1";
20
+ export const PINS_DIR = "pins";
21
+ export const PIPELINE_FILE = "ci/rk-pipeline.json";
22
+ export const PIPELINE_SCHEMA = "rk-pipeline.v1";
23
+
24
+ export class BrokerPinsError extends Error {
25
+ constructor(code, message) { super(message); this.name = "BrokerPinsError"; this.code = code; }
26
+ }
27
+ const fail = (code, message) => { throw new BrokerPinsError(code, message); };
28
+ const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
29
+ const HEX = /^(?:[0-9a-f]{2})+$/;
30
+
31
+ /** The pin record of one platform under the managed data root. */
32
+ export const pinsFile = ({ dataRoot, platform = process.platform }) => {
33
+ if (!dataRoot || !path.isAbsolute(dataRoot)) fail("RK_BROKER_PINS_ROOT", "the managed data root must be an absolute path");
34
+ if (!["darwin", "win32"].includes(platform)) fail("RK_BROKER_PINS_PLATFORM", `${platform} is not a broker host (darwin and win32 only)`);
35
+ return path.join(dataRoot, PINS_DIR, `broker-${platform}.json`);
36
+ };
37
+
38
+ /** `{host, keyId, publicKey, brokerDigest}` or a throw naming what is wrong. The key must be an uncompressed P-256 point whose sha256 is the key id. */
39
+ export function validatePins(pins, label = "the broker pins") {
40
+ const bad = (why) => fail("RK_BROKER_PINS_INVALID", `${label}: ${why}`);
41
+ if (pins === null || typeof pins !== "object" || Array.isArray(pins)) bad("not an object");
42
+ const { host, keyId, publicKey, brokerDigest } = pins;
43
+ if (typeof host !== "string" || !host.trim()) bad("host is missing");
44
+ if (typeof publicKey !== "string" || !HEX.test(publicKey) || publicKey.length !== 130 || !publicKey.startsWith("04")) bad("publicKey is not an uncompressed SEC1 P-256 point in lowercase hex");
45
+ if (typeof keyId !== "string" || keyId !== sha256(Buffer.from(publicKey, "hex"))) bad("keyId is not the sha256 of the public key");
46
+ if (typeof brokerDigest !== "string" || !/^[0-9a-f]{64}$/.test(brokerDigest)) bad("brokerDigest is not a sha256 in lowercase hex");
47
+ return { host, keyId, publicKey, brokerDigest };
48
+ }
49
+
50
+ const isLink = (meta) => meta.isSymbolicLink();
51
+
52
+ /** The enrolled record, or null when none exists. A link, a non-file, another platform's record or a malformed one is a refusal, never null. */
53
+ export function readEnrolledPins({ dataRoot, platform = process.platform }) {
54
+ const file = pinsFile({ dataRoot, platform });
55
+ let meta;
56
+ try { meta = fs.lstatSync(file); } catch (e) { if (e.code === "ENOENT") return null; throw e; }
57
+ if (isLink(meta) || !meta.isFile()) fail("RK_BROKER_PINS_INVALID", `${file} is not a regular file`);
58
+ const dir = fs.lstatSync(path.dirname(file));
59
+ if (isLink(dir) || !dir.isDirectory()) fail("RK_BROKER_PINS_INVALID", `${path.dirname(file)} is not a real directory`);
60
+ if (process.platform !== "win32" && (meta.mode & 0o022 || dir.mode & 0o022)) fail("RK_BROKER_PINS_INVALID", `${file} or its directory is writable by group or others`);
61
+ let record;
62
+ try { record = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { fail("RK_BROKER_PINS_INVALID", `${file} is not JSON (${e.message})`); }
63
+ if (record?.schema !== PINS_SCHEMA) fail("RK_BROKER_PINS_INVALID", `${file} is not a ${PINS_SCHEMA} record`);
64
+ if (record.platform !== platform) fail("RK_BROKER_PINS_INVALID", `${file} is the record of ${record.platform}, not ${platform}`);
65
+ return { ...validatePins(record, file), source: "enrolled", file };
66
+ }
67
+
68
+ const git = (spawn, cwd, args) => {
69
+ const r = spawn("git", args, { cwd, encoding: "utf8" });
70
+ if (r.error || r.status !== 0) return null;
71
+ return String(r.stdout ?? "").trim();
72
+ };
73
+
74
+ /**
75
+ * RightKit's own pins, from the revision of the running gate: the repository that holds `gateFile` and its HEAD commit, only when
76
+ * that repository IS the checkout being compiled (RightKit itself). Null when the gate is not run from a RightKit checkout.
77
+ */
78
+ export function readGateRevisionPins({ gateFile, checkout, platform = process.platform, spawn = spawnSync, realpath = (p) => fs.realpathSync.native(p) }) {
79
+ const here = path.dirname(gateFile);
80
+ const top = git(spawn, here, ["rev-parse", "--show-toplevel"]);
81
+ if (!top) return null;
82
+ let same = false;
83
+ try { same = realpath(top) === realpath(checkout); } catch { same = false; }
84
+ if (!same) return null;
85
+ const revision = git(spawn, here, ["rev-parse", "--verify", "HEAD^{commit}"]);
86
+ if (!revision || !/^[0-9a-f]{40,64}$/.test(revision)) fail("RK_BROKER_PINS_INVALID", "the running gate's revision cannot be read");
87
+ const blob = spawn("git", ["show", `${revision}:${PIPELINE_FILE}`], { cwd: here, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
88
+ if (blob.error || blob.status !== 0) return null;
89
+ let pipeline;
90
+ try { pipeline = JSON.parse(String(blob.stdout)); } catch (e) { fail("RK_BROKER_PINS_INVALID", `${PIPELINE_FILE} at the gate's revision ${revision.slice(0, 12)} is not JSON (${e.message})`); }
91
+ if (pipeline?.schema !== PIPELINE_SCHEMA) fail("RK_BROKER_PINS_INVALID", `${PIPELINE_FILE} at the gate's revision is not ${PIPELINE_SCHEMA}`);
92
+ const entry = pipeline.attestation?.[platform];
93
+ if (!entry) return null;
94
+ return { ...validatePins(entry, `${PIPELINE_FILE} (${platform}) at the gate's revision ${revision.slice(0, 12)}`), source: "rk-pipeline", revision };
95
+ }
96
+
97
+ /**
98
+ * The pins this gate verifies broker receipts against: the enrolled record, else (RightKit only) the running gate's own revision.
99
+ * Throws RK_BROKER_PINS_MISSING when neither exists: an app repository without an enrollment compiles nothing through a broker.
100
+ */
101
+ export function loadBrokerPins({ dataRoot, platform = process.platform, gateFile, checkout, spawn, realpath }) {
102
+ const enrolled = readEnrolledPins({ dataRoot, platform });
103
+ if (enrolled) return enrolled;
104
+ const fallback = gateFile && checkout ? readGateRevisionPins({ gateFile, checkout, platform, spawn, realpath }) : null;
105
+ if (fallback) return fallback;
106
+ return fail("RK_BROKER_PINS_MISSING", `no broker enrollment for ${platform} (${pinsFile({ dataRoot, platform })}), and this is not the RightKit checkout whose gate revision carries ${PIPELINE_FILE} pins; enroll the host's broker explicitly, the gate never enrolls one itself`);
107
+ }
108
+
109
+ /**
110
+ * The explicit enroll operation. Values come from the caller (the output of `rustright-broker --generate-attestation-key` and the
111
+ * installed runtime's `brokerDigest`); nothing is read from the broker, its key file or the environment. An existing record is
112
+ * replaced only with `rotate: true`.
113
+ */
114
+ export function enrollBrokerPins({ dataRoot, platform = process.platform, host, keyId, publicKey, brokerDigest, rotate = false, now = () => new Date() }) {
115
+ const pins = validatePins({ host, keyId, publicKey, brokerDigest }, "the enrollment");
116
+ const file = pinsFile({ dataRoot, platform });
117
+ const dir = path.dirname(file);
118
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
119
+ const meta = fs.lstatSync(dir);
120
+ if (isLink(meta) || !meta.isDirectory()) fail("RK_BROKER_PINS_INVALID", `${dir} is not a real directory`);
121
+ let existing = null;
122
+ try { existing = readEnrolledPins({ dataRoot, platform }); } catch (e) { if (!rotate) throw e; }
123
+ if (existing && !rotate) fail("RK_BROKER_PINS_EXISTS", `${file} already enrolls key ${existing.keyId.slice(0, 12)}; replacing it is a rotation (pass rotate: true)`);
124
+ const record = { schema: PINS_SCHEMA, platform, ...pins, enrolledAt: now().toISOString(), ...(existing ? { rotatedFrom: { keyId: existing.keyId, brokerDigest: existing.brokerDigest } } : {}) };
125
+ const tmp = `${file}.${crypto.randomUUID()}.tmp`;
126
+ fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { flag: "wx", mode: 0o600 });
127
+ try { fs.renameSync(tmp, file); } finally { fs.rmSync(tmp, { force: true }); }
128
+ return { file, record, rotated: Boolean(existing) };
129
+ }
130
+
131
+ /** `node broker-pins.mjs enroll --platform <darwin|win32> --data-root <dir> --host H --key-id K --public-key P --broker-digest D [--rotate]` or `show`. */
132
+ export function main(argv, { out = (m) => process.stdout.write(`${m}\n`), err = (m) => process.stderr.write(`${m}\n`) } = {}) {
133
+ const [command, ...rest] = argv;
134
+ const opt = (name) => { const i = rest.indexOf(name); return i < 0 ? undefined : rest[i + 1]; };
135
+ try {
136
+ const dataRoot = opt("--data-root"); const platform = opt("--platform") ?? process.platform;
137
+ if (command === "enroll") {
138
+ const r = enrollBrokerPins({ dataRoot, platform, host: opt("--host"), keyId: opt("--key-id"), publicKey: opt("--public-key"), brokerDigest: opt("--broker-digest"), rotate: rest.includes("--rotate") });
139
+ out(`enrolled ${r.record.platform} ${r.record.host} key ${r.record.keyId} in ${r.file}${r.rotated ? " (rotation)" : ""}`);
140
+ return 0;
141
+ }
142
+ if (command === "show") {
143
+ const pins = readEnrolledPins({ dataRoot, platform });
144
+ out(pins ? JSON.stringify(pins) : "no enrollment");
145
+ return pins ? 0 : 1;
146
+ }
147
+ err("usage: broker-pins.mjs enroll|show --platform <darwin|win32> --data-root <dir> [--host H --key-id K --public-key P --broker-digest D [--rotate]]");
148
+ return 2;
149
+ } catch (e) { err(`${e.code ?? "error"}: ${e.message}`); return 1; }
150
+ }
151
+
152
+ if (process.argv[1] && (() => { try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })()) process.exit(main(process.argv.slice(2)));
package/build-release.mjs CHANGED
@@ -26,8 +26,10 @@ import { formatAdmission, formatNotRun, runAdmission } from "./admission.mjs";
26
26
  import { sccacheRefusal } from "./admission-host.mjs";
27
27
  import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
28
28
  import { fileURLToPath } from "node:url";
29
- import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
29
+ import { dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
30
+ import { assertInsideSource, assertReleaseSource, gitRootOf, isSnapshotSource, qaAdmissionSourceOptions, resolveReleaseSource, sourceRootOf } from "./release-source-identity.mjs";
30
31
  import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
32
+ import { ArtifactPathError, assertSplitCommandPaths, boundTargetsEvidence, releaseCommandSteps, resolveArtifactPath as resolveSplitArtifactPath, splitArtifactContext } from "./release-artifact-path.mjs";
31
33
  import { admitSourceQa } from "./release-qa-audit.mjs";
32
34
  import { devtoolsEvidence, resolveDevtoolsAllowance } from "./devtools-policy.mjs";
33
35
  import { commandOutputPortable, releaseEnvironment, resolveReleaseLayout, runBuildStateMachine, verifySealedRelease, watchProgress } from "./release-state.mjs";
@@ -44,7 +46,6 @@ import { collectSwiftToolVersions, planSwiftBuild } from "./swift-build-context.
44
46
  import { planIosNativeBuild, resolveIosBuildContext } from "./ios-build-context.mjs";
45
47
  import { packageCommandOptional, requiredBuildExecutables, resolveProjectType, targetUsesMsix } from "./project-type.mjs";
46
48
  import { buildPathPrefix, collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
47
- import { assertCleanSource } from "./source-gate.mjs";
48
49
  import { acquireHeavyWorkSlot, heavyCommandEnvironment, summarizePreBrokerWaits, terminateProcessTree } from "./heavy-command.mjs";
49
50
  import { processGroupCpuSeconds } from "./process-liveness.mjs";
50
51
  import { cancelAndReapManagedRequest, firstManagedRequestId, releaseProgressLimits } from "./progress-control.mjs";
@@ -80,7 +81,7 @@ const ANDROID_PIPELINE_FINGERPRINT = createHash("sha256")
80
81
  .slice(0, 16);
81
82
 
82
83
  /** Assemble preflight inputs from the app's own files (mirrors release.mjs). */
83
- function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativeLayout, target, pinnedCargoLocks = [] }) {
84
+ function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativeLayout, target, pinnedCargoLocks = [], releaseSource = null, sourceRoot = repoRoot, artifactPaths = null }) {
84
85
  let version;
85
86
  try {
86
87
  version = JSON.parse(readFileSync(path.join(appRoot, "package.json"), "utf8")).version;
@@ -88,12 +89,15 @@ function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativ
88
89
  version = undefined;
89
90
  }
90
91
  // An iOS target is built from its Xcode project and local packages only; the ownership scan covers just those roots.
91
- const ownershipScope = preflightOwnershipScope({ repoRoot, appRoot, platform, target, nativeAssembly: config?.nativeAssembly });
92
+ // In a split the scope is the snapshot's (the commit's layout); the preflight reads the checkout for git, the sealed vault and the evidence.
93
+ const ownershipScope = preflightOwnershipScope({ repoRoot: sourceRoot, appRoot, platform, target, nativeAssembly: config?.nativeAssembly });
92
94
  return collectPreflight({
93
95
  platform,
94
96
  ownershipScope,
95
97
  appRoot,
96
98
  repoRoot,
99
+ releaseSource,
100
+ splitTargets: artifactPaths,
97
101
  app: config.app,
98
102
  configPath,
99
103
  // The version check is the doctor's job: by the time `build` runs, seal
@@ -140,10 +144,24 @@ if (resumeFrom && !["prepackage", "sign", "package", "post-sign", "harden"].incl
140
144
  const nativeReal = (file) => { try { return realpathSync.native(file); } catch { return file; } };
141
145
  const configPath = nativeReal(path.resolve(configName));
142
146
  const invocationRoot = path.dirname(configPath);
143
- const repoRoot = git(invocationRoot, ["rev-parse", "--show-toplevel"]);
144
- assertPrimaryReleaseCheckout(repoRoot);
145
- const relativeConfig = path.relative(nativeReal(repoRoot), nativeReal(configPath)).replaceAll("\\", "/");
146
- if (relativeConfig.startsWith("../")) fail("release config must live inside the Git repository");
147
+ // Source identity (release-source-identity.mjs, item 7 row 7.04, decision 2 Q2). Without RK_GATE_CHECKOUT the source is the checkout's HEAD,
148
+ // proven exactly as before (primary worktree, branch-attached HEAD, clean status). Inside a door run it is the SELECTED COMMIT's objects
149
+ // plus the gate's verified snapshot: the checkout may be dirty, ahead or detached, and no HEAD, status or branch of it is read.
150
+ // A split that cannot be proven is refused (never a fallback to the checkout checks).
151
+ let releaseSource;
152
+ try { releaseSource = resolveReleaseSource({ cwd: process.env.RK_GATE_CHECKOUT ? process.cwd() : invocationRoot }); }
153
+ catch (error) { fail(error.code === "RK_SOURCE_NO_REPOSITORY" ? `git rev-parse --show-toplevel failed: ${error.message}` : `${error.code ?? "RK_SOURCE_IDENTITY"}: ${error.message}`); }
154
+ const splitSource = isSnapshotSource(releaseSource);
155
+ // The checkout: every object read and every output (`.right-release`: vault, state, locks, sealed) belongs to it (GC:31, GC:65). The
156
+ // source root is where the config, the build inputs and the QA plan are read: the snapshot in a split, else the checkout itself.
157
+ const repoRoot = gitRootOf(releaseSource);
158
+ const sourceRoot = sourceRootOf(releaseSource);
159
+ try { assertReleaseSource(releaseSource, { commandId: "right-release build", skipCleanCheck: true }); } catch (error) { fail(error.message); }
160
+ try { assertInsideSource(releaseSource, configPath, "the release config"); } catch (error) { fail(error.message); }
161
+ const relativeConfig = path.relative(nativeReal(sourceRoot), nativeReal(configPath)).replaceAll("\\", "/");
162
+ if (relativeConfig.startsWith("../")) fail(splitSource ? "release config must live inside the snapshot" : "release config must live inside the Git repository");
163
+ // A split reads no dirty-checkout condition: the snapshot is the commit's bytes, proven by the door at the start and end of the stage.
164
+ const sourceProblems = (inputs, commitId) => (splitSource ? [] : dirtyBuildInputs(repoRoot, inputs, commitId));
147
165
  const appRoot = path.dirname(configPath);
148
166
  const layout = resolveReleaseLayout({ repoRoot, configPath });
149
167
  const vaultRoot = layout.vaultRoot;
@@ -182,20 +200,17 @@ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
182
200
 
183
201
  try {
184
202
  throwIfInterrupted();
185
- const commit = git(repoRoot, ["rev-parse", "HEAD"]);
203
+ const commit = releaseSource.commit;
186
204
  // A phase-resume never reads unrelated source. Its later build-input gate
187
205
  // remains strict, but requiring a globally clean mono-workspace here would
188
206
  // turn an already-built, signed installer into a needless full rebuild.
189
- if (!resumeFrom) {
190
- assertCleanSource({
191
- status: git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { trim: false }), // untrimmed: a leading space is the first record's X column (#782)
192
- commandId: "right-release build",
193
- });
194
- }
207
+ // The checkout route runs the clean-source gate here (R1, byte for byte as before); a split re-proves its door run instead and reads
208
+ // no status (R2: a split that is no longer proven refuses, it never falls back).
209
+ try { assertReleaseSource(releaseSource, { commandId: "right-release build", skipPrimaryCheckout: true, skipCleanCheck: Boolean(resumeFrom) }); } catch (error) { fail(error.message); }
195
210
  mkdirSync(path.join(vaultRoot, "locks"), { recursive: true });
196
211
  lock = acquireLock(path.join(vaultRoot, "locks", `${platform}.lock.json`));
197
212
  const shortCommit = commit.slice(0, 8);
198
- const dirtyConfig = dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit);
213
+ const dirtyConfig = sourceProblems({ include: [relativeConfig], required: [relativeConfig] }, commit);
199
214
  if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
200
215
  const config = await importReleaseConfig(configPath, { query: `commit=${commit}` });
201
216
  if (!config?.app || !config?.version) fail("release config must expose app and version");
@@ -224,11 +239,25 @@ try {
224
239
  : null;
225
240
  const nativeIos = iosContext?.nativeIos ?? false;
226
241
  const nativeLayout = iosContext ? iosContext.nativeLayout : resolveNativeCargoLayout({ appRoot, config });
242
+ // Row 7.04c. In a door split the snapshot has no target directory: (R-T4) no step the app owns may name a target-relative path, refused here, before
243
+ // anything builds; (R-T1) each Cargo manifest's target is bound once, now, through the published resolver. Checkout mode does neither.
244
+ let artifactPaths = null;
245
+ if (splitSource) {
246
+ try {
247
+ assertSplitCommandPaths(releaseCommandSteps({ target, config, root: appRoot }), splitArtifactContext({ cwd: appRoot, env: process.env, nativeLayout, config, target, snapshotRoot: sourceRoot, resolve: false }));
248
+ artifactPaths = splitArtifactContext({
249
+ cwd: appRoot, nativeLayout, config, target, snapshotRoot: sourceRoot,
250
+ env: { ...process.env, RIGHT_RELEASE_ARTIFACT_DIR: resolveArtifactDir({ app: config.app, version: config.version, managedRoot: resolveManagedRoot({ env: process.env }) }) },
251
+ });
252
+ } catch (error) { if (error instanceof ArtifactPathError) fail(error.message); throw error; }
253
+ }
227
254
  if (platform === "win" && target.signingContract === "windows-raw-exe-authenticode-before-nsis-v1") {
228
255
  assertNsisInPlaceUpgradeContract(appRoot, target.nsisUpgradeContract);
229
256
  }
230
- const { requiredInputs, buildInputs, cargoLockPaths: pinnedCargoLocks } = resolveReleaseBuildInputs({ repoRoot, appRoot, configPath, config, target });
231
- const dirtyInputs = dirtyBuildInputs(repoRoot, buildInputs, commit);
257
+ const { requiredInputs, buildInputs, cargoLockPaths: pinnedCargoLocks } = resolveReleaseBuildInputs({ repoRoot: sourceRoot, appRoot, configPath, config, target });
258
+ // R5: in a split every input resolves inside the snapshot (`hashInputs` below hashes the snapshot's files); the dirty-input gate is the checkout route's.
259
+ for (const file of requiredInputs) try { assertInsideSource(releaseSource, file, "a release build input"); } catch (error) { fail(error.message); }
260
+ const dirtyInputs = sourceProblems(buildInputs, commit);
232
261
  if (dirtyInputs.length > 0) fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
233
262
  const hasPackageJson = existsSync(path.join(appRoot, "package.json"));
234
263
  // iOS records the Xcode toolchain (and xcodegen when a project.yml is named) beside the Node and Rust tools.
@@ -316,7 +345,7 @@ try {
316
345
  // Preflight BEFORE any compile. These preconditions used to be discovered one
317
346
  // per build cycle, minutes deep, with errors that named a symptom instead of a
318
347
  // cause. Blocking problems stop here, in seconds, naming the fix.
319
- const preflight = buildPreflight({ config, configPath, appRoot, repoRoot: layout.repoRoot, platform, nativeLayout, target, pinnedCargoLocks });
348
+ const preflight = buildPreflight({ config, configPath, appRoot, repoRoot: layout.repoRoot, platform, nativeLayout, target, pinnedCargoLocks, releaseSource, sourceRoot, artifactPaths });
320
349
  const preflightBlockers = preflightFailures(preflight);
321
350
  if (preflightBlockers.length > 0) {
322
351
  fail(`preflight found ${preflightBlockers.length} blocking problem(s):\n${formatPreflight(preflightBlockers)}`);
@@ -331,7 +360,8 @@ try {
331
360
  }
332
361
  // Hardening step 4: source QA is recorded, not enforced. A missing or rejected receipt warns loudly and the outcome
333
362
  // is sealed into the manifest (`qa.status`) for `right-release qa audit`. Dev lanes (install-app --dev) never reach this file.
334
- const qaAdmission = admitSourceQa({ repoRoot: layout.repoRoot, releaseId, platform, configName: path.relative(process.cwd(), configPath) || path.basename(configPath), releaseConfigSha256: hashFile(configPath) });
363
+ // A split compares the source QA receipt with the SELECTED commit (its tree, repository identity and lockfile closure from its objects), not HEAD.
364
+ const qaAdmission = admitSourceQa({ repoRoot: layout.repoRoot, releaseId, platform, configName: path.relative(process.cwd(), configPath) || path.basename(configPath), releaseConfigSha256: hashFile(configPath), ...qaAdmissionSourceOptions(releaseSource) });
335
365
 
336
366
  // A tool being INSTALLED is not the same as it being the one PATH picks. The
337
367
  // vendored-OpenSSL build shells out to `perl`, and under Git Bash/MSYS that
@@ -343,6 +373,7 @@ try {
343
373
  ...releaseEnvironment({ root: vaultRoot, cacheRoot: sharedCacheRoot, platform, architecture: cacheIdentity.architecture, app: config.app, cacheKey, kind: "release", appRoot, mode: cacheMode }),
344
374
  ...(pathPrefix ? { PATH: `${pathPrefix}${path.delimiter}${process.env.PATH ?? ""}` } : {}),
345
375
  RIGHT_RELEASE_REPO_ROOT: layout.repoRoot,
376
+ ...(splitSource ? { RIGHT_RELEASE_SOURCE_ROOT: sourceRoot } : {}),
346
377
  RIGHT_RELEASE_APP_ROOT: layout.appRoot,
347
378
  RIGHTKIT_MANAGED_ROOT: resolveManagedRoot({ env: process.env }),
348
379
  RIGHT_RELEASE_ARTIFACT_DIR: resolveArtifactDir({ app: config.app, version: config.version, managedRoot: resolveManagedRoot({ env: process.env }) }),
@@ -353,6 +384,9 @@ try {
353
384
  delete env[name];
354
385
  }
355
386
  }
387
+ // A split's Cargo target is the one the gate bound and handed this process (row 7.09, R-T1): Cache V2's shared target does not replace it, or
388
+ // the bound target the seal reads would not be the one Cargo wrote.
389
+ if (splitSource && !managedCargoTarget && typeof process.env.CARGO_TARGET_DIR === "string" && path.isAbsolute(process.env.CARGO_TARGET_DIR)) env.CARGO_TARGET_DIR = process.env.CARGO_TARGET_DIR;
356
390
  if (cacheMode !== "shared") delete env.RIGHT_RELEASE_CACHE_OWNER;
357
391
  if (cacheMode === "shared" && env.RIGHT_RELEASE_CACHE_OWNER !== "rightkit-v2") fail("shared cache ownership token was not configured");
358
392
  if (cacheMode !== "shared" && !commandExists("sccache")) delete env.RUSTC_WRAPPER;
@@ -495,6 +529,9 @@ try {
495
529
  // cost ~5.9 GB of warm broker intermediates. The bridge's brokerManaged
496
530
  // flag already knows to leave a real directory untouched instead.
497
531
  if (swiftPlan) { /* SwiftPM: no Rust target link */ }
532
+ // A split writes no link into the snapshot (a link is never a ledger entry, decision 2 Q1 rule 2): Cargo writes the cache target
533
+ // directly (CARGO_TARGET_DIR) and the seal resolves `src-tauri/target/...` paths against it (sealRelease managedCargoTarget).
534
+ else if (splitSource) { /* no target link in the snapshot */ }
498
535
  else if (cacheMode === "shared" || managedCargoTarget) targetBridge.ensure();
499
536
  else {
500
537
  const linked = ensureLegacyTargetLink({ targetLink, cacheTarget, fs: { existsSync, realpathSync, renameSync, symlinkSync } });
@@ -538,7 +575,7 @@ try {
538
575
  });
539
576
  },
540
577
  seal: async ({ sealedDir }) => {
541
- sealRelease({ configRoot: appRoot, managedCargoTarget, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot, rightkitRegistryFields: rightKitResolution ? registryManifestFields(rightKitResolution.snapshot, RELEASE_TOOL_VERSION) : undefined });
578
+ sealRelease({ configRoot: appRoot, managedCargoTarget, artifactPaths, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot, rightkitRegistryFields: rightKitResolution ? registryManifestFields(rightKitResolution.snapshot, RELEASE_TOOL_VERSION) : undefined, splitSealSource: splitSource ? { commit: releaseSource.commit, tree: releaseSource.tree, snapshotAppRoot: appRoot, checkoutAppRoot: releaseSource.checkoutAppRoot } : null });
542
579
  verifySealedRelease(sealedDir);
543
580
  if (cacheMode === "shared") markCacheEntrySuccessful({ layout: sharedLayout });
544
581
  checkpoint(stateRoot, "sealed");
@@ -565,8 +602,8 @@ async function admit() {
565
602
  let commit;
566
603
  let config;
567
604
  try {
568
- commit = git(invocationRoot, ["rev-parse", "HEAD"]);
569
- const dirtyConfig = dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit);
605
+ commit = releaseSource.commit;
606
+ const dirtyConfig = sourceProblems({ include: [relativeConfig], required: [relativeConfig] }, commit);
570
607
  if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
571
608
  try { config = await importReleaseConfig(configPath, { query: `commit=${commit}` }); } catch (error) { fail(`release config could not be loaded (${configPath}): ${error instanceof Error ? error.message : String(error)}`); }
572
609
  } catch (error) {
@@ -579,7 +616,7 @@ async function admit() {
579
616
  && existsSync(path.join(repoRoot, ".right-release", "sealed", `${config.app}-${config.version}-${commit.slice(0, 8)}`, platformName, "release-manifest.json"));
580
617
  const reuse = Boolean(resumeFrom) || Boolean(sealed);
581
618
  const result = await runAdmission({
582
- root: appRoot, repoRoot, configPath, config: config ?? {}, platform, commit, skipChecks,
619
+ root: appRoot, repoRoot: sourceRoot, releaseSource, configPath, config: config ?? {}, platform, commit, skipChecks,
583
620
  tiers: reuse ? ["files", "host"] : ["files", "host", "network"],
584
621
  registry: async (ctx) => {
585
622
  const inputs = ctx.buildInputs();
@@ -631,7 +668,7 @@ function sqlCipherFeatures(text) {
631
668
  .filter((value) => /sqlcipher|openssl/i.test(value));
632
669
  }
633
670
 
634
- function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot, rightkitRegistryFields }) {
671
+ function sealRelease({ configRoot, managedCargoTarget, artifactPaths = null, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot, rightkitRegistryFields, splitSealSource = null }) {
635
672
  // Every app's release config names installer/updater artifacts
636
673
  // relative to its resolved native target. On a broker-managed host that
637
674
  // directory is never populated (the broker owns CARGO_TARGET_DIR and
@@ -642,7 +679,11 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, n
642
679
  const targetPrefix = "src-tauri/target/";
643
680
  // A Cargo-less iOS app has no native layout and no managed cargo target: only the default prefix is ever compared.
644
681
  const effectiveTargetPrefix = nativeLayout?.targetPrefix ?? targetPrefix;
645
- const resolveArtifactPath = (file) => {
682
+ const resolveArtifactPath = artifactPaths?.mode === "split" ? (file) => {
683
+ // Row 7.04c (R-T2): a split's declared artifact is read in place in the bound target (or the snapshot, or the managed artifact directory),
684
+ // through the one resolver; the prefix remap below is the checkout route's.
685
+ try { return resolveSplitArtifactPath(file, artifactPaths); } catch (error) { if (error instanceof ArtifactPathError) fail(error.message); throw error; }
686
+ } : (file) => {
646
687
  const normalized = file.replaceAll(path.win32.sep, "/");
647
688
  if (managedCargoTarget && normalized.startsWith(targetPrefix)) {
648
689
  return path.join(managedCargoTarget, normalized.slice(targetPrefix.length));
@@ -793,6 +834,10 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, n
793
834
  app: config.app,
794
835
  version: config.version,
795
836
  commit,
837
+ // A split seal names the selected commit AND its tree and says its bytes came from the door's snapshot (decision 2 Q2 item 5).
838
+ ...(splitSealSource ? { tree: splitSealSource.tree, source: "snapshot" } : {}),
839
+ // The Cargo targets this split bound (R-T1); the door cross-checks them against its own resolution (RK_SPLIT_TARGET_DRIFT).
840
+ ...(artifactPaths?.mode === "split" ? { boundTargets: boundTargetsEvidence(artifactPaths) } : {}),
796
841
  ownershipEvidence,
797
842
  // The exact registry resolution (identities, digests, registries, policy revision, observation time, observed latest)
798
843
  // this release was built against. Upload and resume consume it; neither resolves again.
@@ -814,7 +859,15 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, n
814
859
  if (present.length !== signingIdentity.receiptInputs.length) fail("missing current-build macOS signing or notarization receipt");
815
860
  validateNotarizationReceipt({ receiptPath: signingIdentity.receiptInputs[1], archive: resolveArtifactPath(target.notarize.file) });
816
861
  }
817
- return present.map((file) => ({ file, sha256: hashFile(file) }));
862
+ // A split records each receipt relative to a base the checkout reproduces (its app root), never the snapshot's: the snapshot's bytes are
863
+ // replaced at the next sync. A receipt written INSIDE the snapshot cannot be re-read later, so it is refused (configure sign.receipt under the state root).
864
+ const recorded = (file) => {
865
+ if (!splitSealSource) return file;
866
+ const rel = (base) => path.relative(base, file).replaceAll("\\", "/");
867
+ if (!rel(splitSealSource.snapshotAppRoot).startsWith("../") && rel(splitSealSource.snapshotAppRoot) !== "..") fail(`signing receipt ${file} is inside the snapshot, which the next sync replaces; write it under the state root (${stateRoot}) so the seal can name it`);
868
+ return rel(splitSealSource.checkoutAppRoot);
869
+ };
870
+ return present.map((file) => ({ file: recorded(file), sha256: hashFile(file) }));
818
871
  })() } : null,
819
872
  files,
820
873
  routes,
@@ -0,0 +1,62 @@
1
+ // The gate-owned ledger of every call the runtime cargo wrapper decides (item 7 rows 7.10 and 7.24, Sage decision C).
2
+ //
3
+ // The wrapper (cargo-target-shim.mjs) appends one record BEFORE it acts (`pending`) and one after (`ran`, `verified`, `unverified` or
4
+ // `refused`), to `<ledger dir>/<scan token>.jsonl`. The gate (runScans) reads the file of each scan after it ended and fails the scan
5
+ // on anything but a completed, successful call: a refused call fails its unit even when the test swallowed cargo's exit status, and a
6
+ // call that never completed (the wrapper was killed, or the record was never finished) is unverified, never skipped. Records the gate
7
+ // cannot attribute to a scan (the scan token was cleared) fail the run as a row of their own.
8
+ import crypto from "node:crypto";
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+
12
+ export const LEDGER_SCHEMA = "rightkit.cargo-ledger/1";
13
+ export const SCAN_TOKEN_ENV = "RIGHTKIT_CARGO_SCAN";
14
+ export const UNSCOPED = "unscoped";
15
+ /** Statuses that are a completed success: a direct call that ran, a broker call whose signed receipt verified. */
16
+ export const OK_STATUSES = Object.freeze(["ran", "verified"]);
17
+
18
+ /** The token a scan's calls are filed under: stable per scan id, never the id itself. */
19
+ export const scanTokenOf = (scanId) => crypto.createHash("sha256").update(String(scanId)).digest("hex").slice(0, 16);
20
+
21
+ const tokenFile = (dir, token) => path.join(dir, `${token}.jsonl`);
22
+ const validToken = (token) => (typeof token === "string" && /^[0-9a-f]{16}$/.test(token) ? token : UNSCOPED);
23
+
24
+ /** Appends one record. Throws when it cannot be written: a call that cannot be recorded does not run. */
25
+ export function appendLedger(dir, token, entry) {
26
+ if (!dir || !path.isAbsolute(dir)) throw new Error("the cargo ledger directory is not set");
27
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
28
+ const fd = fs.openSync(tokenFile(dir, validToken(token)), fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW ?? 0), 0o600);
29
+ try { fs.writeSync(fd, `${JSON.stringify({ schema: LEDGER_SCHEMA, at: new Date().toISOString(), ...entry })}\n`); } finally { fs.closeSync(fd); }
30
+ }
31
+
32
+ /**
33
+ * The calls filed under `token`, one per call id (a later record replaces an earlier one), and the problems found: an unreadable
34
+ * line, and every call that is not a completed success.
35
+ */
36
+ export function readLedger(dir, token) {
37
+ const file = tokenFile(dir, validToken(token));
38
+ let text = "";
39
+ try { text = fs.readFileSync(file, "utf8"); } catch (e) { if (e.code === "ENOENT") return { entries: [], problems: [] }; throw e; }
40
+ const byId = new Map(); const problems = [];
41
+ for (const line of text.split("\n")) {
42
+ if (!line.trim()) continue;
43
+ let entry;
44
+ try { entry = JSON.parse(line); } catch { problems.push(`an unreadable ledger record in ${path.basename(file)}`); continue; }
45
+ if (entry?.schema !== LEDGER_SCHEMA || typeof entry.id !== "string") { problems.push(`a malformed ledger record in ${path.basename(file)}`); continue; }
46
+ byId.set(entry.id, { ...(byId.get(entry.id) ?? {}), ...entry });
47
+ }
48
+ const entries = [...byId.values()];
49
+ for (const e of entries) {
50
+ if (OK_STATUSES.includes(e.status)) continue;
51
+ const call = `cargo ${(e.argv ?? []).join(" ")}`.trim();
52
+ problems.push(e.status === "pending" ? `${call} (from ${e.cwd}) never completed: the wrapper recorded it and no outcome, so it is unverified` : `${call} (from ${e.cwd}) was ${e.status}${e.code ? ` (${e.code})` : ""}${e.message ? `: ${e.message}` : ""}`);
53
+ }
54
+ return { entries, problems };
55
+ }
56
+
57
+ /** Ledger files nobody claimed: the scan token was cleared or invented by the code under test. */
58
+ export function unclaimedLedgers(dir, claimedTokens) {
59
+ let names = [];
60
+ try { names = fs.readdirSync(dir); } catch (e) { if (e.code === "ENOENT") return []; throw e; }
61
+ return names.filter((n) => n.endsWith(".jsonl")).map((n) => n.slice(0, -".jsonl".length)).filter((t) => !claimedTokens.has(t));
62
+ }