@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
package/init-command.mjs CHANGED
@@ -253,7 +253,7 @@ export function initTemplate(root, options) {
253
253
 
254
254
  /** What a person does after init: the steps no command can do for them, in order. */
255
255
  export const NEXT_STEPS = Object.freeze([
256
- "pnpm install (installs the pinned packages and, through `prepare`, the pre-push hook)",
256
+ "pnpm install (installs the pinned packages and, through `prepare`, the agent hook; no git hook)",
257
257
  "right-release doctor (the build's own checks for this app, as rows)",
258
258
  "set the app's public legal host in legal-snapshot.config.json, replace legal/PRODUCT_SCHEDULE.md and legal/PRIVACY_NOTICE.md with reviewed text, then `pnpm exec legal-snapshot`",
259
259
  "register the repository on each host: add it to packages/release/ownership-repositories.json in RightKit (and its byte copy under packages/build-control-policy), then run `right-release ownership trust-refresh` on the Mac and the Dell (a person with administrator rights; an agent cannot)",
@@ -621,8 +621,8 @@ async function checkThirdParty({ repoRoot, records, live, findings }) {
621
621
  declarations.get(key).wheres.add(where);
622
622
  };
623
623
 
624
- const rootManifest = await readToml(join(repoRoot, "Cargo.toml"));
625
- const cargoSources = [{ where: "Cargo.toml", manifest: rootManifest }];
624
+ // Every crate is its own Cargo workspace (rightkit#1285): there is no root manifest, each crate declares its own requirements.
625
+ const cargoSources = [];
626
626
  for (const record of records.filter((r) => r.ecosystem === "cargo")) {
627
627
  cargoSources.push({ where: relative(repoRoot, join(record.dir, "Cargo.toml")), manifest: record.manifest });
628
628
  }
@@ -673,11 +673,14 @@ async function checkThirdParty({ repoRoot, records, live, findings }) {
673
673
  });
674
674
 
675
675
  const lockDuplicates = [];
676
- const lockText = await readText(join(repoRoot, "Cargo.lock"));
677
- if (lockText !== null) {
676
+ // One lock per crate workspace: look for duplicate resolutions in each crate's own crates/<x>/Cargo.lock.
677
+ for (const record of records.filter((r) => r.ecosystem === "cargo").sort((a, b) => a.dir.localeCompare(b.dir))) {
678
+ const lockFile = relative(repoRoot, join(record.dir, "Cargo.lock"));
679
+ const lockText = await readText(join(repoRoot, lockFile));
680
+ if (lockText === null) continue;
678
681
  for (const dup of duplicateCargoVersions(lockText)) {
679
- lockDuplicates.push(dup);
680
- findings.push(issue("thirdParty", `crate ${dup.name}`, "duplicate-lock-versions", `Cargo.lock resolves ${dup.versions.join(", ")}`));
682
+ lockDuplicates.push({ ...dup, lock: lockFile });
683
+ findings.push(issue("thirdParty", `crate ${dup.name} (${lockFile})`, "duplicate-lock-versions", `${lockFile} resolves ${dup.versions.join(", ")}`));
681
684
  }
682
685
  }
683
686
  return { declarations: declarations.size, uniquePackages: unique, lockDuplicates };
@@ -914,8 +917,8 @@ export function renderMarkdown(report) {
914
917
  section("account");
915
918
  lines.push(`## ${SECTIONS.thirdParty}`, "", `Declarations checked: ${report.sections.thirdParty.declarations}. Unique external packages: ${report.sections.thirdParty.uniquePackages}.`, "");
916
919
  section("thirdParty");
917
- lines.push("Multiple versions in the root Cargo.lock:", "");
918
- for (const dup of report.sections.thirdParty.lockDuplicates) lines.push(`- \`${dup.name}\`: ${dup.versions.join(", ")}`);
920
+ lines.push("Multiple versions in a crate's own Cargo.lock (one per crate workspace):", "");
921
+ for (const dup of report.sections.thirdParty.lockDuplicates) lines.push(`- \`${dup.name}\` in \`${dup.lock}\`: ${dup.versions.join(", ")}`);
919
922
  if (!report.sections.thirdParty.lockDuplicates.length) lines.push("- none");
920
923
  lines.push("");
921
924
  lines.push(`## ${SECTIONS.apps}`, "", "| App | Present | Duplicate copies in lock files |", "| --- | --- | ---: |");
package/ios-upload.mjs CHANGED
@@ -33,8 +33,8 @@ import { IOS_SIGNING_CONTRACT } from "./ios-package-recipe.mjs";
33
33
  import { ASC_API, createAscClient, createTokenProvider, distributeToTestFlight, normalizeGroupSelection, TestFlightError, testflightPlan } from "./ios-testflight.mjs";
34
34
  import { iosOwnershipScope } from "./ownership-scope.mjs";
35
35
  import { assertArtifactUnchanged, digestBytes, verifyPublicationRelease } from "./publication-artifacts.mjs";
36
- import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
37
- import { assertCleanSource } from "./source-gate.mjs";
36
+ import { assertBindsSource, assertInsideSource, assertReleaseSource, gitRootOf, isSnapshotSource, resolveReleaseSource } from "./release-source-identity.mjs";
37
+ import { verifyOwnershipEvidence } from "./ownership-evidence.mjs";
38
38
  import { targetDistribution } from "./target-distribution.mjs";
39
39
 
40
40
  export const IOS_PROVIDER = "app-store-connect";
@@ -129,28 +129,33 @@ export function iosUploadPlan({ distribution, ipa, receipt }) {
129
129
  }
130
130
 
131
131
  // ---- authorisation -----------------------------------------------------------------------------------------------
132
- const git = (cwd, args) => {
133
- const result = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true });
134
- if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
135
- return result.stdout.trim();
136
- };
137
132
 
138
133
  /** Local checks only; no network, no secret. The sealed release is the only input: build-free by construction. */
139
- export async function authorizeIosUpload({ releaseId, cwd = process.cwd(), configName = defaultReleaseConfigFile(cwd) }) {
134
+ export async function authorizeIosUpload({ releaseId, cwd = process.cwd(), configName = defaultReleaseConfigFile(cwd), env = process.env }) {
140
135
  if (!RELEASE_ID.test(releaseId ?? "")) refuse("IOS_UPLOAD_RELEASE_REQUIRED", "--release <sealed-id> is required");
141
- const repoRoot = git(cwd, ["rev-parse", "--show-toplevel"]);
142
- assertPrimaryReleaseCheckout(repoRoot);
143
- const commit = git(repoRoot, ["rev-parse", "HEAD"]);
144
- assertCleanSource({ status: git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]), commandId: "right-release upload" });
136
+ // Source identity (release-source-identity.mjs, item 7 row 7.04). Outside a door run: the checkout's HEAD, primary worktree and a clean
137
+ // status, as always. Inside one: the selected commit and its verified snapshot, with HEAD, status and branch of the checkout unread.
138
+ const release = resolveReleaseSource({ cwd, env });
139
+ assertReleaseSource(release, { commandId: "right-release upload", env });
140
+ const split = isSnapshotSource(release);
141
+ const repoRoot = gitRootOf(release);
142
+ const commit = release.commit;
145
143
  const configPath = path.resolve(cwd, configName);
144
+ assertInsideSource(release, configPath, "the release config");
146
145
  if (!existsSync(configPath)) refuse("IOS_UPLOAD_CONFIG_INVALID", `release config is required: ${configName}`);
147
146
  // A legacy config is a module that runs on import (a JSON config is data): loaded only after the tree is proven clean.
148
147
  const config = await importReleaseConfig(configPath, { query: `upload=${Date.now()}` });
149
148
  const distribution = validateIosDistribution(targetDistribution(config, "ios"));
150
149
  const sealedDir = path.join(repoRoot, ".right-release", "sealed", releaseId, "ios");
151
- const appRoot = path.dirname(configPath);
150
+ // The sealed release, its signing receipts and the state live where the build wrote them (the checkout's `.right-release`); the scope of
151
+ // the ownership scan is read from the snapshot's own config and project, which are the commit's.
152
+ const appRoot = split ? release.checkoutAppRoot : path.dirname(configPath);
153
+ const scopeRepoRoot = split ? release.snapshot : repoRoot;
154
+ const scopeAppRoot = path.dirname(configPath);
152
155
  // The IPA is verified by a streamed hash and never held in memory (streamRoles); the parts are read as byte ranges at upload time.
153
- const sealed = verifyPublicationRelease({ sealedDir, repoRoot, appRoot, commit, app: config.app, version: config.version, platform: "ios", configPath, releaseId, streamRoles: ["ipa"], ownershipScope: iosOwnershipScope({ repoRoot, appRoot, target: config.targets?.ios, nativeAssembly: config.nativeAssembly }) });
156
+ const sealed = verifyPublicationRelease({ sealedDir, repoRoot, appRoot, commit, app: config.app, version: config.version, platform: "ios", configPath, releaseId, streamRoles: ["ipa"], ownershipScope: iosOwnershipScope({ repoRoot: scopeRepoRoot, appRoot: scopeAppRoot, target: config.targets?.ios, nativeAssembly: config.nativeAssembly }) },
157
+ split ? { verifyOwnership: (expected, root, scanRoot, scope) => verifyOwnershipEvidence(expected, root, scanRoot, scope, release) } : {});
158
+ if (split) assertBindsSource(release, sealed.manifest, "the sealed release");
154
159
  const entries = sealed.manifest.files.filter((entry) => entry.role === "ipa");
155
160
  if (entries.length !== 1) refuse("IOS_IPA_MISSING", "the sealed release must hold exactly one ipa entry");
156
161
  const ipa = sealed.artifacts.get(entries[0].name);
@@ -221,7 +226,7 @@ export async function runIosUpload({
221
226
  acquireLock = acquireFileLock, journalDir, sleep, monotonic, now = () => new Date(),
222
227
  }) {
223
228
  refuseIosAscEnv(env);
224
- const auth = await authorize({ releaseId, configName, cwd });
229
+ const auth = await authorize({ releaseId, configName, cwd, env });
225
230
  const ipaEvidence = { name: auth.entry.name, sha256: auth.entry.sha256, sizeBytes: auth.entry.sizeBytes };
226
231
  const plan = iosUploadPlan({ distribution: auth.distribution, ipa: ipaEvidence, receipt: auth.receipt });
227
232
  const stateRoot = path.join(auth.repoRoot, ".right-release", "state", releaseId, "ios");
package/local-publish.mjs CHANGED
@@ -373,31 +373,26 @@ export function localTestPlan({ kind, unit, dir, config, manifest, batch = false
373
373
 
374
374
  /**
375
375
  * The clippy invocations that replace each crate's own `cargo clippy -p <crate> --no-deps --all-targets
376
- * <featureSets[0]> -- -D warnings`: same flags and lint level, so the same lints on the same code. Crates
377
- * whose first feature set is empty or `--all-features` share one `-p a -p b` invocation (the flags mean the
378
- * same for every crate); any other feature set runs per crate, because `--features x` must not reach a crate
379
- * that lacks x. The caller passes ONE dependency layer: no selected crate depends on another, so `-p` does not
380
- * unify extra features into a selected crate and each is linted with exactly the features it was before.
381
- * Crates with a different RIGHTKIT_REQUIRE_ASSETS (asset-gated) stay apart so the environment is unchanged.
376
+ * <featureSets[0]> -- -D warnings`: same flags and lint level, so the same lints on the same code. Every crate is its own
377
+ * Cargo workspace with its own Cargo.lock (rightkit#1285), so cargo runs in that crate's directory and one invocation never
378
+ * names two crates: one `-p <crate>` run per crate and feature set, in layer order. Crates with a different
379
+ * RIGHTKIT_REQUIRE_ASSETS (asset-gated) keep their own environment.
382
380
  */
383
381
  export function batchClippyInvocations(layer, { featuresOf = () => [], featureSetsOf = (unit) => [featuresOf(unit)], assetGatedOf = () => false } = {}) {
384
- const groups = new Map();
382
+ const invocations = [];
385
383
  for (const unit of layer) {
386
384
  // Every tested feature set of a crate is linted, in order; identical sets of one crate collapse to one run.
387
385
  const sets = featureSetsOf(unit) ?? [[]];
388
386
  const unique = sets.filter((set, index) => sets.findIndex((other) => JSON.stringify(other ?? []) === JSON.stringify(set ?? [])) === index);
389
387
  for (const set of unique) {
390
388
  const features = set ?? [];
391
- const shared = features.length === 0 || (features.length === 1 && features[0] === "--all-features");
392
- const key = shared ? JSON.stringify([features, assetGatedOf(unit)]) : `unit:${unit}:${JSON.stringify(features)}`;
393
- if (!groups.has(key)) groups.set(key, { crates: [], features, assetGated: assetGatedOf(unit) });
394
- groups.get(key).crates.push(unit);
389
+ invocations.push({
390
+ crates: [unit], assetGated: assetGatedOf(unit),
391
+ args: ["clippy", "-p", unit, "--message-format=json-render-diagnostics", "--no-deps", "--all-targets", ...features, "--", "-D", "warnings"],
392
+ });
395
393
  }
396
394
  }
397
- return [...groups.values()].map(({ crates, features, assetGated }) => ({
398
- crates, assetGated,
399
- args: ["clippy", ...crates.flatMap((unit) => ["-p", unit]), "--message-format=json-render-diagnostics", "--no-deps", "--all-targets", ...features, "--", "-D", "warnings"],
400
- }));
395
+ return invocations;
401
396
  }
402
397
 
403
398
  const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
@@ -450,15 +445,47 @@ export async function runLocalPublishUploadEngine(kind, argv, deps = {}) {
450
445
 
451
446
  // Stage state never leaves this process: persisted receipts are reports, not authority to skip unit tests.
452
447
  const unitStages = new WeakMap();
453
- function assertUnitDoor(root, stage, env, identity) {
454
- const commit = defaultGit(["rev-parse", "HEAD"], root);
455
- const tree = defaultGit(["rev-parse", "HEAD^{tree}"], root);
456
- const common = defaultGit(["rev-parse", "--git-common-dir"], root);
448
+
449
+ /**
450
+ * A unit stage built from a door SNAPSHOT (item 7 row 7.06a): `source` is { checkout, commit, tree } of the commit the door selected,
451
+ * and `--workspace` is the gate-owned snapshot of it. Identity is that commit's immutable objects, read from the CHECKOUT's object
452
+ * database, plus the door's start and end verification of the snapshot; it is never the checkout's HEAD or status. The git calls of
453
+ * the unit flow are mapped: HEAD and its tree are the selection, `status` is clean by the door's verify, `show HEAD:<path>` and
454
+ * `ls-files --error-unmatch` read the selected commit, and everything else runs in the checkout.
455
+ */
456
+ export function sourceGit(source, git = defaultGit) {
457
+ return (args, cwd) => {
458
+ const [a, b] = args;
459
+ if (a === "rev-parse" && b === "HEAD") return source.commit;
460
+ if (a === "rev-parse" && b === "HEAD^{tree}") return source.tree;
461
+ if (a === "status") return "";
462
+ if (a === "show" && /^HEAD:/.test(b ?? "")) return git(["show", `${source.commit}:${b.slice(5)}`], source.checkout);
463
+ if (a === "ls-files" && b === "--error-unmatch") { const files = args.slice(args.indexOf("--") + 1); for (const f of files) if (git(["cat-file", "-e", `${source.commit}:${f}`], source.checkout) === null) return null; return files.join("\n"); }
464
+ return git(args, source.checkout);
465
+ };
466
+ }
467
+
468
+ /** The local-build grant read from the SELECTED COMMIT (`git show <commit>:.rightkit-local-build.json`), its repository checked against the checkout's origin. */
469
+ export function localBuildGrantedAt(source, git = defaultGit) {
470
+ try {
471
+ const grant = JSON.parse(git(["show", `${source.commit}:${LOCAL_BUILD_FILE}`], source.checkout) ?? "null");
472
+ if (grant?.allow !== "build-test-publish" || typeof grant.repository !== "string") return false;
473
+ const origin = git(["config", "--get", "remote.origin.url"], source.checkout) ?? "";
474
+ const repo = /github\.com[:/]([^/]+\/[^/.\s]+?)(?:\.git)?\/?$/i.exec(origin)?.[1];
475
+ return !!repo && repo.toLowerCase() === grant.repository.toLowerCase();
476
+ } catch { return false; }
477
+ }
478
+
479
+ function assertUnitDoor(root, stage, env, identity, source = null) {
480
+ const commit = source ? source.commit : defaultGit(["rev-parse", "HEAD"], root);
481
+ const tree = source ? source.tree : defaultGit(["rev-parse", "HEAD^{tree}"], root);
482
+ const common = defaultGit(["rev-parse", "--git-common-dir"], source ? source.checkout : root);
457
483
  if (!/^[0-9a-f]{40}$/.test(commit ?? "") || !/^[0-9a-f]{40}$/.test(tree ?? "") || !common) refuse("cannot bind unit stage to HEAD and tree");
458
484
  if (identity && (identity.commit !== commit || identity.tree !== tree)) refuse("unit stage HEAD or tree changed");
459
- const dirty = defaultGit(["status", "--porcelain", "--untracked-files=no"], root);
485
+ // The snapshot route has no checkout cleanliness to ask: the door verifies the snapshot against the commit at each stage's start and end.
486
+ const dirty = source ? "" : defaultGit(["status", "--porcelain", "--untracked-files=no"], root);
460
487
  if (dirty === null || dirty !== "") refuse("unit stage requires a clean tracked checkout");
461
- const commonDir = path.resolve(root, common);
488
+ const commonDir = path.resolve(source ? source.checkout : root, common);
462
489
  const denial = requireDoorRun({ env, commonDir, commit, command: `unit ${stage}` });
463
490
  if (denial) refuse(denial);
464
491
  let run;
@@ -467,17 +494,17 @@ function assertUnitDoor(root, stage, env, identity) {
467
494
  return { commit, tree };
468
495
  }
469
496
 
470
- export async function compileLocalUnit(kind, argv, { env = process.env } = {}) {
497
+ export async function compileLocalUnit(kind, argv, { env = process.env, source = null } = {}) {
471
498
  const parsed = parseLocalPublishArgs(kind, argv);
472
499
  if (!parsed.qualifyOnly || parsed.batch) refuse("unit stages require one --qualify-only unit and a new bundle");
473
- const identity = assertUnitDoor(parsed.workspaceRoot, "compile", env);
474
- const runner = publishUnitStages(parsed, { env }, null);
500
+ const identity = assertUnitDoor(parsed.workspaceRoot, "compile", env, undefined, source);
501
+ const runner = publishUnitStages(parsed, source ? { env, git: sourceGit(source), localBuildGranted: () => localBuildGrantedAt(source) } : { env }, null);
475
502
  try {
476
503
  const step = await runner.next();
477
504
  if (step.done || step.value?.stage !== "compile") refuse("unit compile did not complete");
478
- assertUnitDoor(parsed.workspaceRoot, "compile", env, identity);
505
+ assertUnitDoor(parsed.workspaceRoot, "compile", env, identity, source);
479
506
  const state = Object.freeze({});
480
- unitStages.set(state, { runner, parsed, identity, stage: "compile", compileRequests: [...(step.value.brokerRequests ?? [])] });
507
+ unitStages.set(state, { runner, parsed, identity, source, stage: "compile", compileRequests: [...(step.value.brokerRequests ?? [])] });
481
508
  return state;
482
509
  } catch (error) { await runner.return(); throw error; }
483
510
  }
@@ -487,10 +514,10 @@ async function advanceLocalUnit(state, expected, stage, env) {
487
514
  if (!entry || entry.stage !== expected) refuse(`unit ${stage} requires this process's completed ${expected} stage`);
488
515
  entry.stage = "running";
489
516
  try {
490
- assertUnitDoor(entry.parsed.workspaceRoot, stage, env, entry.identity);
517
+ assertUnitDoor(entry.parsed.workspaceRoot, stage, env, entry.identity, entry.source);
491
518
  const step = await entry.runner.next(env);
492
519
  if (stage === "package" ? step.done || step.value?.stage !== "package" : !step.done || !step.value?.qualifiedOnly) refuse(`unit ${stage} did not complete`);
493
- assertUnitDoor(entry.parsed.workspaceRoot, stage, env, entry.identity);
520
+ assertUnitDoor(entry.parsed.workspaceRoot, stage, env, entry.identity, entry.source);
494
521
  entry.stage = stage;
495
522
  if (stage === "qualify") unitStages.delete(state);
496
523
  return step.value;
@@ -566,7 +593,10 @@ async function* publishUnitStages(parsed, deps, batch) {
566
593
  if (tree !== batch.tree) refuse(`${unit}: HEAD tree ${tree} differs from the batch tree ${batch.tree}`);
567
594
  }
568
595
  const feedPath = "packages/release/rightkit-versions.json";
569
- const guardedPaths = [dir, ...GATE_CONFIG_FILES, ...(parsed.qualifyOnly && kind === "cargo" ? ["Cargo.toml", "Cargo.lock"] : []), ...(parsed.qualifyOnly && kind === "npm" ? [feedPath] : [])];
596
+ // `dir` holds the crate's own Cargo.toml and Cargo.lock (each crate is its own workspace, #1285); a sibling crate it path-depends on
597
+ // shapes what is built, so its directory must match the commit too.
598
+ const siblingDirs = kind === "cargo" ? pathDependencyDirs(unitPath).map((sibling) => path.relative(workspaceRoot, sibling).split(path.sep).join("/")).filter((sibling) => sibling && !sibling.startsWith("..")) : [];
599
+ const guardedPaths = [dir, ...siblingDirs, ...GATE_CONFIG_FILES, ...(parsed.qualifyOnly && kind === "npm" ? [feedPath] : [])];
570
600
  const dirty = git(["status", "--porcelain", "--", ...guardedPaths], workspaceRoot);
571
601
  if (dirty === null) refuse(`cannot read git status for ${dir}`);
572
602
  if (dirty !== "") refuse(`tree is not clean for ${dir} or the gate config (${GATE_CONFIG_FILES.join(", ")}); commit first:\n${dirty}`);
@@ -612,7 +642,10 @@ async function* publishUnitStages(parsed, deps, batch) {
612
642
  for (const step of plan.steps) {
613
643
  const commandText = `${step.command} ${step.args.join(" ")}`;
614
644
  log(`[right-release] ${plan.id}: ${commandText}`);
615
- const result = await run(step.command, step.command === "cargo" ? withCargoJobs(step.args, parsed.jobs) : step.args, { cwd: workspaceRoot, env });
645
+ // A crate's own steps run in its Cargo workspace (its own Cargo.lock and target directory, #1285); a consumer row names its
646
+ // separate workspace by --manifest-path relative to the repository root.
647
+ const stepCwd = kind === "cargo" && step.command === "cargo" && !step.args.includes("--manifest-path") ? unitPath : workspaceRoot;
648
+ const result = await run(step.command, step.command === "cargo" ? withCargoJobs(step.args, parsed.jobs) : step.args, { cwd: stepCwd, env });
616
649
  const row = { command: commandText, ok: false, isTest: step.test && TEST_COMMAND.test(commandText) };
617
650
  if (result.error || result.status !== 0) {
618
651
  steps.push(row);
@@ -675,11 +708,11 @@ async function* publishUnitStages(parsed, deps, batch) {
675
708
  // its evidence leaves this host, so the publishing host can reuse that exact archive's proof.
676
709
  const packageArgs = cargoPackageArgs(unit, parsed.qualifyOnly);
677
710
  packCommands.push(`cargo ${packageArgs.join(" ")}`);
678
- const packed = await run("cargo", withCargoJobs(packageArgs, parsed.jobs), { cwd: workspaceRoot, env });
711
+ const packed = await run("cargo", withCargoJobs(packageArgs, parsed.jobs), { cwd: unitPath, env });
679
712
  if (packed.error || packed.status !== 0) refuse(`cargo package failed for ${unit}\n${tail(`${packed.stdout ?? ""}\n${packed.stderr ?? ""}`)}`);
680
713
  if (parsed.qualifyOnly) steps.push({ command: `cargo ${packageArgs.join(" ")}`, ok: true });
681
714
  const metadataArgs = [...CARGO_METADATA_ARGS];
682
- const meta = await runCargoReadOnly(metadataArgs, { cwd: workspaceRoot, env }, (_command, args, settings) => run("cargo", args, settings),
715
+ const meta = await runCargoReadOnly(metadataArgs, { cwd: unitPath, env }, (_command, args, settings) => run("cargo", args, settings),
683
716
  (failed) => log(`[right-release] offline Cargo metadata missed cache; retrying online once\n${failed.stdout ?? ""}${failed.stderr ? `\n${failed.stderr}` : ""}`));
684
717
  const actualMetadataArgs = meta.usedOfflineFallback ? metadataArgs.filter((arg) => arg !== "--offline") : metadataArgs;
685
718
  packCommands.push(`cargo ${actualMetadataArgs.join(" ")}`);
@@ -801,11 +834,11 @@ async function runLocalPublishBatch(parsed, deps) {
801
834
  if (!head || !/^[0-9a-f]{40}$/.test(head)) refuse("cannot resolve HEAD");
802
835
  const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
803
836
  if (!tree || !/^[0-9a-f]{40}$/.test(tree)) refuse("cannot resolve the HEAD tree");
804
- // Every batch crate, the workspace manifest and lock, the gate config and EVERY crate's consumer directories must
805
- // match the commit the batch checks are bound to, before the first layer publishes (0.4.1 review A3, A4).
806
- const dirty = git(["status", "--porcelain", "--", ...crates.map((unit) => `crates/${unit}`), "Cargo.toml", "Cargo.lock", ...GATE_CONFIG_FILES, ...batchConsumerDirs], workspaceRoot);
837
+ // Every batch crate (its own Cargo.toml and Cargo.lock live in its directory, #1285), the gate config and EVERY crate's
838
+ // consumer directories must match the commit the batch checks are bound to, before the first layer publishes (0.4.1 review A3, A4).
839
+ const dirty = git(["status", "--porcelain", "--", ...crates.map((unit) => `crates/${unit}`), ...GATE_CONFIG_FILES, ...batchConsumerDirs], workspaceRoot);
807
840
  if (dirty === null) refuse("cannot read git status for the batch");
808
- if (dirty !== "") refuse(`tree is not clean for the batch crates, the workspace manifest, the gate config or a consumer directory; commit first:\n${dirty}`);
841
+ if (dirty !== "") refuse(`tree is not clean for the batch crates, the gate config or a consumer directory; commit first:\n${dirty}`);
809
842
  const registryHas = deps.registryHas ?? defaultRegistryHas;
810
843
  const published = (await Promise.all(crates.map(async (unit) => ((await registryHas({ kind: "cargo", name: unit, version: versions.get(unit) })) ? `${unit}@${versions.get(unit)}` : null)))).filter(Boolean);
811
844
  if (published.length) refuse(`already on the crates.io registry: ${published.join(", ")}; bump the version or remove them from the batch`);
@@ -815,15 +848,18 @@ async function runLocalPublishBatch(parsed, deps) {
815
848
  const env = testEnv(deps, false);
816
849
  const stamp = { commit: head, tree };
817
850
  const checks = []; const commands = [];
818
- const batchCheck = async (name, command, args, crateNames, runEnv = env) => {
851
+ // One recorded check; `cwds` are the crate workspaces it runs in (each crate is its own Cargo workspace, #1285), once each.
852
+ const batchCheck = async (name, command, args, crateNames, runEnv = env, cwds = [workspaceRoot]) => {
819
853
  const text = `${command} ${args.join(" ")}`;
820
854
  log(`[right-release] batch ${name}: ${text}`);
821
- const result = await run(command, args, { cwd: workspaceRoot, env: runEnv });
822
- if (result.error || result.status !== 0) refuse(`batch ${name} failed (${result.error?.message ?? `exit ${result.status}`}): ${text}\n${tail(`${result.stdout ?? ""}\n${result.stderr ?? ""}`)}`);
855
+ for (const cwd of cwds) {
856
+ const result = await run(command, args, { cwd, env: runEnv });
857
+ if (result.error || result.status !== 0) refuse(`batch ${name} failed (${result.error?.message ?? `exit ${result.status}`}): ${text}${cwd === workspaceRoot ? "" : ` (in ${path.relative(workspaceRoot, cwd)})`}\n${tail(`${result.stdout ?? ""}\n${result.stderr ?? ""}`)}`);
858
+ }
823
859
  checks.push({ name, command: text, ok: true, ...stamp, ...(crateNames ? { crates: crateNames } : {}) });
824
860
  commands.push(text);
825
861
  };
826
- await batchCheck("fmt", "cargo", ["fmt", "--all", "--check"]);
862
+ await batchCheck("fmt", "cargo", ["fmt", "--all", "--check"], undefined, env, crates.map((unit) => path.join(workspaceRoot, "crates", unit)));
827
863
  log("[right-release] batch secret-scan: crate directories of the batch");
828
864
  const scan = deps.scanSecrets ?? scanCrateForSecrets;
829
865
  const violations = crates.flatMap((unit) => scan(path.join(workspaceRoot, "crates", unit)).map((file) => `${unit}: ${file}`));
@@ -834,7 +870,7 @@ async function runLocalPublishBatch(parsed, deps) {
834
870
  const featureSetsOf = (unit) => gateConfig?.rust?.[unit]?.featureSets ?? [[]];
835
871
  for (const layer of layers) {
836
872
  for (const invocation of batchClippyInvocations(layer, { featureSetsOf, assetGatedOf: isAssetGated })) {
837
- await batchCheck("clippy", "cargo", invocation.args, invocation.crates, testEnv(deps, invocation.assetGated));
873
+ await batchCheck("clippy", "cargo", invocation.args, invocation.crates, testEnv(deps, invocation.assetGated), invocation.crates.map((unit) => path.join(workspaceRoot, "crates", unit)));
838
874
  }
839
875
  }
840
876
  const receipt = { id: `batch-${head.slice(0, 12)}-${sha256(JSON.stringify(layers)).slice(0, 8)}`, ...stamp, crates, checks };
package/local-qualify.mjs CHANGED
@@ -370,7 +370,7 @@ function brokerIdentity(deps) {
370
370
  return { name: "rustright", client: String(client).replaceAll("\\", "/"), sha256: sha256((deps.readFile ?? fs.readFileSync)(client)) };
371
371
  }
372
372
 
373
- // Cargo only through the broker: `rustright cargo`, or a `cargo` on PATH that is the managed shim (scripts/local-gate.mjs).
373
+ // Cargo only through the broker: `rustright cargo`, or a `cargo` on PATH that is the managed shim.
374
374
  function detectCargoCommand(deps) {
375
375
  const which = (command) => {
376
376
  const result = (deps.run ?? spawnSync)((deps.platform ?? process.platform) === "win32" ? "where" : "which", [command], { encoding: "utf8", windowsHide: true });
@@ -406,14 +406,16 @@ async function localScanRecord({ root, commit, crates, jobs, scratch, deps, log
406
406
  const repo = path.join(exportRoot, "rightkit");
407
407
  const tracked = String((deps.git ?? defaultGit)(["ls-files", "-z"], root) ?? "").split("\0").filter(Boolean);
408
408
  const unitDirs = crates.map((name) => `crates/${name}/`);
409
- // Workspace-level scans (lock, deny, audit, workflows, YAML) plus every scan of the units; other crates and npm packages stay out of scope.
409
+ // Repository-level scans (workflows, YAML) plus every scan of the units, each in its own crate workspace (lock, deny, audit); other crates and npm packages stay out of scope.
410
410
  const files = tracked.filter((file) => (!file.startsWith("crates/") && !file.startsWith("packages/")) || unitDirs.some((dir) => file.startsWith(dir)));
411
411
  const crateFeatures = (deps.readJson ?? defaultReadJson)(path.join(repo, "ci", "gate.config.json"))?.rust ?? {};
412
412
  const plan = (deps.planScans ?? planScans)({ changed: files, contentRoot: repo, liveRoot: root, includeTests: false, localCompile: true, cargoCommand: detectCargoCommand(deps), crateFeatures, readBytes: (file) => fs.readFileSync(file) });
413
413
  const observedTools = modules.observeToolVersions();
414
414
  log(`[local-qualify] scans: ${plan.scans.length} planned over ${files.length} files (class A, then class B through the broker)`);
415
415
  const result = await (deps.runScans ?? runScans)(plan, { classes: ["A", "B"], jobs: Math.max(1, Number(jobs ?? 4) || 4), onResult: (row, progress) => log(`[local-qualify] scan [${progress.done}/${progress.total}] ${row.status.padEnd(7)} ${row.id}`) });
416
- return modules.buildLocalScanRecord({ result, planned: plan.scans, sourceCommit: commit, headCommit: commit, cargoLockSha256: sha256(fs.readFileSync(path.join(root, "Cargo.lock"))), observedTools, units: crates, files: files.length });
416
+ // Every unit is bound to its own crate workspace lock (#1285): crates/<x>/Cargo.lock, no root lock.
417
+ const cargoLocks = Object.fromEntries([...crates].sort().map((name) => [name, { path: `crates/${name}/Cargo.lock`, sha256: sha256(fs.readFileSync(path.join(root, "crates", name, "Cargo.lock"))) }]));
418
+ return modules.buildLocalScanRecord({ result, planned: plan.scans, sourceCommit: commit, headCommit: commit, cargoLocks, observedTools, units: crates, files: files.length });
417
419
  } finally { fs.rmSync(exportRoot, { recursive: true, force: true }); }
418
420
  }
419
421
 
package/local-runtime.mjs CHANGED
@@ -163,8 +163,9 @@ export async function measureRuntimeCrate(options, deps = {}) {
163
163
  const bindings = {};
164
164
  for (const full of [...lsTree([`crates/${crate}/src`]), `crates/${crate}/Cargo.toml`]) bindings[full] = sha256(blob(full));
165
165
  Object.assign(bindings, owner.bindHarnessMetadata(crate, prescription, blob));
166
- for (const full of lsTree(["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo", `crates/${crate}/benches`, "crates/rightkit-child-process/src", "crates/rightkit-child-process/Cargo.toml"])) bindings[full] = sha256(blob(full));
167
- const { specs, problems: scopeProblems = [] } = (deps.measuredPathspecs ?? H.budgets.measuredPathspecs)(crateDir);
166
+ // Each crate is its own Cargo workspace with its own Cargo.lock (#1285): the same context files scripts/ci/runtime-qualify.mjs binds.
167
+ for (const full of lsTree([`crates/${crate}/Cargo.toml`, `crates/${crate}/Cargo.lock`, "rust-toolchain.toml", ".cargo", `crates/${crate}/.cargo`, `crates/${crate}/benches`, "crates/rightkit-child-process/src", "crates/rightkit-child-process/Cargo.toml"])) bindings[full] = sha256(blob(full));
168
+ const { specs, problems: scopeProblems = [] } = (deps.measuredPathspecs ?? H.budgets.measuredPathspecs)(crateDir, { base: root });
168
169
  if (scopeProblems.length) throw new Error(`cannot bind the measured dependency scope: ${scopeProblems.join("; ")}`);
169
170
  for (const full of lsTree(specs)) bindings[full] = sha256(blob(full));
170
171
  const helpers = Object.fromEntries(H.helperPaths().map((name) => [name, sha256(blob(name))]));
@@ -174,9 +175,9 @@ export async function measureRuntimeCrate(options, deps = {}) {
174
175
  const socket = discoverBrokerSocket(deps.env ?? process.env, { platform });
175
176
  if (socket) brokerEnv.RIGHTKIT_BUILD_BROKER_SOCKET = socket;
176
177
  const run = deps.run ?? ((command, args, settings) => spawnCollect(command, args, { maxBuffer: 64 * 1024 * 1024, ...settings }));
177
- const invoke = (args, settings) => run(cargoCommand[0], [...cargoCommand.slice(1), ...args], { cwd: root, windowsHide: true, ...settings });
178
+ const invoke = (args, settings) => run(cargoCommand[0], [...cargoCommand.slice(1), ...args], { cwd: crateDir, windowsHide: true, ...settings });
178
179
  const toolchainOf = async () => {
179
- const rustc = await run(cargoCommand[0] === "rustright" ? "rustright" : "rustc", cargoCommand[0] === "rustright" ? ["rustc", "-Vv"] : ["-Vv"], { cwd: root, env: { ...baseEnv, ...brokerEnv }, windowsHide: true, timeout: 60_000 });
180
+ const rustc = await run(cargoCommand[0] === "rustright" ? "rustright" : "rustc", cargoCommand[0] === "rustright" ? ["rustc", "-Vv"] : ["-Vv"], { cwd: crateDir, env: { ...baseEnv, ...brokerEnv }, windowsHide: true, timeout: 60_000 });
180
181
  const cargo = await invoke(["-V"], { env: { ...baseEnv, ...brokerEnv }, timeout: 60_000 });
181
182
  if (rustc.error || rustc.status !== 0 || cargo.error || cargo.status !== 0) throw new Error("cannot read the toolchain through the broker");
182
183
  return { rustc: String(rustc.stdout).trim(), cargo: String(cargo.stdout).trim() };
@@ -18,6 +18,7 @@ import { buildDmg } from "./dmg-build.mjs";
18
18
  import { spawnSync } from "./background-process.mjs";
19
19
  import { signMacosEntries } from "./mac-sign-core.mjs";
20
20
  import { mainExecutableName } from "./mac-bundle-signing.mjs";
21
+ import { resolveArtifactPath, splitArtifactContext } from "./release-artifact-path.mjs";
21
22
  import { notarizeMacosArtifact } from "./macos-notarization.mjs";
22
23
  import { resolveMacosDeveloperIdIdentity } from "./macos-signing-identity.mjs";
23
24
  import { runMacCodesignCommands, signMacUpdaterFiles } from "./macos-signing-session.mjs";
@@ -160,10 +161,12 @@ const defaultInspect = (cmd, args) => {
160
161
  * signUpdater(files, options), receiptPath, log(event).
161
162
  */
162
163
  export function runMacPackageRecipe({ appRoot, config = {}, target, deps = {} }) {
164
+ let artifactPaths = null;
163
165
  const { recipe, app: declaredApp, dmgFile } = validateMacPackageTarget(target);
164
166
  const {
165
167
  platform = process.platform, env = process.env, fs = nodeFs,
166
- resolveArtifact = (root, p) => path.resolve(root, p),
168
+ // A door split (row 7.04c, R-T2) reads a declared target path in the bound Cargo target, never the snapshot's or the checkout's; outside a split this is path.resolve.
169
+ resolveArtifact = (root, p) => { artifactPaths ??= splitArtifactContext({ cwd: root, env, target }); return artifactPaths.mode === "split" ? resolveArtifactPath(p, artifactPaths) : path.resolve(root, p); },
167
170
  resolveIdentity = resolveMacosDeveloperIdIdentity,
168
171
  signOne = (codesignArgs) => runMacCodesignCommands([codesignArgs], { env })[0],
169
172
  inspect = defaultInspect, evidence, hashFile,
@@ -11,6 +11,7 @@ import { copyFileSync, lstatSync, mkdirSync, readdirSync, realpathSync, renameSy
11
11
  import { execFileSync } from "./background-process.mjs";
12
12
  import path from "node:path";
13
13
  import { importReleaseConfig } from "./release-config.mjs";
14
+ import { ArtifactPathError, isSplitArtifactEnv, loadSplitArtifactContext, resolveArtifactPath } from "./release-artifact-path.mjs";
14
15
 
15
16
  const args = parseArgs(process.argv.slice(2));
16
17
  const usage = "Usage: right-release mirror-root-artifact (--file <artifact> --package-root <dir> [--name <filename>] | --config <file> --platform mac|win [--package-root <dir>])";
@@ -52,15 +53,27 @@ if (args.config) {
52
53
 
53
54
  const packageRoot = realpathSync(path.resolve(rootArg));
54
55
  const managedOutput = path.resolve(process.env.RIGHT_RELEASE_ARTIFACT_DIR);
55
- const repoRoot = path.resolve(git(packageRoot, "rev-parse", "--show-toplevel"));
56
- if (inside(managedOutput, repoRoot)) {
57
- console.error(`right-release mirror-root-artifact: managed output must be outside repository: ${managedOutput}`);
58
- process.exit(2);
56
+ // A door split (row 7.04c): the package root is the snapshot, which has no git directory; the repositories the output must stay out of are the
57
+ // checkout and the snapshot, and a target-prefixed source is the bound Cargo target's (read in place, never the checkout's).
58
+ const splitMirror = isSplitArtifactEnv(process.env);
59
+ const artifactPaths = await loadSplitArtifactContext({ cwd: packageRoot });
60
+ const repoRoots = splitMirror ? [path.resolve(process.env.RK_GATE_CHECKOUT), packageRoot] : [path.resolve(git(packageRoot, "rev-parse", "--show-toplevel"))];
61
+ for (const repoRoot of repoRoots) {
62
+ if (inside(managedOutput, repoRoot)) {
63
+ console.error(`right-release mirror-root-artifact: managed output must be outside repository: ${managedOutput}`);
64
+ process.exit(2);
65
+ }
59
66
  }
60
67
  const safeName = (name) => name === path.basename(name) && /^[A-Za-z0-9][A-Za-z0-9._+-]{0,159}$/.test(name);
61
68
 
62
69
  for (const entry of entries) {
63
- const source = path.resolve(packageRoot, entry.file);
70
+ let source;
71
+ try { source = splitMirror ? resolveArtifactPath(entry.file, artifactPaths) : path.resolve(packageRoot, entry.file); }
72
+ catch (error) {
73
+ if (!(error instanceof ArtifactPathError)) throw error;
74
+ console.error(`right-release mirror-root-artifact: ${error.message}`);
75
+ process.exit(2);
76
+ }
64
77
  const name = entry.name || path.basename(source);
65
78
  if (!safeName(name)) {
66
79
  console.error(`right-release mirror-root-artifact: unsafe artifact name: ${name}`);
@@ -4,7 +4,7 @@
4
4
  // app-specific product domain logic and presentation only.
5
5
  // Non-product code stays in an app only under an approved, expiring entry of .rightkit/ownership-exceptions.json that names the
6
6
  // RightKit issue that moves it. These two rules run wherever the app check runs (`rightkit-gate check`, `right-release check`, the
7
- // pre-push hook, the start of every checked right-release command and RightKit's own preflight):
7
+ // start of every checked right-release command and RightKit's own preflight):
8
8
  //
9
9
  // mandatory-foundation OW-05 an app whose profile requires a foundation owner the app does not declare as a dependency (missing,
10
10
  // or dev-only), or that selects an unknown or unratified profile. An app is a repository whose root
@@ -8,6 +8,7 @@ import { acquireCommittedSource, assertCommittedCheckoutClean, committedSourceDa
8
8
  import { readCommittedKeyAuthority } from "./ownership-key-authority.mjs";
9
9
  import { runningPolicyBundle } from "./ownership-policy-bundle.mjs";
10
10
  import { canonicalRepositoryIdentity, loadTrustedExternal, resolveKeyLogTrust, sha256, validateOwnershipFloor, verifyRepositoryRegistration } from "./ownership-approval.mjs";
11
+ import { isReleaseSource } from "./release-source-brand.mjs";
11
12
 
12
13
  const digestFile = (file) => createHash("sha256").update(readFileSync(file)).digest("hex");
13
14
 
@@ -32,8 +33,22 @@ function createEvidence(repoRoot, {
32
33
  requireCleanCheckout = false,
33
34
  allowPendingAdvisory = false,
34
35
  policyBundleProvider = runningPolicyBundle,
36
+ // A door source split (release-source-identity.mjs `resolveReleaseSource`, item 7 row 7.04): the evidence binds the SELECTED commit's
37
+ // objects, read from the checkout's object database, and never the checkout's HEAD or status. null (and a checkout-mode source) is today's behaviour.
38
+ releaseSource = null,
35
39
  } = {}, readAuthority = readCommittedKeyAuthority, scanCommitted = checkCommittedApp) {
40
+ if (releaseSource !== null && !isReleaseSource(releaseSource)) throw new Error("ownership evidence: the release source was not resolved by resolveReleaseSource");
41
+ const snapshotSource = releaseSource?.mode === "snapshot" ? releaseSource : null;
36
42
  const root = path.resolve(repoRoot);
43
+ // Real paths on both sides: Git's root is resolved (macOS /private/var), callers may pass /var or /tmp.
44
+ const realPath = (p) => { try { return realpathSync.native(p); } catch { return path.resolve(p); } };
45
+ if (snapshotSource && realPath(root) !== realPath(snapshotSource.checkout)) throw new Error(`ownership evidence: in a door source split the git root is the run's checkout ${snapshotSource.checkout}, not ${root}`);
46
+ // The caller names its app root in the snapshot (its cwd); the committed scan places it by its path inside the repository, which is the
47
+ // same relative path in the checkout. Nothing is read from either: the scan reads committed objects.
48
+ if (snapshotSource) {
49
+ const relative = path.relative(realPath(snapshotSource.snapshot), realPath(scanRoot));
50
+ if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) scanRoot = path.join(snapshotSource.checkout, relative);
51
+ }
37
52
  const scope = requestedScope ? [...new Set(requestedScope)].sort() : null;
38
53
  const manifestBytes = readFileSync(manifestPath);
39
54
  const manifest = JSON.parse(manifestBytes.toString("utf8"));
@@ -48,8 +63,14 @@ function createEvidence(repoRoot, {
48
63
  // a useful local backlog scan. It cannot assert a protected source binding or verify
49
64
  // approval signatures, so this result is deliberately advisory.
50
65
  const useCommittedSource = committed && (!enrollmentPending || Boolean(trustState.trust?.rootFingerprint));
51
- if (useCommittedSource && requireCleanCheckout) assertCommittedCheckoutClean(root);
52
- const source = useCommittedSource ? acquireCommittedSource(root, { requiredPaths: [...(trustState.trust?.provenance ?? []).flatMap((record) => record.paths ?? []), ...(trustState.trust?.rootFingerprint && !trustState.trust?.keyAuthority ? [".rightkit/ownership-keys.json"] : [])] }) : null;
66
+ // In a split the evidence is the selected commit's committed source or nothing: the advisory fallback would scan a mutable tree and
67
+ // read HEAD, which are exactly the reads a split replaces. Fail closed.
68
+ if (snapshotSource && !useCommittedSource) throw new Error("ownership evidence in a door source split needs the committed-source scan; this host's trust enrollment cannot provide it (RK_SOURCE_SPLIT)");
69
+ // The clean-checkout condition guards "bytes scanned (committed blobs) = bytes built (working tree)". A split builds a verified snapshot
70
+ // of the commit instead, so the checkout's cleanliness is irrelevant there (GC:24, GC:156 item 1).
71
+ if (useCommittedSource && requireCleanCheckout && !snapshotSource) assertCommittedCheckoutClean(root);
72
+ const selected = snapshotSource ? { commit: snapshotSource.commit, expectedTree: snapshotSource.tree } : {};
73
+ const source = useCommittedSource ? acquireCommittedSource(root, { ...selected, requiredPaths: [...(trustState.trust?.provenance ?? []).flatMap((record) => record.paths ?? []), ...(trustState.trust?.rootFingerprint && !trustState.trust?.keyAuthority ? [".rightkit/ownership-keys.json"] : [])] }) : null;
53
74
  let keyLogEvidence = null;
54
75
  if (trustState.trust?.keyAuthority) {
55
76
  const resolved = readAuthority(trustState.trust);
@@ -59,7 +80,7 @@ function createEvidence(repoRoot, {
59
80
  keyLogEvidence = { keyLogDigest: resolved.digest, keyCheckpoint: resolved.keyCheckpoint, rootFingerprint: resolved.rootFingerprint, keyAuthority: resolved.authority };
60
81
  if (!resolved.resolvedTrust.ownershipKeys.length) throw new Error("protected ownership key authority has no active approval keys");
61
82
  } else if (trustState.trust?.rootFingerprint) {
62
- const keySource = source ?? acquireCommittedSource(root, { requiredPaths: [".rightkit/ownership-keys.json"] });
83
+ const keySource = source ?? acquireCommittedSource(root, { ...selected, requiredPaths: [".rightkit/ownership-keys.json"] });
63
84
  const sourceData = committedSourceDataForInternalUse(keySource);
64
85
  if (!sourceData) throw new Error("protected ownership key log requires authenticated committed source");
65
86
  const entry = sourceData.entries.find((item) => item.rel === ".rightkit/ownership-keys.json");
@@ -70,7 +91,8 @@ function createEvidence(repoRoot, {
70
91
  keyLogEvidence = { keyLogDigest: resolved.keyLogDigest, keyCheckpoint: resolved.checkpoint, rootFingerprint: resolved.trust.rootFingerprint };
71
92
  if (!resolved.trust.ownershipKeys.length) throw new Error("protected ownership key authority has no active approval keys");
72
93
  }
73
- if (useCommittedSource && requireCleanCheckout) assertCommittedCheckoutClean(root, { expectedCommit: source.commit, expectedTree: source.tree });
94
+ if (useCommittedSource && requireCleanCheckout && !snapshotSource) assertCommittedCheckoutClean(root, { expectedCommit: source.commit, expectedTree: source.tree });
95
+ if (snapshotSource && (source.commit !== snapshotSource.commit || source.tree !== snapshotSource.tree)) throw new Error("ownership evidence: the acquired committed source is not the selected commit's");
74
96
  const commit = useCommittedSource ? source.commit : git(root, ["rev-parse", "HEAD"], gitRunner);
75
97
  if (!commit) throw new Error("ownership evidence: scan source did not produce a HEAD commit");
76
98
  const tree = useCommittedSource ? source.tree : git(root, ["rev-parse", `${commit}^{tree}`], gitRunner);
@@ -92,8 +114,7 @@ function createEvidence(repoRoot, {
92
114
  ? validateOwnershipFloor(trustState.trust, { scannerVersion, catalogVersion, catalogDigest, ...(policyBundleDigest !== undefined ? { policyBundleDigest } : {}) })
93
115
  : { ok: true, problems: [], warnings: ["protected ownership floor is unavailable: no protected trust registry is enrolled on this host (warning only)"] };
94
116
  floor.warnings = [...(floor.warnings ?? []), ...(bundleProblem ? [bundleProblem] : [])];
95
- // Real paths on both sides: Git's root is resolved (macOS /private/var), callers may pass /var or /tmp.
96
- const real = (p) => { try { return realpathSync.native(p); } catch { return path.resolve(p); } };
117
+ const real = realPath;
97
118
  const requestedRoot = path.relative(real(root), real(scanRoot)).replaceAll("\\", "/") || ".";
98
119
  if (requestedRoot.startsWith("../") || requestedRoot === ".." || path.isAbsolute(requestedRoot)) throw new Error("ownership evidence scan root must be inside repository");
99
120
  const evidenceDiagnostics = [];
@@ -199,13 +220,13 @@ function createEvidence(repoRoot, {
199
220
  }
200
221
 
201
222
  /** Create scan evidence bound to committed source, catalog, and signed exceptions. */
202
- export function createOwnershipEvidence(repoRoot, scanRoot = repoRoot, scope = null) {
203
- return createEvidence(repoRoot, { scanRoot, scope, committed: true, requireCleanCheckout: true, allowPendingAdvisory: true });
223
+ export function createOwnershipEvidence(repoRoot, scanRoot = repoRoot, scope = null, releaseSource = null) {
224
+ return createEvidence(repoRoot, { scanRoot, scope, committed: true, requireCleanCheckout: true, allowPendingAdvisory: true, releaseSource });
204
225
  }
205
226
 
206
227
  /** Alias spelling makes the immutable-source contract explicit at adapter boundaries. */
207
- export function createCommittedOwnershipEvidence(repoRoot, scanRoot = repoRoot, scope = null) {
208
- return createEvidence(repoRoot, { scanRoot, scope, committed: true, requireCleanCheckout: true });
228
+ export function createCommittedOwnershipEvidence(repoRoot, scanRoot = repoRoot, scope = null, releaseSource = null) {
229
+ return createEvidence(repoRoot, { scanRoot, scope, committed: true, requireCleanCheckout: true, releaseSource });
209
230
  }
210
231
 
211
232
  // Injection seam is deliberately named for tests and is not used by release commands.
@@ -228,12 +249,15 @@ export function runProtectedLocalOwnershipCheck(repoRoot, scanRoot = repoRoot) {
228
249
  }
229
250
 
230
251
  /** Re-scan exact source and compare every sealed binding before release upload. */
231
- export function verifyOwnershipEvidence(expected, repoRoot, scanRoot = repoRoot, scope = null) {
232
- return verifyEvidence(expected, repoRoot, scanRoot, () => createCommittedOwnershipEvidence(repoRoot, scanRoot, scope), scope);
252
+ export function verifyOwnershipEvidence(expected, repoRoot, scanRoot = repoRoot, scope = null, releaseSource = null) {
253
+ return verifyEvidence(expected, repoRoot, scanRoot, () => createCommittedOwnershipEvidence(repoRoot, scanRoot, scope, releaseSource), scope);
233
254
  }
234
255
 
235
256
  export function verifyOwnershipEvidenceForTest(expected, repoRoot, options) {
236
- return verifyEvidence(expected, repoRoot, options.scanRoot ?? repoRoot, () => createOwnershipEvidenceForTest(repoRoot, options), options.scope ?? null);
257
+ // Re-scan the way the evidence was made: committed-git-tree evidence (always the case in a door source split) through the
258
+ // committed-source scan, as verifyOwnershipEvidence does in production.
259
+ const rescan = expected?.source === "committed-git-tree" ? createCommittedOwnershipEvidenceForTest : createOwnershipEvidenceForTest;
260
+ return verifyEvidence(expected, repoRoot, options.scanRoot ?? repoRoot, () => rescan(repoRoot, options), options.scope ?? null);
237
261
  }
238
262
 
239
263
  function verifyEvidence(expected, repoRoot, _scanRoot, createCurrent, scope = null) {