@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.
- package/README.md +25 -9
- package/admission-files.mjs +1 -1
- package/app-check.mjs +94 -20
- package/assemble-app-command.mjs +151 -0
- package/assembled-app.mjs +445 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/bin/rightkit-gate.mjs +928 -82
- package/build-launch.mjs +88 -0
- package/build-ownership.mjs +1231 -0
- package/build-release.mjs +2 -1
- package/cargo-contract.mjs +3 -1
- package/cargo-target-shim.mjs +166 -0
- package/class-a-launch-guard.mjs +126 -0
- package/cli/right-release.mjs +3 -1
- package/codesign-shim.mjs +11 -1
- package/deterministic-scans.mjs +807 -56
- package/development-signing.mjs +4 -1
- package/development.mjs +110 -20
- package/door-policy.mjs +61 -6
- package/gate-executor.mjs +14 -2
- package/gate-stages.mjs +15 -5
- package/heavy-command.mjs +3 -2
- package/install-app.mjs +11 -2
- package/install-assembled-app.mjs +423 -0
- package/ios-dev.mjs +70 -3
- package/linux-artifacts.mjs +93 -0
- package/local-publish.mjs +161 -26
- package/managed-cargo.mjs +47 -5
- package/managed-root-guard.mjs +34 -0
- package/native-helpers.mjs +355 -0
- package/package-recipe-targets.mjs +52 -7
- package/package.json +4 -3
- package/pipeline-receipts.mjs +302 -302
- package/project-type.mjs +2 -2
- package/provenance-verify.mjs +115 -0
- package/publish-cargo.mjs +8 -6
- package/qa-admission-policy.json +10 -1
- package/recipe-build.mjs +73 -5
- package/release-config.mjs +1 -1
- package/release.mjs +15 -5
- package/rightkit-versions.json +13 -7
- package/snapshot-check.mjs +306 -0
- package/snapshot-git.mjs +166 -0
- package/snapshot-request.mjs +161 -0
- package/snapshot-root-config.mjs +76 -0
- package/snapshot.mjs +856 -0
- package/stage-commands.mjs +60 -0
- package/stage-sidecar.mjs +57 -7
- package/test-command.mjs +17 -4
- package/unit-gate.mjs +259 -0
- 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 (
|
|
125
|
-
else if (
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
//
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
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
|
+
}
|