@rightkit/release 0.6.8 → 0.6.11

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 (96) hide show
  1. package/README.md +30 -12
  2. package/admission-files.mjs +4 -1
  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 +105 -27
  7. package/assemble-app-command.mjs +151 -0
  8. package/assembled-app.mjs +445 -0
  9. package/bin/darwin/rightkit-swap +0 -0
  10. package/bin/darwin/rightkit-swap.sign.json +6 -0
  11. package/bin/rightkit-gate.mjs +1608 -345
  12. package/broker-pins.mjs +152 -0
  13. package/build-launch.mjs +88 -0
  14. package/build-ownership.mjs +1231 -0
  15. package/build-release.mjs +82 -28
  16. package/cargo-contract.mjs +3 -1
  17. package/cargo-ledger.mjs +62 -0
  18. package/cargo-target-shim.mjs +238 -0
  19. package/class-a-launch-guard.mjs +126 -0
  20. package/cli/right-release.mjs +31 -5
  21. package/clippy-equivalence.mjs +202 -0
  22. package/clippy-route.mjs +543 -0
  23. package/codesign-shim.mjs +11 -1
  24. package/committed-source.mjs +39 -5
  25. package/crate-workspaces.mjs +87 -0
  26. package/create-mac-updater.mjs +14 -2
  27. package/deterministic-scans.mjs +997 -71
  28. package/development-signing.mjs +4 -1
  29. package/development.mjs +120 -21
  30. package/direct-receipt.mjs +135 -0
  31. package/door-policy.mjs +71 -6
  32. package/door.mjs +6 -6
  33. package/freshness-census.mjs +257 -0
  34. package/gate-executor.mjs +14 -2
  35. package/gate-stages.mjs +91 -15
  36. package/heavy-command.mjs +3 -2
  37. package/hook-retirement.mjs +65 -0
  38. package/init-command.mjs +1 -1
  39. package/install-app.mjs +11 -2
  40. package/install-assembled-app.mjs +423 -0
  41. package/inventory-check.mjs +11 -8
  42. package/ios-dev.mjs +70 -3
  43. package/ios-upload.mjs +20 -15
  44. package/linux-artifacts.mjs +93 -0
  45. package/local-publish.mjs +224 -53
  46. package/local-qualify.mjs +5 -3
  47. package/local-runtime.mjs +5 -4
  48. package/mac-package-recipe.mjs +4 -1
  49. package/managed-cargo.mjs +47 -5
  50. package/managed-root-guard.mjs +34 -0
  51. package/mirror-root-artifact.mjs +18 -5
  52. package/native-helpers.mjs +355 -0
  53. package/ownership-class-rules.mjs +1 -1
  54. package/ownership-evidence.mjs +37 -13
  55. package/ownership-hooks.mjs +20 -59
  56. package/ownership-lockfiles.mjs +14 -1
  57. package/ownership-policy-bundle.mjs +3 -2
  58. package/ownership-staged.mjs +2 -1
  59. package/ownership.mjs +24 -0
  60. package/package-recipe-targets.mjs +84 -10
  61. package/package.json +4 -3
  62. package/pipeline-admission.mjs +4 -4
  63. package/pipeline-receipts.mjs +302 -302
  64. package/preflight.mjs +27 -4
  65. package/project-type.mjs +2 -2
  66. package/provenance-verify.mjs +115 -0
  67. package/publish-cargo.mjs +12 -9
  68. package/qa-admission-policy.json +10 -1
  69. package/recipe-build.mjs +106 -14
  70. package/release-artifact-path.mjs +341 -0
  71. package/release-config.mjs +1 -1
  72. package/release-invocation.mjs +8 -1
  73. package/release-qa-command.mjs +25 -9
  74. package/release-source-brand.mjs +17 -0
  75. package/release-source-identity.mjs +171 -0
  76. package/release.mjs +59 -22
  77. package/rightkit-versions.json +73 -47
  78. package/runtime-budgets.mjs +6 -2
  79. package/scan-digest.mjs +71 -5
  80. package/sign-updater.mjs +6 -3
  81. package/snapshot-broker.mjs +172 -0
  82. package/snapshot-check.mjs +312 -0
  83. package/snapshot-git.mjs +166 -0
  84. package/snapshot-request.mjs +363 -0
  85. package/snapshot-root-config.mjs +76 -0
  86. package/snapshot.mjs +969 -0
  87. package/source-split.mjs +73 -0
  88. package/stage-commands.mjs +60 -0
  89. package/stage-ledger.mjs +204 -0
  90. package/stage-sidecar-command.mjs +5 -1
  91. package/stage-sidecar.mjs +157 -27
  92. package/swap-helper-bundle.mjs +5 -2
  93. package/test-command.mjs +17 -4
  94. package/unit-gate.mjs +301 -0
  95. package/web-build.mjs +40 -6
  96. package/win-package-recipe.mjs +20 -7
package/preflight.mjs CHANGED
@@ -23,6 +23,7 @@ import path from "node:path";
23
23
  import { spawnSync } from "./background-process.mjs";
24
24
  import { brokerManagedHost } from "./cargo-contract.mjs";
25
25
  import { createOwnershipEvidence } from "./ownership-evidence.mjs";
26
+ import { isReleaseSource } from "./release-source-brand.mjs";
26
27
  import { appRuntimeBudgetAdoptionCheck } from "./runtime-budgets.mjs";
27
28
  import { isSwiftProject } from "./project-type.mjs";
28
29
 
@@ -203,6 +204,12 @@ export function collectPreflight({
203
204
  bridge = true,
204
205
  msix = false,
205
206
  findSdkTool = findWindowsSdkTool,
207
+ // A door source split (release-source-identity.mjs `resolveReleaseSource`, row 7.04). Only the ownership check reads it: the evidence is
208
+ // then the selected commit's, read from the checkout's object database, whatever `repoRoot` the caller names.
209
+ releaseSource = null,
210
+ // The artifact-path context of a door split (release-artifact-path.mjs, row 7.04c). When it is a split, the target-bridge check is replaced by
211
+ // the R-T1 check: every manifest has a bound target outside the snapshot, and nothing sits where a snapshot-relative target/ would.
212
+ splitTargets = null,
206
213
  } = {}) {
207
214
  const checks = [];
208
215
  const windows = platform === "win" || platform === "win32";
@@ -219,7 +226,14 @@ export function collectPreflight({
219
226
  );
220
227
  }
221
228
 
222
- if (appRoot && !noBridge) {
229
+ if (appRoot && !noBridge && splitTargets?.mode === "split") {
230
+ const planted = splitTargets.boundTargets.map((bound) => path.join(appRoot, bound.prefix)).filter((dir) => { try { lstatSync(dir); return true; } catch { return false; } });
231
+ checks.push(
232
+ planted.length
233
+ ? fail("split-target", `RK_SPLIT_TARGET_PATH: ${planted.join(", ")} exists in the snapshot; a split binds its target outside it and nothing may sit there`, "remove it from the commit (it is build output) and add the directory to .gitignore")
234
+ : ok("split-target", splitTargets.boundTargets.length ? `target bound outside the snapshot: ${[...new Set(splitTargets.boundTargets.map((bound) => bound.dir))].join(", ")}` : "no target to bind"),
235
+ );
236
+ } else if (appRoot && !noBridge) {
223
237
  const target = targetLink ?? nativeLayout?.targetLink ?? path.join(appRoot, "src-tauri", "target");
224
238
  const targetLabel = nativeLayout?.targetPrefix || "src-tauri/target/";
225
239
  let entry = null;
@@ -339,8 +353,12 @@ export function collectPreflight({
339
353
  checks.push(fail("ownership", `ownership scope refused: ${ownershipScopeError}`, "fix the target's ownership scope configuration; the build refuses it too"));
340
354
  } else if (repoRoot && ownership) {
341
355
  // An app living inside the RightKit workspace repository is scoped to its own directory.
342
- const workspace = existsSync(path.join(repoRoot, "tools", "rightkit", "packages", "release"));
343
- checks.push(ownershipCheck(repoRoot, { scanRoot: workspace && appRoot ? appRoot : repoRoot, scope: ownershipScope, evidenceFactory: ownershipEvidenceFactory }));
356
+ // In a split the layout is the snapshot's (the commit's), the git root is the checkout, and the source is the one the identity module resolved.
357
+ if (releaseSource !== null && !isReleaseSource(releaseSource)) throw new Error("preflight: the release source was not resolved by resolveReleaseSource");
358
+ const split = releaseSource?.mode === "snapshot" ? releaseSource : null;
359
+ const gitRoot = split ? split.checkout : repoRoot;
360
+ const workspace = existsSync(path.join(split ? split.snapshot : repoRoot, "tools", "rightkit", "packages", "release"));
361
+ checks.push(ownershipCheck(gitRoot, { scanRoot: workspace && appRoot ? appRoot : gitRoot, scope: ownershipScope, evidenceFactory: ownershipEvidenceFactory, ...(split ? { releaseSource: split } : {}) }));
344
362
  }
345
363
 
346
364
  return checks;
@@ -349,9 +367,14 @@ export function collectPreflight({
349
367
  /** Release-blocking: the app keeps no unexcepted copy of a RightKit-owned capability. */
350
368
  export function ownershipCheck(repoRoot, options = {}) {
351
369
  let checked;
352
- try { checked = (options.evidenceFactory ?? createOwnershipEvidence)(repoRoot, options.scanRoot ?? repoRoot, options.scope ?? null); }
370
+ const split = options.releaseSource?.mode === "snapshot" ? options.releaseSource : null;
371
+ try { checked = (options.evidenceFactory ?? createOwnershipEvidence)(repoRoot, options.scanRoot ?? repoRoot, options.scope ?? null, ...(split ? [split] : [])); }
353
372
  catch (error) { return fail("ownership", `ownership evidence failed: ${error.message}`, "restore protected RightKit ownership trust and catalog"); }
354
373
  const { result, evidence, floor } = checked;
374
+ // R4: evidence for another commit or tree than the selected one is refused whatever produced it (a factory that read HEAD, a stale one).
375
+ if (split && (evidence?.commit !== split.commit || (evidence?.tree !== undefined && evidence.tree !== split.tree))) {
376
+ return fail("ownership", `ownership evidence is for commit ${String(evidence?.commit ?? "(none)").slice(0, 12)}, not the selected commit ${split.commit.slice(0, 12)}`, "the door run builds the selected commit's snapshot; re-run the door for that commit");
377
+ }
355
378
  if (checked.ok) {
356
379
  const excepted = result.covered.length;
357
380
  const detail = excepted ? `no unapproved copies (${excepted} excepted findings)` : "no copies of RightKit-owned capabilities";
package/project-type.mjs CHANGED
@@ -41,5 +41,5 @@ export function targetUsesMsix(target) {
41
41
  return Boolean(target?.msix) || target?.signingContract === "windows-msix-signed-v1";
42
42
  }
43
43
 
44
- /** SwiftPM targets build and package inside prePackage (mac DMG) or the MSIX step; no separate `package` command is required. */
45
- export const packageCommandOptional = (target, projectType) => targetUsesMsix(target) || Boolean(target?.winPackage || target?.macPackage) || (projectType === "swiftpm" && Boolean(target?.prePackage?.cmd));
44
+ /** Expanded recipes must come from the validator, which strips caller-supplied recipe markers. */
45
+ export const packageCommandOptional = (target, projectType, { expanded = false } = {}) => targetUsesMsix(target) || Boolean(target?.winPackage || target?.macPackage) || (expanded && target?.recipe?.kind === "macPackage") || (projectType === "swiftpm" && Boolean(target?.prePackage?.cmd));
@@ -0,0 +1,115 @@
1
+ // Verifies a RustRight broker job receipt's signed provenance envelope in pure JS (node:crypto only), exactly as
2
+ // rustright-protocol's attestation.rs `verify_receipt` does, so a hosted runner with no broker can authenticate the receipts a local
3
+ // gate run embedded in its qualification. Never trusts a value from the receipt that the caller can pin instead.
4
+ import { createHash, createPublicKey, verify } from "node:crypto";
5
+
6
+ export const PROVENANCE_SCHEMA = "rustright.provenance.v1";
7
+ export const PROVENANCE_DOMAIN = "rustright-provenance-v1";
8
+ export const QUALIFICATION_NONCE_DOMAIN = "rightkit-qualification-v1";
9
+
10
+ export class ProvenanceError extends Error {
11
+ constructor(code, message) { super(message); this.name = "ProvenanceError"; this.code = code; }
12
+ }
13
+ const refuse = (code, message) => { throw new ProvenanceError(code, message); };
14
+
15
+ const sha256Hex = (data) => createHash("sha256").update(data).digest("hex");
16
+ const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
17
+
18
+ function writeString(text, out) {
19
+ let s = '"';
20
+ for (const ch of text) {
21
+ const code = ch.codePointAt(0);
22
+ if (ch === '"') s += '\\"';
23
+ else if (ch === "\\") s += "\\\\";
24
+ else if (code < 0x20) s += `\\u${code.toString(16).padStart(4, "0")}`;
25
+ else s += ch;
26
+ }
27
+ out.push(`${s}"`);
28
+ }
29
+
30
+ function writeCanonical(value, out) {
31
+ if (value === null) out.push("null");
32
+ else if (typeof value === "boolean") out.push(value ? "true" : "false");
33
+ else if (typeof value === "number") {
34
+ // Integers only: serde_json prints floats with its own algorithm, and an integer beyond 2^53 was already corrupted by JSON.parse.
35
+ // Either would make a signature fail for the wrong reason, so refuse loudly instead.
36
+ if (!Number.isSafeInteger(value)) refuse("canonical", "canonical JSON takes only safe integers");
37
+ out.push(String(value));
38
+ } else if (typeof value === "string") writeString(value, out);
39
+ else if (Array.isArray(value)) {
40
+ out.push("[");
41
+ value.forEach((item, index) => { if (index > 0) out.push(","); writeCanonical(item, out); });
42
+ out.push("]");
43
+ } else if (isRecord(value)) {
44
+ // Rust sorts by the UTF-8 bytes of the key; JavaScript's default sort is by UTF-16 code unit and differs above U+FFFF.
45
+ const keys = Object.keys(value).sort((a, b) => Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")));
46
+ out.push("{");
47
+ keys.forEach((key, index) => { if (index > 0) out.push(","); writeString(key, out); out.push(":"); writeCanonical(value[key], out); });
48
+ out.push("}");
49
+ } else refuse("canonical", `canonical JSON cannot carry a ${typeof value}`);
50
+ }
51
+
52
+ /** Canonical JSON: keys sorted by byte order, no whitespace, strings escape only `"`, `\` and control characters < 0x20 (\u00xx lower-case). */
53
+ export function canonicalJson(value) {
54
+ const out = [];
55
+ writeCanonical(value, out);
56
+ return out.join("");
57
+ }
58
+
59
+ /** The exact bytes the broker signs for a payload. */
60
+ export function signedBytes(payload) {
61
+ return Buffer.from(`${PROVENANCE_DOMAIN}\n${canonicalJson(payload)}`, "utf8");
62
+ }
63
+
64
+ const HEX = /^(?:[0-9a-fA-F]{2})+$/;
65
+ const keyFromSec1 = (publicKeyHex) => {
66
+ if (typeof publicKeyHex !== "string" || !HEX.test(publicKeyHex)) refuse("pin", "pinned public key is not hex");
67
+ const bytes = Buffer.from(publicKeyHex, "hex");
68
+ if (bytes.length !== 65 || bytes[0] !== 0x04) refuse("pin", "pinned public key is not an uncompressed SEC1 P-256 point");
69
+ try {
70
+ return createPublicKey({ key: { kty: "EC", crv: "P-256", x: bytes.subarray(1, 33).toString("base64url"), y: bytes.subarray(33, 65).toString("base64url") }, format: "jwk" });
71
+ } catch { return refuse("pin", "pinned public key is not a P-256 point"); }
72
+ };
73
+
74
+ /**
75
+ * Verifies receipt.provenance against pins the caller holds (never from the receipt) and what this invocation expects. Every check
76
+ * fails closed (throws ProvenanceError with a stable `code`) and returns the signed payload on success.
77
+ * pins: { publicKey (SEC1 uncompressed hex), keyId, host, brokerDigest }
78
+ * expected: { jobId, nonce, requestDigest? } requestDigest is checked only when the caller can recompute it.
79
+ */
80
+ export function verifyProvenance(receipt, pins, expected) {
81
+ const envelope = receipt?.provenance;
82
+ if (!isRecord(envelope)) refuse("legacy", "the receipt has no provenance envelope");
83
+ if (envelope.schema !== PROVENANCE_SCHEMA) refuse("schema", "unknown provenance schema");
84
+ const payload = envelope.payload;
85
+ if (!isRecord(payload)) refuse("malformed", "payload is missing");
86
+ const signature = envelope.signature;
87
+ if (!isRecord(signature)) refuse("malformed", "signature is missing");
88
+ if (signature.alg !== "ES256") refuse("alg", "signature algorithm must be ES256");
89
+ if (!isRecord(pins) || typeof pins.publicKey !== "string" || !HEX.test(pins.publicKey)) refuse("pin", "pinned public key is not hex");
90
+ if (sha256Hex(Buffer.from(pins.publicKey, "hex")) !== pins.keyId) refuse("pin", "pinned key id does not match the pinned public key");
91
+ if (signature.keyId !== pins.keyId || payload.keyId !== pins.keyId) refuse("key", "the receipt was not signed by the pinned key");
92
+ const key = keyFromSec1(pins.publicKey);
93
+ if (typeof signature.value !== "string" || !/^[0-9a-fA-F]+$/.test(signature.value)) refuse("signature", "signature is not hex");
94
+ if (signature.value.length !== 128) refuse("signature", "signature is not 64 bytes r||s");
95
+ let good = false;
96
+ try { good = verify("sha256", signedBytes(payload), { key, dsaEncoding: "ieee-p1363" }, Buffer.from(signature.value, "hex")); } catch (error) { if (error instanceof ProvenanceError) throw error; }
97
+ if (!good) refuse("signature", "signature does not verify");
98
+ if (payload.host !== pins.host) refuse("host", "signed for another host");
99
+ if (payload.broker?.brokerDigest !== pins.brokerDigest) refuse("runtime", "signed by another broker runtime");
100
+ if (typeof expected?.jobId !== "string" || payload.jobId !== expected.jobId || receipt.id !== expected.jobId) refuse("job", "the receipt is for another job");
101
+ if (typeof expected.nonce !== "string" || payload.nonce !== expected.nonce) refuse("nonce", "the nonce is not the one this invocation sent");
102
+ if (expected.requestDigest !== undefined && payload.requestDigest !== expected.requestDigest) refuse("request", "the request digest is not the one this invocation computed");
103
+ return payload;
104
+ }
105
+
106
+ /** The nonce a unit gate run sets (RIGHTKIT_ATTEST_NONCE) for the broker jobs of one stage; the hosted admission recomputes it from
107
+ * what it knows of the dispatched commit, so a receipt of another commit, tree, unit or stage cannot match. 64 lowercase hex. */
108
+ export function qualificationNonce({ commit, tree, unit, stage }) {
109
+ return sha256Hex(`${QUALIFICATION_NONCE_DOMAIN}\n${commit}\n${tree}\n${unit}\n${stage}`);
110
+ }
111
+
112
+ /** Broker request ids the client prints (`rustright: request <id>`) in one command's output. */
113
+ export function brokerRequestIds(text) {
114
+ return [...String(text ?? "").matchAll(/^rustright: request ([0-9a-f-]{36})/gm)].map((m) => m[1]);
115
+ }
package/publish-cargo.mjs CHANGED
@@ -335,10 +335,10 @@ function fileViolations(relative, bytes, records) {
335
335
  * (the repository gate) the reviewed records of `<repoRoot>/.rightkit/secret-scan-records.json` apply and each violation is a
336
336
  * `<repo-relative path>[:<line>]: <reason>` line; `repoRoot` (default `crateDir`) is the root those paths and the records are relative to.
337
337
  */
338
- export function scanCrateForSecrets(crateDir, { envTemplates = false, repoRoot = crateDir } = {}) {
339
- if (envTemplates) return scanRepositoryForSecrets(crateDir, repoRoot);
338
+ export function scanCrateForSecrets(crateDir, { envTemplates = false, repoRoot = crateDir, skip = null } = {}) {
339
+ if (envTemplates) return scanRepositoryForSecrets(crateDir, repoRoot, skip);
340
340
  const violations = [];
341
- for (const file of walkFiles(crateDir)) {
341
+ for (const file of walkFiles(crateDir, skip)) {
342
342
  const relative = path.relative(crateDir, file).replaceAll("\\", "/");
343
343
  if (FORBIDDEN_PACKAGE_PATH.test(relative)) {
344
344
  violations.push(relative);
@@ -350,10 +350,10 @@ export function scanCrateForSecrets(crateDir, { envTemplates = false, repoRoot =
350
350
  return [...new Set(violations)].sort();
351
351
  }
352
352
 
353
- function scanRepositoryForSecrets(dir, repoRoot) {
353
+ function scanRepositoryForSecrets(dir, repoRoot, skip = null) {
354
354
  const { records = new Map(), problems = [] } = readSecretScanRecords(repoRoot);
355
355
  const violations = [...problems];
356
- for (const file of walkFiles(dir)) {
356
+ for (const file of walkFiles(dir, skip)) {
357
357
  const inDir = path.relative(dir, file).replaceAll("\\", "/");
358
358
  const relative = path.relative(repoRoot, file).replaceAll("\\", "/");
359
359
  if (neverRecordable(inDir)) {
@@ -397,9 +397,9 @@ export async function runCargoPublish(options, execute = (_command, args, settin
397
397
  const proven = (reuseEvidence || options.trustEvidenceSteps === true) && !options.dryRun ? provenGateSteps(gate.evidence) : {};
398
398
  if (reuseEvidence && (proven.tests !== true || proven.clippy !== true)) throw new Error("--from-evidence requires gate-proven tests and clippy for this crate and commit");
399
399
  if (reuseEvidence) {
400
- const status = (deps.workspaceStatus ?? ((workspaceRoot) => spawnSync("git", ["status", "--porcelain", "--", "Cargo.toml", "Cargo.lock", "ci/gate.config.json", "ci/asset-gated.json"], { cwd: workspaceRoot, encoding: "utf8", windowsHide: true })))(options.workspaceRoot);
400
+ const status = (deps.workspaceStatus ?? ((workspaceRoot) => spawnSync("git", ["status", "--porcelain", "--", `crates/${options.crate}/Cargo.toml`, `crates/${options.crate}/Cargo.lock`, "ci/gate.config.json", "ci/asset-gated.json"], { cwd: workspaceRoot, encoding: "utf8", windowsHide: true })))(options.workspaceRoot);
401
401
  // The gate config decides the steps replayed below, so an uncommitted edit would change what is demanded (0.4.1 review A3).
402
- if (status.error || status.status !== 0 || String(status.stdout ?? "").trim()) throw new Error("--from-evidence requires clean workspace Cargo.toml, Cargo.lock and ci gate config (ci/gate.config.json, ci/asset-gated.json)");
402
+ if (status.error || status.status !== 0 || String(status.stdout ?? "").trim()) throw new Error("--from-evidence requires clean crate Cargo.toml, Cargo.lock and ci gate config (ci/gate.config.json, ci/asset-gated.json)");
403
403
  const proofHost = singleHostOfPolicy(gate.evidence) ?? "darwin";
404
404
  const runs = gate.evidence.runs.filter((run) => run?.host?.os === proofHost);
405
405
  const command = `cargo package --locked -p ${options.crate}`;
@@ -480,7 +480,8 @@ export async function runCargoPublish(options, execute = (_command, args, settin
480
480
  // `execute` may be synchronous (the default) or async (the batch publisher overlaps uploads).
481
481
  const isReadOnlyInspection = ["package contents", "package location"].includes(step.label);
482
482
  const settings = {
483
- cwd: options.workspaceRoot,
483
+ // Every crate is its own Cargo workspace with its own Cargo.lock and target directory (#1285): cargo runs in the crate directory.
484
+ cwd: crateDir,
484
485
  encoding: "utf8",
485
486
  windowsHide: true,
486
487
  stdio: step.capture || (options.offlineReadOnly === true && isReadOnlyInspection) ? "pipe" : "inherit",
@@ -521,7 +522,8 @@ export async function runCargoPublish(options, execute = (_command, args, settin
521
522
  return publication ?? { name: options.crate, version: cargoVersion, dryRun: true };
522
523
  }
523
524
 
524
- function walkFiles(root) {
525
+ // `skip(absolutePath)`: a caller's exclusion (the gate's git-ignored paths of a live tree); a skipped directory is not descended.
526
+ function walkFiles(root, skip = null) {
525
527
  const files = [];
526
528
  const pending = [root];
527
529
  while (pending.length) {
@@ -529,6 +531,7 @@ function walkFiles(root) {
529
531
  for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
530
532
  if (entry.isDirectory() && [".git", "target", "node_modules"].includes(entry.name)) continue;
531
533
  const absolute = path.join(current, entry.name);
534
+ if (skip && skip(absolute)) continue;
532
535
  if (entry.isDirectory()) pending.push(absolute);
533
536
  else if (entry.isFile()) files.push(absolute);
534
537
  }
@@ -1,5 +1,14 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "apps": {},
4
- "approvedRunners": []
4
+ "approvedRunners": [
5
+ {
6
+ "sha256": "3c11788ecb51459cde33c6d98dfad936a6c4c4c409f1d11ef327b0f0781f2986",
7
+ "version": "0.9.1",
8
+ "platform": "win",
9
+ "set_by": "adrian",
10
+ "since": "2026-10-27",
11
+ "note": "Dell runner, Adrian's go 2026-10-28, sha256 3c11788e…2986"
12
+ }
13
+ ]
5
14
  }
package/recipe-build.mjs CHANGED
@@ -30,9 +30,10 @@
30
30
  // requires the planned target directory before and after it (a temporary bridge until the broker exposes artifacts).
31
31
  import { existsSync as nodeExistsSync, readFileSync, readdirSync as nodeReaddirSync, rmSync as nodeRmSync, statSync as nodeStatSync } from "node:fs";
32
32
  import path from "node:path";
33
- import { pathToFileURL } from "node:url";
33
+ import { fileURLToPath, pathToFileURL } from "node:url";
34
34
  import { spawnSync as nodeSpawnSync } from "./background-process.mjs";
35
35
  import { resolveTargetRoot as cargoResolveTargetRoot } from "./cargo-target.mjs";
36
+ import { buildAndEmbedNativeHelpers, validateNativeHelpers } from "./native-helpers.mjs";
36
37
  import { assertTargetDirUnchanged, removeFreshSources, stageArtifacts, verifySameContent } from "./managed-target-stage.mjs";
37
38
 
38
39
  export const RECIPE_BUILD_PLAN_VERSION = 1;
@@ -50,7 +51,10 @@ export class RecipeBuildError extends Error {
50
51
  const fail = (code, message, details) => { throw new RecipeBuildError(code, message, details); };
51
52
  const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
52
53
  const STEP_KEYS = new Set(["phase", "cmd", "args", "cwd", "env"]);
53
- const PLAN_KEYS = new Set(["version", "steps", "cleanup", "stability", "stage"]);
54
+ const PLAN_KEYS = new Set(["version", "steps", "cleanup", "stability", "stage", "verifySidecars", "nativeHelpers", "boundTargets"]);
55
+ const VERIFY_SIDECARS_KEYS = new Set(["receipt", "target"]);
56
+ const NATIVE_HELPERS_KEYS = new Set(["app", "helpers", "version", "forceArch", "configuration"]);
57
+ const UNIVERSAL_TRIPLE = "universal-apple-darwin";
54
58
  const STAGE_KEYS = new Set(["pre", "post", "verify", "resolve"]);
55
59
  const STAGE_ENTRY_KEYS = new Set(["from", "to", "fresh"]);
56
60
  const CLEANUP_KEYS = new Set(["remove", "apps", "dmgVolumes"]);
@@ -61,10 +65,22 @@ const unknownKeys = (value, allowed, label, errors) => { for (const key of Objec
61
65
  const positiveInt = (value) => Number.isInteger(value) && value > 0;
62
66
  const badVolumePrefix = (item) => typeof item !== "string" || !item.trim() || item === "." || item === ".." || item.includes("/") || item.includes("\\") || item.includes(":") || item.includes("\0");
63
67
 
64
- /** A relative path that stays inside the app root; returns the normalized form or undefined and pushes an error. */
65
- function insideApp(value, label, errors, { strict = false } = {}) {
68
+ /**
69
+ * A relative path that stays inside the app root; returns the normalized form or undefined and pushes an error.
70
+ * `bound` (a door split, row 7.04c R-T2): the absolute Cargo target directories the plan binds. A path strictly inside one of them is also
71
+ * accepted, absolute and without `..`: the snapshot has no target directory, so a split's build output is addressed there. Any other
72
+ * absolute path stays refused.
73
+ */
74
+ function insideApp(value, label, errors, { strict = false, bound = [] } = {}) {
66
75
  if (typeof value !== "string" || !value.trim()) { errors.push(`${label} must be a non-empty relative path`); return undefined; }
67
76
  const normalized = value.replaceAll("\\", "/");
77
+ if (bound.length && (path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value))) {
78
+ const parts = normalized.split("/");
79
+ const absolute = path.resolve(value);
80
+ const under = !parts.includes("..") && bound.some((dir) => { const relative = path.relative(dir, absolute); return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); });
81
+ if (!under) { errors.push(`${label} is an absolute path outside every bound Cargo target (${bound.join(", ")}): ${value}`); return undefined; }
82
+ return normalized;
83
+ }
68
84
  if (path.isAbsolute(value) || /^[A-Za-z]:/.test(value) || normalized.split("/").includes("..")) { errors.push(`${label} must be relative and stay inside the app: ${value}`); return undefined; }
69
85
  const resolved = path.posix.normalize(normalized);
70
86
  if (strict && (resolved === "." || resolved === "./" || resolved === "")) { errors.push(`${label} must name a path strictly inside the app, not the app root: ${JSON.stringify(value)}`); return undefined; }
@@ -72,21 +88,31 @@ function insideApp(value, label, errors, { strict = false } = {}) {
72
88
  return normalized;
73
89
  }
74
90
 
75
- function validateCleanup(value, errors) {
91
+ /** `boundTargets`: the absolute Cargo target directories of a door split (row 7.04c). Absent outside a split. */
92
+ function validateBoundTargets(value, errors) {
93
+ if (value === undefined) return undefined;
94
+ if (!Array.isArray(value) || !value.length || value.some((dir) => typeof dir !== "string" || !path.isAbsolute(dir) || dir.replaceAll("\\", "/").split("/").includes(".."))) {
95
+ errors.push("boundTargets must be a non-empty list of absolute directories without ..");
96
+ return undefined;
97
+ }
98
+ return value.map((dir) => path.resolve(dir));
99
+ }
100
+
101
+ function validateCleanup(value, errors, bound = []) {
76
102
  if (value === undefined) return undefined;
77
103
  if (!isObject(value)) { errors.push("cleanup must be an object"); return undefined; }
78
104
  unknownKeys(value, CLEANUP_KEYS, "cleanup", errors);
79
105
  const out = {};
80
106
  if (value.remove !== undefined) {
81
107
  if (!Array.isArray(value.remove)) errors.push("cleanup.remove must be a list of relative paths");
82
- else out.remove = value.remove.map((item, index) => insideApp(item, `cleanup.remove[${index}]`, errors, { strict: true })).filter(Boolean);
108
+ else out.remove = value.remove.map((item, index) => insideApp(item, `cleanup.remove[${index}]`, errors, { strict: true, bound })).filter(Boolean);
83
109
  }
84
110
  if (value.apps !== undefined) {
85
111
  if (!isObject(value.apps)) errors.push("cleanup.apps must be an object");
86
112
  else {
87
113
  unknownKeys(value.apps, APPS_KEYS, "cleanup.apps", errors);
88
114
  for (const key of ["remove", "unregister"]) if (value.apps[key] !== undefined && typeof value.apps[key] !== "boolean") errors.push(`cleanup.apps.${key} must be a boolean`);
89
- const dir = insideApp(value.apps.dir, "cleanup.apps.dir", errors, { strict: true });
115
+ const dir = insideApp(value.apps.dir, "cleanup.apps.dir", errors, { strict: true, bound });
90
116
  if (dir) out.apps = { dir, remove: value.apps.remove === true, unregister: value.apps.unregister === true };
91
117
  }
92
118
  }
@@ -97,13 +123,13 @@ function validateCleanup(value, errors) {
97
123
  return out;
98
124
  }
99
125
 
100
- function validateStability(value, errors) {
126
+ function validateStability(value, errors, bound = []) {
101
127
  if (value === undefined) return undefined;
102
128
  if (!isObject(value)) { errors.push("stability must be an object"); return undefined; }
103
129
  unknownKeys(value, STABILITY_KEYS, "stability", errors);
104
130
  for (const key of ["intervalMs", "checks", "timeoutMs"]) if (value[key] !== undefined && !positiveInt(value[key])) errors.push(`stability.${key} must be a positive integer`);
105
131
  if (!Array.isArray(value.files) || !value.files.length) { errors.push("stability.files must be a non-empty list of relative paths"); return undefined; }
106
- const files = value.files.map((item, index) => insideApp(item, `stability.files[${index}]`, errors, { strict: true })).filter(Boolean);
132
+ const files = value.files.map((item, index) => insideApp(item, `stability.files[${index}]`, errors, { strict: true, bound })).filter(Boolean);
107
133
  return { files, ...(value.intervalMs === undefined ? {} : { intervalMs: value.intervalMs }), ...(value.checks === undefined ? {} : { checks: value.checks }), ...(value.timeoutMs === undefined ? {} : { timeoutMs: value.timeoutMs }) };
108
134
  }
109
135
 
@@ -154,6 +180,29 @@ function validateStage(value, errors) {
154
180
  return out;
155
181
  }
156
182
 
183
+ function validateVerifySidecars(value, errors) {
184
+ if (value === undefined) return undefined;
185
+ if (!isObject(value)) { errors.push("verifySidecars must be { receipt, target }"); return undefined; }
186
+ unknownKeys(value, VERIFY_SIDECARS_KEYS, "verifySidecars", errors);
187
+ if (value.target !== UNIVERSAL_TRIPLE) errors.push(`verifySidecars.target must be ${UNIVERSAL_TRIPLE}`);
188
+ const receipt = insideApp(value.receipt, "verifySidecars.receipt", errors, { strict: true });
189
+ return receipt ? { receipt, target: UNIVERSAL_TRIPLE } : undefined;
190
+ }
191
+
192
+ function validateNativeHelpersSection(value, errors, bound = []) {
193
+ if (value === undefined) return undefined;
194
+ if (!isObject(value)) { errors.push("nativeHelpers must be { app, helpers, version?, forceArch?, configuration? }"); return undefined; }
195
+ unknownKeys(value, NATIVE_HELPERS_KEYS, "nativeHelpers", errors);
196
+ const app = insideApp(value.app, "nativeHelpers.app", errors, { strict: true, bound });
197
+ if (app && !app.replace(/\/+$/, "").endsWith(".app")) errors.push("nativeHelpers.app must name the .app bundle");
198
+ let helpers;
199
+ try { helpers = validateNativeHelpers(value.helpers, { label: "nativeHelpers.helpers" }); } catch (error) { errors.push(...(error.details?.errors ?? [error.message])); }
200
+ if (value.forceArch !== undefined && !["arm64", "x86_64"].includes(value.forceArch)) errors.push("nativeHelpers.forceArch must be arm64 or x86_64");
201
+ if (value.configuration !== undefined && !["release", "debug"].includes(value.configuration)) errors.push("nativeHelpers.configuration must be release or debug");
202
+ if (value.version !== undefined && (typeof value.version !== "string" || !/^\d+(\.\d+){0,2}$/.test(value.version))) errors.push("nativeHelpers.version must be 1-3 numeric parts");
203
+ return app && helpers ? { app, helpers, ...(value.version === undefined ? {} : { version: value.version }), ...(value.forceArch ? { forceArch: value.forceArch } : {}), ...(value.configuration ? { configuration: value.configuration } : {}) } : undefined;
204
+ }
205
+
157
206
  /** Validate a plan strictly; every problem is reported at once as RECIPE_BUILD_PLAN_INVALID. Returns the normalized plan. */
158
207
  export function validateRecipePlan(plan) {
159
208
  const errors = [];
@@ -181,13 +230,19 @@ export function validateRecipePlan(plan) {
181
230
  steps.push({ phase: step.phase, cmd: step.cmd, args: Array.isArray(args) ? [...args] : [], ...(cwd ? { cwd } : {}), ...(isObject(step.env) ? { env: { ...step.env } } : {}) });
182
231
  }
183
232
  if (builds !== 1) errors.push(`exactly one build step is required, found ${builds}`);
184
- const cleanup = validateCleanup(plan.cleanup, errors);
185
- const stability = validateStability(plan.stability, errors);
233
+ const boundTargets = validateBoundTargets(plan.boundTargets, errors);
234
+ const cleanup = validateCleanup(plan.cleanup, errors, boundTargets ?? []);
235
+ const stability = validateStability(plan.stability, errors, boundTargets ?? []);
186
236
  const stage = validateStage(plan.stage, errors);
237
+ const verifySidecars = validateVerifySidecars(plan.verifySidecars, errors);
238
+ const nativeHelpers = validateNativeHelpersSection(plan.nativeHelpers, errors, boundTargets ?? []);
239
+ if (boundTargets && stage && (stage.pre?.length || stage.post?.length)) errors.push("a plan that binds Cargo targets (a door split) plans no stage copies: the build output is read and signed in place (rightkit#924 copies are zero)");
187
240
  if (errors.length) fail("RECIPE_BUILD_PLAN_INVALID", `invalid recipe plan:\n${errors.map((line) => `- ${line}`).join("\n")}`, { errors });
188
- return { version: RECIPE_BUILD_PLAN_VERSION, steps, ...(cleanup ? { cleanup } : {}), ...(stability ? { stability } : {}), ...(stage ? { stage } : {}) };
241
+ return { version: RECIPE_BUILD_PLAN_VERSION, steps, ...(boundTargets ? { boundTargets } : {}), ...(cleanup ? { cleanup } : {}), ...(stability ? { stability } : {}), ...(stage ? { stage } : {}), ...(verifySidecars ? { verifySidecars } : {}), ...(nativeHelpers ? { nativeHelpers } : {}) };
189
242
  }
190
243
 
244
+ /** This package's own right-release entry: the door's RightKit, whatever the app's node_modules holds. */
245
+ export const OWN_RIGHT_RELEASE = fileURLToPath(new URL("./cli/right-release.mjs", import.meta.url));
191
246
  // cmd.exe would reinterpret these in a shell launch; refuse rather than escape.
192
247
  const SHELL_UNSAFE = /[&|<>^%"!\r\n]/;
193
248
 
@@ -196,7 +251,11 @@ const SHELL_UNSAFE = /[&|<>^%"!\r\n]/;
196
251
  * release runs under pnpm). On Windows a non-.exe command is a .cmd shim that only a shell can start, so it is allowed
197
252
  * only when no argument could be reinterpreted by that shell.
198
253
  */
199
- export function resolveLaunch({ cmd, args = [], platform = process.platform, env = process.env, execPath = process.execPath }) {
254
+ export function resolveLaunch({ cmd, args = [], platform = process.platform, env = process.env, execPath = process.execPath, ownRightRelease = OWN_RIGHT_RELEASE }) {
255
+ // A step's `right-release ...` (also `pnpm exec right-release ...` and `pnpm right-release ...`) runs THIS package's own CLI, never the
256
+ // app's node_modules copy that `pnpm exec` would put first on PATH (the same rule as development.mjs resolveCommand).
257
+ if (cmd === "right-release") return { command: execPath, args: [ownRightRelease, ...args], shell: false };
258
+ if (cmd === "pnpm" && ((args[0] === "exec" && args[1] === "right-release") || args[0] === "right-release")) return { command: execPath, args: [ownRightRelease, ...args.slice(args[0] === "exec" ? 2 : 1)], shell: false };
200
259
  if (cmd === "pnpm" && env.npm_execpath) return { command: execPath, args: [env.npm_execpath, ...args], shell: false };
201
260
  if (platform === "win32" && !/\.(?:exe|com)$/i.test(cmd)) {
202
261
  if ([cmd, ...args].some((value) => SHELL_UNSAFE.test(value))) fail("RECIPE_BUILD_LAUNCH_UNSAFE", `${cmd} needs a shell on Windows and its arguments contain characters a shell would reinterpret; run the release under pnpm (npm_execpath) or name an .exe`, { cmd });
@@ -330,6 +389,7 @@ export function runRecipeBuild(rawPlan, { appRoot, env = process.env, platform =
330
389
  const cleaned = runRecipeCleanup(plan.cleanup, { appRoot, platform, log, spawnSync, ...(fs ? { fs } : {}) });
331
390
  let stable = [];
332
391
  let staged = [];
392
+ let nativeHelpers = null;
333
393
  const stagePost = plan.stage?.post ?? [];
334
394
  const resolveEntry = (entry, appKey) => ({ ...entry, [appKey]: path.resolve(appRoot, entry[appKey]) });
335
395
  for (const [index, step] of plan.steps.entries()) {
@@ -342,6 +402,7 @@ export function runRecipeBuild(rawPlan, { appRoot, env = process.env, platform =
342
402
  // Immediately before the step that embeds the real EXE: it must be exactly the signed in-app copy.
343
403
  verifyReal("after the copy-back, before the build step");
344
404
  }
405
+ if (step.phase === "build" && plan.verifySidecars) verifyUniversalSidecars(plan.verifySidecars, { appRoot, spawnSync, env, log, ...(fs?.readFileSync ? { readFile: fs.readFileSync } : {}) });
345
406
  const launch = resolveLaunch({ cmd: step.cmd, args: step.args, platform, env });
346
407
  const cwd = path.resolve(appRoot, step.cwd ?? ".");
347
408
  log(`recipe-build: [${step.phase}] ${step.cmd} ${step.args.join(" ")}`.trimEnd());
@@ -355,8 +416,39 @@ export function runRecipeBuild(rawPlan, { appRoot, env = process.env, platform =
355
416
  if (step.phase === "build" && stagePost.length) waitForStableArtifacts({ ...(plan.stability ?? {}), files: stagePost.map((entry) => entry.from).filter((file) => (fs?.existsSync ?? nodeExistsSync)(file)) }, { appRoot, log, ...(sleep ? { sleep } : {}), ...(now ? { now } : {}), ...(statSync ? { statSync } : {}) });
356
417
  if (step.phase === "build" && stagePost.length) staged.push(...stageArtifacts(stagePost.map((entry) => resolveEntry(entry, "to")), { ...(fs ? { fs } : {}), log }));
357
418
  if (step.phase === "build") stable = waitForStableArtifacts(plan.stability, { appRoot, log, ...(sleep ? { sleep } : {}), ...(now ? { now } : {}), ...(statSync ? { statSync } : {}) });
419
+ // Native Swift helpers are built and embedded into the built .app AFTER the app exists and BEFORE postBuild and signing.
420
+ if (step.phase === "build" && plan.nativeHelpers) {
421
+ const section = plan.nativeHelpers;
422
+ nativeHelpers = buildAndEmbedNativeHelpers({ appRoot, app: path.resolve(appRoot, section.app), helpers: section.helpers, version: section.version, forceArch: section.forceArch, configuration: section.configuration ?? "release", env, spawn: spawnSync, log, ...(fs ? { fs } : {}) });
423
+ }
424
+ }
425
+ return { steps: done, cleaned, stable, staged, nativeHelpers };
426
+ }
427
+
428
+ /**
429
+ * After the prepare steps of a universal macOS build: the stage-sidecar receipt must be for universal-apple-darwin and every
430
+ * externalBin file it lists must be named `<name>-universal-apple-darwin` (the name Tauri's universal build looks for) and hold
431
+ * exactly arm64 and x86_64 (`lipo -archs`). Anything else throws RECIPE_BUILD_SIDECAR_NOT_UNIVERSAL before the Tauri build starts.
432
+ * Options: appRoot, spawnSync, readFile, env, log.
433
+ */
434
+ export function verifyUniversalSidecars(section, { appRoot, spawnSync = nodeSpawnSync, readFile = readFileSync, env = process.env, log = () => {} } = {}) {
435
+ const receiptFile = path.resolve(appRoot, section.receipt);
436
+ let receipt;
437
+ try { receipt = JSON.parse(readFile(receiptFile, "utf8")); } catch (error) { fail("RECIPE_BUILD_SIDECAR_NOT_UNIVERSAL", `cannot read the stage-sidecar receipt ${section.receipt} (${error.message}); stageSidecars must run before the build`); }
438
+ if (!isObject(receipt) || !Array.isArray(receipt.files)) fail("RECIPE_BUILD_SIDECAR_NOT_UNIVERSAL", `${section.receipt} is not a stage-sidecar receipt`);
439
+ if (receipt.target !== section.target) fail("RECIPE_BUILD_SIDECAR_NOT_UNIVERSAL", `${section.receipt} was staged for ${receipt.target}, not ${section.target}`);
440
+ const checked = [];
441
+ for (const file of receipt.files.filter((item) => item?.externalBin === true)) {
442
+ if (typeof file.path !== "string" || !file.path.endsWith(`-${UNIVERSAL_TRIPLE}`)) fail("RECIPE_BUILD_SIDECAR_NOT_UNIVERSAL", `externalBin ${file.name} is staged as ${file.path}, not <name>-${UNIVERSAL_TRIPLE}`);
443
+ const absolute = path.resolve(appRoot, file.path);
444
+ if (path.relative(appRoot, absolute).startsWith("..")) fail("RECIPE_BUILD_SIDECAR_NOT_UNIVERSAL", `externalBin ${file.name} path escapes the app: ${file.path}`);
445
+ const result = spawnSync("lipo", ["-archs", absolute], { cwd: appRoot, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
446
+ const archs = String(result?.stdout ?? "").trim().split(/\s+/).filter(Boolean).sort();
447
+ if (result?.error || result?.status !== 0 || archs.join(" ") !== "arm64 x86_64") fail("RECIPE_BUILD_SIDECAR_NOT_UNIVERSAL", `externalBin ${file.path} must hold exactly arm64 and x86_64, lipo -archs reports ${JSON.stringify(archs.join(" "))}${result?.error ? ` (${result.error.message})` : ""}`, { file: file.path, archs });
448
+ checked.push(file.path);
358
449
  }
359
- return { steps: done, cleaned, stable, staged };
450
+ log(`recipe-build: universal sidecars verified (${checked.length} externalBin file(s))`);
451
+ return checked;
360
452
  }
361
453
 
362
454
  export function parseRecipeBuildArgs(argv) {