@rightkit/release 0.6.20 → 0.6.22

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.
Binary file
@@ -1608,12 +1608,14 @@ async function runStages(o, args, root, git, io) {
1608
1608
  // `main --all` compiles. A broker host's stage-sidecar child reaches Cargo through the stage's carrier (carried-cargo.mjs).
1609
1609
  // right-release runs in appRoot (nested apps), so the repository-relative config is handed over absolute (Astra, second pass).
1610
1610
  // On the snapshot route sidecars are staged ONCE per door run by the ledger bookkeeping (attribution diff, immutable staged entries).
1611
- if (ok && ds) ok = await stageSidecarsOnce({ o, ds, root, git, configs: sidecarConfigs, appDir, cli, commonDir, commit, tree, stage, executor });
1612
- else for (const config of ok ? sidecarConfigs : []) if (!await rightRelease(stage, ["stage-sidecar", "--config", path.join(root, config)])) { ok = false; break; }
1613
- // rightkit#1331: the declared compile inputs that are the app's web build output are produced in the snapshot before the compile reads them.
1611
+ // rightkit#1331: the declared compile inputs that are the app's web build output are produced in the snapshot before ANY compile reads them,
1612
+ // the sidecar builds included: the broker admits a compile only when every declared input is in the snapshot (HeardRight Mac 2026-11-02:
1613
+ // stage-sidecar's cargo build REJECTED "snapshot admission: tauri-app-next/dist: No such file or directory").
1614
1614
  if (ok && ds) ok = await stageWebBuildOnce({ o, ds, root, git, appDir, cli, commonDir, commit, tree, stage, executor });
1615
- // The Windows package-identity MSIX a Tauri app bundles (its release config's windows.identity), produced in the snapshot on Windows only.
1615
+ // The Windows package-identity MSIX a Tauri app bundles (its release config's windows.identity), produced in the snapshot on Windows only, for the same reason.
1616
1616
  if (ok && ds) ok = await stageWindowsIdentityOnce({ o, ds, root, git, appDir, cli, commonDir, commit, tree, stage, executor });
1617
+ if (ok && ds) ok = await stageSidecarsOnce({ o, ds, root, git, configs: sidecarConfigs, appDir, cli, commonDir, commit, tree, stage, executor });
1618
+ else for (const config of ok ? sidecarConfigs : []) if (!await rightRelease(stage, ["stage-sidecar", "--config", path.join(root, config)])) { ok = false; break; }
1617
1619
  let compiledDirs = []; let compiledBins = null; let qualifyOwed = [];
1618
1620
  const reportOutputs = (dirs, bins = null) => { compiledDirs = [...dirs]; compiledBins = bins; };
1619
1621
  if (ok) ok = (await (o.runCompile ?? (() => main(ds ? ["--all", "--commit", commit, "--snapshot", ds.path] : ["--all"], { ...io, cwd: root, readStdin: () => "", ...(ds ? { buildOwnership: ds.own, classAEarlier: true, snapshotLedger: ds.ledger?.entries ?? [], onCompileLedger: (entries) => { ds.compileAdmitted = entries; }, onDirectReceipt: (r) => { directReceipt = r; }, onCompileOutputs: reportOutputs, onQualifyOwed: (list) => { qualifyOwed = list; } } : {}) })))({ reportOutputs })) === 0;
@@ -1636,7 +1638,17 @@ async function runStages(o, args, root, git, io) {
1636
1638
  // compile was already passed (a separately invoked `--to package` re-stages after the sync, so it never meets a missing externalBin).
1637
1639
  // right-release then takes its identity from the selected commit and the verified snapshot (release-source-identity.mjs, row 7.04),
1638
1640
  // never the checkout's HEAD, status or branch; its outputs, state and the sealed directory stay in the checkout's `.right-release`.
1639
- ok = ds ? await stageSidecarsOnce({ o, ds, root, git, configs: sidecarConfigsOf(), appDir, cli, commonDir, commit, tree, stage, executor }) : true;
1641
+ // The declared web build and Windows identity inputs first, as in the compile stage: the sidecar builds are broker-admitted only when every declared input is in the snapshot.
1642
+ ok = true;
1643
+ if (ds) ok = await stageWebBuildOnce({ o, ds, root, git, appDir, cli, commonDir, commit, tree, stage, executor });
1644
+ if (ok && ds) ok = await stageWindowsIdentityOnce({ o, ds, root, git, appDir, cli, commonDir, commit, tree, stage, executor });
1645
+ if (ok && ds) {
1646
+ // The package triple: a Mac recipe with `stageSidecars` builds for its own triple (universal-apple-darwin by default), so the gate stages that triple here, under its own
1647
+ // producer carrier, and the recipe's prepare step (`stage-sidecar --target <triple>`) then finds it current and changes nothing.
1648
+ const packageConfigs = sidecarConfigsOf();
1649
+ const packageTarget = await packageSidecarTarget({ o, ds, configs: packageConfigs });
1650
+ ok = await stageSidecarsOnce({ o, ds, root, git, configs: packageConfigs, appDir, cli, commonDir, commit, tree, stage, executor, target: packageTarget });
1651
+ }
1640
1652
  // Source QA (right-release qa --stage source) exists for mac and win only; a mobile package is the signed build alone.
1641
1653
  // Row 7.04c (R-T1, 7.09): the snapshot holds no Cargo target. On a direct host the checkout's output directory is resolved here, in the primary
1642
1654
  // checkout, and handed to right-release absolute: it binds every manifest's target for this stage, and the sealed manifest must record it back
@@ -1938,6 +1950,16 @@ async function restagedSidecars({ o, ds, prior, now }) {
1938
1950
  return out;
1939
1951
  }
1940
1952
 
1953
+ /**
1954
+ * The --target the package stage stages sidecars for: `package` on a Mac host, which right-release stage-sidecar resolves itself from the
1955
+ * snapshot's config (the Mac package recipe's own triple, e.g. universal-apple-darwin), so this gate never imports app code; null elsewhere (host).
1956
+ */
1957
+ export async function packageSidecarTarget({ o, ds, configs = [] }) {
1958
+ if (o.platform !== "darwin" || !ds) return null;
1959
+ // Read as text, never imported (as committedSidecarConfigs does): only a config whose Mac package recipe stages sidecars gets a package-triple staging.
1960
+ return configs.some((config) => { try { return /\bstageSidecars\b/.test(o.readFile(path.join(ds.path, config))); } catch { return false; } }) ? "package" : null;
1961
+ }
1962
+
1941
1963
  /** The release configs of `commit` (under `appDir`) that declare `sidecars`, read from `base` (the snapshot on the snapshot route): the commit's config decides, never the checkout's. */
1942
1964
  export function committedSidecarConfigs({ o, git, commit, appDir = ".", base }) {
1943
1965
  const listedConfigs = git("ls-tree", "-r", "--name-only", "-z", commit);
@@ -1951,9 +1973,14 @@ export function committedSidecarConfigs({ o, git, commit, appDir = ".", base })
1951
1973
  * step's additions must equal the files it reported plus its receipt (anything else is an unattributed write, RK_CHECK_ERROR). The
1952
1974
  * staged files become immutable `staged` ledger entries. Returns true when nothing is owed or staging passed.
1953
1975
  */
1954
- export async function stageSidecarsOnce({ o, ds, root, git = null, configs, appDir, cli, commonDir, commit, tree, stage, executor = null, tag = "rightkit-gate:" }) {
1955
- if (ds.sidecarsStaged) return true;
1956
- ds.sidecarsStaged = true;
1976
+ export async function stageSidecarsOnce({ o, ds, root, git = null, configs, appDir, cli, commonDir, commit, tree, stage, executor = null, tag = "rightkit-gate:", target = null }) {
1977
+ // Once per door run PER TARGET TRIPLE: `target` null is the host (the compile stage: stage-sidecar's own default, no `--target` passed), anything else is the triple the
1978
+ // package recipe stages for (universal-apple-darwin on a Mac recipe with `stageSidecars`), passed to stage-sidecar as `--target`. stage-sidecar is a no-op for a target whose
1979
+ // receipt and files are already current, so the recipe's own prepare step afterwards changes nothing.
1980
+ const staged = ds.sidecarsStaged instanceof Set ? ds.sidecarsStaged : (ds.sidecarsStaged = new Set());
1981
+ const key = target ?? "host";
1982
+ if (staged.has(key)) return true;
1983
+ staged.add(key);
1957
1984
  if (!configs.length) return true;
1958
1985
  const { attributionProblems, reportedPaths } = await import("../stage-ledger.mjs");
1959
1986
  const source = sourceSplitOf({ root, ds, appDir });
@@ -1961,13 +1988,13 @@ export async function stageSidecarsOnce({ o, ds, root, git = null, configs, appD
1961
1988
  for (const config of configs) {
1962
1989
  const before = ds.ledger?.entries ?? [];
1963
1990
  // The stage-sidecar child's Cargo calls (its builds and metadata queries) are carried on a broker host (carried-cargo.mjs).
1964
- const carrier = await carrierForChildren({ o, ds, root, git, executor, stage, tag });
1991
+ const carrier = await carrierForChildren({ o, ds, root, git, executor, stage, tag, producer: true });
1965
1992
  if (carrier.refusal) { o.err(`${tag} BLOCKED: ${carrier.refusal}; nothing was staged`); return false; }
1966
1993
  const run = startRun({ commonDir, commit, tree, stage, source });
1967
1994
  let r;
1968
1995
  try {
1969
- o.out(`${tag} [${stage}] right-release stage-sidecar --config ${path.join(ds.path, config)} (from the snapshot ${ds.path})`);
1970
- r = (o.spawnCapture ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, encoding: "utf8", maxBuffer: 1 << 26 })))(process.execPath, [cli, "stage-sidecar", "--config", path.join(ds.path, config), "--json"], { cwd: snapshotAppRootOf(ds.path, appDir), env: carriedEnv(carrier, { ...cleanEnv, ...run.env }) });
1996
+ o.out(`${tag} [${stage}] right-release stage-sidecar --config ${path.join(ds.path, config)}${target ? ` --target ${target}` : ""} (from the snapshot ${ds.path})`);
1997
+ r = (o.spawnCapture ?? ((cmd, a, opts) => spawnInherit(cmd, a, { ...opts, encoding: "utf8", maxBuffer: 1 << 26 })))(process.execPath, [cli, "stage-sidecar", "--config", path.join(ds.path, config), ...(target ? ["--target", target] : []), "--json"], { cwd: snapshotAppRootOf(ds.path, appDir), env: carriedEnv(carrier, { ...cleanEnv, ...run.env }) });
1971
1998
  } finally { endRun(run); }
1972
1999
  const carriedOk = await carrier.finish({ stage });
1973
2000
  if (r?.stderr) o.err(String(r.stderr).trimEnd());
@@ -1983,7 +2010,7 @@ export async function stageSidecarsOnce({ o, ds, root, git = null, configs, appD
1983
2010
  const configDir = path.posix.dirname(String(config).replaceAll("\\", "/"));
1984
2011
  const underApp = (rel) => (configDir === "." ? rel : `${configDir}/${rel}`).replaceAll("\\", "/");
1985
2012
  const receiptAbs = typeof result.receiptPath === "string" ? path.relative(ds.path, result.receiptPath).split(path.sep).join("/") : null;
1986
- const problems = [...walked.problems, ...attributionProblems({ before, after: walked.entries, reported: [...reportedPaths(result)].map(underApp), receipt: receiptAbs })];
2013
+ const problems = [...walked.problems, ...attributionProblems({ before, after: walked.entries, reported: [...reportedPaths(result)].map(underApp), receipt: receiptAbs, removable: before.filter((e) => e.kind === "staged" && e.producer?.stage === "sidecar" && e.producer?.step === config).map((e) => e.path) /* only THIS config's earlier staged files (Oracle: another config's build must never delete them unreported) */ })];
1987
2014
  if (problems.length) { for (const p of problems.slice(0, 10)) o.err(`${tag} RK_CHECK_ERROR: [${stage}] stage-sidecar ${config}: ${p}`); return false; }
1988
2015
  const priorPaths = new Set(before.map((e) => e.path));
1989
2016
  const priorByPath = new Map(before.map((e) => [e.path, e]));
@@ -2042,7 +2069,7 @@ export async function stageWebBuildOnce({ o, ds, root, git = null, appDir, cli,
2042
2069
  for (const config of configs) {
2043
2070
  if (!absent().length) break;
2044
2071
  const before = ds.ledger?.entries ?? [];
2045
- const carrier = await carrierForChildren({ o, ds, root, git, executor, stage, tag });
2072
+ const carrier = await carrierForChildren({ o, ds, root, git, executor, stage, tag, producer: true });
2046
2073
  if (carrier.refusal) { o.err(`${tag} BLOCKED: ${carrier.refusal}; the web build did not run`); return false; }
2047
2074
  const run = startRun({ commonDir, commit, tree, stage, source });
2048
2075
  let r;
@@ -2094,7 +2121,7 @@ export async function stageWindowsIdentityOnce({ o, ds, root, git = null, appDir
2094
2121
  const producer = path.join(path.dirname(cli), "..", "windows-identity.mjs");
2095
2122
  for (const config of configs) {
2096
2123
  const before = ds.ledger?.entries ?? [];
2097
- const carrier = await carrierForChildren({ o, ds, root, git, executor, stage, tag });
2124
+ const carrier = await carrierForChildren({ o, ds, root, git, executor, stage, tag, producer: true });
2098
2125
  if (carrier.refusal) { o.err(`${tag} BLOCKED: ${carrier.refusal}; the Windows identity package did not build`); return false; }
2099
2126
  const run = startRun({ commonDir, commit, tree, stage, source });
2100
2127
  let r;
@@ -2725,7 +2752,7 @@ async function snapshotCargoWorkspaces({ git, ds, root }) {
2725
2752
  * child's environment; `finish` runs after the child ended, fails on any call that was refused, unverified or unfinished and on any
2726
2753
  * receipt the gate cannot verify again itself, and removes the carrier. A direct executor, or no snapshot, has nothing to carry.
2727
2754
  */
2728
- async function carrierForChildren({ o, ds, root, git, executor, stage, tag = "rightkit-gate:" }) {
2755
+ async function carrierForChildren({ o, ds, root, git, executor, stage, tag = "rightkit-gate:", producer = false }) {
2729
2756
  const none = { env: {}, carried: false, refusal: null, async finish() { return true; }, cleanup() {} };
2730
2757
  if (!ds || executor?.kind !== "broker") return none;
2731
2758
  let base = null;
@@ -2734,6 +2761,15 @@ async function carrierForChildren({ o, ds, root, git, executor, stage, tag = "ri
2734
2761
  const carried = await import("../carried-cargo.mjs");
2735
2762
  const { brokerSection } = await import("../snapshot-broker.mjs");
2736
2763
  const sb = await carrierBroker({ o, root, ds, executor });
2764
+ // Only a PRODUCER carrier (the gate's own web-build, windows-identity and stage-sidecar steps, which write declared inputs by design) takes the gate's digest of the
2765
+ // declared inputs at stage open (absent included); the end-of-stage ledger re-verify then accepts any receipt state for an input that digest says the step changed.
2766
+ // Every other carrier passes none: each receipt, adapter receipts included, must equal the stage-end digest of every declared input (strict), so a child that swaps
2767
+ // an input around an admission and restores it is refused. A malformed declaration or an unreadable input is the refusal below, as before.
2768
+ let openInputDigests = null;
2769
+ if (producer) {
2770
+ const { readDeclaredInputs: readInputsOf, declaredInputDigestsOf: digestInputsOf } = await import("../snapshot-request.mjs");
2771
+ openInputDigests = digestInputsOf(ds.path, readInputsOf(ds.path), { allowAbsent: true });
2772
+ }
2737
2773
  // Installing the carrier map sends nothing, so it records nothing: the child's own uncarried probes (`rustc -vV`, `--version`) never
2738
2774
  // reach the broker under a snapshot, and each carried request records its sub-handle just before it is sent (snapshot-broker.mjs).
2739
2775
  const checkoutDirs = await snapshotCargoWorkspaces({ git, ds, root });
@@ -2752,7 +2788,7 @@ async function carrierForChildren({ o, ds, root, git, executor, stage, tag = "ri
2752
2788
  finished = true;
2753
2789
  let problems;
2754
2790
  try {
2755
- problems = await installed.problems(sb);
2791
+ problems = await installed.problems(sb, openInputDigests ? { openInputDigests } : {});
2756
2792
  // The output directories the stage's verified receipts name: the package stage's sealed manifest must record only these.
2757
2793
  for (const dir of installed.outputDirs?.() ?? []) ((ds.carriedByStage ??= {})[at] ??= new Set()).add(dir);
2758
2794
  // And by workspace, for the next stage's children: a receipt already names the output directory, so none asks Cargo for it.
@@ -2868,6 +2904,11 @@ async function runDevTest(o, args, root, git, io, to, appRoot, appDir) {
2868
2904
  // app's own prepare step may stage them too; the ledger's attribution check, not an environment marker, is what keeps that single and exact.
2869
2905
  if (ds) {
2870
2906
  const sidecarConfigs = committedSidecarConfigs({ o, git, commit, appDir, base: ds.path });
2907
+ // The declared web build and Windows identity inputs first: the sidecar builds are broker-admitted only when every declared input is in the snapshot.
2908
+ const rr = fileURLToPath(new URL("../cli/right-release.mjs", import.meta.url));
2909
+ if (!(await stageWebBuildOnce({ o, ds, root, git, appDir, cli: rr, commonDir, commit, tree, stage: to, executor, tag })) || !(await stageWindowsIdentityOnce({ o, ds, root, git, appDir, cli: rr, commonDir, commit, tree, stage: to, executor, tag }))) {
2910
+ o.err(`${tag} --to ${to} stopped: the declared compile inputs could not be produced; nothing compiled`); return 1;
2911
+ }
2871
2912
  const staged = await stageSidecarsOnce({ o, ds, root, git, configs: sidecarConfigs, appDir, cli: fileURLToPath(new URL("../cli/right-release.mjs", import.meta.url)), commonDir, commit, tree, stage: to, executor, tag });
2872
2913
  if (!staged) { o.err(`${tag} --to ${to} stopped: the declared sidecars could not be staged; nothing compiled`); return 1; }
2873
2914
  }
@@ -4044,12 +4085,20 @@ export async function main(argv, io = {}) {
4044
4085
  const unitByName = new Map(rust.units.map((u) => [u.name, u]));
4045
4086
  const known = new Set(unitByName.keys());
4046
4087
  const builtBins = {};
4047
- const addBins = (dir, unit) => { if (dir && unit?.testLinksBins && unit.bins?.length) builtBins[dir] = [...new Set([...(builtBins[dir] ?? []), ...unit.bins])].sort(); };
4088
+ // Where Cargo put them: `<dir>/debug` on a direct host, `<dir>/<triple>/debug` on a broker host, whose jobs build with --target <triple> (the receipt
4089
+ // names it, `targetTriple`). A broker row with no triple records nothing for its directory: a guessed path could vouch for a stale binary.
4090
+ const addBins = (dir, unit, profile) => {
4091
+ if (!dir || !profile || !unit?.testLinksBins || !unit.bins?.length) return;
4092
+ const prior = builtBins[dir];
4093
+ if (prior && prior.profile !== profile) { builtBins[dir] = { profile: null, bins: [] }; return; }
4094
+ builtBins[dir] = { profile, bins: [...new Set([...(prior?.bins ?? []), ...unit.bins])].sort() };
4095
+ };
4048
4096
  for (const r of scanResult.scans) {
4049
4097
  // A row answered from the scan cache compiled nothing in this run: the bytes in the target are not vouched for by it.
4050
4098
  if (r.status !== "pass" || r.cached === true || !/^test:rust(?:-serial)?:/.test(String(r.id))) continue;
4051
4099
  const unit = unitByName.get(rustUnitOfScanId(r.id, known));
4052
- addBins(snapshotBroker ? r.snapshotEvidence?.outputDir : targetDirs?.[unit?.cargoDir ?? "."], unit);
4100
+ if (snapshotBroker) addBins(r.snapshotEvidence?.outputDir, unit, r.snapshotEvidence?.targetTriple ? `${r.snapshotEvidence.targetTriple}/debug` : null);
4101
+ else addBins(targetDirs?.[unit?.cargoDir ?? "."], unit, "debug");
4053
4102
  }
4054
4103
  const outputWorkspaces = (snapshotBroker ? scanResult.scans.map((r) => r.snapshotEvidence?.outputDir) : Object.values(targetDirs ?? {})).filter(Boolean).map((dir) => [dir]);
4055
4104
  o.onCompileOutputs?.(outputWorkspaces.map(([dir]) => dir), builtBins);
package/carried-cargo.mjs CHANGED
@@ -136,10 +136,10 @@ function finish(ledgerDir, dir, workspaces = {}) {
136
136
  const checkoutRoots = Object.values(workspaces);
137
137
  return {
138
138
  /** Every call of the stage that is not a completed, verified success, and every verified call the gate cannot verify again itself. */
139
- async problems(sb) {
139
+ async problems(sb, opts = {}) {
140
140
  const { entries, problems } = readLedger(ledgerDir, UNSCOPED);
141
141
  const out = [...problems];
142
- if (sb) out.push(...await verifyBrokerLedger(entries, sb));
142
+ if (sb) out.push(...await verifyBrokerLedger(entries, sb, opts));
143
143
  return out;
144
144
  },
145
145
  /** The verified calls' output directories, in call order (the receipts'; an adapter job's nested children's included). */
@@ -1348,7 +1348,9 @@ export function planScans({
1348
1348
  // A package that depends on another workspace package is therefore checked through a generated project that maps each sibling's
1349
1349
  // exported entry points to its committed sources (TSC_PROJECT, built by the runner); every other package keeps its own tsconfig.
1350
1350
  // `--noEmit` stays on the command line as well as in the project: the class A launch guard judges a tsc launch by its argv alone.
1351
- const workspaceDirs = (npmMap ? [...npmMap.values()].map((u) => u.dir) : listDir(path.join(liveRoot, "packages")).map((n) => `packages/${n}`)).sort();
1351
+ // Every workspace package is a possible sibling, whatever subset this plan checks (a publication plans only its units, and their
1352
+ // siblings' sources must still be mapped: publish scans of @rightkit/workspace-ui could not resolve @rightkit/mail-ui/*).
1353
+ const workspaceDirs = [...new Set([...(npmMap ? [...npmMap.values()].map((u) => u.dir) : []), ...listDir(path.join(liveRoot, "packages")).map((n) => `packages/${n}`)])].sort();
1352
1354
  const manifestOf = (dir) => { try { return JSON.parse(readFile(path.join(liveRoot, dir, "package.json")).replace(/^/, "")); } catch { return null; } };
1353
1355
  const workspaceNames = new Map(workspaceDirs.map((d) => [manifestOf(d)?.name, d]).filter(([n]) => typeof n === "string"));
1354
1356
  const usesWorkspaceSibling = (dir) => {
@@ -2596,7 +2598,9 @@ export async function runScans(plan, options = {}) {
2596
2598
  // generated outside the checked tree and removed after the run, so the check reads nothing a commit lacks and writes nothing.
2597
2599
  let tscProject = null;
2598
2600
  if (args.includes(TSC_PROJECT)) {
2599
- try { tscProject = writeTscProject({ contentRoot, packageDir: path.join(rootDir(s.root), s.cwd ?? ""), workspace: s.tscWorkspace ?? [] }); }
2601
+ // The siblings are mapped in the same tree tsc runs in (a publication scans a content checkout and a live checkout of
2602
+ // one verified commit; mixing them puts the package outside the project's rootDir, TS6059).
2603
+ try { tscProject = writeTscProject({ contentRoot: rootDir(s.root), packageDir: path.join(rootDir(s.root), s.cwd ?? ""), workspace: s.tscWorkspace ?? [] }); }
2600
2604
  catch (e) { advisory?.cleanup?.(); return { ...row, status: "fail", code: "TSC_PROJECT", detail: `${s.id}: the check project could not be built from the committed workspace: ${e.message}` }; }
2601
2605
  args = args.map((a) => (a === TSC_PROJECT ? tscProject.file : a));
2602
2606
  }
@@ -2755,7 +2759,7 @@ export async function runScans(plan, options = {}) {
2755
2759
  const ref = readRequestRef(path.join(carrier.refDir, "request.json"));
2756
2760
  const receipt = await deps.snapshotBroker.receiptOf(ref.requestId);
2757
2761
  const { evidence } = verifySnapshotReceipt(receipt, { pins: deps.snapshotBroker.pins, jobId: ref.requestId, carrier: carrier.made.carrier, argv: carrier.cargoArgv, workspace: ref.workspace, observedEnv: observedEnvOf({ ...deps.env, ...s.env, ...(extraEnv ?? {}) }, deps.platform ?? process.platform), checkout: deps.snapshotBroker.checkout, outputRoots: deps.snapshotBroker.outputRoots, platform: deps.snapshotBroker.platform ?? deps.platform ?? process.platform });
2758
- row.snapshotEvidence = { requestId: ref.requestId, outputDir: evidence.outputDir, cargoDir: s.cwd ?? ".", treeAtStart: evidence.snapshot.treeAtStart, treeAtExit: evidence.snapshot.treeAtExit };
2762
+ row.snapshotEvidence = { requestId: ref.requestId, outputDir: evidence.outputDir, cargoDir: s.cwd ?? ".", targetTriple: typeof receipt?.targetTriple === "string" && /^[A-Za-z0-9_.]+(?:-[A-Za-z0-9_.]+){1,4}$/.test(receipt.targetTriple) ? receipt.targetTriple : null, treeAtStart: evidence.snapshot.treeAtStart, treeAtExit: evidence.snapshot.treeAtExit };
2759
2763
  } catch (e) {
2760
2764
  status = "fail"; code = "SNAPSHOT_EVIDENCE"; detail = `${s.id}: the broker's snapshot evidence did not verify (${e.code ?? "error"}: ${e.message})`;
2761
2765
  }
package/gate-stages.mjs CHANGED
@@ -234,10 +234,11 @@ export function compileKeyOf(env = {}) {
234
234
  export const COMPILE_PROFILE_DIR = "debug";
235
235
 
236
236
  /** How `binaries` was chosen: only the bin targets the compile built in that directory (a consumer requires this marker). */
237
- export const BUILT_BINARIES_SCHEMA = "built-bin-targets/1";
237
+ export const BUILT_BINARIES_SCHEMA = "built-bin-targets/2";
238
238
 
239
239
  /**
240
- * The sha256 of each binary this compile built (rustright#209 item 5): for every output directory, the files `<dir>/debug/<bin>[.exe]` of the
240
+ * The sha256 of each binary this compile built (rustright#209 item 5): for every output directory, the files `<dir>/<profile>/<bin>[.exe]` (`debug`, or
241
+ * `<triple>/debug` for a broker build with --target; each row's `path` names it, schema built-bin-targets/2) of the
241
242
  * bin targets the compile built there (`builtBins`, { [dir]: [bin target name] }: the units whose scans ran `cargo build -p <crate>` in that
242
243
  * directory's workspace, so each binary was rebuilt or proven fresh by Cargo for this commit). Any other file in `debug/`, a stale binary of a
243
244
  * removed or renamed target or of a package this run did not build, is never recorded. A census proves nothing once truncated (a broker
@@ -247,12 +248,16 @@ export const BUILT_BINARIES_SCHEMA = "built-bin-targets/1";
247
248
  export function compiledBinaries(builtBins = {}, { platform = process.platform, stat = (f) => fs.statSync(f), readFile = (f) => fs.readFileSync(f) } = {}) {
248
249
  const out = [];
249
250
  for (const dir of Object.keys(builtBins ?? {}).filter((d) => typeof d === "string" && d).sort()) {
250
- for (const bin of [...new Set(builtBins[dir] ?? [])].sort()) {
251
+ // { profile, bins }: profile is the directory under `dir` Cargo wrote them to (`debug`, or `<triple>/debug` for a --target build); a bare array is `debug`.
252
+ const entry = Array.isArray(builtBins[dir]) ? { profile: COMPILE_PROFILE_DIR, bins: builtBins[dir] } : builtBins[dir] ?? {};
253
+ if (typeof entry.profile !== "string" || !/^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)?$/.test(entry.profile) || entry.profile.split("/").some((c) => c === "." || c === "..")) continue;
254
+ for (const bin of [...new Set(entry.bins ?? [])].sort()) {
251
255
  const name = `${bin}${platform === "win32" ? ".exe" : ""}`;
252
- const file = path.join(dir, COMPILE_PROFILE_DIR, name);
256
+ const rel = `${entry.profile}/${name}`;
257
+ const file = path.join(dir, ...rel.split("/"));
253
258
  let info = null;
254
259
  try { info = stat(file); } catch { info = null; }
255
- out.push(info?.isFile?.() ? { dir, name, size: info.size, sha256: sha256(readFile(file)) } : { dir, name, size: null, sha256: null });
260
+ out.push(info?.isFile?.() ? { dir, path: rel, name, size: info.size, sha256: sha256(readFile(file)) } : { dir, path: rel, name, size: null, sha256: null });
256
261
  }
257
262
  }
258
263
  return out;
@@ -272,6 +272,16 @@ function stageSidecarsSpec(value) {
272
272
  };
273
273
  }
274
274
 
275
+ /** The triple a `targets.mac.macPackage` spec builds for: `triple`, else this host's arch when `universal: false`, else universal-apple-darwin. */
276
+ export function macPackageTriple(spec) {
277
+ return spec.triple ?? (spec.universal === false ? (process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin") : "universal-apple-darwin");
278
+ }
279
+
280
+ /** True when the macPackage spec stages its declared sidecars itself (`stageSidecars: true | { config?, receipt? }`, the recipe's first prepare step). */
281
+ export function macPackageStagesSidecars(spec) {
282
+ return spec.stageSidecars !== undefined && spec.stageSidecars !== false;
283
+ }
284
+
275
285
  export function expandMacPackageTarget(target, { appRoot, config = {}, env = process.env, resolveTargetRoot = cargoResolveTargetRoot, managedRoots }) {
276
286
  if (!target?.macPackage) return withoutRecipe(target);
277
287
  const spec = target.macPackage;
@@ -291,7 +301,7 @@ export function expandMacPackageTarget(target, { appRoot, config = {}, env = pro
291
301
  if (spec.updater !== undefined && spec.updater !== true && spec.updater !== false && typeof spec.updater !== "string") fail("targets.mac.macPackage.updater must be true or an app-relative .tar.gz path");
292
302
  if (typeof spec.updater === "string" && !/\.tar\.gz$/i.test(relativeInside(spec.updater, "targets.mac.macPackage.updater"))) fail("targets.mac.macPackage.updater must end in .tar.gz");
293
303
  // Tauri's universal-apple-darwin target builds both slices and lipos them itself.
294
- const triple = spec.triple ?? (spec.universal === false ? (process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin") : "universal-apple-darwin");
304
+ const triple = macPackageTriple(spec);
295
305
  if (spec.triple !== undefined) {
296
306
  const wrong = [...MAC_TRIPLES, "universal-apple-darwin"].find((other) => other !== triple && target.notarize.app.replaceAll("\\", "/").split("/").includes(other));
297
307
  if (wrong) fail(`targets.mac.macPackage.triple is ${triple} but notarize.app is under ${wrong}; the built .app lives in the ${triple} directory`);
@@ -301,7 +311,7 @@ export function expandMacPackageTarget(target, { appRoot, config = {}, env = pro
301
311
  const sidecars = stageSidecarsSpec(spec.stageSidecars);
302
312
  if (sidecars) {
303
313
  // First prepare step: stage the declared sidecars for this triple (universal: both slices, lipo, <name>-universal-apple-darwin).
304
- extras.prepare = [{ cmd: "right-release", args: ["stage-sidecar", "--target", triple, ...(sidecars.config ? ["--config", sidecars.config] : [])] }, ...extras.prepare];
314
+ extras.prepare = [{ cmd: "right-release", args: ["stage-sidecar", "--target", triple, "--if-stale", ...(sidecars.config ? ["--config", sidecars.config] : [])] }, ...extras.prepare];
305
315
  }
306
316
  let helpers;
307
317
  if (spec.nativeHelpers !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.6.20",
3
+ "version": "0.6.22",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
@@ -49,7 +49,7 @@
49
49
  "@rightkit/qa": "0.6.2",
50
50
  "@rightkit/qa-chrome": "0.1.12",
51
51
  "@rightkit/react-shell": "0.7.2",
52
- "@rightkit/release": "0.6.20",
52
+ "@rightkit/release": "0.6.22",
53
53
  "@rightkit/settings-ui": "0.7.0",
54
54
  "@rightkit/social-ui": "0.1.0",
55
55
  "@rightkit/system-ui": "0.1.0",
@@ -27,7 +27,7 @@ import { removeCarrier, sweepSnapshotRequests } from "./carrier-census.mjs";
27
27
  import { DEFAULT_RETRY_WINDOW_MS, RETRYABLE_OUTCOME_CODES, clientGuardMs, diagnoseControlReply, drainFrames, operationDeadlineEnv, operationOutcomeInOutput, overCapProblem, refusalInCapturedOutput, retryPauseMs, sleepSync } from "./broker-wire.mjs";
28
28
  import { OWNERSHIP_ENV, OWNERSHIP_FILE_ENV, markCarriedSend, markCarriedUnsent } from "./ownership-mark.mjs";
29
29
  import {
30
- MANAGED_RUST_ENVIRONMENT_KEYS, SNAPSHOT_REQUEST_SCHEMA, SnapshotRequestError, argvSha256, isCallHandleOf, lockedArgv, observedEnvOf, readDeclaredInputs, readRequestRef, verifySnapshotReceipt, writeSnapshotRequest,
30
+ MANAGED_RUST_ENVIRONMENT_KEYS, SNAPSHOT_REQUEST_SCHEMA, SnapshotRequestError, argvSha256, declaredInputDigestsOf, isCallHandleOf, lockedArgv, observedEnvOf, readDeclaredInputs, readRequestRef, verifySnapshotReceipt, writeSnapshotRequest,
31
31
  } from "./snapshot-request.mjs";
32
32
 
33
33
  /**
@@ -424,6 +424,13 @@ function carryBrokerCargoOnce({ words, cwd, env, section, workspace, checkoutWor
424
424
  const seen = operationOutcomeInOutput(`${out.stdout}\n${out.stderr}`) ?? (r.error?.code === "ETIMEDOUT" ? { code: "operation-deadline", deadlineMs: null, requestId: ref?.requestId ?? null, cancelConfirmed: null, terminalState: null, guard: true } : null);
425
425
  if (seen) outcome = { status: "unverified", code: seen.code, message: seen.code === "operation-deadline" ? `the operation deadline${seen.deadlineMs ? ` of ${seen.deadlineMs} ms` : ""} passed${seen.guard ? " and the client was stopped by the gate's outer guard" : ""}; request ${seen.requestId ?? ref?.requestId ?? "unknown"}: cancelConfirmed: ${seen.cancelConfirmed ?? "unknown"}, terminal state: ${seen.terminalState ?? "not observed"}; the job is neither abandoned nor resubmitted and ownership stays UNKNOWN until every sub-handle is REAPED` : seen.code === "broker-busy" ? "every broker connection was busy (RK_BROKER_BUSY: a wait, not an outage); nothing was admitted" : "the broker was unavailable (RK_BROKER_UNAVAILABLE)", ...(ref ? { requestId: ref.requestId, workspace: ref.workspace } : {}) };
426
426
  }
427
+ // The client's own account of a call that never got a request reference (it refused the carrier, or the broker refused the request before
428
+ // accepting it): its `rustright: <reason>` lines, which capture mode would otherwise drop (HeardRight Mac package 2026-11-02: exit 125 with
429
+ // no reason in the gate log). A bare `rustright: request <id>` line is not a reason.
430
+ if (outcome.status === "unverified" && capture && ref === null) {
431
+ const said = out.stderr.split(/\r?\n/).map((line) => line.trim()).filter((line) => /^rustright(?:[: ]|-)/.test(line) && !/^rustright: request [0-9a-f-]+$/.test(line));
432
+ if (said.length) outcome = { ...outcome, message: `${outcome.message}; the RustRight client said (exit ${r.status ?? "none"}): ${said.slice(-3).join(" | ").slice(0, 600)}` };
433
+ }
427
434
  if (outcome.status === "unverified" && outcome.code === "operation-deadline") {
428
435
  const settled = reconcileAfterCancel({ section, callHandle: made.carrier.ownership.handle, childEnv });
429
436
  outcome = { ...outcome, message: `${outcome.message}; ${settled.note}`, reaped: settled.reaped, subHandle: settled.subHandle };
@@ -691,9 +698,26 @@ export const runBrokerCargo = (call) => carryBrokerCargo(call).status;
691
698
  * again now, verifies against THIS gate's commit, snapshot, ownership handle and pins, and its carrier was used once: a nonce or a
692
699
  * request id that appears twice in the run (`seen`) is a replay. Returns the problems; none means every call verified.
693
700
  */
694
- export async function verifyBrokerLedger(entries, sb, { seen = { nonces: new Set(), ids: new Set(), handles: new Set() } } = {}) {
701
+ export async function verifyBrokerLedger(entries, sb, { seen = { nonces: new Set(), ids: new Set(), handles: new Set() }, openInputDigests = null } = {}) {
695
702
  seen.handles ??= new Set();
696
703
  const problems = [];
704
+ // `openInputDigests` is supplied ONLY by a producer carrier (the gate's own web-build, windows-identity and stage-sidecar steps, which write declared inputs by design):
705
+ // the inputs whose digest at stage open (the gate's own, allowAbsent) differs from the digest at stage end, computed once, are then "produced" and accept any well-formed
706
+ // receipt entry. Without it (every other carrier) nothing is produced: every receipt, cargo AND adapter, must equal the stage-end digest input by input, so an input a child
707
+ // swapped around an admission and restored is refused. closeSnapshotStage's attribution check is what makes "produced" safe. A failure to digest ends the verification with that problem.
708
+ let inputPolicy = null;
709
+ const ledgered = entries.some((e) => e.mode === "broker" && e.status === "verified");
710
+ if (ledgered) {
711
+ try {
712
+ const end = declaredInputDigestsOf(sb.snapshot.path, readDeclaredInputs(sb.snapshot.path), { platform: sb.platform, allowAbsent: true });
713
+ const produced = new Set();
714
+ if (Array.isArray(openInputDigests)) {
715
+ if (openInputDigests.length !== end.length) throw new SnapshotRequestError("ledger", "the stage-open digest of the declared inputs does not match the declaration");
716
+ end.forEach((d, i) => { if (openInputDigests[i]?.path !== d.path) throw new SnapshotRequestError("ledger", "the stage-open digest of the declared inputs is not in declaration order"); if (canonicalJson(openInputDigests[i]) !== canonicalJson(d)) produced.add(d.path); });
717
+ }
718
+ inputPolicy = { produced, current: end };
719
+ } catch (error) { return [`the gate cannot digest the snapshot's declared inputs at the end of the stage (${error.code ?? "error"}: ${error.message})`]; }
720
+ }
697
721
  for (const e of entries) {
698
722
  if (e.mode !== "broker" || e.status !== "verified") continue;
699
723
  const adapter = typeof e.adapter === "string" && e.adapter ? e.adapter : null;
@@ -726,7 +750,7 @@ export async function verifyBrokerLedger(entries, sb, { seen = { nonces: new Set
726
750
  ownership: { handle: e.ownershipHandle }, primaryCwd: e.checkoutCwd, argvSha256: argvSha256(e.argv), nonce: e.nonce,
727
751
  };
728
752
  const receipt = await sb.receiptOf(e.requestId);
729
- const verified = verifySnapshotReceipt(receipt, { pins: sb.pins, jobId: e.requestId, carrier, argv: e.argv, workspace: e.workspace, observedEnv: e.observedEnv ?? {}, adapter, adapterEnv: e.adapterEnv ?? {}, checkout: sb.checkout, outputRoots: sb.outputRoots, platform: sb.platform });
753
+ const verified = verifySnapshotReceipt(receipt, { pins: sb.pins, jobId: e.requestId, carrier, argv: e.argv, workspace: e.workspace, observedEnv: e.observedEnv ?? {}, adapter, adapterEnv: e.adapterEnv ?? {}, checkout: sb.checkout, outputRoots: sb.outputRoots, platform: sb.platform, inputPolicy });
730
754
  if (adapter !== null) {
731
755
  // The children the ledger records (the bundle root and the stage's output directories are read from them) are the signed ones, and no more.
732
756
  if (canonicalJson(e.nestedChildren ?? null) !== canonicalJson(verified.nestedChildren)) throw new SnapshotRequestError("nested", "the nested children the call recorded are not the ones the broker's receipt signs");
@@ -305,7 +305,10 @@ export function installSnapshotDependencies({ checkout, snapshot, commit, tree,
305
305
  const ms = now() - started;
306
306
  if (failed(r) || /No projects matched the filters/.test(`${r?.stdout ?? ""}\n${r?.stderr ?? ""}`)) {
307
307
  const out = `${r?.stdout ?? ""}\n${r?.stderr ?? ""}`;
308
- const miss = /ERR_PNPM_NO_OFFLINE_TARBALL[^\n]*?(https?:\/\/\S+?\/-\/(\S+?)\.tgz)/.exec(out);
308
+ // pnpm 12 prints the code on its own line and wraps the tarball URL ("from https://\n registry.npmjs.org/..."): the URL is rejoined first,
309
+ // and the code and its URL may be a few lines apart (Dell, 2026-11-02: the specific message never fired; only progress lines showed).
310
+ const joined = out.replace(/(https?:\/\/)\s+/g, "$1");
311
+ const miss = /ERR_PNPM_NO_OFFLINE_TARBALL[\s\S]{0,600}?(https?:\/\/\S+?\/-\/(\S+?)\.tgz)/.exec(joined);
309
312
  const message = miss
310
313
  ? `root ${root}: ${miss[2]} is missing from the store ${storeDir} (offline install, no network fallback); install the checkout's dependencies so the store holds it, then run the check again`
311
314
  : `root ${root}: offline dependency install failed (store ${storeDir}): ${tail(r) || r?.error?.message || `exit ${r?.status}`}`;
@@ -356,9 +356,9 @@ export function assertSuppliedInputs(snapshotRoot, supplied) {
356
356
  * anywhere, a special file, or a limit breach is a refusal. The name is the input's path components joined by `/` on EVERY platform (GB-49: RustRight hashes
357
357
  * `/` on Windows too, so a `\` in a declaration or a platform separator never reaches the hash), UTF-8, no Unicode normalisation.
358
358
  *
359
- * `allowAbsent` is set ONLY for a non-compiling snapshot-bound request (the carried read-only set): a declared input that does not exist in the
360
- * snapshot (ENOENT/ENOTDIR on a component; a link is still refused) is then the entry `{ path, absent: true }` (no `sha256`, 0 files, 0 bytes), the
361
- * wire form RustRight signs. A compile request never allows it: a missing declared input is a refusal there (the build outputs must exist).
359
+ * `allowAbsent` makes a declared input that does not exist in the snapshot (ENOENT/ENOTDIR on a component; a link is still refused) the entry
360
+ * `{ path, absent: true }` (no `sha256`, 0 files, 0 bytes), the wire form RustRight signs. The broker admits every request with an absent input
361
+ * (a compile is refused at exit only if an input CHANGED while it ran, rustright d378d32), so the receipt check digests with it for every class.
362
362
  */
363
363
  export function declaredInputDigestsOf(snapshotRoot, inputs, { allowAbsent = false } = {}) {
364
364
  let files = 0; let bytes = 0;
@@ -528,7 +528,8 @@ export function verifyNestedChildren(evidence, receipt, { snapshotPath, checkout
528
528
  * Beyond the signature, the job, the nonce and the request digest, the receipt must carry (item 7 row 7.22):
529
529
  * - signed SUCCEEDED with exit 0, and signed snapshotEvidence equal to the receipt's copies (commit, ownership handle, request id,
530
530
  * snapshot path, `treeAtStart` = `treeAtExit` = the expected tree);
531
- * - `declaredInputDigests` equal to the gate's own digest of the snapshot's declared inputs, read from the snapshot now;
531
+ * - `declaredInputDigests` well formed (the declaration's paths in order, each `{path, sha256}` or `{path, absent:true}`), and for a cargo request equal to the gate's own
532
+ * digest of the snapshot's declared inputs read now (absent included; an adapter request skips the comparison, it may produce an input); `inputPolicy` replaces that digest (see below);
532
533
  * - `outputDir` absolute, outside the snapshot and the checkout, and a managed generation target under one of `outputRoots`;
533
534
  * - signed stageEvidence (freshness, attribution, toolchain) equal to the receipt's;
534
535
  * - for a compile, freshness.causeEvidence (rustright.cause-evidence.v1) coherent with its census and with a verdict of `explained`: refused as `evidence` when missing,
@@ -536,7 +537,7 @@ export function verifyNestedChildren(evidence, receipt, { snapshotPath, checkout
536
537
  * - timingsMs, exit, diagnosticsPath, brokerVersion and executor present with their types; the retired `verdict` and `refreshed` are refused (GB-30: Class A and its verdict reuse are the gate's).
537
538
  * `checkout` and `outputRoots` are required: without them nothing can be said about where the outputs went, so it refuses.
538
539
  */
539
- export function verifySnapshotReceipt(receipt, { pins, jobId, carrier, argv, workspace, observedEnv = {}, adapter = null, adapterEnv = {}, checkout, outputRoots, platform = process.platform, canonicalPath = (p) => fs.realpathSync.native(p), digestInputs = declaredInputDigestsOf, readInputs = readDeclaredInputs }) {
540
+ export function verifySnapshotReceipt(receipt, { pins, jobId, carrier, argv, workspace, observedEnv = {}, adapter = null, adapterEnv = {}, checkout, outputRoots, platform = process.platform, canonicalPath = (p) => fs.realpathSync.native(p), digestInputs = declaredInputDigestsOf, readInputs = readDeclaredInputs, inputPolicy = null }) {
540
541
  const requestDigest = requestDigestOf(snapshotRequestCore({ argv, workspace, observedEnv, carrier, adapter, adapterEnv }));
541
542
  const payload = verifyProvenance(receipt, pins, { jobId, nonce: carrier.nonce, requestDigest });
542
543
  // The signed outcome, not the client's exit code: a passing row needs the broker to have signed SUCCEEDED with exit 0 and no
@@ -563,21 +564,36 @@ export function verifySnapshotReceipt(receipt, { pins, jobId, carrier, argv, wor
563
564
  commit: carrier.commit, tree: carrier.snapshot.expectedTree, jobId, handle: carrier.ownership.handle, snapshotPath: carrier.snapshot.path,
564
565
  samePath: (a, b) => { try { return canonicalPath(a) === canonicalPath(b); } catch { return false; } },
565
566
  });
566
- // Declared inputs: what the broker digested in place must be what the gate digests in the snapshot, from the same manifest.
567
- // An absent entry ({path, absent:true}) is evidence only for a non-compiling snapshot-bound request, and only when the gate's own read of the
568
- // snapshot finds the input absent too: the gate digests with `allowAbsent` for that class alone, so a receipt that says absent where the gate
569
- // sees the input present (or present where it is absent) differs below and is refused. A compile receipt never carries one.
570
- const nonCompile = adapter === null && !isCompileClass(argv);
571
- if (!nonCompile && Array.isArray(evidence.declaredInputDigests) && evidence.declaredInputDigests.some((d) => isObject(d) && (d.absent !== undefined || d.sha256 === undefined))) {
572
- fail("evidence", "the signed declaredInputDigests carry an absent declared input on a compile request (cargo " + `${cargoSubcommandOf(argv)}); only a non-compiling request may record one`);
573
- }
574
- let gateInputs; let gateDigests;
567
+ // Declared inputs. The broker admits every snapshot request with a declared input absent and records it as `{ path, absent: true }` in the signed
568
+ // snapshotEvidence.declaredInputDigests (declaration order); a compile is refused at exit if any declared input changed while it ran, an absent one
569
+ // appearing included (rustright d378d32). So an absent entry is evidence on any request, and the receipt is judged per class:
570
+ // - a cargo request (adapter === null): the receipt's digests equal the gate's current digest of the snapshot's inputs (the child copies the next
571
+ // file only after the call returns, and the broker refused a compile whose inputs changed during it);
572
+ // - an adapter request: the digest comparison is skipped (the job may be the producer of an input), but the receipt's entries must be well formed,
573
+ // the declaration's paths in declaration order, each exactly `{ path, sha256 }` or exactly `{ path, absent: true }`;
574
+ // - with `inputPolicy` ({ produced: Set<path>, current: digest array }) the gate's own digest is replaced: an input whose path is in `produced` accepts any
575
+ // well-formed entry (absent or any sha256); every other input must equal `current`'s entry for that path (the swap-around-admission refusal).
576
+ let gateInputs; let gateDigests = null;
575
577
  try {
576
578
  gateInputs = readInputs(carrier.snapshot.path);
577
- gateDigests = digestInputs(carrier.snapshot.path, gateInputs, { platform, allowAbsent: nonCompile });
579
+ if (!inputPolicy && adapter === null) gateDigests = digestInputs(carrier.snapshot.path, gateInputs, { platform, allowAbsent: true });
578
580
  } catch (e) { fail("evidence", `the gate cannot digest the snapshot's declared inputs (${e.message})`); }
579
581
  if (!same(gateInputs, carrier.declaredInputs?.inputs)) fail("evidence", "the snapshot's declared inputs are not the ones this request carried (declaredInputs differs from the snapshot declaration)");
580
- if (!same(evidence.declaredInputDigests, gateDigests)) fail("evidence", "the signed declaredInputDigests are not the gate's own digest of the snapshot's declared inputs (an input the receipt records absent must be absent in the snapshot, and the reverse)");
582
+ const signedInputs = evidence.declaredInputDigests;
583
+ const wellFormed = Array.isArray(signedInputs) && signedInputs.length === gateInputs.length && signedInputs.every((d, i) => {
584
+ if (!isObject(d) || d.path !== gateInputs[i]) return false;
585
+ const keys = Object.keys(d).sort().join();
586
+ return (keys === "path,sha256" && typeof d.sha256 === "string" && /^[0-9a-f]{64}$/.test(d.sha256)) || (keys === "absent,path" && d.absent === true);
587
+ });
588
+ if (!wellFormed) fail("evidence", "the signed declaredInputDigests are not the snapshot's declared inputs in declaration order, each exactly { path, sha256 } or { path, absent: true }");
589
+ if (inputPolicy) {
590
+ const { produced, current } = inputPolicy;
591
+ if (!(produced instanceof Set) || !Array.isArray(current) || current.length !== gateInputs.length) fail("evidence", "the gate's input policy is not { produced: Set, current: one digest per declared input }");
592
+ signedInputs.forEach((d, i) => {
593
+ if (produced.has(gateInputs[i])) return;
594
+ if (!same(d, current[i])) fail("evidence", `the signed declaredInputDigests entry for ${gateInputs[i]} is not the gate's own digest of that input at the end of the stage, and the stage did not change it (an input the receipt records absent must be absent, and the reverse)`);
595
+ });
596
+ } else if (adapter === null && !same(signedInputs, gateDigests)) fail("evidence", "the signed declaredInputDigests are not the gate's own digest of the snapshot's declared inputs (an input the receipt records absent must be absent in the snapshot, and the reverse)");
581
597
  // Output directory: where the outputs went must be the broker's managed generation target, never under the snapshot or the checkout.
582
598
  const out = evidence.outputDir;
583
599
  if (typeof out !== "string" || !path.isAbsolute(out) && !path.win32.isAbsolute(out)) fail("evidence", "the signed evidence names no absolute output directory");
package/stage-ledger.mjs CHANGED
@@ -137,12 +137,15 @@ export const sameEntries = (x, y) => { const d = diffEntries(x, y); return !d.ad
137
137
  * The attribution diff of a staging step: every path the step added must be one it reported (or its receipt), and every path it
138
138
  * reported must exist. Anything else is an unattributed write. `reported` and `receipt` are snapshot-relative posix paths.
139
139
  */
140
- export function attributionProblems({ before, after, reported, receipt }) {
140
+ export function attributionProblems({ before, after, reported, receipt, removable = [] }) {
141
141
  const d = diffEntries(before, after);
142
142
  const allowed = new Set([...reported, ...(receipt ? [receipt] : [])]);
143
+ // A step re-staging another target may replace a file an earlier staging step staged (a CHANGED path it reports is allowed above) and may remove one (a path in `removable`,
144
+ // the staged sidecar outputs of the step's own earlier staging, which a `wipeStageDir` re-stage deletes); nothing else may be removed.
145
+ const removableSet = new Set(removable);
143
146
  const problems = [];
144
147
  for (const p of [...d.added, ...d.changed]) if (!allowed.has(p)) problems.push(`${p} was written by the staging step but it did not report it`);
145
- for (const p of d.removed) problems.push(`${p} was removed by the staging step`);
148
+ for (const p of d.removed) if (!removableSet.has(p)) problems.push(`${p} was removed by the staging step`);
146
149
  const present = new Set(after.map((e) => e.path));
147
150
  for (const p of allowed) if (!present.has(p)) problems.push(`${p} was reported by the staging step but is not in the snapshot as a ledger entry`);
148
151
  return problems;
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // `right-release stage-sidecar [--config right-release.config.json] [--target <triple>] [--check] [--json]` (bogusyogi/rightkit#345):
2
+ // `right-release stage-sidecar [--config right-release.config.json] [--target <triple>|package] [--if-stale] [--check] [--json]` (bogusyogi/rightkit#345):
3
3
  // stages the config's declarative `sidecars` into src-tauri/binaries with Tauri externalBin naming. `--check` validates
4
4
  // the manifest and prints the plan without building, downloading, copying or deleting anything (for template gates).
5
5
  import path from "node:path";
@@ -9,7 +9,7 @@ import { planSidecarStaging, resolveTargetTriple, stageSidecars } from "./stage-
9
9
  import { readSourceSplit } from "./source-split.mjs";
10
10
 
11
11
  export function parseStageSidecarArgs(argv, cwd = process.cwd()) {
12
- const opts = { config: defaultReleaseConfigFile(cwd), target: undefined, json: false, check: false };
12
+ const opts = { config: defaultReleaseConfigFile(cwd), target: undefined, json: false, check: false, ifStale: false };
13
13
  for (let i = 0; i < argv.length; i += 1) {
14
14
  const arg = argv[i];
15
15
  if (arg === "--config" || arg === "--target") {
@@ -19,6 +19,7 @@ export function parseStageSidecarArgs(argv, cwd = process.cwd()) {
19
19
  i += 1;
20
20
  } else if (arg === "--json") opts.json = true;
21
21
  else if (arg === "--check") opts.check = true;
22
+ else if (arg === "--if-stale") opts.ifStale = true;
22
23
  else throw new Error(`unknown stage-sidecar argument: ${arg}`);
23
24
  }
24
25
  return opts;
@@ -57,15 +58,23 @@ async function main() {
57
58
  const config = await importReleaseConfig(configPath, { lenient: true });
58
59
  if (config?.sidecars === undefined) throw new Error(`${opts.config} declares no sidecars`);
59
60
  if (opts.check) {
60
- const target = opts.target ?? process.env.TAURI_ENV_TARGET_TRIPLE ?? nodeHostTriple();
61
+ const target = (opts.target === "package" ? undefined : opts.target) ?? process.env.TAURI_ENV_TARGET_TRIPLE ?? nodeHostTriple();
61
62
  const plan = planSidecarStaging({ manifest: config.sidecars, target });
62
63
  if (opts.json) console.log(JSON.stringify(plan, null, 2));
63
64
  else for (const line of formatSidecarPlan(plan)) console.log(line);
64
65
  return;
65
66
  }
66
- const target = resolveTargetTriple({ target: opts.target, cwd: appRoot });
67
+ // `--target package` (the gate's package stage): the triple the config's Mac package recipe stages for (its first prepare step's own
68
+ // --target), decided here in the child that already loaded the config, so the gate never imports app code; otherwise the host's.
69
+ let wanted = opts.target;
70
+ if (wanted === "package") {
71
+ const { macPackageStagesSidecars, macPackageTriple } = await import("./package-recipe-targets.mjs");
72
+ const mac = process.platform === "darwin" ? config?.targets?.mac?.macPackage : undefined;
73
+ wanted = mac && macPackageStagesSidecars(mac) ? macPackageTriple(mac) : undefined;
74
+ }
75
+ const target = resolveTargetTriple({ target: wanted, cwd: appRoot });
67
76
  // Warnings (a never-run row, a catalog row that is not staged) go to stderr; stdout stays the staged-file list or the --json document.
68
- const result = await stageSidecars({ appRoot, manifest: config.sidecars, target, ...(split ? { cacheRoot: split.checkoutAppRoot } : {}), deps: { warn: (line) => console.error(line) } });
77
+ const result = await stageSidecars({ appRoot, manifest: config.sidecars, target, ifStale: opts.ifStale, ...(split ? { cacheRoot: split.checkoutAppRoot } : {}), deps: { warn: (line) => console.error(line) } });
69
78
  if (opts.json) console.log(JSON.stringify(result, null, 2));
70
79
  else {
71
80
  for (const file of result.files ?? []) console.log(`right-release stage-sidecar: staged ${typeof file === "string" ? file : file.path ?? JSON.stringify(file)}`);
@@ -74,5 +83,11 @@ async function main() {
74
83
  }
75
84
 
76
85
  if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
77
- main().catch((error) => { console.error(`right-release stage-sidecar: ${error.code ? `[${error.code}] ` : ""}${error.message}`); process.exitCode = 1; });
86
+ main().catch((error) => {
87
+ console.error(`right-release stage-sidecar: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
88
+ // The failed tool's own last words (a captured rustright or cargo stderr), which the message alone does not carry.
89
+ const tail = String(error.details?.stderr ?? "").trim().split(/\r?\n/).slice(-8).join("\n");
90
+ if (tail) console.error(`right-release stage-sidecar: its stderr ended:\n${tail}`);
91
+ process.exitCode = 1;
92
+ });
78
93
  }
package/stage-sidecar.mjs CHANGED
@@ -1936,12 +1936,11 @@ export function planSidecarStaging({ manifest, target, env = process.env } = {})
1936
1936
  * fs (existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, renameSync, chmodSync, statSync, readdirSync, rmSync), run, fetch (https downloads of `remote`), fetchBytes, extractMember, loadMediaCatalog, loadNativeCatalog, makeTempDir, removeDir, now, pid, log (progress), warn (warnings; defaults to log), env.
1937
1937
  * The API is quiet by default; the `right-release stage-sidecar` command passes `warn` so the CLI prints warnings on stderr.
1938
1938
  */
1939
- export async function stageSidecars({ appRoot, manifest, target, cacheRoot, deps = {} } = {}) {
1939
+ export async function stageSidecars({ appRoot, manifest, target, cacheRoot, ifStale = false, deps = {} } = {}) {
1940
1940
  if (typeof appRoot !== "string" || !path.isAbsolute(appRoot)) fail("STAGE_SIDECAR_CONFIG_INVALID", "appRoot must be an absolute path");
1941
1941
  if (cacheRoot !== undefined && (typeof cacheRoot !== "string" || !path.isAbsolute(cacheRoot))) fail("STAGE_SIDECAR_CONFIG_INVALID", "cacheRoot must be an absolute path");
1942
1942
  const env = deps.env ?? process.env;
1943
1943
  const plan = validateSidecarManifest(manifest, { target, mode: resolveBuildMode(env) });
1944
- assertNotNested(plan, env);
1945
1944
  // A source split (source-split.mjs; rightkit-gate's snapshot route): `appRoot` is the snapshot of the commit, where the declared
1946
1945
  // sources are read and where the staged files and the receipt go (the Tauri build script reads them there). `cacheRoot` is the
1947
1946
  // checkout's app directory, which keeps every cache, archive, slice and build tree right-release owns (`.right-release/...`) and
@@ -1969,6 +1968,14 @@ export async function stageSidecars({ appRoot, manifest, target, cacheRoot, deps
1969
1968
  makeTempDir: deps.makeTempDir ?? (() => nodeFs.mkdtempSync(path.join(os.tmpdir(), "rightkit-stage-"))),
1970
1969
  removeDir: deps.removeDir ?? ((dir) => nodeFs.rmSync(dir, { recursive: true, force: true })),
1971
1970
  };
1971
+ // Already staged: when the receipt at the plan's receipt path is for this manifest and target and every file it names is on disk with its recorded sha256, nothing is built,
1972
+ // downloaded, copied or written (the gate staged the package triple first, rightkit-gate stageSidecarsOnce, so a recipe's own prepare step changes nothing). Judged before the
1973
+ // nested-broker refusal, which guards only a step that would build.
1974
+ // Opt-in (`ifStale`, `stage-sidecar --if-stale`): only a Mac package recipe's own prepare step asks for it. The check is structural (no source hash), so every
1975
+ // other caller, the gate's staging step included, builds as before.
1976
+ const current = ifStale ? currentStageReceipt(plan, ctx) : null;
1977
+ if (current) return { receipt: current, receiptPath: path.resolve(appRoot, plan.receiptPath), files: current.files, externalBinNames: current.files.filter((file) => file.externalBin).map((file) => file.name), unchanged: true };
1978
+ assertNotNested(plan, env);
1972
1979
  await claimNativeOutputs(plan, ctx);
1973
1980
  const files = [];
1974
1981
  const skipped = [];
@@ -2009,6 +2016,40 @@ export async function stageSidecars({ appRoot, manifest, target, cacheRoot, deps
2009
2016
  return { receipt, receiptPath: receiptFile, files, externalBinNames: files.filter((file) => file.externalBin).map((file) => file.name) };
2010
2017
  }
2011
2018
 
2019
+ /**
2020
+ * The receipt at the plan's receipt path when staging is already current, else null: a valid receipt of this target whose skipped rows are the plan's, that has a file of the
2021
+ * same kind and name for every named row the plan stages, and whose every file is on disk, a regular file, with its recorded sha256 (and, for a plan that wipes its stage
2022
+ * directory, nothing else in that directory). A receipt that cannot be read or does not match is not current; nothing is ever written.
2023
+ */
2024
+ function currentStageReceipt(plan, ctx) {
2025
+ const { fs, appRoot } = ctx;
2026
+ try {
2027
+ const receiptFile = path.resolve(appRoot, plan.receiptPath);
2028
+ const receipt = validateStageSidecarReceipt(JSON.parse(fs.readFileSync(receiptFile, "utf8")), { target: plan.target.triple });
2029
+ const planned = plan.entries.filter((entry) => !entry.skipped);
2030
+ const skipped = plan.entries.filter((entry) => entry.skipped).map((entry) => ({ kind: entry.kind, index: entry.index, platforms: entry.platforms }));
2031
+ if (JSON.stringify(skipped) !== JSON.stringify(receipt.skipped ?? [])) return null;
2032
+ if (receipt.files.length < planned.length) return null;
2033
+ for (const entry of planned) if (typeof entry.name === "string" && ["cargo-bin", "command", "cmake", "prebuilt", "remote"].includes(entry.kind) && !receipt.files.some((file) => file.kind === entry.kind && file.name === entry.name)) return null;
2034
+ const named = new Set();
2035
+ for (const file of receipt.files) {
2036
+ const full = path.resolve(appRoot, file.path);
2037
+ const meta = fs.statSync(full);
2038
+ if (!meta.isFile() || sha256Of(fs.readFileSync(full)) !== file.sha256) return null;
2039
+ named.add(full.toLowerCase());
2040
+ }
2041
+ if (plan.wipeStageDir) {
2042
+ named.add(receiptFile.toLowerCase());
2043
+ const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).every((item) => {
2044
+ const full = path.join(dir, item.name);
2045
+ return item.isDirectory() ? walk(full) : named.has(path.resolve(full).toLowerCase());
2046
+ });
2047
+ if (!walk(path.resolve(appRoot, plan.stageDir))) return null;
2048
+ }
2049
+ return receipt;
2050
+ } catch { return null; }
2051
+ }
2052
+
2012
2053
  /** Structural check of a staging receipt; `expected` may pin target and names. */
2013
2054
  export function validateStageSidecarReceipt(receipt, expected = {}) {
2014
2055
  const bad = (message) => fail("STAGE_SIDECAR_RECEIPT_INVALID", `stage sidecar receipt: ${message}`);