@rightkit/release 0.6.10 → 0.6.12

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 +772 -355
  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 +63 -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
@@ -1,41 +1,22 @@
1
- // right-release hooks install: writes the advisory pre-commit ownership gate into an app repository.
1
+ // right-release hooks: the advisory pre-commit ownership gate is retired (docs/GATE-CONTRACT.md: "Commit and push run no gate and no
2
+ // local hook checks"). Nothing here writes a hook any more.
2
3
  //
3
- // right-release hooks install [--app <dir>]
4
+ // right-release hooks install [--app <dir>] (`right-release hooks install` is `rightkit-gate install-hook`: it retires the hook)
4
5
  //
5
- // The hook runs the app's pinned @rightkit/release (node_modules) via `right-release ownership --staged`.
6
- // An existing foreign pre-commit is preserved as pre-commit.rightkit-foreign and chained first.
7
- // The gate is advisory: `git commit --no-verify` bypasses it; broker admission and the release gate
8
- // remain authoritative.
9
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
6
+ // An earlier release wrote `.git/hooks/pre-commit` (or the one in core.hooksPath) running `right-release ownership --staged`, and
7
+ // kept a foreign pre-commit as pre-commit.rightkit-foreign. Retirement removes the hook by its own marker only and puts the foreign
8
+ // pre-commit back. `right-release ownership --staged` stays a manual command; broker admission and the release gate remain
9
+ // authoritative.
10
+ import { lstatSync, readFileSync, readdirSync, realpathSync, renameSync, rmdirSync, unlinkSync } from "node:fs";
10
11
  import path from "node:path";
11
12
  import { fileURLToPath } from "node:url";
12
13
  import { spawnSync } from "./background-process.mjs";
14
+ import { HOOK_MARKER, retireHooksIn } from "./hook-retirement.mjs";
13
15
  import { repositoryRootFor } from "./ownership-staged.mjs";
14
16
 
15
- export const HOOK_MARKER = "rightkit-ownership-precommit v1";
16
- const FOREIGN = "pre-commit.rightkit-foreign";
17
- const PINNED_CLI = path.join("node_modules", "@rightkit", "release", "cli", "right-release.mjs");
17
+ export { HOOK_MARKER };
18
18
 
19
- export function renderPreCommitHook() {
20
- return [
21
- "#!/bin/sh",
22
- `# ${HOOK_MARKER}: advisory ownership gate installed by \`right-release hooks install\`.`,
23
- "# Block-tier findings without a valid exception fail here; --no-verify bypasses this gate.",
24
- "# Authoritative enforcement remains in broker admission and the release gate.",
25
- "ROOT=\"$(git rev-parse --show-toplevel)\" || exit 1",
26
- "HOOKS_DIR=\"$(dirname \"$0\")\"",
27
- `if [ -x "$HOOKS_DIR/${FOREIGN}" ]; then`,
28
- ` "$HOOKS_DIR/${FOREIGN}" "$@" || exit $?`,
29
- "fi",
30
- `GATE="$ROOT/${PINNED_CLI.split(path.sep).join("/")}"`,
31
- "if [ ! -f \"$GATE\" ]; then",
32
- " echo \"[rightkit] @rightkit/release is not installed in this repository; add it as a devDependency, then re-run: right-release hooks install\" >&2",
33
- " exit 1",
34
- "fi",
35
- "exec node \"$GATE\" ownership --staged",
36
- "",
37
- ].join("\n");
38
- }
19
+ const FS_OPS = Object.freeze({ readdir: readdirSync, readFile: (file) => readFileSync(file, "utf8"), lstat: lstatSync, unlink: unlinkSync, rename: renameSync, rmdir: rmdirSync });
39
20
 
40
21
  function git(cwd, args) {
41
22
  const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8", windowsHide: true, env: Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^GIT_/i.test(k))) });
@@ -52,46 +33,26 @@ export function hooksDirectoryFor(root) {
52
33
  return path.resolve(root, common);
53
34
  }
54
35
 
55
- /**
56
- * Idempotent install. Returns { status: "installed" | "updated" | "unchanged" | "chained", file }.
57
- */
58
- export function installPreCommitHook(repoDir) {
36
+ /** Remove the ownership pre-commit hook an earlier release wrote, restoring the foreign pre-commit it displaced. { status: "retired" | "restored" | "absent", file } */
37
+ export function retirePreCommitHook(repoDir) {
59
38
  const root = repositoryRootFor(repoDir);
60
- const pinned = path.join(root, PINNED_CLI);
61
- if (!existsSync(pinned)) throw new Error(`@rightkit/release is not installed at ${pinned}; add it as a devDependency and run pnpm install first`);
62
39
  const hooksDir = hooksDirectoryFor(root);
40
+ const result = retireHooksIn(hooksDir, FS_OPS, { scripts: false });
63
41
  const file = path.join(hooksDir, "pre-commit");
64
- const foreign = path.join(hooksDir, FOREIGN);
65
- const body = renderPreCommitHook();
66
- mkdirSync(hooksDir, { recursive: true });
67
- let status = "installed";
68
- if (lstatSync(file, { throwIfNoEntry: false })) {
69
- const current = readFileSync(file, "utf8");
70
- if (current === body) return { status: "unchanged", file };
71
- if (current.includes(HOOK_MARKER)) status = "updated";
72
- else {
73
- if (existsSync(path.join(hooksDir, FOREIGN))) throw new Error(`${FOREIGN} already exists; resolve the chained hook manually before installing`);
74
- renameSync(file, foreign);
75
- status = "chained";
76
- }
77
- }
78
- const temporary = `${file}.${process.pid}.tmp`;
79
- writeFileSync(temporary, body, { mode: 0o755 });
80
- chmodSync(temporary, 0o755);
81
- renameSync(temporary, file);
82
- return { status, file };
42
+ return { status: result.restored.length ? "restored" : result.removed.includes("pre-commit") ? "retired" : "absent", file };
83
43
  }
84
44
 
85
45
  export function main(args, { print = (line) => console.log(line) } = {}) {
86
46
  const flag = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
87
47
  if (args[0] !== "install") {
88
- print("right-release hooks install [--app <dir>]");
48
+ print("right-release hooks install [--app <dir>] (retires the ownership pre-commit hook; no hook is written)");
89
49
  return args.includes("--help") ? 0 : 2;
90
50
  }
91
51
  try {
92
- const result = installPreCommitHook(path.resolve(flag("--app") ?? process.cwd()));
93
- print(`[right-release] pre-commit ownership gate ${result.status}: ${result.file}`);
94
- if (result.status === "chained") print(`[right-release] previous pre-commit preserved as ${FOREIGN} and runs first`);
52
+ const result = retirePreCommitHook(path.resolve(flag("--app") ?? process.cwd()));
53
+ print(`[right-release] pre-commit ownership gate retired (${result.status}): ${result.file}`);
54
+ if (result.status === "restored") print("[right-release] the previous pre-commit hook is back in place");
55
+ print("[right-release] commit runs no local hook check; `right-release ownership --staged` is still available by hand");
95
56
  return 0;
96
57
  } catch (error) {
97
58
  print(`[right-release] hooks install FAILED: ${error.message}`);
@@ -322,9 +322,22 @@ export function transitiveDependencies(appRoot, inventory, manifests, catalogOwn
322
322
  if (bytes && !contentDigests.some((item) => item.path === rel)) contentDigests.push({ path: rel, digest: sha256(bytes) });
323
323
  return bytes;
324
324
  };
325
+ // Directories the repository declares as gate fixtures (ci/gate.config.json `fixtures`: consumer recipes, vendored crates,
326
+ // spikes) are samples and harnesses, never the app's code: the gate builds none of them as units, and neither are they scanned here.
327
+ let fixtures = [];
328
+ try {
329
+ const rel = "ci/gate.config.json";
330
+ const config = inventory.snapshotFiles?.get(`${inventory.pathPrefix ?? ""}${rel}`)?.bytes ?? files.get(rel)?.bytes
331
+ ?? (!inventory.committed && existsSync(path.resolve(appRoot, rel)) ? readFileSync(path.resolve(appRoot, rel)) : undefined);
332
+ if (config) fixtures = (JSON.parse(config.toString("utf8")).fixtures ?? []).map((d) => String(d).replace(/\/+$/, ""));
333
+ } catch { fixtures = []; }
334
+ const underFixture = (rel) => fixtures.some((d) => rel === d || rel.startsWith(`${d}/`));
325
335
  for (const manifest of manifests) {
326
336
  const base = path.posix.basename(manifest.rel), ecosystem = base === "Cargo.toml" ? "cargo" : base === "package.json" ? "npm" : null;
327
337
  if (!ecosystem || !manifest.bytes) continue;
338
+ if (underFixture(manifest.rel)) continue;
339
+ // A Cargo manifest with its own [workspace] table is a workspace root: its lock sits beside it and Cargo never looks higher.
340
+ const workspaceRoot = ecosystem === "cargo" && /^\s*\[workspace\]\s*$/m.test(manifest.bytes.toString("utf8"));
328
341
  const names = ecosystem === "cargo" ? ["Cargo.lock"] : OWNERSHIP_LOCKFILES.slice(1);
329
342
  let dir = path.posix.dirname(manifest.rel), found = false;
330
343
  try {
@@ -337,7 +350,7 @@ export function transitiveDependencies(appRoot, inventory, manifests, catalogOwn
337
350
  }
338
351
  found = true; break;
339
352
  }
340
- if (dir === ".") break;
353
+ if (dir === "." || workspaceRoot) break;
341
354
  dir = path.posix.dirname(dir);
342
355
  }
343
356
  if (!found) diagnostic(manifest.rel, "lockfile-missing", ecosystem);
@@ -17,10 +17,11 @@ const CATALOG_FILE = "rightkit-ownership.json";
17
17
  const REGISTRY_FILE = "ownership-repositories.json";
18
18
  // Step 4: the release QA requirement floor is protected like the catalog. Dropping a required check changes the digest.
19
19
  export const QA_FLOOR_FILE = "release-qa-floor.json";
20
- // Modules that make or carry enforcement decisions. Their import closure is pinned.
20
+ // Modules that make or carry enforcement decisions. Their import closure is pinned. ownership-hooks.mjs left the list with the
21
+ // advisory pre-commit hook it used to write (GATE-CONTRACT: commit runs no local hook check): it retires that hook and decides nothing.
21
22
  export const POLICY_BUNDLE_ENTRYPOINTS = Object.freeze([
22
23
  "ownership-scan-worker.mjs", "ownership-evidence.mjs", "ownership-verdict.mjs", "ownership-admission.mjs",
23
- "ownership-staged.mjs", "ownership-hooks.mjs", "ownership-key-trust.mjs", "release-qa-receipt.mjs",
24
+ "ownership-staged.mjs", "ownership-key-trust.mjs", "release-qa-receipt.mjs",
24
25
  ]);
25
26
  // Data files are pinned through their own digests, not as source files.
26
27
  const DATA_FILES = new Set([CATALOG_FILE, REGISTRY_FILE, ALLOWLIST_FILE, QA_FLOOR_FILE]);
@@ -1,4 +1,5 @@
1
- // Advisory pre-commit ownership gate: scans the STAGED tree (index content), never the working tree.
1
+ // Ownership gate for the STAGED tree (index content), never the working tree: the manual command `right-release ownership --staged`.
2
+ // No git hook runs it any more (docs/GATE-CONTRACT.md: commit runs no local hook check).
2
3
  // Authoritative enforcement stays in broker admission and the release gate.
3
4
  //
4
5
  // right-release ownership --staged [--app <dir>] [--json]
package/ownership.mjs CHANGED
@@ -382,6 +382,9 @@ function resolveDependencyVersions(appRoot, inventory, deps, coverage, contentDi
382
382
  const refs = lock.package.filter((p) => p.name === owner).flatMap((p) => p.dependencies ?? []);
383
383
  candidates = candidates.filter((p) => refs.some((ref) => ref === `${d.name} ${p.version}` || ref.startsWith(`${d.name} ${p.version} (`)));
384
384
  }
385
+ // A crate may declare two versions of one package under different names (windows 0.61 and 0.62 lanes): the declared
386
+ // requirement picks the resolved one, by Cargo's default caret rule (or an exact `=` pin).
387
+ if (candidates.length > 1 && typeof d.requirement === "string") candidates = candidates.filter((p) => cargoRequirementMatches(d.requirement, p.version));
385
388
  if (candidates.length !== 1 || typeof candidates[0].version !== "string") throw new Error(`Cargo.lock resolution missing or ambiguous: ${d.name}`);
386
389
  d.resolvedVersion = candidates[0].version;
387
390
  }
@@ -917,3 +920,24 @@ export function checkCommittedApp(appRoot, manifest, options = {}) {
917
920
  export function checkCommittedAppForTest(appRoot, manifest, options = {}) {
918
921
  return checkCommitted(appRoot, manifest, options, options.acquireCommittedSourceForTest ?? acquireCommittedSource, options.readAuthorityForTest);
919
922
  }
923
+
924
+ /** Cargo's default requirement (`0.61`, `^1.2`, `=0.3.4`, `>=2.10.1, <3`) against a resolved version; anything else matches. */
925
+ function cargoRequirementMatches(requirement, version) {
926
+ const parse = (v) => String(v).split("+")[0].split("-")[0].split(".").map((n) => Number(n));
927
+ const [x, y = 0, z = 0] = parse(version);
928
+ const cmp = (a, b) => a[0] - b[0] || (a[1] ?? 0) - (b[1] ?? 0) || (a[2] ?? 0) - (b[2] ?? 0);
929
+ return requirement.split(",").map((r) => r.trim()).filter(Boolean).every((r) => {
930
+ const m = /^(=|\^|>=|<=|>|<|~)?\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(r);
931
+ if (!m) return true;
932
+ const op = m[1] ?? "^"; const want = [Number(m[2]), m[3] === undefined ? undefined : Number(m[3]), m[4] === undefined ? undefined : Number(m[4])];
933
+ const v = [x, y, z]; const c = cmp(v, want);
934
+ if (op === "=") return want.every((n, i) => n === undefined || n === v[i]);
935
+ if (op === ">=") return c >= 0; if (op === ">") return c > 0; if (op === "<=") return c <= 0; if (op === "<") return c < 0;
936
+ if (op === "~") return c >= 0 && v[0] === want[0] && (want[1] === undefined || v[1] === want[1]);
937
+ if (c < 0) return false;
938
+ if (want[0] > 0) return v[0] === want[0];
939
+ if (want[1] === undefined) return v[0] === 0;
940
+ if (want[1] > 0) return v[0] === 0 && v[1] === want[1];
941
+ return v[0] === 0 && v[1] === 0 && (want[2] === undefined || v[2] === want[2]);
942
+ });
943
+ }
@@ -8,6 +8,7 @@ import { existsSync } from "node:fs";
8
8
  import path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { resolveTargetRoot as cargoResolveTargetRoot } from "./cargo-target.mjs";
11
+ import { ArtifactPathError, SPLIT_TARGET_UNRESOLVED_CODE, isSplitArtifactEnv } from "./release-artifact-path.mjs";
11
12
  import { defaultManagedRoots, isUnderManagedRoot } from "./managed-target-stage.mjs";
12
13
  import { NativeHelpersError, nativeHelperNestedCode, validateNativeHelpers } from "./native-helpers.mjs";
13
14
  import { validateWinPackage, WinPackageConfigError } from "./win-package-recipe.mjs";
@@ -79,13 +80,13 @@ const configArgs = (extras, appRoot, cwd) => (extras.tauriConfig ? ["--config",
79
80
  * release.mjs runs ONE prePackage command: node recipe-build.mjs runs the built-in cleanup, prepare, the Tauri build,
80
81
  * the artifact stability poll and postBuild in order. `cleanup` and `stability` are recipe-build plan sections.
81
82
  */
82
- function recipeBuildCommand({ extras, build, env, cleanup, stability, stage, timeoutMs, verifySidecars, nativeHelpers }) {
83
+ function recipeBuildCommand({ extras, build, env, cleanup, stability, stage, timeoutMs, verifySidecars, nativeHelpers, boundTargets }) {
83
84
  const steps = [
84
85
  ...extras.prepare.map((step) => ({ phase: "prepare", ...step })),
85
86
  { phase: "build", cmd: build.cmd, args: build.args, cwd: build.cwd },
86
87
  ...extras.postBuild.map((step) => ({ phase: "postBuild", ...step })),
87
88
  ];
88
- const plan = { version: 1, steps, ...(cleanup ? { cleanup } : {}), ...(stability ? { stability } : {}), ...(stage && (stage.pre?.length || stage.post?.length) ? { stage } : {}), ...(verifySidecars ? { verifySidecars } : {}), ...(nativeHelpers ? { nativeHelpers } : {}) };
89
+ const plan = { version: 1, steps, ...(boundTargets?.length ? { boundTargets } : {}), ...(cleanup ? { cleanup } : {}), ...(stability ? { stability } : {}), ...(stage && (stage.pre?.length || stage.post?.length) ? { stage } : {}), ...(verifySidecars ? { verifySidecars } : {}), ...(nativeHelpers ? { nativeHelpers } : {}) };
89
90
  return { cmd: process.execPath, args: [RECIPE_BUILD, "--plan", JSON.stringify(plan)], env, ...(timeoutMs ? { timeoutMs } : {}) };
90
91
  }
91
92
 
@@ -114,6 +115,9 @@ function targetRootMapper({ spec, appRoot, cwd, env, resolveTargetRoot, label, m
114
115
  function buildTargetRootMapper({ spec, appRoot, cwd, env, resolveTargetRoot, label, managedRoots, staged }) {
115
116
  const explicit = spec.cargoTargetRoot;
116
117
  if (explicit !== undefined && explicit !== true && explicit !== false && typeof explicit !== "string") fail(`${label}.cargoTargetRoot must be true or an app-relative Cargo.toml path`);
118
+ // A door split (row 7.04c, R-T1/R-T3): the snapshot has no target directory, so every declared target path is the bound target's, absolute, and
119
+ // nothing is copied (the #924 bridge plans zero staged entries): the build output is read and signed in place.
120
+ if (isSplitArtifactEnv(env)) return splitTargetRootMapper({ spec, appRoot, cwd, resolveTargetRoot, label });
117
121
  const envDir = typeof env.CARGO_TARGET_DIR === "string" && env.CARGO_TARGET_DIR.trim() !== "";
118
122
  if (explicit === false || (!explicit && !envDir)) return (declared) => declared;
119
123
  const manifestRel = typeof explicit === "string" ? relativeInside(explicit, `${label}.cargoTargetRoot`) : path.posix.join(cwd === "." ? "" : cwd, "src-tauri", "Cargo.toml");
@@ -141,9 +145,34 @@ function buildTargetRootMapper({ spec, appRoot, cwd, env, resolveTargetRoot, lab
141
145
  };
142
146
  }
143
147
 
144
- function winPrePackage({ extras, cwd, appRoot, triple, jobs, timeoutMs, cleanup, stability, stage }) {
148
+ function splitTargetRootMapper({ spec, appRoot, cwd, resolveTargetRoot, label }) {
149
+ const explicit = spec.cargoTargetRoot;
150
+ const manifestRel = typeof explicit === "string" ? relativeInside(explicit, `${label}.cargoTargetRoot`) : path.posix.join(cwd === "." ? "" : cwd, "src-tauri", "Cargo.toml");
151
+ const manifest = path.resolve(appRoot, manifestRel);
152
+ // An app that declares no recipe target and has no src-tauri manifest has nothing to bind; a declared one that is missing is refused below.
153
+ if (explicit !== true && typeof explicit !== "string" && !existsSync(manifest)) return (declared) => declared;
154
+ let targetDir;
155
+ try { targetDir = resolveTargetRoot(manifest); } catch (error) {
156
+ throw new ArtifactPathError(SPLIT_TARGET_UNRESOLVED_CODE, `${label}: cannot resolve the Cargo target directory of ${manifest} (${error.message}); a split never falls back to a snapshot-relative target/`);
157
+ }
158
+ const relativeToApp = path.relative(appRoot, targetDir);
159
+ if (relativeToApp === "" || (!relativeToApp.startsWith("..") && !path.isAbsolute(relativeToApp))) {
160
+ throw new ArtifactPathError(SPLIT_TARGET_UNRESOLVED_CODE, `${label}: the Cargo target directory ${targetDir} is inside the snapshot; a split binds it outside (CARGO_TARGET_DIR, row 7.09)`);
161
+ }
162
+ const prefix = path.posix.join(path.posix.dirname(manifestRel).replace(/^\.$/, ""), "target");
163
+ const mapper = (declared) => {
164
+ const normalized = String(declared).replaceAll("\\", "/");
165
+ if (normalized !== prefix && !normalized.startsWith(`${prefix}/`)) return declared;
166
+ if (normalized.split("/").includes("..")) fail(`${label}: ${declared} must not contain ..`);
167
+ return path.resolve(targetDir, normalized.slice(prefix.length + 1));
168
+ };
169
+ mapper.bound = [targetDir];
170
+ return mapper;
171
+ }
172
+
173
+ function winPrePackage({ extras, cwd, appRoot, triple, jobs, timeoutMs, cleanup, stability, stage, boundTargets }) {
145
174
  const args = ["exec", "tauri", "build", "--no-bundle", "--target", triple, ...configArgs(extras, appRoot, cwd), ...buildCargoArgs(extras, jobs)];
146
- return recipeBuildCommand({ extras, build: { cmd: "pnpm", args, cwd }, env: QUIET_TAURI_ENV, cleanup, stability, stage, timeoutMs });
175
+ return recipeBuildCommand({ extras, build: { cmd: "pnpm", args, cwd }, env: QUIET_TAURI_ENV, cleanup, stability, stage, timeoutMs, boundTargets });
147
176
  }
148
177
 
149
178
  /** `targets.win.winPackage` → prePackage (`tauri build --no-bundle`) and package (`tauri bundle --bundles nsis`). */
@@ -193,7 +222,7 @@ export function expandWinPackageTarget(target, { appRoot, config = {}, env = pro
193
222
  return {
194
223
  ...rest,
195
224
  signingContract: "windows-raw-exe-authenticode-before-nsis-v1",
196
- prePackage: winPrePackage({ extras, cwd, appRoot: plan.appRoot, triple: plan.triple, jobs, timeoutMs: timeout, cleanup, stability: { files: [rawExe] }, stage: prePackageStage }),
225
+ prePackage: winPrePackage({ extras, cwd, appRoot: plan.appRoot, triple: plan.triple, jobs, timeoutMs: timeout, cleanup, stability: { files: [rawExe] }, stage: prePackageStage, boundTargets: mapPath.bound }),
197
226
  package: bundleCommand,
198
227
  sign: { ...(target.sign ?? {}), prePackageFiles: [rawExe], files: [installerFile] },
199
228
  recipe: { kind: "winPackage", productName: plan.productName, updater: plan.updater },
@@ -327,7 +356,7 @@ export function expandMacPackageTarget(target, { appRoot, config = {}, env = pro
327
356
  return {
328
357
  ...rest,
329
358
  ...postSign,
330
- prePackage: wrapped ? recipeBuildCommand({ extras, build: { cmd: "pnpm", args, cwd }, env: QUIET_TAURI_ENV, cleanup, stage: macStage, verifySidecars, nativeHelpers: nativeHelpersPlan }) : { cmd: "pnpm", args, cwd, env: QUIET_TAURI_ENV },
359
+ prePackage: wrapped ? recipeBuildCommand({ extras, build: { cmd: "pnpm", args, cwd }, env: QUIET_TAURI_ENV, cleanup, stage: macStage, verifySidecars, nativeHelpers: nativeHelpersPlan, boundTargets: mapPath.bound }) : { cmd: "pnpm", args, cwd, env: QUIET_TAURI_ENV },
331
360
  // Helpers are signed by release.mjs inside-out through sign.nestedCode (deepest path first, hardened runtime, each helper's own entitlements), then the app.
332
361
  sign: { ...(target.sign ?? {}), ...(helpers ? { nestedCode: { ...nativeHelperNestedCode(helpers), ...(target.sign?.nestedCode ?? {}) } } : {}), prePackageFiles },
333
362
  notarize: { ...target.notarize, app, appBeforePackage: true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.6.10",
3
+ "version": "0.6.12",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
@@ -239,13 +239,13 @@ function admitRuntime(bin, rest, options, label) {
239
239
  }
240
240
 
241
241
  /**
242
- * `rightkit-gate install-hook`: writes the committed pre-push hook and sets core.hooksPath, so an app's `prepare` script may run it.
243
- * It builds, signs, packages and uploads nothing. The gate itself (`rightkit-gate`, `--base`) compiles and is run by the hook, never
244
- * by a release config; only the installer and the version probe are admitted.
242
+ * `rightkit-gate install-hook`: retires the git hooks an earlier release wrote and registers the agent hook, so an app's `prepare` script may run it.
243
+ * It builds, signs, packages and uploads nothing. The gate itself (`rightkit-gate`, `--base`) compiles and is never run by a release
244
+ * config; only the installer and the version probe are admitted.
245
245
  */
246
246
  function admitRightKitGate(args, label) {
247
247
  if (args.length === 1 && (args[0] === "install-hook" || args[0] === "--version")) return admit(`rightkit-gate-${args[0].replace(/^--/, "")}`, "owner");
248
- return refuse("R1-owner-subcommand", `${label}: only rightkit-gate install-hook (and --version) is an admitted pipeline command; the gate itself runs from the pre-push hook`);
248
+ return refuse("R1-owner-subcommand", `${label}: only rightkit-gate install-hook (and --version) is an admitted pipeline command; the gate itself is run through the door, never by a release config`);
249
249
  }
250
250
 
251
251
  function admitRightRelease(args, label) {
package/preflight.mjs CHANGED
@@ -23,6 +23,7 @@ import path from "node:path";
23
23
  import { spawnSync } from "./background-process.mjs";
24
24
  import { brokerManagedHost } from "./cargo-contract.mjs";
25
25
  import { createOwnershipEvidence } from "./ownership-evidence.mjs";
26
+ import { isReleaseSource } from "./release-source-brand.mjs";
26
27
  import { appRuntimeBudgetAdoptionCheck } from "./runtime-budgets.mjs";
27
28
  import { isSwiftProject } from "./project-type.mjs";
28
29
 
@@ -203,6 +204,12 @@ export function collectPreflight({
203
204
  bridge = true,
204
205
  msix = false,
205
206
  findSdkTool = findWindowsSdkTool,
207
+ // A door source split (release-source-identity.mjs `resolveReleaseSource`, row 7.04). Only the ownership check reads it: the evidence is
208
+ // then the selected commit's, read from the checkout's object database, whatever `repoRoot` the caller names.
209
+ releaseSource = null,
210
+ // The artifact-path context of a door split (release-artifact-path.mjs, row 7.04c). When it is a split, the target-bridge check is replaced by
211
+ // the R-T1 check: every manifest has a bound target outside the snapshot, and nothing sits where a snapshot-relative target/ would.
212
+ splitTargets = null,
206
213
  } = {}) {
207
214
  const checks = [];
208
215
  const windows = platform === "win" || platform === "win32";
@@ -219,7 +226,14 @@ export function collectPreflight({
219
226
  );
220
227
  }
221
228
 
222
- if (appRoot && !noBridge) {
229
+ if (appRoot && !noBridge && splitTargets?.mode === "split") {
230
+ const planted = splitTargets.boundTargets.map((bound) => path.join(appRoot, bound.prefix)).filter((dir) => { try { lstatSync(dir); return true; } catch { return false; } });
231
+ checks.push(
232
+ planted.length
233
+ ? fail("split-target", `RK_SPLIT_TARGET_PATH: ${planted.join(", ")} exists in the snapshot; a split binds its target outside it and nothing may sit there`, "remove it from the commit (it is build output) and add the directory to .gitignore")
234
+ : ok("split-target", splitTargets.boundTargets.length ? `target bound outside the snapshot: ${[...new Set(splitTargets.boundTargets.map((bound) => bound.dir))].join(", ")}` : "no target to bind"),
235
+ );
236
+ } else if (appRoot && !noBridge) {
223
237
  const target = targetLink ?? nativeLayout?.targetLink ?? path.join(appRoot, "src-tauri", "target");
224
238
  const targetLabel = nativeLayout?.targetPrefix || "src-tauri/target/";
225
239
  let entry = null;
@@ -339,8 +353,12 @@ export function collectPreflight({
339
353
  checks.push(fail("ownership", `ownership scope refused: ${ownershipScopeError}`, "fix the target's ownership scope configuration; the build refuses it too"));
340
354
  } else if (repoRoot && ownership) {
341
355
  // An app living inside the RightKit workspace repository is scoped to its own directory.
342
- const workspace = existsSync(path.join(repoRoot, "tools", "rightkit", "packages", "release"));
343
- checks.push(ownershipCheck(repoRoot, { scanRoot: workspace && appRoot ? appRoot : repoRoot, scope: ownershipScope, evidenceFactory: ownershipEvidenceFactory }));
356
+ // In a split the layout is the snapshot's (the commit's), the git root is the checkout, and the source is the one the identity module resolved.
357
+ if (releaseSource !== null && !isReleaseSource(releaseSource)) throw new Error("preflight: the release source was not resolved by resolveReleaseSource");
358
+ const split = releaseSource?.mode === "snapshot" ? releaseSource : null;
359
+ const gitRoot = split ? split.checkout : repoRoot;
360
+ const workspace = existsSync(path.join(split ? split.snapshot : repoRoot, "tools", "rightkit", "packages", "release"));
361
+ checks.push(ownershipCheck(gitRoot, { scanRoot: workspace && appRoot ? appRoot : gitRoot, scope: ownershipScope, evidenceFactory: ownershipEvidenceFactory, ...(split ? { releaseSource: split } : {}) }));
344
362
  }
345
363
 
346
364
  return checks;
@@ -349,9 +367,14 @@ export function collectPreflight({
349
367
  /** Release-blocking: the app keeps no unexcepted copy of a RightKit-owned capability. */
350
368
  export function ownershipCheck(repoRoot, options = {}) {
351
369
  let checked;
352
- try { checked = (options.evidenceFactory ?? createOwnershipEvidence)(repoRoot, options.scanRoot ?? repoRoot, options.scope ?? null); }
370
+ const split = options.releaseSource?.mode === "snapshot" ? options.releaseSource : null;
371
+ try { checked = (options.evidenceFactory ?? createOwnershipEvidence)(repoRoot, options.scanRoot ?? repoRoot, options.scope ?? null, ...(split ? [split] : [])); }
353
372
  catch (error) { return fail("ownership", `ownership evidence failed: ${error.message}`, "restore protected RightKit ownership trust and catalog"); }
354
373
  const { result, evidence, floor } = checked;
374
+ // R4: evidence for another commit or tree than the selected one is refused whatever produced it (a factory that read HEAD, a stale one).
375
+ if (split && (evidence?.commit !== split.commit || (evidence?.tree !== undefined && evidence.tree !== split.tree))) {
376
+ return fail("ownership", `ownership evidence is for commit ${String(evidence?.commit ?? "(none)").slice(0, 12)}, not the selected commit ${split.commit.slice(0, 12)}`, "the door run builds the selected commit's snapshot; re-run the door for that commit");
377
+ }
355
378
  if (checked.ok) {
356
379
  const excepted = result.covered.length;
357
380
  const detail = excepted ? `no unapproved copies (${excepted} excepted findings)` : "no copies of RightKit-owned capabilities";
package/publish-cargo.mjs CHANGED
@@ -397,9 +397,9 @@ export async function runCargoPublish(options, execute = (_command, args, settin
397
397
  const proven = (reuseEvidence || options.trustEvidenceSteps === true) && !options.dryRun ? provenGateSteps(gate.evidence) : {};
398
398
  if (reuseEvidence && (proven.tests !== true || proven.clippy !== true)) throw new Error("--from-evidence requires gate-proven tests and clippy for this crate and commit");
399
399
  if (reuseEvidence) {
400
- const status = (deps.workspaceStatus ?? ((workspaceRoot) => spawnSync("git", ["status", "--porcelain", "--", "Cargo.toml", "Cargo.lock", "ci/gate.config.json", "ci/asset-gated.json"], { cwd: workspaceRoot, encoding: "utf8", windowsHide: true })))(options.workspaceRoot);
400
+ const status = (deps.workspaceStatus ?? ((workspaceRoot) => spawnSync("git", ["status", "--porcelain", "--", `crates/${options.crate}/Cargo.toml`, `crates/${options.crate}/Cargo.lock`, "ci/gate.config.json", "ci/asset-gated.json"], { cwd: workspaceRoot, encoding: "utf8", windowsHide: true })))(options.workspaceRoot);
401
401
  // The gate config decides the steps replayed below, so an uncommitted edit would change what is demanded (0.4.1 review A3).
402
- if (status.error || status.status !== 0 || String(status.stdout ?? "").trim()) throw new Error("--from-evidence requires clean workspace Cargo.toml, Cargo.lock and ci gate config (ci/gate.config.json, ci/asset-gated.json)");
402
+ if (status.error || status.status !== 0 || String(status.stdout ?? "").trim()) throw new Error("--from-evidence requires clean crate Cargo.toml, Cargo.lock and ci gate config (ci/gate.config.json, ci/asset-gated.json)");
403
403
  const proofHost = singleHostOfPolicy(gate.evidence) ?? "darwin";
404
404
  const runs = gate.evidence.runs.filter((run) => run?.host?.os === proofHost);
405
405
  const command = `cargo package --locked -p ${options.crate}`;
@@ -480,7 +480,8 @@ export async function runCargoPublish(options, execute = (_command, args, settin
480
480
  // `execute` may be synchronous (the default) or async (the batch publisher overlaps uploads).
481
481
  const isReadOnlyInspection = ["package contents", "package location"].includes(step.label);
482
482
  const settings = {
483
- cwd: options.workspaceRoot,
483
+ // Every crate is its own Cargo workspace with its own Cargo.lock and target directory (#1285): cargo runs in the crate directory.
484
+ cwd: crateDir,
484
485
  encoding: "utf8",
485
486
  windowsHide: true,
486
487
  stdio: step.capture || (options.offlineReadOnly === true && isReadOnlyInspection) ? "pipe" : "inherit",
package/recipe-build.mjs CHANGED
@@ -51,7 +51,7 @@ export class RecipeBuildError extends Error {
51
51
  const fail = (code, message, details) => { throw new RecipeBuildError(code, message, details); };
52
52
  const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
53
53
  const STEP_KEYS = new Set(["phase", "cmd", "args", "cwd", "env"]);
54
- const PLAN_KEYS = new Set(["version", "steps", "cleanup", "stability", "stage", "verifySidecars", "nativeHelpers"]);
54
+ const PLAN_KEYS = new Set(["version", "steps", "cleanup", "stability", "stage", "verifySidecars", "nativeHelpers", "boundTargets"]);
55
55
  const VERIFY_SIDECARS_KEYS = new Set(["receipt", "target"]);
56
56
  const NATIVE_HELPERS_KEYS = new Set(["app", "helpers", "version", "forceArch", "configuration"]);
57
57
  const UNIVERSAL_TRIPLE = "universal-apple-darwin";
@@ -65,10 +65,22 @@ const unknownKeys = (value, allowed, label, errors) => { for (const key of Objec
65
65
  const positiveInt = (value) => Number.isInteger(value) && value > 0;
66
66
  const badVolumePrefix = (item) => typeof item !== "string" || !item.trim() || item === "." || item === ".." || item.includes("/") || item.includes("\\") || item.includes(":") || item.includes("\0");
67
67
 
68
- /** A relative path that stays inside the app root; returns the normalized form or undefined and pushes an error. */
69
- function insideApp(value, label, errors, { strict = false } = {}) {
68
+ /**
69
+ * A relative path that stays inside the app root; returns the normalized form or undefined and pushes an error.
70
+ * `bound` (a door split, row 7.04c R-T2): the absolute Cargo target directories the plan binds. A path strictly inside one of them is also
71
+ * accepted, absolute and without `..`: the snapshot has no target directory, so a split's build output is addressed there. Any other
72
+ * absolute path stays refused.
73
+ */
74
+ function insideApp(value, label, errors, { strict = false, bound = [] } = {}) {
70
75
  if (typeof value !== "string" || !value.trim()) { errors.push(`${label} must be a non-empty relative path`); return undefined; }
71
76
  const normalized = value.replaceAll("\\", "/");
77
+ if (bound.length && (path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value))) {
78
+ const parts = normalized.split("/");
79
+ const absolute = path.resolve(value);
80
+ const under = !parts.includes("..") && bound.some((dir) => { const relative = path.relative(dir, absolute); return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); });
81
+ if (!under) { errors.push(`${label} is an absolute path outside every bound Cargo target (${bound.join(", ")}): ${value}`); return undefined; }
82
+ return normalized;
83
+ }
72
84
  if (path.isAbsolute(value) || /^[A-Za-z]:/.test(value) || normalized.split("/").includes("..")) { errors.push(`${label} must be relative and stay inside the app: ${value}`); return undefined; }
73
85
  const resolved = path.posix.normalize(normalized);
74
86
  if (strict && (resolved === "." || resolved === "./" || resolved === "")) { errors.push(`${label} must name a path strictly inside the app, not the app root: ${JSON.stringify(value)}`); return undefined; }
@@ -76,21 +88,31 @@ function insideApp(value, label, errors, { strict = false } = {}) {
76
88
  return normalized;
77
89
  }
78
90
 
79
- function validateCleanup(value, errors) {
91
+ /** `boundTargets`: the absolute Cargo target directories of a door split (row 7.04c). Absent outside a split. */
92
+ function validateBoundTargets(value, errors) {
93
+ if (value === undefined) return undefined;
94
+ if (!Array.isArray(value) || !value.length || value.some((dir) => typeof dir !== "string" || !path.isAbsolute(dir) || dir.replaceAll("\\", "/").split("/").includes(".."))) {
95
+ errors.push("boundTargets must be a non-empty list of absolute directories without ..");
96
+ return undefined;
97
+ }
98
+ return value.map((dir) => path.resolve(dir));
99
+ }
100
+
101
+ function validateCleanup(value, errors, bound = []) {
80
102
  if (value === undefined) return undefined;
81
103
  if (!isObject(value)) { errors.push("cleanup must be an object"); return undefined; }
82
104
  unknownKeys(value, CLEANUP_KEYS, "cleanup", errors);
83
105
  const out = {};
84
106
  if (value.remove !== undefined) {
85
107
  if (!Array.isArray(value.remove)) errors.push("cleanup.remove must be a list of relative paths");
86
- else out.remove = value.remove.map((item, index) => insideApp(item, `cleanup.remove[${index}]`, errors, { strict: true })).filter(Boolean);
108
+ else out.remove = value.remove.map((item, index) => insideApp(item, `cleanup.remove[${index}]`, errors, { strict: true, bound })).filter(Boolean);
87
109
  }
88
110
  if (value.apps !== undefined) {
89
111
  if (!isObject(value.apps)) errors.push("cleanup.apps must be an object");
90
112
  else {
91
113
  unknownKeys(value.apps, APPS_KEYS, "cleanup.apps", errors);
92
114
  for (const key of ["remove", "unregister"]) if (value.apps[key] !== undefined && typeof value.apps[key] !== "boolean") errors.push(`cleanup.apps.${key} must be a boolean`);
93
- const dir = insideApp(value.apps.dir, "cleanup.apps.dir", errors, { strict: true });
115
+ const dir = insideApp(value.apps.dir, "cleanup.apps.dir", errors, { strict: true, bound });
94
116
  if (dir) out.apps = { dir, remove: value.apps.remove === true, unregister: value.apps.unregister === true };
95
117
  }
96
118
  }
@@ -101,13 +123,13 @@ function validateCleanup(value, errors) {
101
123
  return out;
102
124
  }
103
125
 
104
- function validateStability(value, errors) {
126
+ function validateStability(value, errors, bound = []) {
105
127
  if (value === undefined) return undefined;
106
128
  if (!isObject(value)) { errors.push("stability must be an object"); return undefined; }
107
129
  unknownKeys(value, STABILITY_KEYS, "stability", errors);
108
130
  for (const key of ["intervalMs", "checks", "timeoutMs"]) if (value[key] !== undefined && !positiveInt(value[key])) errors.push(`stability.${key} must be a positive integer`);
109
131
  if (!Array.isArray(value.files) || !value.files.length) { errors.push("stability.files must be a non-empty list of relative paths"); return undefined; }
110
- const files = value.files.map((item, index) => insideApp(item, `stability.files[${index}]`, errors, { strict: true })).filter(Boolean);
132
+ const files = value.files.map((item, index) => insideApp(item, `stability.files[${index}]`, errors, { strict: true, bound })).filter(Boolean);
111
133
  return { files, ...(value.intervalMs === undefined ? {} : { intervalMs: value.intervalMs }), ...(value.checks === undefined ? {} : { checks: value.checks }), ...(value.timeoutMs === undefined ? {} : { timeoutMs: value.timeoutMs }) };
112
134
  }
113
135
 
@@ -167,11 +189,11 @@ function validateVerifySidecars(value, errors) {
167
189
  return receipt ? { receipt, target: UNIVERSAL_TRIPLE } : undefined;
168
190
  }
169
191
 
170
- function validateNativeHelpersSection(value, errors) {
192
+ function validateNativeHelpersSection(value, errors, bound = []) {
171
193
  if (value === undefined) return undefined;
172
194
  if (!isObject(value)) { errors.push("nativeHelpers must be { app, helpers, version?, forceArch?, configuration? }"); return undefined; }
173
195
  unknownKeys(value, NATIVE_HELPERS_KEYS, "nativeHelpers", errors);
174
- const app = insideApp(value.app, "nativeHelpers.app", errors, { strict: true });
196
+ const app = insideApp(value.app, "nativeHelpers.app", errors, { strict: true, bound });
175
197
  if (app && !app.replace(/\/+$/, "").endsWith(".app")) errors.push("nativeHelpers.app must name the .app bundle");
176
198
  let helpers;
177
199
  try { helpers = validateNativeHelpers(value.helpers, { label: "nativeHelpers.helpers" }); } catch (error) { errors.push(...(error.details?.errors ?? [error.message])); }
@@ -208,13 +230,15 @@ export function validateRecipePlan(plan) {
208
230
  steps.push({ phase: step.phase, cmd: step.cmd, args: Array.isArray(args) ? [...args] : [], ...(cwd ? { cwd } : {}), ...(isObject(step.env) ? { env: { ...step.env } } : {}) });
209
231
  }
210
232
  if (builds !== 1) errors.push(`exactly one build step is required, found ${builds}`);
211
- const cleanup = validateCleanup(plan.cleanup, errors);
212
- const stability = validateStability(plan.stability, errors);
233
+ const boundTargets = validateBoundTargets(plan.boundTargets, errors);
234
+ const cleanup = validateCleanup(plan.cleanup, errors, boundTargets ?? []);
235
+ const stability = validateStability(plan.stability, errors, boundTargets ?? []);
213
236
  const stage = validateStage(plan.stage, errors);
214
237
  const verifySidecars = validateVerifySidecars(plan.verifySidecars, errors);
215
- const nativeHelpers = validateNativeHelpersSection(plan.nativeHelpers, errors);
238
+ const nativeHelpers = validateNativeHelpersSection(plan.nativeHelpers, errors, boundTargets ?? []);
239
+ if (boundTargets && stage && (stage.pre?.length || stage.post?.length)) errors.push("a plan that binds Cargo targets (a door split) plans no stage copies: the build output is read and signed in place (rightkit#924 copies are zero)");
216
240
  if (errors.length) fail("RECIPE_BUILD_PLAN_INVALID", `invalid recipe plan:\n${errors.map((line) => `- ${line}`).join("\n")}`, { errors });
217
- return { version: RECIPE_BUILD_PLAN_VERSION, steps, ...(cleanup ? { cleanup } : {}), ...(stability ? { stability } : {}), ...(stage ? { stage } : {}), ...(verifySidecars ? { verifySidecars } : {}), ...(nativeHelpers ? { nativeHelpers } : {}) };
241
+ return { version: RECIPE_BUILD_PLAN_VERSION, steps, ...(boundTargets ? { boundTargets } : {}), ...(cleanup ? { cleanup } : {}), ...(stability ? { stability } : {}), ...(stage ? { stage } : {}), ...(verifySidecars ? { verifySidecars } : {}), ...(nativeHelpers ? { nativeHelpers } : {}) };
218
242
  }
219
243
 
220
244
  /** This package's own right-release entry: the door's RightKit, whatever the app's node_modules holds. */