@rightkit/release 0.6.21 → 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
@@ -1642,7 +1642,13 @@ async function runStages(o, args, root, git, io) {
1642
1642
  ok = true;
1643
1643
  if (ds) ok = await stageWebBuildOnce({ o, ds, root, git, appDir, cli, commonDir, commit, tree, stage, executor });
1644
1644
  if (ok && ds) ok = await stageWindowsIdentityOnce({ o, ds, root, git, appDir, cli, commonDir, commit, tree, stage, executor });
1645
- if (ok && ds) ok = await stageSidecarsOnce({ o, ds, root, git, configs: sidecarConfigsOf(), 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
+ }
1646
1652
  // Source QA (right-release qa --stage source) exists for mac and win only; a mobile package is the signed build alone.
1647
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
1648
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
@@ -1944,6 +1950,16 @@ async function restagedSidecars({ o, ds, prior, now }) {
1944
1950
  return out;
1945
1951
  }
1946
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
+
1947
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. */
1948
1964
  export function committedSidecarConfigs({ o, git, commit, appDir = ".", base }) {
1949
1965
  const listedConfigs = git("ls-tree", "-r", "--name-only", "-z", commit);
@@ -1957,9 +1973,14 @@ export function committedSidecarConfigs({ o, git, commit, appDir = ".", base })
1957
1973
  * step's additions must equal the files it reported plus its receipt (anything else is an unattributed write, RK_CHECK_ERROR). The
1958
1974
  * staged files become immutable `staged` ledger entries. Returns true when nothing is owed or staging passed.
1959
1975
  */
1960
- export async function stageSidecarsOnce({ o, ds, root, git = null, configs, appDir, cli, commonDir, commit, tree, stage, executor = null, tag = "rightkit-gate:" }) {
1961
- if (ds.sidecarsStaged) return true;
1962
- 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);
1963
1984
  if (!configs.length) return true;
1964
1985
  const { attributionProblems, reportedPaths } = await import("../stage-ledger.mjs");
1965
1986
  const source = sourceSplitOf({ root, ds, appDir });
@@ -1967,13 +1988,13 @@ export async function stageSidecarsOnce({ o, ds, root, git = null, configs, appD
1967
1988
  for (const config of configs) {
1968
1989
  const before = ds.ledger?.entries ?? [];
1969
1990
  // The stage-sidecar child's Cargo calls (its builds and metadata queries) are carried on a broker host (carried-cargo.mjs).
1970
- 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 });
1971
1992
  if (carrier.refusal) { o.err(`${tag} BLOCKED: ${carrier.refusal}; nothing was staged`); return false; }
1972
1993
  const run = startRun({ commonDir, commit, tree, stage, source });
1973
1994
  let r;
1974
1995
  try {
1975
- o.out(`${tag} [${stage}] right-release stage-sidecar --config ${path.join(ds.path, config)} (from the snapshot ${ds.path})`);
1976
- 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 }) });
1977
1998
  } finally { endRun(run); }
1978
1999
  const carriedOk = await carrier.finish({ stage });
1979
2000
  if (r?.stderr) o.err(String(r.stderr).trimEnd());
@@ -1989,7 +2010,7 @@ export async function stageSidecarsOnce({ o, ds, root, git = null, configs, appD
1989
2010
  const configDir = path.posix.dirname(String(config).replaceAll("\\", "/"));
1990
2011
  const underApp = (rel) => (configDir === "." ? rel : `${configDir}/${rel}`).replaceAll("\\", "/");
1991
2012
  const receiptAbs = typeof result.receiptPath === "string" ? path.relative(ds.path, result.receiptPath).split(path.sep).join("/") : null;
1992
- 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) */ })];
1993
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; }
1994
2015
  const priorPaths = new Set(before.map((e) => e.path));
1995
2016
  const priorByPath = new Map(before.map((e) => [e.path, e]));
@@ -2048,7 +2069,7 @@ export async function stageWebBuildOnce({ o, ds, root, git = null, appDir, cli,
2048
2069
  for (const config of configs) {
2049
2070
  if (!absent().length) break;
2050
2071
  const before = ds.ledger?.entries ?? [];
2051
- 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 });
2052
2073
  if (carrier.refusal) { o.err(`${tag} BLOCKED: ${carrier.refusal}; the web build did not run`); return false; }
2053
2074
  const run = startRun({ commonDir, commit, tree, stage, source });
2054
2075
  let r;
@@ -2100,7 +2121,7 @@ export async function stageWindowsIdentityOnce({ o, ds, root, git = null, appDir
2100
2121
  const producer = path.join(path.dirname(cli), "..", "windows-identity.mjs");
2101
2122
  for (const config of configs) {
2102
2123
  const before = ds.ledger?.entries ?? [];
2103
- 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 });
2104
2125
  if (carrier.refusal) { o.err(`${tag} BLOCKED: ${carrier.refusal}; the Windows identity package did not build`); return false; }
2105
2126
  const run = startRun({ commonDir, commit, tree, stage, source });
2106
2127
  let r;
@@ -2731,7 +2752,7 @@ async function snapshotCargoWorkspaces({ git, ds, root }) {
2731
2752
  * child's environment; `finish` runs after the child ended, fails on any call that was refused, unverified or unfinished and on any
2732
2753
  * receipt the gate cannot verify again itself, and removes the carrier. A direct executor, or no snapshot, has nothing to carry.
2733
2754
  */
2734
- 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 }) {
2735
2756
  const none = { env: {}, carried: false, refusal: null, async finish() { return true; }, cleanup() {} };
2736
2757
  if (!ds || executor?.kind !== "broker") return none;
2737
2758
  let base = null;
@@ -2740,6 +2761,15 @@ async function carrierForChildren({ o, ds, root, git, executor, stage, tag = "ri
2740
2761
  const carried = await import("../carried-cargo.mjs");
2741
2762
  const { brokerSection } = await import("../snapshot-broker.mjs");
2742
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
+ }
2743
2773
  // Installing the carrier map sends nothing, so it records nothing: the child's own uncarried probes (`rustc -vV`, `--version`) never
2744
2774
  // reach the broker under a snapshot, and each carried request records its sub-handle just before it is sent (snapshot-broker.mjs).
2745
2775
  const checkoutDirs = await snapshotCargoWorkspaces({ git, ds, root });
@@ -2758,7 +2788,7 @@ async function carrierForChildren({ o, ds, root, git, executor, stage, tag = "ri
2758
2788
  finished = true;
2759
2789
  let problems;
2760
2790
  try {
2761
- problems = await installed.problems(sb);
2791
+ problems = await installed.problems(sb, openInputDigests ? { openInputDigests } : {});
2762
2792
  // The output directories the stage's verified receipts name: the package stage's sealed manifest must record only these.
2763
2793
  for (const dir of installed.outputDirs?.() ?? []) ((ds.carriedByStage ??= {})[at] ??= new Set()).add(dir);
2764
2794
  // And by workspace, for the next stage's children: a receipt already names the output directory, so none asks Cargo for it.
@@ -4055,12 +4085,20 @@ export async function main(argv, io = {}) {
4055
4085
  const unitByName = new Map(rust.units.map((u) => [u.name, u]));
4056
4086
  const known = new Set(unitByName.keys());
4057
4087
  const builtBins = {};
4058
- 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
+ };
4059
4096
  for (const r of scanResult.scans) {
4060
4097
  // A row answered from the scan cache compiled nothing in this run: the bytes in the target are not vouched for by it.
4061
4098
  if (r.status !== "pass" || r.cached === true || !/^test:rust(?:-serial)?:/.test(String(r.id))) continue;
4062
4099
  const unit = unitByName.get(rustUnitOfScanId(r.id, known));
4063
- 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");
4064
4102
  }
4065
4103
  const outputWorkspaces = (snapshotBroker ? scanResult.scans.map((r) => r.snapshotEvidence?.outputDir) : Object.values(targetDirs ?? {})).filter(Boolean).map((dir) => [dir]);
4066
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). */
@@ -2759,7 +2759,7 @@ export async function runScans(plan, options = {}) {
2759
2759
  const ref = readRequestRef(path.join(carrier.refDir, "request.json"));
2760
2760
  const receipt = await deps.snapshotBroker.receiptOf(ref.requestId);
2761
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 });
2762
- 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 };
2763
2763
  } catch (e) {
2764
2764
  status = "fail"; code = "SNAPSHOT_EVIDENCE"; detail = `${s.id}: the broker's snapshot evidence did not verify (${e.code ?? "error"}: ${e.message})`;
2765
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.21",
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.21",
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
  /**
@@ -698,9 +698,26 @@ export const runBrokerCargo = (call) => carryBrokerCargo(call).status;
698
698
  * again now, verifies against THIS gate's commit, snapshot, ownership handle and pins, and its carrier was used once: a nonce or a
699
699
  * request id that appears twice in the run (`seen`) is a replay. Returns the problems; none means every call verified.
700
700
  */
701
- 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 } = {}) {
702
702
  seen.handles ??= new Set();
703
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
+ }
704
721
  for (const e of entries) {
705
722
  if (e.mode !== "broker" || e.status !== "verified") continue;
706
723
  const adapter = typeof e.adapter === "string" && e.adapter ? e.adapter : null;
@@ -733,7 +750,7 @@ export async function verifyBrokerLedger(entries, sb, { seen = { nonces: new Set
733
750
  ownership: { handle: e.ownershipHandle }, primaryCwd: e.checkoutCwd, argvSha256: argvSha256(e.argv), nonce: e.nonce,
734
751
  };
735
752
  const receipt = await sb.receiptOf(e.requestId);
736
- 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 });
737
754
  if (adapter !== null) {
738
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.
739
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");
@@ -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)}`);
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}`);