@rightkit/release 0.6.8 → 0.6.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +30 -12
  2. package/admission-files.mjs +4 -1
  3. package/admission.mjs +2 -2
  4. package/advisory-exceptions.mjs +41 -5
  5. package/android-play-upload.mjs +16 -15
  6. package/app-check.mjs +105 -27
  7. package/assemble-app-command.mjs +151 -0
  8. package/assembled-app.mjs +445 -0
  9. package/bin/darwin/rightkit-swap +0 -0
  10. package/bin/darwin/rightkit-swap.sign.json +6 -0
  11. package/bin/rightkit-gate.mjs +1608 -345
  12. package/broker-pins.mjs +152 -0
  13. package/build-launch.mjs +88 -0
  14. package/build-ownership.mjs +1231 -0
  15. package/build-release.mjs +82 -28
  16. package/cargo-contract.mjs +3 -1
  17. package/cargo-ledger.mjs +62 -0
  18. package/cargo-target-shim.mjs +238 -0
  19. package/class-a-launch-guard.mjs +126 -0
  20. package/cli/right-release.mjs +31 -5
  21. package/clippy-equivalence.mjs +202 -0
  22. package/clippy-route.mjs +543 -0
  23. package/codesign-shim.mjs +11 -1
  24. package/committed-source.mjs +39 -5
  25. package/crate-workspaces.mjs +87 -0
  26. package/create-mac-updater.mjs +14 -2
  27. package/deterministic-scans.mjs +997 -71
  28. package/development-signing.mjs +4 -1
  29. package/development.mjs +120 -21
  30. package/direct-receipt.mjs +135 -0
  31. package/door-policy.mjs +71 -6
  32. package/door.mjs +6 -6
  33. package/freshness-census.mjs +257 -0
  34. package/gate-executor.mjs +14 -2
  35. package/gate-stages.mjs +91 -15
  36. package/heavy-command.mjs +3 -2
  37. package/hook-retirement.mjs +65 -0
  38. package/init-command.mjs +1 -1
  39. package/install-app.mjs +11 -2
  40. package/install-assembled-app.mjs +423 -0
  41. package/inventory-check.mjs +11 -8
  42. package/ios-dev.mjs +70 -3
  43. package/ios-upload.mjs +20 -15
  44. package/linux-artifacts.mjs +93 -0
  45. package/local-publish.mjs +224 -53
  46. package/local-qualify.mjs +5 -3
  47. package/local-runtime.mjs +5 -4
  48. package/mac-package-recipe.mjs +4 -1
  49. package/managed-cargo.mjs +47 -5
  50. package/managed-root-guard.mjs +34 -0
  51. package/mirror-root-artifact.mjs +18 -5
  52. package/native-helpers.mjs +355 -0
  53. package/ownership-class-rules.mjs +1 -1
  54. package/ownership-evidence.mjs +37 -13
  55. package/ownership-hooks.mjs +20 -59
  56. package/ownership-lockfiles.mjs +14 -1
  57. package/ownership-policy-bundle.mjs +3 -2
  58. package/ownership-staged.mjs +2 -1
  59. package/ownership.mjs +24 -0
  60. package/package-recipe-targets.mjs +84 -10
  61. package/package.json +4 -3
  62. package/pipeline-admission.mjs +4 -4
  63. package/pipeline-receipts.mjs +302 -302
  64. package/preflight.mjs +27 -4
  65. package/project-type.mjs +2 -2
  66. package/provenance-verify.mjs +115 -0
  67. package/publish-cargo.mjs +12 -9
  68. package/qa-admission-policy.json +10 -1
  69. package/recipe-build.mjs +106 -14
  70. package/release-artifact-path.mjs +341 -0
  71. package/release-config.mjs +1 -1
  72. package/release-invocation.mjs +8 -1
  73. package/release-qa-command.mjs +25 -9
  74. package/release-source-brand.mjs +17 -0
  75. package/release-source-identity.mjs +171 -0
  76. package/release.mjs +59 -22
  77. package/rightkit-versions.json +73 -47
  78. package/runtime-budgets.mjs +6 -2
  79. package/scan-digest.mjs +71 -5
  80. package/sign-updater.mjs +6 -3
  81. package/snapshot-broker.mjs +172 -0
  82. package/snapshot-check.mjs +312 -0
  83. package/snapshot-git.mjs +166 -0
  84. package/snapshot-request.mjs +363 -0
  85. package/snapshot-root-config.mjs +76 -0
  86. package/snapshot.mjs +969 -0
  87. package/source-split.mjs +73 -0
  88. package/stage-commands.mjs +60 -0
  89. package/stage-ledger.mjs +204 -0
  90. package/stage-sidecar-command.mjs +5 -1
  91. package/stage-sidecar.mjs +157 -27
  92. package/swap-helper-bundle.mjs +5 -2
  93. package/test-command.mjs +17 -4
  94. package/unit-gate.mjs +301 -0
  95. package/web-build.mjs +40 -6
  96. package/win-package-recipe.mjs +20 -7
package/local-publish.mjs CHANGED
@@ -13,6 +13,7 @@
13
13
  // all-features check, packaging, gate, publish and registry readback, and writes its own evidence file. Crates
14
14
  // of one dependency layer publish concurrently (bounded); layer N+1 starts only after every layer-N crate is
15
15
  // visible in the registry index with the verified checksum.
16
+ import { buildLaunch } from "./build-launch.mjs";
16
17
  import crypto from "node:crypto";
17
18
  import fs from "node:fs";
18
19
  import os from "node:os";
@@ -28,6 +29,7 @@ import { refuseHostUpload } from "./host-upload-refusal.mjs";
28
29
  import { pathDependencyDirs, readCargoPackage } from "./crate-graph.mjs";
29
30
  import { recordPublished, stageSourceVersions, workspaceFeedOptions } from "./rightkit-sync.mjs";
30
31
  import { prepareSwapHelper, SWAP_HELPER_CARGO_ARGS, SWAP_HELPER_PACKAGE } from "./swap-helper-bundle.mjs";
32
+ import { requireDoorRun, gateStateDir } from "./gate-stages.mjs";
31
33
 
32
34
  export const LOCAL_OPERATOR = "agent (local-build grant)";
33
35
  export const LOCAL_HOST_POLICY_REASON = "Adrian 2026-10-14 local-build grant (.rightkit-local-build.json); Windows covered by the next CI run";
@@ -46,6 +48,12 @@ export const DEFAULT_CONCURRENCY = 4;
46
48
  const MAX_CONCURRENCY = 8;
47
49
  const CRATE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
48
50
  const JOBS_SUBCOMMANDS = new Set(["test", "clippy", "check", "package"]);
51
+ // The argv of the broker jobs a unit's compile and package stages launch. Built here, once, for the stages themselves and for
52
+ // stage-commands.mjs, which derives what a qualification's signed receipts must show (the hosted admission and the gate share it).
53
+ export const qualifyFmtArgs = (unit) => ["fmt", "-p", unit, "--check"];
54
+ export const npmPackArgs = (packDir, scratch) => ["--dir", packDir, "pack", "--pack-destination", scratch];
55
+ export const cargoPackageArgs = (unit, qualifyOnly) => ["package", "--locked", ...(qualifyOnly ? [] : ["--no-verify"]), "-p", unit];
56
+ export const CARGO_METADATA_ARGS = Object.freeze(["metadata", "--offline", "--format-version", "1", "--no-deps"]);
49
57
  export function withCargoJobs(args, jobs) {
50
58
  return jobs !== undefined && JOBS_SUBCOMMANDS.has(args[0])
51
59
  ? [args[0], "--jobs", String(jobs), ...args.slice(1)] : args;
@@ -121,8 +129,8 @@ export function parseLocalPublishArgs(kind, argv, cwd = process.cwd()) {
121
129
  if (arg === "--local") local = true;
122
130
  else if (arg === flag) unit = argv[++i];
123
131
  else if (arg === "--workspace") workspace = argv[++i];
124
- else if (kind === "cargo" && arg === "--qualify-only") qualifyOnly = true;
125
- else if (kind === "cargo" && arg === "--bundle") bundle = argv[++i];
132
+ else if (arg === "--qualify-only") qualifyOnly = true;
133
+ else if (arg === "--bundle") bundle = argv[++i];
126
134
  // Same meaning as publish.yml's allow_ignored: this unit may have ignored tests; the count is recorded.
127
135
  else if (arg === "--allow-ignored") allowIgnored = true;
128
136
  else if (kind === "cargo" && arg === "--crates") crates = crateList(argv[++i], "--crates");
@@ -365,31 +373,26 @@ export function localTestPlan({ kind, unit, dir, config, manifest, batch = false
365
373
 
366
374
  /**
367
375
  * The clippy invocations that replace each crate's own `cargo clippy -p <crate> --no-deps --all-targets
368
- * <featureSets[0]> -- -D warnings`: same flags and lint level, so the same lints on the same code. Crates
369
- * whose first feature set is empty or `--all-features` share one `-p a -p b` invocation (the flags mean the
370
- * same for every crate); any other feature set runs per crate, because `--features x` must not reach a crate
371
- * that lacks x. The caller passes ONE dependency layer: no selected crate depends on another, so `-p` does not
372
- * unify extra features into a selected crate and each is linted with exactly the features it was before.
373
- * 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.
374
380
  */
375
381
  export function batchClippyInvocations(layer, { featuresOf = () => [], featureSetsOf = (unit) => [featuresOf(unit)], assetGatedOf = () => false } = {}) {
376
- const groups = new Map();
382
+ const invocations = [];
377
383
  for (const unit of layer) {
378
384
  // Every tested feature set of a crate is linted, in order; identical sets of one crate collapse to one run.
379
385
  const sets = featureSetsOf(unit) ?? [[]];
380
386
  const unique = sets.filter((set, index) => sets.findIndex((other) => JSON.stringify(other ?? []) === JSON.stringify(set ?? [])) === index);
381
387
  for (const set of unique) {
382
388
  const features = set ?? [];
383
- const shared = features.length === 0 || (features.length === 1 && features[0] === "--all-features");
384
- const key = shared ? JSON.stringify([features, assetGatedOf(unit)]) : `unit:${unit}:${JSON.stringify(features)}`;
385
- if (!groups.has(key)) groups.set(key, { crates: [], features, assetGated: assetGatedOf(unit) });
386
- 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
+ });
387
393
  }
388
394
  }
389
- return [...groups.values()].map(({ crates, features, assetGated }) => ({
390
- crates, assetGated,
391
- args: ["clippy", ...crates.flatMap((unit) => ["-p", unit]), "--message-format=json-render-diagnostics", "--no-deps", "--all-targets", ...features, "--", "-D", "warnings"],
392
- }));
395
+ return invocations;
393
396
  }
394
397
 
395
398
  const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
@@ -414,6 +417,19 @@ function assertLocalHost(deps, workspaceRoot, kind) {
414
417
 
415
418
  // Host uploads are retired: only --qualify-only (no upload) runs. The upload engine below is kept for direct calls in
416
419
  // tests; no CLI path reaches it (see host-upload-refusal.mjs).
420
+ /** One door: a publish engine entry (the CLI's `publish <kind> --local`, or a direct call of the engine) runs only inside a
421
+ * live rightkit-gate door run for the workspace's HEAD (RK_GATE_RUN; gate-stages requireDoorRun). The unit stages
422
+ * (compileLocalUnit and its successors) never pass through here: they are the gate's own path and carry their own door. */
423
+ export function requireEngineDoor(parsed, { env = process.env, git = defaultGit, command = "right-release publish --local" } = {}) {
424
+ const root = parsed.workspaceRoot;
425
+ const common = git(["rev-parse", "--git-common-dir"], root);
426
+ const commit = git(["rev-parse", "HEAD"], root);
427
+ if (!common || !/^[0-9a-f]{40}$/.test(commit ?? "")) refuse(`${command} runs only inside a rightkit-gate door run of a git checkout (no HEAD or common dir in ${root})`);
428
+ const denial = requireDoorRun({ env, commonDir: path.resolve(root, common), commit, command });
429
+ if (denial) refuse(denial);
430
+ return { commit, commonDir: path.resolve(root, common) };
431
+ }
432
+
417
433
  export async function runLocalPublish(kind, argv, deps = {}) {
418
434
  const parsed = parseLocalPublishArgs(kind, argv, deps.cwd);
419
435
  if (!parsed.qualifyOnly) refuseHostUpload(`right-release publish ${kind} --local`);
@@ -422,10 +438,101 @@ export async function runLocalPublish(kind, argv, deps = {}) {
422
438
 
423
439
  export async function runLocalPublishUploadEngine(kind, argv, deps = {}) {
424
440
  const parsed = parseLocalPublishArgs(kind, argv, deps.cwd);
441
+ (deps.requireDoor ?? requireEngineDoor)(parsed, { env: deps.env ?? process.env, command: `right-release publish ${kind} --local` });
425
442
  if (parsed.batch) return runLocalPublishBatch(parsed, deps);
426
443
  return publishUnit(parsed, deps, null);
427
444
  }
428
445
 
446
+ // Stage state never leaves this process: persisted receipts are reports, not authority to skip unit tests.
447
+ const unitStages = new WeakMap();
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);
483
+ if (!/^[0-9a-f]{40}$/.test(commit ?? "") || !/^[0-9a-f]{40}$/.test(tree ?? "") || !common) refuse("cannot bind unit stage to HEAD and tree");
484
+ if (identity && (identity.commit !== commit || identity.tree !== tree)) refuse("unit stage HEAD or tree changed");
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);
487
+ if (dirty === null || dirty !== "") refuse("unit stage requires a clean tracked checkout");
488
+ const commonDir = path.resolve(source ? source.checkout : root, common);
489
+ const denial = requireDoorRun({ env, commonDir, commit, command: `unit ${stage}` });
490
+ if (denial) refuse(denial);
491
+ let run;
492
+ try { run = JSON.parse(fs.readFileSync(path.join(gateStateDir(commonDir), "runs", `${env.RK_GATE_RUN}.json`), "utf8")); } catch { refuse("cannot read the unit door run"); }
493
+ if (run.stage !== stage || run.tree !== tree) refuse(`unit ${stage} needs its own live door run for this tree`);
494
+ return { commit, tree };
495
+ }
496
+
497
+ export async function compileLocalUnit(kind, argv, { env = process.env, source = null } = {}) {
498
+ const parsed = parseLocalPublishArgs(kind, argv);
499
+ if (!parsed.qualifyOnly || parsed.batch) refuse("unit stages require one --qualify-only unit and a new bundle");
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);
502
+ try {
503
+ const step = await runner.next();
504
+ if (step.done || step.value?.stage !== "compile") refuse("unit compile did not complete");
505
+ assertUnitDoor(parsed.workspaceRoot, "compile", env, identity, source);
506
+ const state = Object.freeze({});
507
+ unitStages.set(state, { runner, parsed, identity, source, stage: "compile", compileRequests: [...(step.value.brokerRequests ?? [])] });
508
+ return state;
509
+ } catch (error) { await runner.return(); throw error; }
510
+ }
511
+
512
+ async function advanceLocalUnit(state, expected, stage, env) {
513
+ const entry = unitStages.get(state);
514
+ if (!entry || entry.stage !== expected) refuse(`unit ${stage} requires this process's completed ${expected} stage`);
515
+ entry.stage = "running";
516
+ try {
517
+ assertUnitDoor(entry.parsed.workspaceRoot, stage, env, entry.identity, entry.source);
518
+ const step = await entry.runner.next(env);
519
+ if (stage === "package" ? step.done || step.value?.stage !== "package" : !step.done || !step.value?.qualifiedOnly) refuse(`unit ${stage} did not complete`);
520
+ assertUnitDoor(entry.parsed.workspaceRoot, stage, env, entry.identity, entry.source);
521
+ entry.stage = stage;
522
+ if (stage === "qualify") unitStages.delete(state);
523
+ return step.value;
524
+ } catch (error) { unitStages.delete(state); await entry.runner.return(); throw error; }
525
+ }
526
+
527
+ export const packageLocalUnit = (state, { env = process.env } = {}) => advanceLocalUnit(state, "compile", "package", env);
528
+ export const qualifyLocalUnit = (state, { env = process.env } = {}) => advanceLocalUnit(state, "package", "qualify", env);
529
+ /** The broker request ids of this process's completed compile stage (the package stage's ride on its result). */
530
+ export const localUnitCompileRequests = (state) => [...(unitStages.get(state)?.compileRequests ?? [])];
531
+ export async function closeLocalUnit(state) {
532
+ const entry = unitStages.get(state);
533
+ if (entry) { unitStages.delete(state); await entry.runner.return(); }
534
+ }
535
+
429
536
  /**
430
537
  * Qualify and publish ONE unit. `batch` (cargo batch mode only) is `{ head, tree, receipt, commands }`: the
431
538
  * batch-level fmt, secret scan and clippy already ran at `head`/`tree`, so this unit skips its own clippy and
@@ -433,11 +540,31 @@ export async function runLocalPublishUploadEngine(kind, argv, deps = {}) {
433
540
  * publish and the registry readback) runs for this unit exactly as in single mode.
434
541
  */
435
542
  async function publishUnit(parsed, deps, batch) {
543
+ const runner = publishUnitStages(parsed, deps, batch);
544
+ let result;
545
+ do { result = await runner.next(); } while (!result.done);
546
+ return result.value;
547
+ }
548
+
549
+ async function* publishUnitStages(parsed, deps, batch) {
436
550
  const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
437
551
  const { kind, unit, allowIgnored, workspaceRoot } = parsed;
438
552
  const git = deps.git ?? defaultGit;
439
553
  const readJson = deps.readJson ?? defaultReadJson;
440
- const run = deps.run ?? (batch ? defaultRunAsync : defaultRun);
554
+ // Every broker job a stage launches prints `rustright: request <id>`; the unit gate fetches those jobs' signed receipts, so the ids
555
+ // are collected here per stage and handed over at each stage yield (compile, then package). Nothing about what runs changes.
556
+ const brokerRequests = [];
557
+ const noteBrokerRequests = (result) => { for (const [, id] of `${result?.stdout ?? ""}\n${result?.stderr ?? ""}`.matchAll(/^rustright: request ([0-9a-f-]{36})/gm)) if (!brokerRequests.includes(id)) brokerRequests.push(id); return result; };
558
+ const launch = deps.run ?? (batch ? defaultRunAsync : defaultRun);
559
+ // Every pnpm step goes through the one door for build and test launches (build-launch.mjs): a rightkit-pnpm broker job on a
560
+ // managed host (refused, never run directly, when the broker is unreachable), direct on a host without one. A run headed to release
561
+ // then also has the signed receipt each stage owes (stage-commands.mjs). Other commands (cargo is already a broker job) are unchanged.
562
+ const run = (command, args, options) => {
563
+ const target = command === "pnpm" ? buildLaunch(command, args, { env: options?.env ?? process.env, platform: deps.platform ?? process.platform, exists: fs.existsSync }) : { command, args };
564
+ const result = launch(target.command, target.args, options);
565
+ return typeof result?.then === "function" ? result.then(noteBrokerRequests) : noteBrokerRequests(result);
566
+ };
567
+
441
568
 
442
569
  // 1. Preconditions, fail closed.
443
570
  const platform = assertLocalHost(deps, workspaceRoot, kind);
@@ -465,9 +592,24 @@ async function publishUnit(parsed, deps, batch) {
465
592
  const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
466
593
  if (tree !== batch.tree) refuse(`${unit}: HEAD tree ${tree} differs from the batch tree ${batch.tree}`);
467
594
  }
468
- const dirty = git(["status", "--porcelain", "--", dir, ...GATE_CONFIG_FILES, ...(parsed.qualifyOnly ? ["Cargo.toml", "Cargo.lock"] : [])], workspaceRoot);
595
+ const feedPath = "packages/release/rightkit-versions.json";
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] : [])];
600
+ const dirty = git(["status", "--porcelain", "--", ...guardedPaths], workspaceRoot);
469
601
  if (dirty === null) refuse(`cannot read git status for ${dir}`);
470
602
  if (dirty !== "") refuse(`tree is not clean for ${dir} or the gate config (${GATE_CONFIG_FILES.join(", ")}); commit first:\n${dirty}`);
603
+ if (parsed.qualifyOnly && kind === "npm") {
604
+ const feedFile = path.join(workspaceRoot, feedPath);
605
+ if (unit === "@rightkit/release" && !fs.existsSync(feedFile)) refuse(`${unit}: committed ${feedPath} is required before packing`);
606
+ if (fs.existsSync(feedFile)) {
607
+ const committedFeed = git(["show", `HEAD:${feedPath}`], workspaceRoot);
608
+ if (committedFeed === null || committedFeed !== fs.readFileSync(feedFile, "utf8").trim()) refuse(`${feedPath} must match the committed HEAD before qualification`);
609
+ const feed = readJson(feedFile) ?? refuse(`cannot read ${feedPath}`);
610
+ if (feed.stagedNpm?.[unit] !== version && feed.registry?.npm?.[unit] !== version) refuse(`${unit}@${version}: stage the version in ${feedPath} and commit it before qualification`);
611
+ }
612
+ }
471
613
  if (await (deps.registryHas ?? defaultRegistryHas)({ kind, name: unit, version })) refuse(`${unit}@${version} is already on the ${kind === "npm" ? "npm" : "crates.io"} registry; bump the version`);
472
614
  log(`[right-release] local publish ${kind}:${unit}@${version} at ${head.slice(0, 12)} (local-build grant, ${LOCAL_HOST_POLICIES[platform]} policy)`);
473
615
 
@@ -484,10 +626,10 @@ async function publishUnit(parsed, deps, batch) {
484
626
  if (consumerDirty === null) refuse(`cannot read git status for the consumer rows of ${unit}`);
485
627
  if (consumerDirty !== "") refuse(`tree is not clean for the consumer rows of ${unit}; commit first:\n${consumerDirty}`);
486
628
  }
487
- if (parsed.qualifyOnly) {
629
+ if (parsed.qualifyOnly && kind === "cargo") {
488
630
  const violations = (deps.scanSecrets ?? scanCrateForSecrets)(unitPath);
489
631
  if (violations.length) refuse(`qualification secret scan rejected: ${violations.join(", ")}`);
490
- plan.steps.unshift({ command: "cargo", args: ["fmt", "-p", unit, "--check"], test: false });
632
+ plan.steps.unshift({ command: "cargo", args: qualifyFmtArgs(unit), test: false });
491
633
  }
492
634
  const modules = await (deps.loadWorkspaceModules ?? defaultLoadWorkspaceModules)(workspaceRoot);
493
635
  const { ResultScanner, TEST_COMMAND, judgeUnit } = modules.results;
@@ -496,11 +638,14 @@ async function publishUnit(parsed, deps, batch) {
496
638
  // written reason in ci/gate.config.json (rust.<crate>.allowIgnored). The count stays in the evidence.
497
639
  const configReason = kind === "cargo" && typeof gateConfig?.rust?.[unit]?.allowIgnored === "string" ? gateConfig.rust[unit].allowIgnored.trim() : "";
498
640
  const ignoredAllowed = Boolean(allowIgnored || parsed.allowIgnoredUnits?.includes(unit) || configReason);
499
- const steps = parsed.qualifyOnly ? [{ command: `scanCrateForSecrets ${dir}`, ok: true }] : [];
641
+ const steps = parsed.qualifyOnly && kind === "cargo" ? [{ command: `scanCrateForSecrets ${dir}`, ok: true }] : [];
500
642
  for (const step of plan.steps) {
501
643
  const commandText = `${step.command} ${step.args.join(" ")}`;
502
644
  log(`[right-release] ${plan.id}: ${commandText}`);
503
- 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 });
504
649
  const row = { command: commandText, ok: false, isTest: step.test && TEST_COMMAND.test(commandText) };
505
650
  if (result.error || result.status !== 0) {
506
651
  steps.push(row);
@@ -519,40 +664,55 @@ async function publishUnit(parsed, deps, batch) {
519
664
  if (verdict.status === "fail" || steps.some((s) => s.failed > 0)) refuse(`${plan.id} not qualified: ${verdict.problems.join("; ") || "failed tests reported"}`);
520
665
  if (!(verdict.executed > 0)) refuse(`${plan.id} executed no test`);
521
666
  log(`[right-release] ${plan.id}: ${verdict.executed} passed, 0 failed, ${verdict.skipped} skipped, ${verdict.ignored} ignored${isAssetGated ? " (asset-gated: RIGHTKIT_REQUIRE_ASSETS=0, skips recorded)" : ""}`);
667
+ const packageEnv = yield { stage: "compile", kind, name: unit, version, commit: head, brokerRequests: brokerRequests.splice(0) };
668
+ if (packageEnv) Object.assign(env, testEnv({ env: packageEnv }, isAssetGated));
522
669
 
523
670
  // 3. Pack the artifact and hash it.
524
- const scratch = fs.mkdtempSync(path.join(deps.tmpdir ?? os.tmpdir(), "rk-local-publish-"));
671
+ // An npm pack is a broker job whose --dir and --pack-destination the adapter accepts only under the host's scratch root, so it
672
+ // packs there (the workspace rule for run output) rather than in the OS temp directory.
673
+ const scratchRoot = deps.tmpdir ?? (kind === "npm" ? (platform === "win32" ? "D:\\scratch" : "/Volumes/D/scratch") : os.tmpdir());
674
+ const scratch = fs.mkdtempSync(path.join(scratchRoot, "rk-local-publish-"));
525
675
  const cleanup = () => fs.rmSync(scratch, { recursive: true, force: true });
526
676
  try {
527
677
  let artifactFile; const packCommands = [];
678
+ let packDir = dir;
528
679
  if (kind === "npm" && unit === SWAP_HELPER_PACKAGE) {
680
+ if (parsed.qualifyOnly) {
681
+ // Signing writes its binary and receipt. Keep both in the pack copy, never the source checkout.
682
+ packDir = path.join(scratch, "package-source");
683
+ fs.cpSync(unitPath, packDir, { recursive: true, filter: (source) => path.basename(source) !== "node_modules" });
684
+ }
529
685
  // #258: the package ships a Developer-ID-signed bin/darwin/rightkit-swap; build, sign and verify it before packing (fails closed).
530
- try { (deps.prepareSwapHelper ?? prepareSwapHelper)({ workspaceRoot, packageDir: unitPath, platform, run, env, log }); }
686
+ try { (deps.prepareSwapHelper ?? prepareSwapHelper)({ workspaceRoot, packageDir: parsed.qualifyOnly ? packDir : unitPath, platform, run, env, log }); }
531
687
  catch (error) { refuse(`${error.code ? `[${error.code}] ` : ""}${error.message}`); }
532
688
  packCommands.push(`rustright cargo ${SWAP_HELPER_CARGO_ARGS.join(" ")} && codesign --verify --strict bin/darwin/rightkit-swap`);
533
689
  }
534
690
  if (kind === "npm") {
535
691
  // The feed a tarball ships must list the version it ships with (#319): stage it before packing.
536
692
  const feedFile = path.join(workspaceRoot, "packages", "release", "rightkit-versions.json");
537
- if (fs.existsSync(feedFile)) await withVersionsLock(() => (deps.stageSourceVersions ?? stageSourceVersions)([{ registry: "npm", name: unit, version }], { file: feedFile, root: workspaceRoot, log }));
538
- const args = ["--dir", dir, "pack", "--pack-destination", scratch];
693
+ if (!parsed.qualifyOnly && fs.existsSync(feedFile)) await withVersionsLock(() => (deps.stageSourceVersions ?? stageSourceVersions)([{ registry: "npm", name: unit, version }], { file: feedFile, root: workspaceRoot, log }));
694
+ // The tarball goes to an empty `pack` directory inside the scratch directory: the adapter takes --pack-destination only as a
695
+ // child of a scratch directory (never the scratch directory itself) and refuses one that already holds a tarball.
696
+ const packOut = path.join(scratch, "pack");
697
+ fs.mkdirSync(packOut);
698
+ const args = npmPackArgs(packDir, packOut);
539
699
  packCommands.push(`pnpm ${args.join(" ")}`);
540
700
  const packed = await run("pnpm", args, { cwd: workspaceRoot, env });
541
701
  if (packed.error || packed.status !== 0) refuse(`pnpm pack failed for ${unit}\n${tail(`${packed.stdout ?? ""}\n${packed.stderr ?? ""}`)}`);
542
- artifactFile = path.join(scratch, tarballName(unit, version));
702
+ artifactFile = path.join(packOut, tarballName(unit, version));
543
703
  if (!fs.existsSync(artifactFile)) refuse(`pnpm pack did not produce ${path.basename(artifactFile)}`);
544
704
  const checked = modules.checkNpmTarball({ file: artifactFile, name: unit, version, dir, spawn: deps.spawnSync ?? spawnSync });
545
705
  if (checked.problems.length) refuse(`${unit}: packed tarball refused:\n - ${checked.problems.join("\n - ")}`);
546
706
  } else {
547
707
  // A combined publish verifies once in `cargo publish`; detached qualification verifies here before
548
708
  // its evidence leaves this host, so the publishing host can reuse that exact archive's proof.
549
- const packageArgs = ["package", "--locked", ...(parsed.qualifyOnly ? [] : ["--no-verify"]), "-p", unit];
709
+ const packageArgs = cargoPackageArgs(unit, parsed.qualifyOnly);
550
710
  packCommands.push(`cargo ${packageArgs.join(" ")}`);
551
- 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 });
552
712
  if (packed.error || packed.status !== 0) refuse(`cargo package failed for ${unit}\n${tail(`${packed.stdout ?? ""}\n${packed.stderr ?? ""}`)}`);
553
713
  if (parsed.qualifyOnly) steps.push({ command: `cargo ${packageArgs.join(" ")}`, ok: true });
554
- const metadataArgs = ["metadata", "--offline", "--format-version", "1", "--no-deps"];
555
- const meta = await runCargoReadOnly(metadataArgs, { cwd: workspaceRoot, env }, (_command, args, settings) => run("cargo", args, settings),
714
+ const metadataArgs = [...CARGO_METADATA_ARGS];
715
+ const meta = await runCargoReadOnly(metadataArgs, { cwd: unitPath, env }, (_command, args, settings) => run("cargo", args, settings),
556
716
  (failed) => log(`[right-release] offline Cargo metadata missed cache; retrying online once\n${failed.stdout ?? ""}${failed.stderr ? `\n${failed.stderr}` : ""}`));
557
717
  const actualMetadataArgs = meta.usedOfflineFallback ? metadataArgs.filter((arg) => arg !== "--offline") : metadataArgs;
558
718
  packCommands.push(`cargo ${actualMetadataArgs.join(" ")}`);
@@ -565,6 +725,16 @@ async function publishUnit(parsed, deps, batch) {
565
725
  if (!fs.existsSync(artifactFile)) refuse(`cargo package did not produce ${artifactFile}`);
566
726
  }
567
727
  const artifactSha256 = sha256(fs.readFileSync(artifactFile));
728
+ if (parsed.qualifyOnly && (git(["rev-parse", "HEAD"], workspaceRoot) !== head || git(["status", "--porcelain", "--", ...guardedPaths], workspaceRoot) !== "")) refuse("qualification inputs changed while preparing the artifact; no bundle was created");
729
+ if (parsed.qualifyOnly) {
730
+ fs.mkdirSync(parsed.bundleDir, { recursive: false });
731
+ const bundledArtifact = path.join(parsed.bundleDir, path.basename(artifactFile));
732
+ fs.copyFileSync(artifactFile, bundledArtifact, fs.constants.COPYFILE_EXCL);
733
+ if (sha256(fs.readFileSync(bundledArtifact)) !== artifactSha256) refuse("qualification archive changed while copying");
734
+ artifactFile = bundledArtifact;
735
+ yield { stage: "package", kind, name: unit, version, commit: head, artifactFile, artifactSha256, brokerRequests: brokerRequests.splice(0) };
736
+ if (sha256(fs.readFileSync(artifactFile)) !== artifactSha256) refuse("qualification archive changed after packaging");
737
+ }
568
738
 
569
739
  // 4. Evidence in the normal schema, then the EXISTING gate.
570
740
  const commands = [...plan.steps.map((s) => `${s.command} ${s.args.join(" ")}`), ...packCommands, ...(batch ? batch.commands : [])];
@@ -583,23 +753,21 @@ async function publishUnit(parsed, deps, batch) {
583
753
  steps: steps.map(({ command, ok, executed, failed, ignored, skipped, binaries }) => ({ command, ok, ...(executed === undefined ? {} : { executed, failed, ignored, skipped, binaries }) })),
584
754
  }],
585
755
  };
586
- const evidencePath = path.resolve(defaultEvidencePath(workspaceRoot, kind, unit));
756
+ // A qualification writes its evidence beside the bytes, in the bundle: the tracked release-evidence/gate file is the record of a
757
+ // host publish, and rewriting it would dirty the checkout the unit door guards (the first `--to release --unit` run stopped
758
+ // there, 2026-10-27). Never overwrite a bundle's evidence.
759
+ const evidencePath = parsed.qualifyOnly ? path.join(parsed.bundleDir, "gate.json") : path.resolve(defaultEvidencePath(workspaceRoot, kind, unit));
587
760
  fs.mkdirSync(path.dirname(evidencePath), { recursive: true });
588
- fs.writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`);
761
+ fs.writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`, { flag: parsed.qualifyOnly ? "wx" : "w" });
589
762
  log(`[right-release] wrote ${evidencePath} (artifact sha256 ${artifactSha256})`);
590
- const gateOptions = { kind, name: unit, version, packagePath: unitPath, evidencePath };
763
+ const gateOptions = { kind, name: unit, version, packagePath: unitPath, evidencePath, artifactBytes: fs.readFileSync(artifactFile) };
591
764
  (deps.enforceGate ?? enforcePublicationGate)(gateOptions, { write: (line) => log(line.replace(/\n$/, "")) });
592
765
 
593
766
  if (parsed.qualifyOnly) {
594
- // Keep bytes and their normal gate evidence together outside the checkout. Never overwrite a bundle.
595
- fs.mkdirSync(parsed.bundleDir, { recursive: false });
596
- const bundledArtifact = path.join(parsed.bundleDir, path.basename(artifactFile));
597
- const bundledEvidence = path.join(parsed.bundleDir, "gate.json");
598
- fs.copyFileSync(artifactFile, bundledArtifact, fs.constants.COPYFILE_EXCL);
599
- if (sha256(fs.readFileSync(bundledArtifact)) !== artifactSha256) refuse("qualification archive changed while copying");
600
- fs.copyFileSync(evidencePath, bundledEvidence, fs.constants.COPYFILE_EXCL);
601
- log(`[right-release] qualified ${unit}@${version}; bundle ${parsed.bundleDir}; publish cargo --crate ${unit} --from-evidence ${bundledEvidence}`);
602
- return { kind, name: unit, version, commit: head, artifactSha256, evidencePath: bundledEvidence, artifactFile: bundledArtifact, qualifiedOnly: true };
767
+ // Bytes and evidence stay together outside the checkout.
768
+ const bundledEvidence = evidencePath;
769
+ log(`[right-release] qualified ${unit}@${version}; bundle ${parsed.bundleDir}${kind === "cargo" ? `; publish cargo --crate ${unit} --from-evidence ${bundledEvidence}` : ""}`);
770
+ return { kind, name: unit, version, commit: head, artifactSha256, evidencePath: bundledEvidence, artifactFile, qualifiedOnly: true };
603
771
  }
604
772
 
605
773
  // 5. The EXISTING publish path.
@@ -666,11 +834,11 @@ async function runLocalPublishBatch(parsed, deps) {
666
834
  if (!head || !/^[0-9a-f]{40}$/.test(head)) refuse("cannot resolve HEAD");
667
835
  const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
668
836
  if (!tree || !/^[0-9a-f]{40}$/.test(tree)) refuse("cannot resolve the HEAD tree");
669
- // Every batch crate, the workspace manifest and lock, the gate config and EVERY crate's consumer directories must
670
- // match the commit the batch checks are bound to, before the first layer publishes (0.4.1 review A3, A4).
671
- 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);
672
840
  if (dirty === null) refuse("cannot read git status for the batch");
673
- 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}`);
674
842
  const registryHas = deps.registryHas ?? defaultRegistryHas;
675
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);
676
844
  if (published.length) refuse(`already on the crates.io registry: ${published.join(", ")}; bump the version or remove them from the batch`);
@@ -680,15 +848,18 @@ async function runLocalPublishBatch(parsed, deps) {
680
848
  const env = testEnv(deps, false);
681
849
  const stamp = { commit: head, tree };
682
850
  const checks = []; const commands = [];
683
- 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]) => {
684
853
  const text = `${command} ${args.join(" ")}`;
685
854
  log(`[right-release] batch ${name}: ${text}`);
686
- const result = await run(command, args, { cwd: workspaceRoot, env: runEnv });
687
- 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
+ }
688
859
  checks.push({ name, command: text, ok: true, ...stamp, ...(crateNames ? { crates: crateNames } : {}) });
689
860
  commands.push(text);
690
861
  };
691
- 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)));
692
863
  log("[right-release] batch secret-scan: crate directories of the batch");
693
864
  const scan = deps.scanSecrets ?? scanCrateForSecrets;
694
865
  const violations = crates.flatMap((unit) => scan(path.join(workspaceRoot, "crates", unit)).map((file) => `${unit}: ${file}`));
@@ -699,7 +870,7 @@ async function runLocalPublishBatch(parsed, deps) {
699
870
  const featureSetsOf = (unit) => gateConfig?.rust?.[unit]?.featureSets ?? [[]];
700
871
  for (const layer of layers) {
701
872
  for (const invocation of batchClippyInvocations(layer, { featureSetsOf, assetGatedOf: isAssetGated })) {
702
- 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)));
703
874
  }
704
875
  }
705
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,