@rightkit/release 0.6.10 → 0.6.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +7 -5
  2. package/admission-files.mjs +3 -0
  3. package/admission.mjs +2 -2
  4. package/advisory-exceptions.mjs +41 -5
  5. package/android-play-upload.mjs +16 -15
  6. package/app-check.mjs +13 -9
  7. package/bin/rightkit-gate.mjs +772 -355
  8. package/broker-pins.mjs +152 -0
  9. package/build-release.mjs +80 -27
  10. package/cargo-ledger.mjs +62 -0
  11. package/cargo-target-shim.mjs +98 -26
  12. package/cli/right-release.mjs +28 -4
  13. package/clippy-equivalence.mjs +202 -0
  14. package/clippy-route.mjs +543 -0
  15. package/committed-source.mjs +39 -5
  16. package/crate-workspaces.mjs +87 -0
  17. package/create-mac-updater.mjs +14 -2
  18. package/deterministic-scans.mjs +196 -21
  19. package/development.mjs +13 -4
  20. package/direct-receipt.mjs +135 -0
  21. package/door-policy.mjs +10 -0
  22. package/door.mjs +6 -6
  23. package/freshness-census.mjs +257 -0
  24. package/gate-stages.mjs +76 -10
  25. package/hook-retirement.mjs +65 -0
  26. package/init-command.mjs +1 -1
  27. package/inventory-check.mjs +11 -8
  28. package/ios-upload.mjs +20 -15
  29. package/local-publish.mjs +77 -41
  30. package/local-qualify.mjs +5 -3
  31. package/local-runtime.mjs +5 -4
  32. package/mac-package-recipe.mjs +4 -1
  33. package/mirror-root-artifact.mjs +18 -5
  34. package/ownership-class-rules.mjs +1 -1
  35. package/ownership-evidence.mjs +37 -13
  36. package/ownership-hooks.mjs +20 -59
  37. package/ownership-lockfiles.mjs +14 -1
  38. package/ownership-policy-bundle.mjs +3 -2
  39. package/ownership-staged.mjs +2 -1
  40. package/ownership.mjs +24 -0
  41. package/package-recipe-targets.mjs +35 -6
  42. package/package.json +1 -1
  43. package/pipeline-admission.mjs +4 -4
  44. package/preflight.mjs +27 -4
  45. package/publish-cargo.mjs +4 -3
  46. package/recipe-build.mjs +38 -14
  47. package/release-artifact-path.mjs +341 -0
  48. package/release-invocation.mjs +8 -1
  49. package/release-qa-command.mjs +25 -9
  50. package/release-source-brand.mjs +17 -0
  51. package/release-source-identity.mjs +171 -0
  52. package/release.mjs +44 -17
  53. package/rightkit-versions.json +63 -42
  54. package/runtime-budgets.mjs +6 -2
  55. package/scan-digest.mjs +71 -5
  56. package/sign-updater.mjs +6 -3
  57. package/snapshot-broker.mjs +172 -0
  58. package/snapshot-check.mjs +12 -6
  59. package/snapshot-request.mjs +207 -5
  60. package/snapshot.mjs +123 -10
  61. package/source-split.mjs +73 -0
  62. package/stage-commands.mjs +2 -2
  63. package/stage-ledger.mjs +204 -0
  64. package/stage-sidecar-command.mjs +5 -1
  65. package/stage-sidecar.mjs +100 -20
  66. package/swap-helper-bundle.mjs +5 -2
  67. package/unit-gate.mjs +45 -3
  68. package/web-build.mjs +6 -4
  69. package/win-package-recipe.mjs +20 -7
package/stage-sidecar.mjs CHANGED
@@ -665,8 +665,8 @@ async function defaultFetchBytes(url) {
665
665
  /** Absolute catalog file for a validated spec. Crate resolution reads the rightkit metadata listing; npm walks up node_modules. */
666
666
  function resolveCatalogFile(spec, ctx, kind) {
667
667
  const label = kind === "ffmpeg" ? "rightkit-ffmpeg" : "native-libraries";
668
- if (spec.via === "absolute") return spec.file;
669
- if (spec.via === "path") return path.resolve(ctx.appRoot, spec.file);
668
+ if (spec.via === "absolute") return requireSnapshotSource(ctx, spec.file, `the ${label} catalog`);
669
+ if (spec.via === "path") return requireSnapshotSource(ctx, path.resolve(ctx.appRoot, spec.file), `the ${label} catalog`);
670
670
  if (spec.via === "crate") {
671
671
  const meta = ctx.run("rustright", ["cargo", "metadata", "--format-version", "1"], { cwd: ctx.appRoot, env: ctx.env });
672
672
  let packages;
@@ -751,6 +751,23 @@ function cleanDest(ctx, entry, dest, { directory = false } = {}) {
751
751
  try { ctx.fs.rmSync(dest, { recursive: directory, force: true }); } catch (error) { fail("STAGE_SIDECAR_CLEAN_REFUSED", `cannot clean ${dest} (${error.message}); a directory is only removed for a directory resource`, { dest, cause: error }); }
752
752
  }
753
753
 
754
+ /**
755
+ * Warm-cache rule (GATE-CONTRACT "rebuild only what changed"): a staged file carries the mtime of the cache file it was copied from, a
756
+ * lipo or merge output the newest mtime of its inputs, so a re-stage after the snapshot sync (which removes every ledger entry) does not
757
+ * bump the mtime and re-trigger the app crate (tauri-build emits rerun-if-changed for the staged sidecar). Bytes are bound by the
758
+ * ledger, never by mtime. A file system without `utimesSync` or `statSync().mtime` (a test double) is left as written.
759
+ */
760
+ function carryMtime(ctx, dest, sources) {
761
+ const { fs } = ctx;
762
+ if (typeof fs.utimesSync !== "function") return;
763
+ let newest = null;
764
+ for (const source of sources) {
765
+ let st; try { st = fs.statSync(source); } catch { continue; }
766
+ if (st?.mtime instanceof Date && (!newest || st.mtime > newest)) newest = st.mtime;
767
+ }
768
+ if (newest) { try { fs.utimesSync(dest, newest, newest); } catch { /* the mtime is a cache hint, never a binding */ } }
769
+ }
770
+
754
771
  function copyInto(ctx, source, dest, { executable }) {
755
772
  const { fs, windows } = ctx;
756
773
  const sourceSha256 = sha256Of(fs.readFileSync(source));
@@ -765,11 +782,44 @@ function copyInto(ctx, source, dest, { executable }) {
765
782
  } else if (executable && !windows) fs.chmodSync(dest, 0o755);
766
783
  const sha256 = sha256Of(fs.readFileSync(dest));
767
784
  if (sha256 !== sourceSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `staged ${dest} differs from its source ${source}`, { expected: sourceSha256, actual: sha256 });
785
+ carryMtime(ctx, dest, [source]);
768
786
  return { sha256, sizeBytes: fs.statSync(dest).size, changed };
769
787
  }
770
788
 
771
789
  const rel = (ctx, file) => path.relative(ctx.appRoot, file).replaceAll("\\", "/");
772
790
 
791
+ /**
792
+ * The boundary of a split run (row 7.05a): the only checkout bytes it may read are cache entries whose digest the commit pins or
793
+ * tool-invalidated build caches, both under `ctx.cacheRoot`. A SOURCE the config names (a local `source.dir`, an ffmpeg `path`) must
794
+ * resolve inside the snapshot's app root, never into the checkout or anywhere else.
795
+ */
796
+ function requireSnapshotSource(ctx, file, label) {
797
+ if (!ctx.split) return file;
798
+ const relative = path.relative(ctx.appRoot, path.resolve(file));
799
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) fail("STAGE_SIDECAR_SOURCE_OUTSIDE_SNAPSHOT", `${label} resolves to ${file}, outside the snapshot's app root ${ctx.appRoot}; a split run reads sources only from the snapshot (checkout bytes enter only as digest-pinned cache entries)`, { file });
800
+ return file;
801
+ }
802
+ /**
803
+ * The environment a split run hands to the commands it starts (a `command` entry, an ffmpeg `stage` command): Cargo's output directory is
804
+ * the checkout's, resolved there once and passed absolute, so a command that reaches Cargo (`rustright cargo run -p ...`) never writes
805
+ * `target/` into the snapshot. Without a split, or when the checkout holds no Cargo workspace, the environment is returned as it is.
806
+ */
807
+ function splitCommandEnv(ctx) {
808
+ if (!ctx.split) return ctx.env;
809
+ if (ctx.splitEnv === undefined) {
810
+ const resolved = ctx.run("rustright", ["cargo", "metadata", "--format-version", "1", "--no-deps"], { cwd: ctx.cacheRoot, env: ctx.env });
811
+ let dir;
812
+ try { dir = JSON.parse(resolved?.stdout).target_directory; } catch { /* no workspace in the checkout: nothing to pin */ }
813
+ ctx.splitEnv = !resolved?.error && resolved?.status === 0 && typeof dir === "string" && path.isAbsolute(dir) ? { ...ctx.env, CARGO_TARGET_DIR: path.resolve(dir) } : ctx.env;
814
+ }
815
+ return ctx.splitEnv;
816
+ }
817
+
818
+ /** A cache directory the config names (`cacheDir`): the checkout's `.right-release` under a split, the app root otherwise. */
819
+ const cacheDirFor = (ctx, dir) => (ctx.split ? path.resolve(ctx.cacheRoot, ".right-release", dir) : path.resolve(ctx.appRoot, dir));
820
+ /** The per-arch slice directory of a universal staging: absolute under the checkout's cache root on a split, app-relative otherwise. */
821
+ const sliceDirFor = (ctx, triple) => (ctx.split ? path.resolve(ctx.cacheRoot, SLICE_ROOT, triple) : `${SLICE_ROOT}/${triple}`);
822
+
773
823
  /** `lipo -archs <file>` as a list; a failing lipo is a typed error. */
774
824
  function lipoArchs(ctx, file) {
775
825
  const result = ctx.run("lipo", ["-archs", file], { cwd: ctx.appRoot, env: ctx.env });
@@ -804,6 +854,7 @@ function lipoMergeInto(ctx, slices, dest, label, { executable = false } = {}) {
804
854
  fs.rmSync(temporary, { force: true });
805
855
  if (executable) fs.chmodSync(dest, 0o755);
806
856
  }
857
+ carryMtime(ctx, dest, slices);
807
858
  return { sha256, sizeBytes: fs.statSync(dest).size, changed };
808
859
  }
809
860
 
@@ -834,6 +885,7 @@ function stageCargoBin(entry, ctx) {
834
885
  const changed = !(fs.existsSync(dest) && sha256Of(fs.readFileSync(dest)) === sha256);
835
886
  fs.chmodSync(temporary, 0o755);
836
887
  fs.renameSync(temporary, dest);
888
+ carryMtime(ctx, dest, slices.map((slice) => slice.source));
837
889
  return [{ kind: "cargo-bin", name: entry.name, path: rel(ctx, dest), sha256, sizeBytes: fs.statSync(dest).size, changed, sourceSha256: sha256, package: entry.package, bin: entry.bin, profile: entry.profile, features: entry.features, externalBin: !entry.dest, slices: slices.map((slice) => ({ triple: slice.triple, sha256: slice.sourceSha256 })) }];
838
890
  }
839
891
 
@@ -851,17 +903,29 @@ function stageCargoBuild(entry, ctx, triple) {
851
903
  "-p", entry.package, "--bin", entry.bin, "--target", triple, "--profile", entry.profile,
852
904
  ...(entry.features.length ? ["--features", entry.features.join(",")] : []), "--message-format=json-render-diagnostics"];
853
905
  ctx.log(`stage-sidecar: rightkit ${args.join(" ")}`);
854
- const built = run("rustright", args, { cwd: appRoot, env });
906
+ // From a snapshot, Cargo would resolve `target/` (or a relative build.target-dir) against the snapshot: its output directory is
907
+ // resolved in the checkout instead and passed as an absolute CARGO_TARGET_DIR, so the warm outputs stay the checkout's and the
908
+ // snapshot holds no target/ (docs/GATE-CONTRACT.md "Output directory & cache").
909
+ let buildEnv = env;
910
+ if (ctx.split) {
911
+ const checkoutManifest = entry.manifestPath ? ["--manifest-path", path.resolve(ctx.cacheRoot, entry.manifestPath)] : [];
912
+ const resolved = run("rustright", ["cargo", "metadata", "--format-version", "1", "--no-deps", ...checkoutManifest], { cwd: ctx.cacheRoot, env });
913
+ let pinned;
914
+ try { pinned = JSON.parse(resolved?.stdout).target_directory; } catch { /* refused below */ }
915
+ if (resolved?.error || resolved?.status !== 0 || typeof pinned !== "string" || !path.isAbsolute(pinned)) fail("STAGE_SIDECAR_BUILD_FAILED", `the checkout's cargo output directory for ${entry.package} cannot be resolved (${resolved?.error?.message ?? `exit ${resolved?.status}`}); nothing was built`, { package: entry.package });
916
+ buildEnv = { ...env, CARGO_TARGET_DIR: path.resolve(pinned) };
917
+ }
918
+ const built = run("rustright", args, { cwd: appRoot, env: buildEnv });
855
919
  if (built?.error || built?.status !== 0) fail("STAGE_SIDECAR_BUILD_FAILED", `rustright cargo build of ${entry.package} failed (${built?.error?.message ?? `exit ${built?.status}`})`, { package: entry.package, stderr: tail(built?.stderr) });
856
920
  let source = artifactFromStream(built.stdout, entry.bin);
857
921
  if (!source || !fs.existsSync(source)) {
858
- const meta = run("rustright", ["cargo", "metadata", "--format-version", "1", "--no-deps", ...(entry.manifestPath ? ["--manifest-path", path.resolve(appRoot, entry.manifestPath)] : [])], { cwd: appRoot, env });
922
+ const meta = run("rustright", ["cargo", "metadata", "--format-version", "1", "--no-deps", ...(entry.manifestPath ? ["--manifest-path", path.resolve(appRoot, entry.manifestPath)] : [])], { cwd: appRoot, env: buildEnv });
859
923
  let targetDir;
860
924
  try { targetDir = JSON.parse(meta.stdout).target_directory; } catch { /* refused below */ }
861
925
  if (meta?.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir)) targetDir = undefined;
862
926
  // A managed target root: the broker/cargo metadata target_directory (which cargo derives from CARGO_TARGET_DIR) is
863
927
  // authoritative; an explicit CARGO_TARGET_DIR in this environment is tried too, resolved against the app root.
864
- const envDir = typeof env.CARGO_TARGET_DIR === "string" && env.CARGO_TARGET_DIR.trim() ? path.resolve(appRoot, env.CARGO_TARGET_DIR) : undefined;
928
+ const envDir = typeof buildEnv.CARGO_TARGET_DIR === "string" && buildEnv.CARGO_TARGET_DIR.trim() ? path.resolve(appRoot, buildEnv.CARGO_TARGET_DIR) : undefined;
865
929
  if (!targetDir && !envDir) fail("STAGE_SIDECAR_BINARY_NOT_FOUND", `cargo reported no ${entry.bin} executable and its target directory cannot be read`, { bin: entry.bin });
866
930
  const candidates = builtBinaryCandidates({ targetDir, targetDirs: envDir ? [envDir] : [], triple, profile: entry.profile, bin: entry.bin, windows: ctx.windows });
867
931
  source = candidates.find((file) => fs.existsSync(file));
@@ -877,7 +941,7 @@ const ffmpegSourceFor = (sources, tool, arch) => { const spec = sources?.[tool];
877
941
  /** The binary a local ffmpeg source names: the file itself, or `<dir>/<member>` / `<dir>/bin/<member>` (`.exe` added for a Windows target). */
878
942
  function resolveLocalFfmpegSource(ctx, spec, tool) {
879
943
  const { fs } = ctx;
880
- const base = path.resolve(ctx.appRoot, spec.path);
944
+ const base = requireSnapshotSource(ctx, path.resolve(ctx.appRoot, spec.path), `the ffmpeg ${tool} source path`);
881
945
  if (fs.existsSync(base) && fs.statSync(base).isFile()) return base;
882
946
  const member = spec.member ?? `${tool}${ctx.windows ? ".exe" : ""}`;
883
947
  const candidates = [path.join(base, member), path.join(base, "bin", member)];
@@ -955,15 +1019,17 @@ function probeFfmpegBinary(ctx, { file, tool, target }) {
955
1019
  */
956
1020
  async function stageFfmpegSlice(entry, ctx, resolved, { config, t, writeNotices, flat = false }) {
957
1021
  const { appRoot, run, env, fs } = ctx;
958
- const planned = planMediaSidecar({ config, catalog: resolved.catalog, crateDir: resolved.crateDir, crateVersion: resolved.crateVersion, catalogBytes: resolved.catalogBytes, root: appRoot });
1022
+ // A universal slice is staged under SLICE_ROOT, which a split keeps in the checkout's cache root, never in the snapshot.
1023
+ const sliceRoot = ctx.split && String(config.stageDir ?? "").startsWith(SLICE_ROOT) ? ctx.cacheRoot : appRoot;
1024
+ const planned = planMediaSidecar({ config, catalog: resolved.catalog, crateDir: resolved.crateDir, crateVersion: resolved.crateVersion, catalogBytes: resolved.catalogBytes, root: sliceRoot });
959
1025
  const plan = flat ? { ...planned, files: planned.files.map((file) => ({ ...file, stagedPath: path.resolve(appRoot, entry.resourceDir, file.bundledName) })) } : planned;
960
1026
  await fetchFfmpegSources(entry, ctx, plan, t.arch);
961
1027
  if (config.stage) {
962
1028
  const universal = ctx.target.arch === "universal";
963
1029
  // A universal entry stages once per slice: `{arch}`, `{triple}` and `{stageDir}` in the stage command's arguments name the slice.
964
- const args = (config.stage.args ?? []).map((arg) => (universal ? String(arg).replaceAll("{arch}", t.arch).replaceAll("{triple}", t.triple).replaceAll("{stageDir}", config.stageDir) : arg));
1030
+ const args = (config.stage.args ?? []).map((arg) => (universal ? String(arg).replaceAll("{arch}", t.arch).replaceAll("{triple}", t.triple).replaceAll("{stageDir}", sliceRoot === appRoot ? config.stageDir : path.resolve(sliceRoot, config.stageDir)) : arg));
965
1031
  ctx.log(`stage-sidecar: ${config.stage.cmd} ${args.join(" ")}`);
966
- const staged = run(config.stage.cmd, args, { cwd: appRoot, env });
1032
+ const staged = run(config.stage.cmd, args, { cwd: appRoot, env: splitCommandEnv(ctx) });
967
1033
  if (staged?.error || staged?.status !== 0) fail("STAGE_SIDECAR_BUILD_FAILED", `ffmpeg stage command failed (${staged?.error?.message ?? `exit ${staged?.status}`})`);
968
1034
  }
969
1035
  const noticeFiles = writeNotices ? writeMediaSidecarNotices(plan, { exists: fs.existsSync, mkdir: fs.mkdirSync, copy: fs.copyFileSync, write: fs.writeFileSync }) : [];
@@ -987,13 +1053,20 @@ async function stageFfmpegUniversal(entry, ctx, resolved) {
987
1053
  results.push({ slice, ...(await stageFfmpegSlice(entry, ctx, resolved, { config: slice.media, t: parseTargetTriple(slice.triple), writeNotices: false })) });
988
1054
  }
989
1055
  // One notice set and one source offer that names the rows of every slice (a per-slice offer would overwrite the other).
1056
+ // A split plans each slice under the checkout's cache root (SLICE_ROOT), so the slice plans name their notices there too. The notices are FINAL
1057
+ // outputs and belong in the snapshot beside the merged file: they are rebased onto the app root before anything is written, never into the checkout.
1058
+ const toApp = (file) => {
1059
+ if (!ctx.split) return file;
1060
+ const relative = path.relative(ctx.cacheRoot, file);
1061
+ return relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative) ? file : path.resolve(appRoot, relative);
1062
+ };
990
1063
  const noticeByDest = new Map();
991
- for (const { plan } of results) for (const notice of plan.notices) noticeByDest.set(notice.dest, notice);
1064
+ for (const { plan } of results) for (const notice of plan.notices) noticeByDest.set(toApp(notice.dest), { ...notice, dest: toApp(notice.dest) });
992
1065
  const rowsById = new Map();
993
1066
  for (const { plan } of results) for (const row of plan.rows) rowsById.set(row.id, row);
994
1067
  const combined = {
995
1068
  notices: [...noticeByDest.values()],
996
- offerPath: results[0].plan.offerPath,
1069
+ offerPath: toApp(results[0].plan.offerPath),
997
1070
  offer: sourceOfferText({ rows: [...rowsById.values()], contact: entry.media.notices.sourceOfferContact, crateVersion: resolved.crateVersion }),
998
1071
  };
999
1072
  const noticeFiles = writeMediaSidecarNotices(combined, { exists: fs.existsSync, mkdir: fs.mkdirSync, copy: fs.copyFileSync, write: fs.writeFileSync });
@@ -1229,14 +1302,14 @@ async function prepareNativeFile(entry, ctx, loaded, item, { destDir, scratchDir
1229
1302
  if (fs.existsSync(dest) && !nativeDigestMismatch(entry, row, fs.readFileSync(dest), { fromSource: Boolean(entry.source) })) return prepared;
1230
1303
 
1231
1304
  if (entry.source) {
1232
- const file = path.resolve(appRoot, entry.source.dir, row.artifactFileName);
1305
+ const file = requireSnapshotSource(ctx, path.resolve(appRoot, entry.source.dir, row.artifactFileName), `source.dir of the ${where} row ${row.id}`);
1233
1306
  if (!(fs.existsSync(file) && fs.statSync(file).isFile())) fail("STAGE_SIDECAR_BINARY_NOT_FOUND", `the ${where} row ${row.id} is staged from source.dir ${entry.source.dir}, which has no ${row.artifactFileName} (looked at ${file}); nothing is downloaded for a source dir`, { file });
1234
1307
  const mismatch = nativeDigestMismatch(entry, row, fs.readFileSync(file), { fromSource: true });
1235
1308
  if (mismatch) failNativeMismatch(row, file, mismatch);
1236
1309
  return { ...prepared, from: file };
1237
1310
  }
1238
1311
  if (!row.archive) fail("STAGE_SIDECAR_CONFIG_INVALID", `the ${where} row ${row.id} has no archive, so it is staged from a local build: set source: { dir } on this entry to the directory holding ${row.artifactFileName}`);
1239
- const archive = path.resolve(appRoot, entry.cacheDir, archiveFileName(row.archive.url));
1312
+ const archive = path.join(cacheDirFor(ctx, entry.cacheDir), archiveFileName(row.archive.url));
1240
1313
  if (!(fs.existsSync(archive) && sha256Of(fs.readFileSync(archive)) === row.archive.sha256)) {
1241
1314
  const bytes = await ctx.fetchBytes(row.archive.url);
1242
1315
  const actual = sha256Of(bytes);
@@ -1378,7 +1451,7 @@ async function stageNativeLibrary(entry, ctx) {
1378
1451
  }
1379
1452
  // Otherwise each arch's files are staged thin under SLICE_ROOT, then merged file by file into the entry's resource directory.
1380
1453
  const slices = [];
1381
- for (const plan of plans) slices.push({ triple: plan.triple, files: await stageNativeSet(entry, ctx, loaded, plan.items, { destDir: `${SLICE_ROOT}/${plan.triple}` }) });
1454
+ for (const plan of plans) slices.push({ triple: plan.triple, files: await stageNativeSet(entry, ctx, loaded, plan.items, { destDir: sliceDirFor(ctx, plan.triple) }) });
1382
1455
  const [first, second] = slices.map((slice) => slice.files.map((file) => file.outName));
1383
1456
  if ([...first].sort().join("\n") !== [...second].sort().join("\n")) fail("STAGE_SIDECAR_NOT_APPROVED", `the arch rows name different library files (aarch64: ${first.join(", ")}; x86_64: ${second.join(", ")}); each file needs one name on both arches (a single file can be renamed with "as")`);
1384
1457
  return first.map((name) => {
@@ -1446,7 +1519,7 @@ function stageCommand(entry, ctx) {
1446
1519
  const cwd = entry.cwd ? path.resolve(appRoot, entry.cwd) : appRoot;
1447
1520
  cleanDest(ctx, entry, destFor(entry, ctx));
1448
1521
  ctx.log(`stage-sidecar: ${entry.cmd} ${entry.args.join(" ")}`);
1449
- const built = run(entry.cmd, entry.args, { cwd, env });
1522
+ const built = run(entry.cmd, entry.args, { cwd, env: splitCommandEnv(ctx) });
1450
1523
  if (built?.error || built?.status !== 0) fail("STAGE_SIDECAR_BUILD_FAILED", `command ${entry.name} (${entry.cmd}) failed (${built?.error?.message ?? `exit ${built?.status}`})`, { name: entry.name, stderr: tail(built?.stderr) });
1451
1524
  const source = path.resolve(appRoot, entry.output);
1452
1525
  if (!fs.existsSync(source) || !fs.statSync(source).isFile()) fail("STAGE_SIDECAR_BINARY_NOT_FOUND", `command ${entry.name} did not produce ${entry.output}`, { name: entry.name, output: entry.output });
@@ -1463,8 +1536,8 @@ function stageCommand(entry, ctx) {
1463
1536
  * fails closed with nothing staged. Returns { cached, fromCache }.
1464
1537
  */
1465
1538
  async function ensureCachedDownload(ctx, { url, sha256, size }) {
1466
- const { appRoot, fs } = ctx;
1467
- const cached = path.resolve(appRoot, REMOTE_CACHE_DIR, sha256);
1539
+ const { fs } = ctx;
1540
+ const cached = path.resolve(ctx.cacheRoot, REMOTE_CACHE_DIR, sha256);
1468
1541
  if (fs.existsSync(cached) && sha256Of(fs.readFileSync(cached)) === sha256) return { cached, fromCache: true };
1469
1542
  ctx.log(`stage-sidecar: GET ${url}`);
1470
1543
  let response;
@@ -1596,7 +1669,7 @@ function stageCmake(entry, ctx) {
1596
1669
  const { appRoot, fs, run, env, target } = ctx;
1597
1670
  const cmake = resolveCmakeBinary(ctx, entry);
1598
1671
  const generator = entry.platformGenerators?.[target.platform] ?? entry.generator;
1599
- const root = path.resolve(appRoot, CMAKE_ROOT, entry.name);
1672
+ const root = path.resolve(ctx.cacheRoot, CMAKE_ROOT, entry.name);
1600
1673
  const srcDir = path.join(root, "src");
1601
1674
  const buildDir = path.join(root, "build");
1602
1675
  const dest = destFor(entry, ctx);
@@ -1783,15 +1856,22 @@ export function planSidecarStaging({ manifest, target, env = process.env } = {})
1783
1856
  * 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.
1784
1857
  * The API is quiet by default; the `right-release stage-sidecar` command passes `warn` so the CLI prints warnings on stderr.
1785
1858
  */
1786
- export async function stageSidecars({ appRoot, manifest, target, deps = {} } = {}) {
1859
+ export async function stageSidecars({ appRoot, manifest, target, cacheRoot, deps = {} } = {}) {
1787
1860
  if (typeof appRoot !== "string" || !path.isAbsolute(appRoot)) fail("STAGE_SIDECAR_CONFIG_INVALID", "appRoot must be an absolute path");
1861
+ if (cacheRoot !== undefined && (typeof cacheRoot !== "string" || !path.isAbsolute(cacheRoot))) fail("STAGE_SIDECAR_CONFIG_INVALID", "cacheRoot must be an absolute path");
1788
1862
  const env = deps.env ?? process.env;
1789
1863
  const plan = validateSidecarManifest(manifest, { target, mode: resolveBuildMode(env) });
1790
1864
  assertNotNested(plan, env);
1865
+ // A source split (source-split.mjs; rightkit-gate's snapshot route): `appRoot` is the snapshot of the commit, where the declared
1866
+ // sources are read and where the staged files and the receipt go (the Tauri build script reads them there). `cacheRoot` is the
1867
+ // checkout's app directory, which keeps every cache, archive, slice and build tree right-release owns (`.right-release/...`) and
1868
+ // where Cargo's target directory is resolved: only final outputs and the receipt are written under the snapshot (row 7.05a). Checkout
1869
+ // bytes enter a split run only as cache entries whose digest the commit pins, or as tool-invalidated build caches.
1870
+ const split = cacheRoot !== undefined && path.resolve(cacheRoot) !== path.resolve(appRoot);
1791
1871
  const fs = deps.fs ?? nodeFs;
1792
1872
  const run = deps.run ?? defaultRun;
1793
1873
  const ctx = {
1794
- appRoot, target: plan.target, stageDir: plan.stageDir, fs, env, run, protectedPaths: protectedOutputPaths(plan),
1874
+ appRoot, cacheRoot: split ? path.resolve(cacheRoot) : appRoot, split, target: plan.target, stageDir: plan.stageDir, fs, env, run, protectedPaths: protectedOutputPaths(plan),
1795
1875
  windows: plan.target.os === "windows",
1796
1876
  pid: deps.pid ?? process.pid,
1797
1877
  host: deps.host ?? { os: { darwin: "macos", win32: "windows", linux: "linux" }[process.platform] ?? process.platform, arch: { arm64: "aarch64", x64: "x86_64" }[process.arch] ?? process.arch },
@@ -18,7 +18,10 @@ export const SWAP_HELPER_PACKAGE = "@rightkit/release";
18
18
  export const SWAP_HELPER_BIN = ["bin", "darwin", "rightkit-swap"];
19
19
  export const SWAP_HELPER_RECEIPT = ["bin", "darwin", "rightkit-swap.sign.json"];
20
20
  // rightkit-swap is gated on the crate feature "fs" (crates/rightkit-fs/Cargo.toml [[bin]] required-features).
21
- export const SWAP_HELPER_CARGO_ARGS = ["build", "--release", "-p", "rightkit-fs", "--features", "fs", "--bin", "rightkit-swap"];
21
+ // Every crate is its own Cargo workspace (rightkit#1285): there is no root manifest, so the build and the target-directory read name crates/rightkit-fs.
22
+ export const SWAP_HELPER_MANIFEST = "crates/rightkit-fs/Cargo.toml";
23
+ export const SWAP_HELPER_CARGO_ARGS = ["build", "--manifest-path", SWAP_HELPER_MANIFEST, "--release", "-p", "rightkit-fs", "--features", "fs", "--bin", "rightkit-swap"];
24
+ export const SWAP_HELPER_METADATA_ARGS = ["metadata", "--format-version", "1", "--no-deps", "--manifest-path", SWAP_HELPER_MANIFEST];
22
25
 
23
26
  /** Typed error. Codes: SWAP_HELPER_BUILD_FAILED, SWAP_HELPER_SIGN_FAILED, SWAP_HELPER_MISSING, SWAP_HELPER_UNSIGNED. */
24
27
  export class SwapHelperError extends Error {
@@ -67,7 +70,7 @@ export function prepareSwapHelper({
67
70
  const fail = (code, message) => { throw new SwapHelperError(code, message); };
68
71
  const built = run("cargo", SWAP_HELPER_CARGO_ARGS, { cwd: workspaceRoot, env });
69
72
  if (built.error || built.status !== 0) fail("SWAP_HELPER_BUILD_FAILED", `rustright cargo ${SWAP_HELPER_CARGO_ARGS.join(" ")} failed (${built.error?.message ?? `exit ${built.status}`})\n${tail(`${built.stdout ?? ""}\n${built.stderr ?? ""}`)}`);
70
- const meta = run("cargo", ["metadata", "--format-version", "1", "--no-deps"], { cwd: workspaceRoot, env });
73
+ const meta = run("cargo", SWAP_HELPER_METADATA_ARGS, { cwd: workspaceRoot, env });
71
74
  let targetDir;
72
75
  try { targetDir = JSON.parse(meta.stdout).target_directory; } catch { /* refused below */ }
73
76
  if (meta.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir)) fail("SWAP_HELPER_BUILD_FAILED", "cannot read the Cargo target directory to find rightkit-swap");
package/unit-gate.mjs CHANGED
@@ -183,12 +183,52 @@ export function resolveUnit(selector, root, { granted = localBuildGranted, readJ
183
183
  return { kind, name, version: manifest.version };
184
184
  }
185
185
 
186
+ /** `ci/known-red.json` hosts by `process.platform`; any other platform (Linux) has no known-red table. */
187
+ const KNOWN_RED_HOST = { darwin: "macos", win32: "windows" };
188
+
189
+ /**
190
+ * The ci/known-red.json entry that bars this unit from a compile, or null. A unit listed for the host platform is not compiled,
191
+ * packaged or qualified there; one that is headed to release is barred when it is listed for either host (a known-red unit is never
192
+ * published, AGENTS.md; scripts/ci/publish.mjs refuses it the same way). An entry is keyed by the crate name or `rust:<crate>`, the
193
+ * unit's directory or `npm:<directory>` (the ids scripts/ci/gate.mjs and publish.mjs use). A missing file lists nothing; an unreadable
194
+ * one refuses (a table that cannot be read is not an empty table).
195
+ */
196
+ export function knownRedEntry({ unit, root, platform, toRelease = false, readText = (file) => fs.readFileSync(file, "utf8") }) {
197
+ let table;
198
+ try { table = JSON.parse(readText(path.join(root, "ci", "known-red.json"))); }
199
+ catch (error) { if (error?.code === "ENOENT") return null; throw new Error(`ci/known-red.json cannot be read (${error?.message}); a known-red table that cannot be read is not an empty one`); }
200
+ const dir = unit.kind === "npm" ? `packages/${unit.name.slice("@rightkit/".length)}` : `crates/${unit.name}`;
201
+ const keys = [unit.name, `rust:${unit.name}`, `${unit.kind}:${unit.name}`, dir, `npm:${dir}`];
202
+ const hosts = toRelease ? Object.keys(table ?? {}) : [KNOWN_RED_HOST[platform]].filter(Boolean);
203
+ for (const host of hosts) {
204
+ const key = keys.find((k) => Object.hasOwn(table?.[host] ?? {}, k));
205
+ if (key !== undefined) {
206
+ const reason = table[host][key];
207
+ return { host, key, reason: typeof reason === "string" ? reason : JSON.stringify(reason) };
208
+ }
209
+ }
210
+ return null;
211
+ }
212
+
186
213
  /** One invocation reruns all owed stages; reports are never read as admission evidence. */
187
- export async function runUnitGate({ selector, to, root, commonDir, commit, tree, executor, env, platform, check, loadOwner = () => import("./local-publish.mjs"), out = console.log, err = console.error, releaser = null, resolve = resolveUnit, writeReport, fetchReceipt = readBrokerReceipt, stageCommands = defaultStageCommands }) {
214
+ /**
215
+ * `source` ({ checkout, commit, tree }) marks a run built from the door's snapshot of that commit (row 7.06a): `root` is then the
216
+ * snapshot, `commonDir` the checkout's, and the stages take their identity from the selection (local-publish.mjs sourceGit). `stageGuard`
217
+ * ({ start(stage), end(stage) } returning true or false) is the door's ledger verify around each stage (7.08); `envExtra` carries the
218
+ * Cargo output pin. Without `source` every behavior is the checkout route's, unchanged.
219
+ */
220
+ export async function runUnitGate({ selector, to, root, commonDir, commit, tree, executor, env, platform, check, source = null, stageGuard = null, envExtra = {}, loadOwner = () => import("./local-publish.mjs"), out = console.log, err = console.error, releaser = null, resolve = resolveUnit, writeReport, fetchReceipt = readBrokerReceipt, stageCommands = defaultStageCommands, readKnownRed }) {
188
221
  const stages = stagesTo(to);
189
222
  if (!stages) { err(`rightkit-gate: invalid unit stage ${to}`); return 2; }
190
223
  let unit;
191
224
  try { unit = resolve(selector, root); } catch (error) { err(`rightkit-gate: BLOCKED: ${error.message}`); return 1; }
225
+ // Before any compile (so --to compile|package|qualify|release; --to check compiles nothing): a unit recorded red is refused, not built.
226
+ if (stages.includes("compile")) {
227
+ try {
228
+ const red = knownRedEntry({ unit, root, platform, toRelease: stages.includes("release"), ...(readKnownRed ? { readText: readKnownRed } : {}) });
229
+ if (red) { err(`rightkit-gate: BLOCKED: ${selector} is listed in ci/known-red.json for ${red.host} (${red.key}: ${red.reason}); a known-red unit is not compiled, packaged, qualified or published. Fix the defect and remove the entry first`); return 1; }
230
+ } catch (error) { err(`rightkit-gate: BLOCKED: ${error.message}`); return 1; }
231
+ }
192
232
  const scratch = platform === "win32" ? "D:\\scratch" : "/Volumes/D/scratch";
193
233
  const bundle = path.join(scratch, `rk-unit-${randomBytes(12).toString("hex")}`);
194
234
  const argv = ["--local", unit.kind === "npm" ? "--package" : "--crate", unit.name, "--workspace", root, "--qualify-only", "--bundle", bundle];
@@ -204,7 +244,7 @@ export async function runUnitGate({ selector, to, root, commonDir, commit, tree,
204
244
  const stageEnv = async (stage, action) => {
205
245
  const run = startRun({ commonDir, commit, tree, stage });
206
246
  const nonce = attested && ATTESTED_STAGES.includes(stage) ? { RIGHTKIT_ATTEST_NONCE: qualificationNonce({ commit, tree, unit: `${unit.kind}:${unit.name}`, stage }) } : {};
207
- try { return await action({ ...env, ...nonce, ...run.env }); } finally { endRun(run); }
247
+ try { return await action({ ...env, ...envExtra, ...nonce, ...run.env }); } finally { endRun(run); }
208
248
  };
209
249
  const receipts = {};
210
250
  const collectReceipts = (stage, ids) => {
@@ -222,11 +262,12 @@ export async function runUnitGate({ selector, to, root, commonDir, commit, tree,
222
262
  try {
223
263
  for (const stage of stages) {
224
264
  currentStage = stage;
265
+ if (stageGuard && !(await stageGuard.start(stage))) throw new Error(`RK_CHECK_ERROR: the snapshot is not the commit's bytes plus the ledger before the ${stage} stage`);
225
266
  // A unit run headed to qualify or release owes the release tier: it runs here, before the compile, and qualify does not run it again.
226
267
  if (stage === "check") { checkTier = stages.includes("qualify") ? "release" : "compile"; if (await check(checkTier) !== 0) throw new Error(`class A check (${checkTier} tier) failed`); }
227
268
  else if (stage === "compile") {
228
269
  owner = await loadOwner();
229
- state = await stageEnv(stage, (runEnv) => owner.compileLocalUnit(unit.kind, argv, { env: runEnv }));
270
+ state = await stageEnv(stage, (runEnv) => owner.compileLocalUnit(unit.kind, argv, { env: runEnv, ...(source ? { source } : {}) }));
230
271
  collectReceipts(stage, owner.localUnitCompileRequests?.(state));
231
272
  } else if (stage === "package") {
232
273
  artifact = await stageEnv(stage, (runEnv) => owner.packageLocalUnit(state, { env: runEnv }));
@@ -246,6 +287,7 @@ export async function runUnitGate({ selector, to, root, commonDir, commit, tree,
246
287
  const qualification = writeQualification({ report, artifact, unit, commit, tree, runtime, executor });
247
288
  released = await stageEnv(stage, (runEnv) => (releaser ?? releaseUnit)({ unit, root, commit, executor, env: runEnv, artifact, qualification, platform, out }));
248
289
  }
290
+ if (stageGuard && !(await stageGuard.end(stage))) throw new Error(`RK_CHECK_ERROR: the ${stage} stage's result covers no known bytes (a snapshot byte changed, or a write is outside the ledger)`);
249
291
  report.stages[stage] = { ok: true, ...(stage === "package" || stage === "qualify" ? { artifactFile: artifact.artifactFile, artifactSha256: artifact.artifactSha256 } : {}), ...(receipts[stage] ? { receipts: receipts[stage] } : {}), ...(released ?? {}) };
250
292
  save();
251
293
  out(`rightkit-gate: [${stage}] ${unit.kind}:${unit.name} passed`);
package/web-build.mjs CHANGED
@@ -23,6 +23,7 @@ import { buildLaunch } from "./build-launch.mjs";
23
23
  import { CommandSpecError, loadAppConfig, resolveInvocation, validateStep } from "./command-spec.mjs";
24
24
  import { defaultReleaseConfigFile } from "./release-config.mjs";
25
25
  import { requireDoorRun } from "./gate-stages.mjs";
26
+ import { doorContextFor } from "./source-split.mjs";
26
27
 
27
28
  export function parseWebBuildArgs(argv) {
28
29
  const opts = { config: defaultReleaseConfigFile(), dryRun: false };
@@ -53,10 +54,11 @@ export function planWebBuild(config) {
53
54
  * bound to a commit: refused. `spawnSyncFn` runs git; `requireDoor` is gate-stages.mjs requireDoorRun.
54
55
  */
55
56
  export function webBuildDoorRefusal({ appRoot, env = process.env, spawnSyncFn = spawnSync, requireDoor = requireDoorRun }) {
56
- const gitOut = (a) => { const r = spawnSyncFn("git", a, { cwd: appRoot, encoding: "utf8" }); return r.status === 0 ? String(r.stdout).trim() : ""; };
57
- const common = gitOut(["rev-parse", "--git-common-dir"]);
58
- if (!common) return "right-release web-build runs only inside a git repository, under a door run";
59
- return requireDoor({ env, commonDir: path.resolve(appRoot, common), commit: gitOut(["rev-parse", "HEAD"]), command: "right-release web-build" });
57
+ // Inside a source split (the door's snapshot of a commit) the git context is the door run's checkout and commit, not the cwd's.
58
+ let context;
59
+ try { context = doorContextFor({ cwd: appRoot, env, run: spawnSyncFn }); } catch (error) { return `right-release web-build: ${error.code ?? "RK_SOURCE_SPLIT"}: ${error.message}`; }
60
+ if (!context) return "right-release web-build runs only inside a git repository, under a door run";
61
+ return requireDoor({ env, commonDir: context.commonDir, commit: context.commit, command: "right-release web-build" });
60
62
  }
61
63
 
62
64
  function doorError(refusal) { const error = new Error(refusal); error.code = "RK_DOOR"; return error; }
@@ -20,6 +20,7 @@ import path from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { spawnSync } from "./background-process.mjs";
22
22
  import { verifyNsisEmbeddedBinary } from "./nsis-payload.mjs";
23
+ import { resolveArtifactPath, splitArtifactContext } from "./release-artifact-path.mjs";
23
24
  import { signingReceiptPath } from "./signing-receipts.mjs";
24
25
  import { patchTauriBundleType } from "./tauri-bundle-marker.mjs";
25
26
 
@@ -198,7 +199,9 @@ export function defaultWinPackageDeps({ appRoot, target, env }) {
198
199
  }
199
200
 
200
201
  /** Where a produced artifact really is: the declared path, else the managed artifact directory (same rule as release.mjs). */
201
- function locate(declared, { env, fs }) {
202
+ function locate(declared, { env, fs, split = false }) {
203
+ // A door split (row 7.04c, R-T2): the declared path is already the bound target's; there is no artifact-directory fallback for it.
204
+ if (split) return declared;
202
205
  if (fs.existsSync(declared)) return declared;
203
206
  const managed = env.RIGHT_RELEASE_ARTIFACT_DIR;
204
207
  if (managed) {
@@ -219,6 +222,15 @@ export function runWinPackageRecipe({ appRoot, config = {}, target, deps = {} }
219
222
  const base = defaultWinPackageDeps({ appRoot: plan.appRoot, target, env });
220
223
  const d = { ...base, ...deps, env, fs: { ...base.fs, ...(deps.fs ?? {}) } };
221
224
  const { fs } = d;
225
+ // A door split has no target directory in its snapshot: the plan's `<app>/src-tauri/target/...` paths mean the bound target (R-T1, R-T3), where the
226
+ // raw EXE is deleted before the build, signed in place, bundled and verified. Outside a split this is the identity.
227
+ const artifactPaths = d.artifactPaths ?? splitArtifactContext({ cwd: plan.appRoot, env, target });
228
+ const split = artifactPaths.mode === "split";
229
+ const placed = (declared) => {
230
+ if (!split) return declared;
231
+ const relative = path.relative(plan.appRoot, declared).replaceAll("\\", "/");
232
+ return resolveArtifactPath(relative.startsWith("..") || path.isAbsolute(relative) ? declared : relative, artifactPaths);
233
+ };
222
234
  const dry = d.dryRun === true;
223
235
  const phases = [];
224
236
  const phase = (name, work) => {
@@ -244,15 +256,16 @@ export function runWinPackageRecipe({ appRoot, config = {}, target, deps = {} }
244
256
 
245
257
  if (d.platform !== "win32" && !dry) throw new WinPackageRecipeError("build", `the Windows package recipe must run on Windows, not ${d.platform}`);
246
258
 
247
- let raw = plan.rawExe;
259
+ const rawExe = placed(plan.rawExe);
260
+ let raw = rawExe;
248
261
  const sidecars = [...plan.sidecars];
249
262
 
250
263
  phase("build", () => {
251
- if (!dry) fs.rmSync(plan.rawExe, { force: true });
264
+ if (!dry) fs.rmSync(rawExe, { force: true });
252
265
  tauri(["build", "--no-bundle", "--target", plan.triple], quiet);
253
266
  if (dry) return;
254
- raw = locate(plan.rawExe, { env, fs });
255
- if (!fs.existsSync(raw)) throw new Error(`tauri build did not produce the raw EXE: ${plan.rawExe}`);
267
+ raw = locate(rawExe, { env, fs, split });
268
+ if (!fs.existsSync(raw)) throw new Error(`tauri build did not produce the raw EXE: ${rawExe}`);
256
269
  for (const sidecar of sidecars) if (!fs.existsSync(sidecar)) throw new Error(`declared sidecar does not exist: ${sidecar}`);
257
270
  });
258
271
 
@@ -270,14 +283,14 @@ export function runWinPackageRecipe({ appRoot, config = {}, target, deps = {} }
270
283
  return { sha256: byFile.get(path.resolve(raw).toLowerCase()), sidecars: Object.fromEntries(sidecars.map((file) => [file, byFile.get(path.resolve(file).toLowerCase())])) };
271
284
  });
272
285
 
273
- const installerBase = plan.installerFile;
286
+ const installerBase = placed(plan.installerFile);
274
287
  let installer = installerBase;
275
288
  phase("bundle", () => {
276
289
  if (!dry) for (const stale of [installerBase, `${installerBase}.sig`, `${installerBase}.zip`, `${installerBase}.zip.sig`]) fs.rmSync(stale, { force: true });
277
290
  const override = { bundle: { createUpdaterArtifacts: false, windows: { nsis: { installMode: plan.perMachine ? "perMachine" : "currentUser" } } } };
278
291
  tauri(["bundle", "--bundles", "nsis", "--target", plan.triple, "--config", JSON.stringify(override)], quiet);
279
292
  if (dry) return;
280
- installer = locate(installerBase, { env, fs });
293
+ installer = locate(installerBase, { env, fs, split });
281
294
  if (!fs.existsSync(installer)) throw new Error(`tauri bundle did not produce the installer: ${installerBase}`);
282
295
  });
283
296