@rightkit/release 0.6.8 → 0.6.10

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 (52) hide show
  1. package/README.md +25 -9
  2. package/admission-files.mjs +1 -1
  3. package/app-check.mjs +94 -20
  4. package/assemble-app-command.mjs +151 -0
  5. package/assembled-app.mjs +445 -0
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/darwin/rightkit-swap.sign.json +6 -0
  8. package/bin/rightkit-gate.mjs +928 -82
  9. package/build-launch.mjs +88 -0
  10. package/build-ownership.mjs +1231 -0
  11. package/build-release.mjs +2 -1
  12. package/cargo-contract.mjs +3 -1
  13. package/cargo-target-shim.mjs +166 -0
  14. package/class-a-launch-guard.mjs +126 -0
  15. package/cli/right-release.mjs +3 -1
  16. package/codesign-shim.mjs +11 -1
  17. package/deterministic-scans.mjs +807 -56
  18. package/development-signing.mjs +4 -1
  19. package/development.mjs +110 -20
  20. package/door-policy.mjs +61 -6
  21. package/gate-executor.mjs +14 -2
  22. package/gate-stages.mjs +15 -5
  23. package/heavy-command.mjs +3 -2
  24. package/install-app.mjs +11 -2
  25. package/install-assembled-app.mjs +423 -0
  26. package/ios-dev.mjs +70 -3
  27. package/linux-artifacts.mjs +93 -0
  28. package/local-publish.mjs +161 -26
  29. package/managed-cargo.mjs +47 -5
  30. package/managed-root-guard.mjs +34 -0
  31. package/native-helpers.mjs +355 -0
  32. package/package-recipe-targets.mjs +52 -7
  33. package/package.json +4 -3
  34. package/pipeline-receipts.mjs +302 -302
  35. package/project-type.mjs +2 -2
  36. package/provenance-verify.mjs +115 -0
  37. package/publish-cargo.mjs +8 -6
  38. package/qa-admission-policy.json +10 -1
  39. package/recipe-build.mjs +73 -5
  40. package/release-config.mjs +1 -1
  41. package/release.mjs +15 -5
  42. package/rightkit-versions.json +13 -7
  43. package/snapshot-check.mjs +306 -0
  44. package/snapshot-git.mjs +166 -0
  45. package/snapshot-request.mjs +161 -0
  46. package/snapshot-root-config.mjs +76 -0
  47. package/snapshot.mjs +856 -0
  48. package/stage-commands.mjs +60 -0
  49. package/stage-sidecar.mjs +57 -7
  50. package/test-command.mjs +17 -4
  51. package/unit-gate.mjs +259 -0
  52. package/web-build.mjs +38 -6
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");
@@ -414,6 +422,19 @@ function assertLocalHost(deps, workspaceRoot, kind) {
414
422
 
415
423
  // Host uploads are retired: only --qualify-only (no upload) runs. The upload engine below is kept for direct calls in
416
424
  // tests; no CLI path reaches it (see host-upload-refusal.mjs).
425
+ /** One door: a publish engine entry (the CLI's `publish <kind> --local`, or a direct call of the engine) runs only inside a
426
+ * live rightkit-gate door run for the workspace's HEAD (RK_GATE_RUN; gate-stages requireDoorRun). The unit stages
427
+ * (compileLocalUnit and its successors) never pass through here: they are the gate's own path and carry their own door. */
428
+ export function requireEngineDoor(parsed, { env = process.env, git = defaultGit, command = "right-release publish --local" } = {}) {
429
+ const root = parsed.workspaceRoot;
430
+ const common = git(["rev-parse", "--git-common-dir"], root);
431
+ const commit = git(["rev-parse", "HEAD"], root);
432
+ 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})`);
433
+ const denial = requireDoorRun({ env, commonDir: path.resolve(root, common), commit, command });
434
+ if (denial) refuse(denial);
435
+ return { commit, commonDir: path.resolve(root, common) };
436
+ }
437
+
417
438
  export async function runLocalPublish(kind, argv, deps = {}) {
418
439
  const parsed = parseLocalPublishArgs(kind, argv, deps.cwd);
419
440
  if (!parsed.qualifyOnly) refuseHostUpload(`right-release publish ${kind} --local`);
@@ -422,10 +443,69 @@ export async function runLocalPublish(kind, argv, deps = {}) {
422
443
 
423
444
  export async function runLocalPublishUploadEngine(kind, argv, deps = {}) {
424
445
  const parsed = parseLocalPublishArgs(kind, argv, deps.cwd);
446
+ (deps.requireDoor ?? requireEngineDoor)(parsed, { env: deps.env ?? process.env, command: `right-release publish ${kind} --local` });
425
447
  if (parsed.batch) return runLocalPublishBatch(parsed, deps);
426
448
  return publishUnit(parsed, deps, null);
427
449
  }
428
450
 
451
+ // Stage state never leaves this process: persisted receipts are reports, not authority to skip unit tests.
452
+ 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);
457
+ if (!/^[0-9a-f]{40}$/.test(commit ?? "") || !/^[0-9a-f]{40}$/.test(tree ?? "") || !common) refuse("cannot bind unit stage to HEAD and tree");
458
+ 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);
460
+ if (dirty === null || dirty !== "") refuse("unit stage requires a clean tracked checkout");
461
+ const commonDir = path.resolve(root, common);
462
+ const denial = requireDoorRun({ env, commonDir, commit, command: `unit ${stage}` });
463
+ if (denial) refuse(denial);
464
+ let run;
465
+ 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"); }
466
+ if (run.stage !== stage || run.tree !== tree) refuse(`unit ${stage} needs its own live door run for this tree`);
467
+ return { commit, tree };
468
+ }
469
+
470
+ export async function compileLocalUnit(kind, argv, { env = process.env } = {}) {
471
+ const parsed = parseLocalPublishArgs(kind, argv);
472
+ 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);
475
+ try {
476
+ const step = await runner.next();
477
+ if (step.done || step.value?.stage !== "compile") refuse("unit compile did not complete");
478
+ assertUnitDoor(parsed.workspaceRoot, "compile", env, identity);
479
+ const state = Object.freeze({});
480
+ unitStages.set(state, { runner, parsed, identity, stage: "compile", compileRequests: [...(step.value.brokerRequests ?? [])] });
481
+ return state;
482
+ } catch (error) { await runner.return(); throw error; }
483
+ }
484
+
485
+ async function advanceLocalUnit(state, expected, stage, env) {
486
+ const entry = unitStages.get(state);
487
+ if (!entry || entry.stage !== expected) refuse(`unit ${stage} requires this process's completed ${expected} stage`);
488
+ entry.stage = "running";
489
+ try {
490
+ assertUnitDoor(entry.parsed.workspaceRoot, stage, env, entry.identity);
491
+ const step = await entry.runner.next(env);
492
+ 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);
494
+ entry.stage = stage;
495
+ if (stage === "qualify") unitStages.delete(state);
496
+ return step.value;
497
+ } catch (error) { unitStages.delete(state); await entry.runner.return(); throw error; }
498
+ }
499
+
500
+ export const packageLocalUnit = (state, { env = process.env } = {}) => advanceLocalUnit(state, "compile", "package", env);
501
+ export const qualifyLocalUnit = (state, { env = process.env } = {}) => advanceLocalUnit(state, "package", "qualify", env);
502
+ /** The broker request ids of this process's completed compile stage (the package stage's ride on its result). */
503
+ export const localUnitCompileRequests = (state) => [...(unitStages.get(state)?.compileRequests ?? [])];
504
+ export async function closeLocalUnit(state) {
505
+ const entry = unitStages.get(state);
506
+ if (entry) { unitStages.delete(state); await entry.runner.return(); }
507
+ }
508
+
429
509
  /**
430
510
  * Qualify and publish ONE unit. `batch` (cargo batch mode only) is `{ head, tree, receipt, commands }`: the
431
511
  * batch-level fmt, secret scan and clippy already ran at `head`/`tree`, so this unit skips its own clippy and
@@ -433,11 +513,31 @@ export async function runLocalPublishUploadEngine(kind, argv, deps = {}) {
433
513
  * publish and the registry readback) runs for this unit exactly as in single mode.
434
514
  */
435
515
  async function publishUnit(parsed, deps, batch) {
516
+ const runner = publishUnitStages(parsed, deps, batch);
517
+ let result;
518
+ do { result = await runner.next(); } while (!result.done);
519
+ return result.value;
520
+ }
521
+
522
+ async function* publishUnitStages(parsed, deps, batch) {
436
523
  const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
437
524
  const { kind, unit, allowIgnored, workspaceRoot } = parsed;
438
525
  const git = deps.git ?? defaultGit;
439
526
  const readJson = deps.readJson ?? defaultReadJson;
440
- const run = deps.run ?? (batch ? defaultRunAsync : defaultRun);
527
+ // Every broker job a stage launches prints `rustright: request <id>`; the unit gate fetches those jobs' signed receipts, so the ids
528
+ // are collected here per stage and handed over at each stage yield (compile, then package). Nothing about what runs changes.
529
+ const brokerRequests = [];
530
+ 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; };
531
+ const launch = deps.run ?? (batch ? defaultRunAsync : defaultRun);
532
+ // Every pnpm step goes through the one door for build and test launches (build-launch.mjs): a rightkit-pnpm broker job on a
533
+ // managed host (refused, never run directly, when the broker is unreachable), direct on a host without one. A run headed to release
534
+ // then also has the signed receipt each stage owes (stage-commands.mjs). Other commands (cargo is already a broker job) are unchanged.
535
+ const run = (command, args, options) => {
536
+ const target = command === "pnpm" ? buildLaunch(command, args, { env: options?.env ?? process.env, platform: deps.platform ?? process.platform, exists: fs.existsSync }) : { command, args };
537
+ const result = launch(target.command, target.args, options);
538
+ return typeof result?.then === "function" ? result.then(noteBrokerRequests) : noteBrokerRequests(result);
539
+ };
540
+
441
541
 
442
542
  // 1. Preconditions, fail closed.
443
543
  const platform = assertLocalHost(deps, workspaceRoot, kind);
@@ -465,9 +565,21 @@ async function publishUnit(parsed, deps, batch) {
465
565
  const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
466
566
  if (tree !== batch.tree) refuse(`${unit}: HEAD tree ${tree} differs from the batch tree ${batch.tree}`);
467
567
  }
468
- const dirty = git(["status", "--porcelain", "--", dir, ...GATE_CONFIG_FILES, ...(parsed.qualifyOnly ? ["Cargo.toml", "Cargo.lock"] : [])], workspaceRoot);
568
+ 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] : [])];
570
+ const dirty = git(["status", "--porcelain", "--", ...guardedPaths], workspaceRoot);
469
571
  if (dirty === null) refuse(`cannot read git status for ${dir}`);
470
572
  if (dirty !== "") refuse(`tree is not clean for ${dir} or the gate config (${GATE_CONFIG_FILES.join(", ")}); commit first:\n${dirty}`);
573
+ if (parsed.qualifyOnly && kind === "npm") {
574
+ const feedFile = path.join(workspaceRoot, feedPath);
575
+ if (unit === "@rightkit/release" && !fs.existsSync(feedFile)) refuse(`${unit}: committed ${feedPath} is required before packing`);
576
+ if (fs.existsSync(feedFile)) {
577
+ const committedFeed = git(["show", `HEAD:${feedPath}`], workspaceRoot);
578
+ if (committedFeed === null || committedFeed !== fs.readFileSync(feedFile, "utf8").trim()) refuse(`${feedPath} must match the committed HEAD before qualification`);
579
+ const feed = readJson(feedFile) ?? refuse(`cannot read ${feedPath}`);
580
+ if (feed.stagedNpm?.[unit] !== version && feed.registry?.npm?.[unit] !== version) refuse(`${unit}@${version}: stage the version in ${feedPath} and commit it before qualification`);
581
+ }
582
+ }
471
583
  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
584
  log(`[right-release] local publish ${kind}:${unit}@${version} at ${head.slice(0, 12)} (local-build grant, ${LOCAL_HOST_POLICIES[platform]} policy)`);
473
585
 
@@ -484,10 +596,10 @@ async function publishUnit(parsed, deps, batch) {
484
596
  if (consumerDirty === null) refuse(`cannot read git status for the consumer rows of ${unit}`);
485
597
  if (consumerDirty !== "") refuse(`tree is not clean for the consumer rows of ${unit}; commit first:\n${consumerDirty}`);
486
598
  }
487
- if (parsed.qualifyOnly) {
599
+ if (parsed.qualifyOnly && kind === "cargo") {
488
600
  const violations = (deps.scanSecrets ?? scanCrateForSecrets)(unitPath);
489
601
  if (violations.length) refuse(`qualification secret scan rejected: ${violations.join(", ")}`);
490
- plan.steps.unshift({ command: "cargo", args: ["fmt", "-p", unit, "--check"], test: false });
602
+ plan.steps.unshift({ command: "cargo", args: qualifyFmtArgs(unit), test: false });
491
603
  }
492
604
  const modules = await (deps.loadWorkspaceModules ?? defaultLoadWorkspaceModules)(workspaceRoot);
493
605
  const { ResultScanner, TEST_COMMAND, judgeUnit } = modules.results;
@@ -496,7 +608,7 @@ async function publishUnit(parsed, deps, batch) {
496
608
  // written reason in ci/gate.config.json (rust.<crate>.allowIgnored). The count stays in the evidence.
497
609
  const configReason = kind === "cargo" && typeof gateConfig?.rust?.[unit]?.allowIgnored === "string" ? gateConfig.rust[unit].allowIgnored.trim() : "";
498
610
  const ignoredAllowed = Boolean(allowIgnored || parsed.allowIgnoredUnits?.includes(unit) || configReason);
499
- const steps = parsed.qualifyOnly ? [{ command: `scanCrateForSecrets ${dir}`, ok: true }] : [];
611
+ const steps = parsed.qualifyOnly && kind === "cargo" ? [{ command: `scanCrateForSecrets ${dir}`, ok: true }] : [];
500
612
  for (const step of plan.steps) {
501
613
  const commandText = `${step.command} ${step.args.join(" ")}`;
502
614
  log(`[right-release] ${plan.id}: ${commandText}`);
@@ -519,39 +631,54 @@ async function publishUnit(parsed, deps, batch) {
519
631
  if (verdict.status === "fail" || steps.some((s) => s.failed > 0)) refuse(`${plan.id} not qualified: ${verdict.problems.join("; ") || "failed tests reported"}`);
520
632
  if (!(verdict.executed > 0)) refuse(`${plan.id} executed no test`);
521
633
  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)" : ""}`);
634
+ const packageEnv = yield { stage: "compile", kind, name: unit, version, commit: head, brokerRequests: brokerRequests.splice(0) };
635
+ if (packageEnv) Object.assign(env, testEnv({ env: packageEnv }, isAssetGated));
522
636
 
523
637
  // 3. Pack the artifact and hash it.
524
- const scratch = fs.mkdtempSync(path.join(deps.tmpdir ?? os.tmpdir(), "rk-local-publish-"));
638
+ // An npm pack is a broker job whose --dir and --pack-destination the adapter accepts only under the host's scratch root, so it
639
+ // packs there (the workspace rule for run output) rather than in the OS temp directory.
640
+ const scratchRoot = deps.tmpdir ?? (kind === "npm" ? (platform === "win32" ? "D:\\scratch" : "/Volumes/D/scratch") : os.tmpdir());
641
+ const scratch = fs.mkdtempSync(path.join(scratchRoot, "rk-local-publish-"));
525
642
  const cleanup = () => fs.rmSync(scratch, { recursive: true, force: true });
526
643
  try {
527
644
  let artifactFile; const packCommands = [];
645
+ let packDir = dir;
528
646
  if (kind === "npm" && unit === SWAP_HELPER_PACKAGE) {
647
+ if (parsed.qualifyOnly) {
648
+ // Signing writes its binary and receipt. Keep both in the pack copy, never the source checkout.
649
+ packDir = path.join(scratch, "package-source");
650
+ fs.cpSync(unitPath, packDir, { recursive: true, filter: (source) => path.basename(source) !== "node_modules" });
651
+ }
529
652
  // #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 }); }
653
+ try { (deps.prepareSwapHelper ?? prepareSwapHelper)({ workspaceRoot, packageDir: parsed.qualifyOnly ? packDir : unitPath, platform, run, env, log }); }
531
654
  catch (error) { refuse(`${error.code ? `[${error.code}] ` : ""}${error.message}`); }
532
655
  packCommands.push(`rustright cargo ${SWAP_HELPER_CARGO_ARGS.join(" ")} && codesign --verify --strict bin/darwin/rightkit-swap`);
533
656
  }
534
657
  if (kind === "npm") {
535
658
  // The feed a tarball ships must list the version it ships with (#319): stage it before packing.
536
659
  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];
660
+ if (!parsed.qualifyOnly && fs.existsSync(feedFile)) await withVersionsLock(() => (deps.stageSourceVersions ?? stageSourceVersions)([{ registry: "npm", name: unit, version }], { file: feedFile, root: workspaceRoot, log }));
661
+ // The tarball goes to an empty `pack` directory inside the scratch directory: the adapter takes --pack-destination only as a
662
+ // child of a scratch directory (never the scratch directory itself) and refuses one that already holds a tarball.
663
+ const packOut = path.join(scratch, "pack");
664
+ fs.mkdirSync(packOut);
665
+ const args = npmPackArgs(packDir, packOut);
539
666
  packCommands.push(`pnpm ${args.join(" ")}`);
540
667
  const packed = await run("pnpm", args, { cwd: workspaceRoot, env });
541
668
  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));
669
+ artifactFile = path.join(packOut, tarballName(unit, version));
543
670
  if (!fs.existsSync(artifactFile)) refuse(`pnpm pack did not produce ${path.basename(artifactFile)}`);
544
671
  const checked = modules.checkNpmTarball({ file: artifactFile, name: unit, version, dir, spawn: deps.spawnSync ?? spawnSync });
545
672
  if (checked.problems.length) refuse(`${unit}: packed tarball refused:\n - ${checked.problems.join("\n - ")}`);
546
673
  } else {
547
674
  // A combined publish verifies once in `cargo publish`; detached qualification verifies here before
548
675
  // 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];
676
+ const packageArgs = cargoPackageArgs(unit, parsed.qualifyOnly);
550
677
  packCommands.push(`cargo ${packageArgs.join(" ")}`);
551
678
  const packed = await run("cargo", withCargoJobs(packageArgs, parsed.jobs), { cwd: workspaceRoot, env });
552
679
  if (packed.error || packed.status !== 0) refuse(`cargo package failed for ${unit}\n${tail(`${packed.stdout ?? ""}\n${packed.stderr ?? ""}`)}`);
553
680
  if (parsed.qualifyOnly) steps.push({ command: `cargo ${packageArgs.join(" ")}`, ok: true });
554
- const metadataArgs = ["metadata", "--offline", "--format-version", "1", "--no-deps"];
681
+ const metadataArgs = [...CARGO_METADATA_ARGS];
555
682
  const meta = await runCargoReadOnly(metadataArgs, { cwd: workspaceRoot, env }, (_command, args, settings) => run("cargo", args, settings),
556
683
  (failed) => log(`[right-release] offline Cargo metadata missed cache; retrying online once\n${failed.stdout ?? ""}${failed.stderr ? `\n${failed.stderr}` : ""}`));
557
684
  const actualMetadataArgs = meta.usedOfflineFallback ? metadataArgs.filter((arg) => arg !== "--offline") : metadataArgs;
@@ -565,6 +692,16 @@ async function publishUnit(parsed, deps, batch) {
565
692
  if (!fs.existsSync(artifactFile)) refuse(`cargo package did not produce ${artifactFile}`);
566
693
  }
567
694
  const artifactSha256 = sha256(fs.readFileSync(artifactFile));
695
+ 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");
696
+ if (parsed.qualifyOnly) {
697
+ fs.mkdirSync(parsed.bundleDir, { recursive: false });
698
+ const bundledArtifact = path.join(parsed.bundleDir, path.basename(artifactFile));
699
+ fs.copyFileSync(artifactFile, bundledArtifact, fs.constants.COPYFILE_EXCL);
700
+ if (sha256(fs.readFileSync(bundledArtifact)) !== artifactSha256) refuse("qualification archive changed while copying");
701
+ artifactFile = bundledArtifact;
702
+ yield { stage: "package", kind, name: unit, version, commit: head, artifactFile, artifactSha256, brokerRequests: brokerRequests.splice(0) };
703
+ if (sha256(fs.readFileSync(artifactFile)) !== artifactSha256) refuse("qualification archive changed after packaging");
704
+ }
568
705
 
569
706
  // 4. Evidence in the normal schema, then the EXISTING gate.
570
707
  const commands = [...plan.steps.map((s) => `${s.command} ${s.args.join(" ")}`), ...packCommands, ...(batch ? batch.commands : [])];
@@ -583,23 +720,21 @@ async function publishUnit(parsed, deps, batch) {
583
720
  steps: steps.map(({ command, ok, executed, failed, ignored, skipped, binaries }) => ({ command, ok, ...(executed === undefined ? {} : { executed, failed, ignored, skipped, binaries }) })),
584
721
  }],
585
722
  };
586
- const evidencePath = path.resolve(defaultEvidencePath(workspaceRoot, kind, unit));
723
+ // A qualification writes its evidence beside the bytes, in the bundle: the tracked release-evidence/gate file is the record of a
724
+ // host publish, and rewriting it would dirty the checkout the unit door guards (the first `--to release --unit` run stopped
725
+ // there, 2026-10-27). Never overwrite a bundle's evidence.
726
+ const evidencePath = parsed.qualifyOnly ? path.join(parsed.bundleDir, "gate.json") : path.resolve(defaultEvidencePath(workspaceRoot, kind, unit));
587
727
  fs.mkdirSync(path.dirname(evidencePath), { recursive: true });
588
- fs.writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`);
728
+ fs.writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`, { flag: parsed.qualifyOnly ? "wx" : "w" });
589
729
  log(`[right-release] wrote ${evidencePath} (artifact sha256 ${artifactSha256})`);
590
- const gateOptions = { kind, name: unit, version, packagePath: unitPath, evidencePath };
730
+ const gateOptions = { kind, name: unit, version, packagePath: unitPath, evidencePath, artifactBytes: fs.readFileSync(artifactFile) };
591
731
  (deps.enforceGate ?? enforcePublicationGate)(gateOptions, { write: (line) => log(line.replace(/\n$/, "")) });
592
732
 
593
733
  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 };
734
+ // Bytes and evidence stay together outside the checkout.
735
+ const bundledEvidence = evidencePath;
736
+ log(`[right-release] qualified ${unit}@${version}; bundle ${parsed.bundleDir}${kind === "cargo" ? `; publish cargo --crate ${unit} --from-evidence ${bundledEvidence}` : ""}`);
737
+ return { kind, name: unit, version, commit: head, artifactSha256, evidencePath: bundledEvidence, artifactFile, qualifiedOnly: true };
603
738
  }
604
739
 
605
740
  // 5. The EXISTING publish path.
package/managed-cargo.mjs CHANGED
@@ -1,11 +1,51 @@
1
1
  import { discoverBrokerSocket, managedControlCandidates, resolveManagedRoots } from './managed-roots.mjs';
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { homedir, userInfo } from "node:os";
4
- import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { closeSync, existsSync, mkdirSync, openSync, readSync, rmSync, writeFileSync } from "node:fs";
5
5
  import path from "node:path";
6
6
  import { spawn as spawnProcess, spawnSync } from "./background-process.mjs";
7
7
 
8
+ /**
9
+ * Linux, and any host that is neither Mac nor Windows, never uses the RustRight broker: WSL2, a VM, a cloud or CI runner, Linux hosted on
10
+ * the Dell (GATE-CONTRACT §1 "One entry"; joint plan step 4). Every managed-cargo helper follows this one policy, whatever control-root,
11
+ * socket or PATH entries the environment carries.
12
+ */
13
+ export const directPlatform = (platform = process.platform) => platform !== "darwin" && platform !== "win32";
14
+
15
+ const MANAGED_AGENT_BIN = /(?:^|\/)(?:\.rightkit-managed|rightkitmanagedagent|rightkit\/build-control)\/agent-bin\/?$/i;
16
+ function fileHead(file) {
17
+ let fd = null;
18
+ try { fd = openSync(file, "r"); const b = Buffer.alloc(4096); const n = readSync(fd, b, 0, 4096, 0); return b.subarray(0, n).toString("latin1"); }
19
+ catch { return ""; } finally { if (fd !== null) try { closeSync(fd); } catch { /* closed */ } }
20
+ }
21
+
22
+ /**
23
+ * On a direct (Linux) host: PATH without the directories whose `cargo` is a RustRight shim (the managed agent-bin, a configured
24
+ * RIGHTKIT_MANAGED_AGENT_BIN, or a script that routes through the broker, read 4 KiB at most), and the first real cargo left on it.
25
+ * Plain `cargo` then can only be a real toolchain; with none, a cargo launch fails as missing, never through a shim.
26
+ */
27
+ export function directCargoPath({ env = process.env, exists = existsSync, readHead = fileHead } = {}) {
28
+ const configured = env.RIGHTKIT_MANAGED_AGENT_BIN ? path.posix.resolve(env.RIGHTKIT_MANAGED_AGENT_BIN) : null;
29
+ const kept = []; const dropped = []; let cargo = null;
30
+ for (const dir of String(env.PATH ?? "").split(":").filter(Boolean)) {
31
+ const candidate = path.posix.join(dir, "cargo");
32
+ const isShim = exists(candidate) && (MANAGED_AGENT_BIN.test(dir) || (configured && path.posix.resolve(dir) === configured) || /cargo-guard\.mjs|rustright/i.test(readHead(candidate)));
33
+ if (isShim) { dropped.push(dir); continue; }
34
+ kept.push(dir);
35
+ if (!cargo && exists(candidate)) cargo = candidate;
36
+ }
37
+ return { path: kept.join(":"), dropped, cargo };
38
+ }
39
+
40
+ /** `env` for a direct host: PATH without RustRight shim directories. Unchanged on Mac and Windows. */
41
+ export function directCargoEnv(env = process.env, platform = process.platform, exists = existsSync) {
42
+ if (!directPlatform(platform)) return env;
43
+ const direct = directCargoPath({ env, exists });
44
+ return direct.dropped.length ? { ...env, PATH: direct.path } : env;
45
+ }
46
+
8
47
  export function managedRuntimeClient(env = process.env, platform = process.platform, exists = existsSync) {
48
+ if (directPlatform(platform)) return undefined;
9
49
  const roots = managedControlCandidates({ env, platform });
10
50
  const paths = platform === 'win32' ? path.win32 : path.posix;
11
51
  // runtime-current ships the native `rustright` client.
@@ -54,7 +94,8 @@ export function acquireManagedRunLease({ command, env = process.env, platform =
54
94
  return { path: file, release() { rmSync(file, { force: true }); } };
55
95
  }
56
96
 
57
- export function managedHost(env) {
97
+ export function managedHost(env, platform = process.platform) {
98
+ if (directPlatform(platform)) return false; // Linux is never broker-managed (directPlatform), whatever the env names
58
99
  return Boolean(env.RIGHTKIT_BUILD_BROKER_SOCKET)
59
100
  || /(?:^|[;:])[^;:]*[/\\](?:\.rightkit-managed|rightkitmanagedagent|rightkit[/\\]build-control)[/\\]agent-bin(?:[;:]|$)/i.test(String(env.PATH ?? ""));
60
101
  }
@@ -75,9 +116,10 @@ export function runCargoSync(args, options = {}, { platform = process.platform,
75
116
  }
76
117
  // The Mac never falls back to direct Cargo, except on GitHub Actions runners, which have no
77
118
  // RightKit runtime and use the toolchain's cargo (release-candidate signing jobs).
78
- const hostedCi = env.GITHUB_ACTIONS === 'true' && !managedHost(env);
79
- if ((platform === 'darwin' && !hostedCi) || (managedHost(env) && platform === 'win32')) return unavailable('installed RightKit runtime client is unavailable for managed Cargo metadata');
80
- return spawn("cargo", args, options);
119
+ const hostedCi = env.GITHUB_ACTIONS === 'true' && !managedHost(env, platform);
120
+ if ((platform === 'darwin' && !hostedCi) || (managedHost(env, platform) && platform === 'win32')) return unavailable('installed RightKit runtime client is unavailable for managed Cargo metadata');
121
+ // A direct (Linux) host runs the real toolchain: RustRight shim directories are taken off PATH first.
122
+ return spawn("cargo", args, directPlatform(platform) ? { ...options, env: directCargoEnv(env, platform, exists) } : options);
81
123
  }
82
124
 
83
125
  function unavailable(message) {
@@ -0,0 +1,34 @@
1
+ // The managed-root guard (item 5, signed item5-sage-decisions-2.md D4 rule 1): the gate never creates the host's managed root, and on
2
+ // the Mac never accepts one on the boot volume. It lives here, not in snapshot.mjs, because build-ownership.mjs guards the root as the
3
+ // FIRST action of an acquisition and snapshot.mjs already imports build-ownership.mjs. snapshot.mjs re-exports both names.
4
+ import fs from "node:fs";
5
+
6
+ export class SnapshotError extends Error {
7
+ /** `code` is "mismatch" (the snapshot is not the commit's tree), "busy" (another sync holds it), "refused" (the commit cannot be
8
+ * materialised exactly, or the root is not one a snapshot may live in: a gitlink, an unsafe path, a link out of the snapshot, a
9
+ * missing managed root, an open-mode root, an unknown revision) or "error" (could not run). `reason`, when present, names the
10
+ * refusal: "gitlink", "unknown-revision", "managed-root-missing", "managed-root-on-boot-volume", "root-open-mode",
11
+ * "root-not-owned", "root-parent-missing". */
12
+ constructor(code, message, reason) { super(message); this.code = code; if (reason) this.reason = reason; }
13
+ }
14
+
15
+ const lstat = (p) => { try { return fs.lstatSync(p, { bigint: true }); } catch (e) { if (e.code === "ENOENT" || e.code === "ENOTDIR") return null; throw e; } };
16
+ const deviceOf = (p) => fs.statSync(p, { bigint: true }).dev;
17
+
18
+ /**
19
+ * The managed root must already exist (the gate never creates it) and, on the Mac, live on another device than `/`: with the volume
20
+ * unmounted, `/Volumes/D/.rightkit-managed` made on the boot volume (by anything) would otherwise hold the snapshots. Volume identity
21
+ * and the disk floor for the broker's own writes are the broker's (BC B16). Throws SnapshotError("refused") with a named reason.
22
+ */
23
+ export function requireManagedRoot(managedRoot, { platform = process.platform, device = deviceOf } = {}) {
24
+ const st = lstat(managedRoot);
25
+ if (!st) throw new SnapshotError("refused", `the managed root ${managedRoot} does not exist: the gate never creates it (the volume may be unmounted); nothing was written`, "managed-root-missing");
26
+ if (!st.isDirectory()) throw new SnapshotError("refused", `the managed root ${managedRoot} is not a plain directory (a link is never followed)`, "managed-root-missing");
27
+ if (platform === "darwin") {
28
+ let here; let boot;
29
+ try { here = device(managedRoot); boot = device("/"); }
30
+ catch (e) { throw new SnapshotError("error", `cannot read the device of the managed root ${managedRoot} or of /: ${e.message}`); }
31
+ if (here === boot) throw new SnapshotError("refused", `the managed root ${managedRoot} is on the same device as / (the boot volume): a snapshot there would not be on the managed volume (is the volume mounted?)`, "managed-root-on-boot-volume");
32
+ }
33
+ return managedRoot;
34
+ }