@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
@@ -0,0 +1,341 @@
1
+ // One resolver for every declared artifact path (item 7 row 7.04c; signed/item7-sage-decisions-3.md R-T1 to R-T5; docs/GATE-CONTRACT.md
2
+ // "Commit-only build" and "Output directory & cache").
3
+ //
4
+ // A release config names its outputs relative to the app: `src-tauri/target/release/bundle/...`, a root `App.dmg`. Checkout mode reaches
5
+ // them through the app root (and the managed artifact directory), exactly as it always did. A door split has no `target/` in its
6
+ // snapshot (a link into the editable checkout is refused, GC:24, and a copy is the rightkit#924 bridge), so the same declared path must
7
+ // mean the Cargo target the gate bound for that manifest. This module is the one place that decides which:
8
+ //
9
+ // checkout today's `resolveReleaseArtifactPath`, byte for byte: `<app root>/<declared>` when it exists, else
10
+ // `RIGHT_RELEASE_ARTIFACT_DIR/<basename>` when that exists, else `<app root>/<declared>`.
11
+ // split R-T2:
12
+ // 1. A relative path starting with a target prefix maps to `<bound target>/<rest>`. No fallback to the snapshot, the checkout or the artifact directory.
13
+ // 2. An absolute path is accepted only inside a bound target or inside RIGHT_RELEASE_ARTIFACT_DIR (realpath; case-folded on Windows).
14
+ // Anything else is RK_SPLIT_TARGET_PATH, a path into the editable checkout other than its bound target included.
15
+ // 3. A relative path with a `..` segment is refused.
16
+ // 4. Any other relative path resolves to `<snapshot app root>/P` when it exists, then `RIGHT_RELEASE_ARTIFACT_DIR/<basename>` when
17
+ // that exists, else `<snapshot app root>/P`. It never resolves to the checkout app root: a stale DMG left in the editable checkout
18
+ // must never be sealed.
19
+ // 5. Outputs written under rule 4 into the snapshot are Q1 `generated` ledger entries (stage-ledger.mjs); the door refuses a
20
+ // non-ignored one at the end of the stage.
21
+ //
22
+ // The bound target of a Cargo manifest is `resolveTargetRoot(<snapshot manifest>)` (cargo-target.mjs, the published resolver), resolved
23
+ // once. On Linux it equals the absolute CARGO_TARGET_DIR the gate resolved in the checkout and passed (row 7.09); on Mac and Dell it is the
24
+ // snapshot carrier's answer. A manifest whose target cannot be resolved is RK_SPLIT_TARGET_UNRESOLVED: there is never a snapshot-relative
25
+ // `target/` fallback (R7). The door cross-checks the recorded targets against its own resolution (RK_SPLIT_TARGET_DRIFT).
26
+ import { existsSync, realpathSync } from "node:fs";
27
+ import path from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+ import { resolveTargetRoot as cargoResolveTargetRoot } from "./cargo-target.mjs";
30
+ import { expandRecipeBuildCommands } from "./recipe-build.mjs";
31
+ import { SourceSplitError, readSourceSplit } from "./source-split.mjs";
32
+
33
+ export const SPLIT_TARGET_PATH_CODE = "RK_SPLIT_TARGET_PATH";
34
+ export const SPLIT_TARGET_UNRESOLVED_CODE = "RK_SPLIT_TARGET_UNRESOLVED";
35
+ export const SPLIT_TARGET_DRIFT_CODE = "RK_SPLIT_TARGET_DRIFT";
36
+
37
+ const DEFAULT_MANIFEST = "src-tauri/Cargo.toml";
38
+ const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
39
+
40
+ /** A declared artifact path or bound target a split refuses. `code` is one of the three codes above. */
41
+ export class ArtifactPathError extends Error {
42
+ constructor(code, message, problems = []) { super(`${code}: ${message}`); this.name = "ArtifactPathError"; this.code = code; this.problems = problems; }
43
+ }
44
+
45
+ const posix = (value) => String(value).replaceAll("\\", "/");
46
+ const folded = (value) => (process.platform === "win32" ? value.toLowerCase() : value);
47
+ const isAbsoluteDeclared = (value) => path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) || /^[\\/]{2}/.test(value);
48
+
49
+ /** The real path of the deepest existing ancestor of `file`, with the missing tail appended: comparable before the file exists. */
50
+ function canonical(file) {
51
+ const absolute = path.resolve(file);
52
+ let head = absolute;
53
+ const tail = [];
54
+ for (;;) {
55
+ try { return folded(path.join(realpathSync.native(head), ...tail.reverse())); } catch { /* not there yet */ }
56
+ const parent = path.dirname(head);
57
+ if (parent === head) return folded(absolute);
58
+ tail.push(path.basename(head));
59
+ head = parent;
60
+ }
61
+ }
62
+
63
+ const insideCanonical = (child, parent) => {
64
+ const relative = path.relative(parent, child);
65
+ return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
66
+ };
67
+
68
+ /** True when this process is a child of a door run (`RK_GATE_CHECKOUT` names the checkout): artifacts resolve through the split. */
69
+ export const isSplitArtifactEnv = (env = process.env) => typeof env?.RK_GATE_CHECKOUT === "string" && env.RK_GATE_CHECKOUT !== "";
70
+
71
+ /** `<dir>/` form, posix, relative: the prefix a declared path starts with. */
72
+ function prefixOf(appRoot, dir) {
73
+ const relative = posix(path.relative(appRoot, dir)).replace(/\/+$/, "");
74
+ return relative && relative !== "." && !relative.startsWith("..") ? `${relative}/` : "";
75
+ }
76
+
77
+ /**
78
+ * Every Cargo manifest the app declares, with the target prefix its declared paths use (R-T1): the native layout's prefixes, each recipe
79
+ * `cargoTargetRoot` manifest, and the default `<cargoDir>/target/`. `[{ prefix, manifest }]`, longest prefix first, one row per prefix.
80
+ */
81
+ export function declaredTargetManifests({ appRoot, nativeLayout = null, target = null, exists = existsSync } = {}) {
82
+ const rows = [];
83
+ const add = (prefix, manifest) => { if (prefix && !rows.some((row) => row.prefix === prefix)) rows.push({ prefix, manifest: path.resolve(manifest) }); };
84
+ if (nativeLayout?.manifestPath) for (const prefix of nativeLayout.targetPrefixes ?? [nativeLayout.targetPrefix]) add(posix(prefix ?? "").replace(/\/*$/, "/").replace(/^\/$/, ""), nativeLayout.manifestPath);
85
+ // A declarative recipe: `cargoTargetRoot: true` is `<tauriCwd>/src-tauri/Cargo.toml`, a string is an app-relative Cargo.toml.
86
+ const targets = Array.isArray(target) ? target : [target];
87
+ for (const spec of targets.flatMap((entry) => [entry?.winPackage, entry?.macPackage])) {
88
+ if (!spec || typeof spec !== "object") continue;
89
+ const explicit = spec.cargoTargetRoot;
90
+ if (explicit !== true && typeof explicit !== "string") continue;
91
+ const cwd = typeof spec.tauriCwd === "string" && spec.tauriCwd !== "." ? posix(spec.tauriCwd) : "";
92
+ const manifestRel = typeof explicit === "string" ? posix(explicit) : path.posix.join(cwd, DEFAULT_MANIFEST);
93
+ const manifest = path.resolve(appRoot, manifestRel);
94
+ add(prefixOf(appRoot, path.join(path.dirname(manifest), "target")), manifest);
95
+ }
96
+ // The historical default: an app with `src-tauri/Cargo.toml` and no declared layout still addresses `src-tauri/target/`.
97
+ const fallback = path.resolve(appRoot, DEFAULT_MANIFEST);
98
+ if (!rows.length && exists(fallback)) add("src-tauri/target/", fallback);
99
+ return rows.sort((left, right) => right.prefix.length - left.prefix.length);
100
+ }
101
+
102
+ /**
103
+ * The artifact-path context of this process.
104
+ * `{ mode: "checkout", appRoot, artifactDir, env }` (no door run: nothing changes)
105
+ * `{ mode: "split", snapshotAppRoot, boundTargets: [{ prefix, manifest, dir }], artifactDir, ... }`
106
+ * `cwd` is the app root (the snapshot's in a split). `nativeLayout` is resolveNativeCargoLayout's answer (null for a Cargo-less app), `target`
107
+ * the UNEXPANDED `config.targets[platform]` (the recipe keys are gone after expansion; a list of targets binds each). `resolve: false` skips the Cargo calls (the prefixes
108
+ * alone, for the command scan). `resolveTargetRoot`, `exists` and `snapshotRoot` are injectable.
109
+ * Throws ArtifactPathError RK_SPLIT_TARGET_UNRESOLVED when a manifest's target cannot be resolved or resolves inside the snapshot, and
110
+ * RK_SPLIT_TARGET_DRIFT when it differs from the absolute CARGO_TARGET_DIR the gate handed this process.
111
+ */
112
+ export function splitArtifactContext({
113
+ cwd, env = process.env, nativeLayout = null, config = {}, target = null, resolveTargetRoot = cargoResolveTargetRoot,
114
+ exists = existsSync, snapshotRoot, resolve = true,
115
+ } = {}) {
116
+ void config;
117
+ if (!cwd) throw new TypeError("splitArtifactContext requires cwd (the app root)");
118
+ const appRoot = path.resolve(cwd);
119
+ const artifactDir = env.RIGHT_RELEASE_ARTIFACT_DIR ? path.resolve(env.RIGHT_RELEASE_ARTIFACT_DIR) : null;
120
+ if (!isSplitArtifactEnv(env)) return Object.freeze({ mode: "checkout", appRoot, snapshotAppRoot: null, boundTargets: Object.freeze([]), prefixes: Object.freeze([]), artifactDir, env, exists });
121
+ const declared = declaredTargetManifests({ appRoot, nativeLayout, target, exists });
122
+ let root = snapshotRoot ? path.resolve(snapshotRoot) : null;
123
+ if (!root) { try { root = readSourceSplit({ env, cwd: appRoot })?.snapshot ?? null; } catch (error) { if (!(error instanceof SourceSplitError)) throw error; } }
124
+ const snapshot = canonical(root ?? appRoot);
125
+ const pinned = typeof env.CARGO_TARGET_DIR === "string" && path.isAbsolute(env.CARGO_TARGET_DIR) ? canonical(env.CARGO_TARGET_DIR) : null;
126
+ const cache = new Map();
127
+ const boundTargets = declared.map(({ prefix, manifest }) => {
128
+ if (!resolve) return Object.freeze({ prefix, manifest, dir: null });
129
+ if (!cache.has(manifest)) cache.set(manifest, bindManifest({ manifest, appRoot, snapshot, pinned, resolveTargetRoot, exists }));
130
+ return Object.freeze({ prefix, manifest, dir: cache.get(manifest) });
131
+ });
132
+ return Object.freeze({
133
+ mode: "split", appRoot, snapshotAppRoot: appRoot, snapshotRoot: root ?? appRoot, boundTargets: Object.freeze(boundTargets),
134
+ prefixes: Object.freeze(declared.map((row) => row.prefix)), artifactDir, env, exists,
135
+ });
136
+ }
137
+
138
+ function bindManifest({ manifest, appRoot, snapshot, pinned, resolveTargetRoot, exists }) {
139
+ if (!exists(manifest)) throw new ArtifactPathError(SPLIT_TARGET_UNRESOLVED_CODE, `the Cargo manifest ${manifest} is not in the snapshot, so its target directory cannot be bound; a split never falls back to a snapshot-relative target/`);
140
+ let dir;
141
+ try { dir = resolveTargetRoot(manifest); } catch (error) {
142
+ throw new ArtifactPathError(SPLIT_TARGET_UNRESOLVED_CODE, `cargo metadata for ${manifest} reported no target directory (${error.message}); a split never falls back to a snapshot-relative target/ (the snapshot carrier must serve cargo metadata for a split child)`);
143
+ }
144
+ if (typeof dir !== "string" || !path.isAbsolute(dir)) throw new ArtifactPathError(SPLIT_TARGET_UNRESOLVED_CODE, `cargo metadata for ${manifest} did not report an absolute target directory`);
145
+ const real = canonical(dir);
146
+ if (insideCanonical(real, snapshot) || insideCanonical(real, canonical(appRoot))) {
147
+ throw new ArtifactPathError(SPLIT_TARGET_UNRESOLVED_CODE, `the Cargo target of ${manifest} resolves inside the snapshot (${dir}); the gate binds a split's target outside it (CARGO_TARGET_DIR, row 7.09) and never to a snapshot-relative target/`);
148
+ }
149
+ if (pinned && real !== pinned) throw new ArtifactPathError(SPLIT_TARGET_DRIFT_CODE, `the Cargo target of ${manifest} is ${dir}, not the CARGO_TARGET_DIR the gate bound for this stage`);
150
+ return path.resolve(dir);
151
+ }
152
+
153
+ /**
154
+ * The context for a RightKit subcommand that gets only its argv and cwd (create-mac-updater, sign-updater, mirror-root-artifact, win-package-recipe):
155
+ * it reads the release config in `cwd`, so the target prefixes are the app's own, for every platform's target. Checkout mode needs no config.
156
+ */
157
+ export async function loadSplitArtifactContext({ cwd = process.cwd(), env = process.env, configName, ...inject } = {}) {
158
+ if (!isSplitArtifactEnv(env)) return splitArtifactContext({ cwd, env, ...inject });
159
+ const { defaultReleaseConfigFile, importReleaseConfig } = await import("./release-config.mjs");
160
+ const { resolveNativeCargoLayout, nativeCargoDeclared } = await import("./native-cargo-layout.mjs");
161
+ const appRoot = path.resolve(cwd);
162
+ let config = {};
163
+ try { config = await importReleaseConfig(path.resolve(appRoot, configName ?? defaultReleaseConfigFile(appRoot)), { lenient: true }); } catch { /* an app with no readable config binds the default manifest only */ }
164
+ let nativeLayout = null;
165
+ try { if (nativeCargoDeclared({ appRoot, config })) nativeLayout = resolveNativeCargoLayout({ appRoot, config }); } catch { nativeLayout = null; }
166
+ return splitArtifactContext({ cwd: appRoot, env, nativeLayout, config, target: Object.values(config?.targets ?? {}), ...inject });
167
+ }
168
+
169
+ const insideBound = (file, ctx) => ctx.boundTargets.find((bound) => bound.dir && insideCanonical(canonical(file), canonical(bound.dir)));
170
+
171
+ /**
172
+ * The file a declared artifact path means (R-T2). Checkout mode is the historical rule; see the module header for the split rules.
173
+ * Throws ArtifactPathError RK_SPLIT_TARGET_PATH for a split path the rules refuse.
174
+ */
175
+ export function resolveArtifactPath(declared, ctx, { write = false } = {}) {
176
+ if (typeof declared !== "string" || !declared) throw new TypeError("a declared artifact path is required");
177
+ if (!ctx || (ctx.mode !== "split" && ctx.mode !== "checkout")) throw new TypeError("resolveArtifactPath requires a context from splitArtifactContext");
178
+ if (ctx.mode === "checkout") {
179
+ const local = path.resolve(ctx.appRoot, declared);
180
+ if (ctx.exists(local)) return local;
181
+ const managed = ctx.env.RIGHT_RELEASE_ARTIFACT_DIR;
182
+ if (managed) {
183
+ const candidate = path.join(path.resolve(managed), path.basename(declared));
184
+ if (ctx.exists(candidate)) return candidate;
185
+ }
186
+ return local;
187
+ }
188
+ const normalized = posix(declared);
189
+ if (isAbsoluteDeclared(declared)) {
190
+ const absolute = path.resolve(declared);
191
+ if (insideBound(absolute, ctx)) return absolute;
192
+ if (ctx.artifactDir && insideCanonical(canonical(absolute), canonical(ctx.artifactDir))) return absolute;
193
+ throw new ArtifactPathError(SPLIT_TARGET_PATH_CODE, `${declared} is neither inside a bound Cargo target (${ctx.boundTargets.map((bound) => bound.dir).filter(Boolean).join(", ") || "none"}) nor inside RIGHT_RELEASE_ARTIFACT_DIR; a split reads declared artifacts only there, never from the editable checkout or an unrelated directory`);
194
+ }
195
+ if (normalized.split("/").includes("..")) throw new ArtifactPathError(SPLIT_TARGET_PATH_CODE, `${declared} contains a .. segment; a split resolves a declared artifact path only below the snapshot app root or a bound target`);
196
+ const relative = path.posix.normalize(normalized).replace(/^\.\//, "");
197
+ for (const bound of ctx.boundTargets) {
198
+ const prefix = bound.prefix.replace(/\/+$/, "");
199
+ if (relative === prefix || relative.startsWith(`${prefix}/`)) {
200
+ if (!bound.dir) throw new ArtifactPathError(SPLIT_TARGET_UNRESOLVED_CODE, `${declared} is below ${prefix}/, whose Cargo target was not resolved`);
201
+ return path.join(bound.dir, relative.slice(prefix.length).replace(/^\/+/, ""));
202
+ }
203
+ }
204
+ const local = path.resolve(ctx.snapshotAppRoot, relative);
205
+ // A writer (a file the step is about to create) never lands in the managed artifact directory by accident of a stale copy there.
206
+ if (write) return local;
207
+ if (ctx.exists(local)) {
208
+ // A link out of the snapshot would be a checkout read in disguise.
209
+ if (!insideCanonical(canonical(local), canonical(ctx.snapshotRoot ?? ctx.snapshotAppRoot))) throw new ArtifactPathError(SPLIT_TARGET_PATH_CODE, `${declared} resolves outside the snapshot (${local}); a split never reads the editable checkout`);
210
+ return local;
211
+ }
212
+ if (ctx.artifactDir) {
213
+ const candidate = path.join(ctx.artifactDir, path.basename(relative));
214
+ if (ctx.exists(candidate)) return candidate;
215
+ }
216
+ return local;
217
+ }
218
+
219
+ /**
220
+ * Where a step writes a declared output (create-mac-updater's tarball, a DMG): the same refusals and target mapping as `resolveArtifactPath`, but a
221
+ * relative non-target path is always `<snapshot app root>/P`. Checkout mode is `path.resolve(app root, declared)`, as those writers always did.
222
+ */
223
+ export function resolveArtifactWritePath(declared, ctx) {
224
+ if (ctx?.mode !== "split") return path.resolve(ctx?.appRoot ?? process.cwd(), declared);
225
+ return resolveArtifactPath(declared, ctx, { write: true });
226
+ }
227
+
228
+ /** What the sealed manifest records so the door can cross-check it (`boundTargets`, R-T1): `[{ prefix, manifest, dir }]`, manifest posix-relative to the app. */
229
+ export function boundTargetsEvidence(ctx) {
230
+ if (ctx?.mode !== "split") return [];
231
+ return ctx.boundTargets.map((bound) => ({ prefix: bound.prefix, manifest: posix(path.relative(ctx.snapshotAppRoot, bound.manifest)), dir: bound.dir }));
232
+ }
233
+
234
+ /**
235
+ * The door's cross-check (R-T1, 7.08): every target the child recorded must be the one the door resolved itself (`expected`, absolute
236
+ * directories: on Linux the gate's 7.09 resolution, on Mac and Dell the verified receipts' `outputDir`s). Returns problems, [] when equal.
237
+ * A recorded list that is missing or empty while the door expected a target is a problem too: the child's value is never trusted alone.
238
+ */
239
+ export function crossCheckBoundTargets({ recorded, expected }) {
240
+ const want = [...new Set((expected ?? []).filter(Boolean).map((dir) => canonical(dir)))];
241
+ const got = Array.isArray(recorded) ? recorded.map((entry) => (entry?.dir ? canonical(entry.dir) : null)) : [];
242
+ const problems = [];
243
+ if (want.length && !got.length) problems.push(`the sealed manifest records no bound Cargo target, but the gate bound ${want.join(", ")}`);
244
+ for (const [index, dir] of got.entries()) {
245
+ if (!dir) problems.push(`boundTargets[${index}] records no directory`);
246
+ else if (!want.includes(dir)) problems.push(`boundTargets[${index}] is ${recorded[index].dir}, not the directory the gate bound (${want.join(", ") || "none"})`);
247
+ }
248
+ return problems;
249
+ }
250
+
251
+ // ---- R-T4: no target-prefixed relative path in a command a RightKit entry does not own -------------------------------------------
252
+
253
+ const RIGHTKIT_BINS = new Set(["right-release", "rightkit-qa"]);
254
+ const RIGHTKIT_SCRIPTS = new Set(["recipe-build.mjs", "create-mac-updater.mjs", "sign-updater.mjs", "mirror-root-artifact.mjs", "win-package-recipe.mjs", "mac-package-recipe.mjs", "right-release.mjs"]);
255
+ const binName = (token) => path.posix.basename(posix(token)).replace(/\.(?:cmd|exe|ps1)$/i, "");
256
+
257
+ /** True for `right-release ...`, `rightkit-qa ...`, `pnpm exec right-release ...` and `node <this package's own script> ...`: commands RightKit resolves itself. */
258
+ export function isRightKitEntry(command) {
259
+ const cmd = typeof command === "string" ? command : command?.cmd;
260
+ const args = typeof command === "string" ? [] : (command?.args ?? []).map(String);
261
+ if (typeof cmd !== "string") return false;
262
+ if (RIGHTKIT_BINS.has(binName(cmd))) return true;
263
+ if (["pnpm", "npx"].includes(binName(cmd)) && args[0] === "exec" && RIGHTKIT_BINS.has(binName(args[1] ?? ""))) return true;
264
+ if (["pnpm", "npx"].includes(binName(cmd)) && RIGHTKIT_BINS.has(binName(args[0] ?? ""))) return true;
265
+ if (binName(cmd) === "node" && args[0]) {
266
+ const script = path.resolve(args[0]);
267
+ if (!RIGHTKIT_SCRIPTS.has(path.basename(script))) return false;
268
+ const dir = path.dirname(script);
269
+ return dir === TOOL_ROOT || dir === path.join(TOOL_ROOT, "cli") || /[\\/]@rightkit[\\/]release(?:[\\/]cli)?$/.test(dir);
270
+ }
271
+ return false;
272
+ }
273
+
274
+ const escapeRegExp = (value) => String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
275
+
276
+ /** The first target-prefixed relative path in `text` (at token start, or after `=`, a quote, `:`, `,` or whitespace; `\` read as `/`), or null. */
277
+ export function findTargetPrefixedPath(text, prefixes) {
278
+ const value = posix(text);
279
+ for (const prefix of prefixes) {
280
+ const stem = escapeRegExp(prefix.replace(/\/+$/, ""));
281
+ const match = new RegExp(`(?:^|[=:"',\\s])(?:\\./)?(${stem}(?:/[^\\s"',]*)?)(?=$|[\\s"',])`).exec(value);
282
+ if (match) return match[1];
283
+ }
284
+ return null;
285
+ }
286
+
287
+ const COMMAND_KEYS = ["prePackage", "package", "postBuild", "postSign", "preSeal", "publish"];
288
+
289
+ /**
290
+ * Every command step a release runs for `target` (R-T4): prePackage, package, postBuild, postSign, preSeal and publish (each one command or a
291
+ * list), the native package hook, and the prepare / postBuild steps inside a declarative recipe's `recipe-build` plan (the wrapper itself is a
292
+ * RightKit entry; the steps it runs are the app's). `[{ name, cmd, args?, env? }]`. A recipe plan that cannot be read throws: fail closed.
293
+ */
294
+ export function releaseCommandSteps({ target, config = {}, root = process.cwd() }) {
295
+ const steps = [];
296
+ const add = (name, command) => {
297
+ if (!command || typeof command !== "object") return;
298
+ for (const [index, step] of expandRecipeBuildCommands(command, { root }).entries()) steps.push({ name: index === 0 ? name : `${name} (recipe step ${index})`, ...step });
299
+ };
300
+ for (const key of COMMAND_KEYS) {
301
+ const value = target?.[key];
302
+ (Array.isArray(value) ? value : [value]).forEach((command, index, list) => add(list.length > 1 ? `${key}[${index}]` : key, command));
303
+ }
304
+ add("nativeAssembly.packageHook", config?.nativeAssembly?.packageHook);
305
+ // A declarative recipe still unexpanded (the controller reads the raw config): the steps it will run around the Tauri build.
306
+ for (const recipe of ["winPackage", "macPackage"]) {
307
+ for (const phase of ["prepare", "postBuild"]) {
308
+ const list = target?.[recipe]?.[phase];
309
+ if (Array.isArray(list)) list.forEach((command, index) => add(`${recipe}.${phase}[${index}]`, command));
310
+ }
311
+ }
312
+ return steps;
313
+ }
314
+
315
+ /**
316
+ * R-T4: a split's command step must not name a target-prefixed relative path (`src-tauri/target/...`): the snapshot has no such directory, and a
317
+ * non-RightKit command cannot be remapped. `steps` are `{ name?, cmd, args?, env? }` (or a command string); RightKit entries are skipped.
318
+ * Throws ArtifactPathError RK_SPLIT_TARGET_PATH naming each step and the fix, before anything builds. A checkout-mode context checks nothing.
319
+ */
320
+ export function assertSplitCommandPaths(steps, ctx) {
321
+ if (!ctx || ctx.mode !== "split") return;
322
+ const prefixes = ctx.prefixes ?? ctx.boundTargets.map((bound) => bound.prefix);
323
+ if (!prefixes.length) return;
324
+ const problems = [];
325
+ steps.forEach((step, index) => {
326
+ const command = typeof step === "string" ? { cmd: step } : step;
327
+ if (!command || isRightKitEntry(command)) return;
328
+ const name = command.name ?? `step ${index + 1}`;
329
+ const values = [command.cmd, ...(command.args ?? []), ...Object.values(command.env ?? {})].filter((value) => typeof value === "string");
330
+ for (const value of values) {
331
+ const hit = findTargetPrefixedPath(value, prefixes);
332
+ if (hit) {
333
+ problems.push(`${name} (${command.cmd}) names the target-relative path ${hit}`);
334
+ break;
335
+ }
336
+ }
337
+ });
338
+ if (problems.length) {
339
+ throw new ArtifactPathError(SPLIT_TARGET_PATH_CODE, `${problems.join("; ")}. A door split has no target directory in its snapshot, and only RightKit's own steps are remapped. Pass the path absolute from resolveTargetRoot (@rightkit/release/cargo-target.mjs), as postright and cutright do; nothing was built`, problems);
340
+ }
341
+ }
@@ -41,7 +41,7 @@ export const LEGACY_EXECUTABLE_EXTENSIONS = Object.freeze([".mjs", ".js", ".cjs"
41
41
  export const TOP_LEVEL_KEYS = Object.freeze([
42
42
  "$schema", "$comment", "schema", "app", "version", "channel", "distribution", "packageManager", "workdir", "targets", "development",
43
43
  "deps", "checks", "buildInputs", "hostedWorkflows", "devtools", "releaseSwitches", "qa", "nativeAssembly", "legal", "web", "tests",
44
- "sidecars", "releaseDependencies", "appStoreConnect", "storage", "cargoProfileExempt", "runtimeArtifacts",
44
+ "sidecars", "assembly", "releaseDependencies", "appStoreConnect", "storage", "cargoProfileExempt", "runtimeArtifacts",
45
45
  ]);
46
46
  export const TEMPLATE_TOKENS = Object.freeze(["app", "version", "home", "localAppData"]);
47
47
 
@@ -17,7 +17,13 @@ function canonical(file) {
17
17
  return path.normalize(realpathSync.native(file)).toLowerCase();
18
18
  }
19
19
 
20
- export function assertPrimaryReleaseCheckout(repoRoot, env = process.env) {
20
+ /**
21
+ * The release must run from the primary Git worktree. `split` is a door source split (release-source-identity.mjs, row 7.04): the
22
+ * process then builds the selected commit's snapshot, so the checkout's branch, its HEAD and whether it is detached are irrelevant
23
+ * (GATE-CONTRACT.md: the primary checkout's state is not a build input). Only the primary-worktree identity is still required. Without
24
+ * `split` every check is exactly as it was.
25
+ */
26
+ export function assertPrimaryReleaseCheckout(repoRoot, env = process.env, { split = false } = {}) {
21
27
  const worktrees = git(repoRoot, ["worktree", "list", "--porcelain"]);
22
28
  const gitDir = git(repoRoot, ["rev-parse", "--git-dir"]).trim();
23
29
  const configured = spawnSync("git", ["config", "--path", "--get", "core.worktree"], {
@@ -33,6 +39,7 @@ export function assertPrimaryReleaseCheckout(repoRoot, env = process.env) {
33
39
  if (canonical(repoRoot) !== canonical(primary)) {
34
40
  throw new Error(`right-release must be invoked from the primary Git worktree: ${primary}`);
35
41
  }
42
+ if (split) return;
36
43
  const branch = spawnSync("git", ["symbolic-ref", "--quiet", "HEAD"], { cwd: repoRoot, encoding: "utf8", windowsHide: true });
37
44
  if (
38
45
  branch.status !== 0
@@ -25,8 +25,7 @@ import path from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
26
  import { spawnSync } from "./background-process.mjs";
27
27
  import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
28
- import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
29
- import { assertCleanSource } from "./source-gate.mjs";
28
+ import { NO_REPOSITORY_CODE, assertBindsSource, assertInsideSource, assertReleaseSource, gitRootOf, isSnapshotSource, resolveReleaseSource, snapshotSourceIdentity } from "./release-source-identity.mjs";
30
29
  import { verifySealedRelease } from "./release-state.mjs";
31
30
  import { auditMain } from "./release-qa-audit.mjs";
32
31
  import { journeyRequiredItem, QA_RUN_ENV, relativePosix, validateJourneysReceipt } from "./release-qa-hardening.mjs";
@@ -102,11 +101,18 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
102
101
  const executionStartedAt = new Date();
103
102
  const gitRunner = deps.gitRunner ?? spawnSync;
104
103
  const appRoot = path.dirname(configPath);
105
- const repoRoot = String(gitRunner("git", ["-C", appRoot, "rev-parse", "--show-toplevel"], { encoding: "utf8", windowsHide: true }).stdout ?? "").trim();
106
- if (!repoRoot) throw new Error("release config must live inside a Git repository");
107
- if (!deps.skipPrimaryCheckout) assertPrimaryReleaseCheckout(repoRoot);
108
- assertCleanSource({ status: String(gitRunner("git", ["-C", repoRoot, "status", "--porcelain=v1", "-z", "--untracked-files=all"], { encoding: "utf8", windowsHide: true }).stdout ?? ""), commandId: "right-release qa" });
109
- const source = sourceIdentity(repoRoot, gitRunner);
104
+ // Source identity (release-source-identity.mjs, item 7 row 7.04). Outside a door run the checkout is the source, proven by the primary
105
+ // worktree and a clean status exactly as before. Inside one (RK_GATE_CHECKOUT) the source is the selected commit's objects and its
106
+ // verified snapshot: no HEAD, status or branch of the checkout is read, and the config, QA plan and journeys all come from the snapshot.
107
+ let release;
108
+ try { release = resolveReleaseSource({ cwd: appRoot, env: deps.env ?? process.env, git: gitRunner }); }
109
+ catch (error) { if (error.code === NO_REPOSITORY_CODE) throw new Error("release config must live inside a Git repository"); throw error; }
110
+ const split = isSnapshotSource(release);
111
+ // Outputs, the sealed vault and the QA receipts live in the checkout's `.right-release` either way (GC:31, GC:65).
112
+ const repoRoot = gitRootOf(release);
113
+ assertReleaseSource(release, { commandId: "right-release qa", env: deps.env ?? process.env, git: gitRunner, skipPrimaryCheckout: Boolean(deps.skipPrimaryCheckout) });
114
+ assertInsideSource(release, configPath, "the release config");
115
+ const source = split ? snapshotSourceIdentity(release, gitRunner) : sourceIdentity(repoRoot, gitRunner);
110
116
  const config = await importReleaseConfig(configPath, { query: `qa=${source.commit}` });
111
117
  if (!config?.app || !config?.version) throw new Error("release config must expose app and version");
112
118
  const { floor, digest: floorDigest } = loadQaFloor(deps.floorDir);
@@ -121,13 +127,23 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
121
127
  else {
122
128
  if (!releaseId) throw new Error("--release <sealed-id> is required for the installed stage");
123
129
  sealed = verifySealedRelease(path.join(repoRoot, ".right-release", "sealed", releaseId, platform === "win" ? "windows" : "mac"));
124
- if (sealed.manifest.commit !== source.commit) throw new Error("sealed release was built from a different commit than the current checkout");
130
+ // R4: a split binds the sealed release to the selected commit (and its tree), never to the checkout's HEAD.
131
+ if (split) assertBindsSource(release, sealed.manifest, "the sealed release");
132
+ else if (sealed.manifest.commit !== source.commit) throw new Error("sealed release was built from a different commit than the current checkout");
125
133
  sourceReceiptDigest = sealed.manifest.qa?.sourceReceiptDigest ?? null;
126
134
  if (!sourceReceiptDigest) throw new Error("sealed release records no source QA receipt; it was not built through the QA gate; rebuild after `right-release qa --stage source`");
127
135
  artifacts = sealed.manifest.files.map((file) => ({ name: file.name, sha256: file.sha256, sizeBytes: file.sizeBytes }));
128
136
  }
129
137
 
130
138
  const key = ensureQaReceiptKey({ env: deps.env ?? process.env, credentialStore: deps.credentialStore, provision: deps.provision ?? provisionKeyOnHost });
139
+ // R5: the QA plan, the journeys manifest and a named journeys receipt are read from the snapshot, and so is a runner the config names.
140
+ const journeysDeclared = config.qa.journeys ?? null;
141
+ assertInsideSource(release, path.resolve(appRoot, config.qa.config), "qa.config");
142
+ if (journeysDeclared) {
143
+ assertInsideSource(release, path.resolve(appRoot, journeysDeclared.manifest ?? "journeys.toml"), "qa.journeys.manifest");
144
+ if (journeysDeclared.receipt) assertInsideSource(release, path.resolve(appRoot, String(journeysDeclared.receipt)), "qa.journeys.receipt");
145
+ }
146
+ if (config.qa.runner && !deps.runnerFile) assertInsideSource(release, path.resolve(appRoot, config.qa.runner), "qa.runner");
131
147
  const runnerFile = deps.runnerFile ?? resolveRunner({ qa: config.qa, appRoot });
132
148
  const runnerSha = sha256File(runnerFile);
133
149
  const journeysConfig = config.qa.journeys ?? null;
@@ -183,7 +199,7 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
183
199
  const manifestFile = path.resolve(appRoot, journeysConfig.manifest ?? "journeys.toml");
184
200
  const receiptFile = journeysConfig.receipt ? path.resolve(appRoot, String(journeysConfig.receipt)) : journeysReceiptFromOutput(journeysRun?.stdout);
185
201
  const validation = receiptFile
186
- ? validateJourneysReceipt({ runnerFile, runnerSha256: runnerSha, receiptFile, manifestFile, manifestRelPath: relativePosix(repoRoot, manifestFile), platform, spawn: deps.spawnValidator ?? deps.spawnRunner ?? spawnSync })
202
+ ? validateJourneysReceipt({ runnerFile, runnerSha256: runnerSha, receiptFile, manifestFile, manifestRelPath: relativePosix(split ? release.snapshot : repoRoot, manifestFile), platform, spawn: deps.spawnValidator ?? deps.spawnRunner ?? spawnSync })
187
203
  : { ok: false, problems: ["rightkit-qa journeys run reported no run directory, so there is no journeys receipt to validate"], block: null };
188
204
  if (!deps.quiet) for (const problem of validation.problems) console.error(`[qa] journeys: ${problem}`);
189
205
  journeys = validation.block;
@@ -0,0 +1,17 @@
1
+ // The mark of a release source minted by release-source-identity.mjs `resolveReleaseSource` (item 7 row 7.04).
2
+ //
3
+ // A leaf with no imports, so that the ownership evidence (an enforcement entrypoint whose import closure is pinned into the policy
4
+ // bundle) can refuse a hand-built source without pulling the door's modules into that closure. Only the resolver marks a source; a
5
+ // plain object with the same fields is not one.
6
+ const minted = new WeakSet();
7
+
8
+ /** Marks `source` as resolved by the identity module and returns it. */
9
+ export function brandReleaseSource(source) {
10
+ minted.add(source);
11
+ return source;
12
+ }
13
+
14
+ /** Whether `source` is an object `brandReleaseSource` marked. */
15
+ export function isReleaseSource(source) {
16
+ return typeof source === "object" && source !== null && minted.has(source);
17
+ }