@rightkit/release 0.6.7 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +35 -10
  2. package/admission-files.mjs +1 -1
  3. package/app-check.mjs +94 -20
  4. package/assemble-app-command.mjs +151 -0
  5. package/assembled-app.mjs +445 -0
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/darwin/rightkit-swap.sign.json +6 -0
  8. package/bin/rightkit-gate.mjs +957 -83
  9. package/build-launch.mjs +88 -0
  10. package/build-ownership.mjs +1231 -0
  11. package/build-release.mjs +2 -1
  12. package/cargo-contract.mjs +3 -1
  13. package/cargo-target-shim.mjs +166 -0
  14. package/class-a-launch-guard.mjs +126 -0
  15. package/cli/right-release.mjs +3 -1
  16. package/codesign-shim.mjs +11 -1
  17. package/deterministic-scans.mjs +807 -56
  18. package/development-signing.mjs +4 -1
  19. package/development.mjs +110 -20
  20. package/door-policy.mjs +61 -6
  21. package/door.mjs +23 -0
  22. package/gate-executor.mjs +14 -2
  23. package/gate-stages.mjs +15 -5
  24. package/heavy-command.mjs +3 -2
  25. package/install-app.mjs +11 -2
  26. package/install-assembled-app.mjs +423 -0
  27. package/ios-dev.mjs +70 -3
  28. package/linux-artifacts.mjs +93 -0
  29. package/local-publish.mjs +161 -26
  30. package/managed-cargo.mjs +47 -5
  31. package/managed-root-guard.mjs +34 -0
  32. package/native-helpers.mjs +355 -0
  33. package/package-recipe-targets.mjs +52 -7
  34. package/package.json +5 -3
  35. package/pipeline-receipts.mjs +302 -302
  36. package/project-type.mjs +2 -2
  37. package/provenance-verify.mjs +115 -0
  38. package/publish-cargo.mjs +8 -6
  39. package/qa-admission-policy.json +10 -1
  40. package/recipe-build.mjs +73 -5
  41. package/release-config.mjs +1 -1
  42. package/release.mjs +15 -5
  43. package/rightkit-versions.json +13 -7
  44. package/snapshot-check.mjs +306 -0
  45. package/snapshot-git.mjs +166 -0
  46. package/snapshot-request.mjs +161 -0
  47. package/snapshot-root-config.mjs +76 -0
  48. package/snapshot.mjs +856 -0
  49. package/stage-commands.mjs +60 -0
  50. package/stage-sidecar.mjs +57 -7
  51. package/test-command.mjs +17 -4
  52. package/unit-gate.mjs +259 -0
  53. package/web-build.mjs +38 -6
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)) {
@@ -521,7 +521,8 @@ export async function runCargoPublish(options, execute = (_command, args, settin
521
521
  return publication ?? { name: options.crate, version: cargoVersion, dryRun: true };
522
522
  }
523
523
 
524
- function walkFiles(root) {
524
+ // `skip(absolutePath)`: a caller's exclusion (the gate's git-ignored paths of a live tree); a skipped directory is not descended.
525
+ function walkFiles(root, skip = null) {
525
526
  const files = [];
526
527
  const pending = [root];
527
528
  while (pending.length) {
@@ -529,6 +530,7 @@ function walkFiles(root) {
529
530
  for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
530
531
  if (entry.isDirectory() && [".git", "target", "node_modules"].includes(entry.name)) continue;
531
532
  const absolute = path.join(current, entry.name);
533
+ if (skip && skip(absolute)) continue;
532
534
  if (entry.isDirectory()) pending.push(absolute);
533
535
  else if (entry.isFile()) files.push(absolute);
534
536
  }
@@ -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"]);
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"]);
@@ -154,6 +158,29 @@ function validateStage(value, errors) {
154
158
  return out;
155
159
  }
156
160
 
161
+ function validateVerifySidecars(value, errors) {
162
+ if (value === undefined) return undefined;
163
+ if (!isObject(value)) { errors.push("verifySidecars must be { receipt, target }"); return undefined; }
164
+ unknownKeys(value, VERIFY_SIDECARS_KEYS, "verifySidecars", errors);
165
+ if (value.target !== UNIVERSAL_TRIPLE) errors.push(`verifySidecars.target must be ${UNIVERSAL_TRIPLE}`);
166
+ const receipt = insideApp(value.receipt, "verifySidecars.receipt", errors, { strict: true });
167
+ return receipt ? { receipt, target: UNIVERSAL_TRIPLE } : undefined;
168
+ }
169
+
170
+ function validateNativeHelpersSection(value, errors) {
171
+ if (value === undefined) return undefined;
172
+ if (!isObject(value)) { errors.push("nativeHelpers must be { app, helpers, version?, forceArch?, configuration? }"); return undefined; }
173
+ unknownKeys(value, NATIVE_HELPERS_KEYS, "nativeHelpers", errors);
174
+ const app = insideApp(value.app, "nativeHelpers.app", errors, { strict: true });
175
+ if (app && !app.replace(/\/+$/, "").endsWith(".app")) errors.push("nativeHelpers.app must name the .app bundle");
176
+ let helpers;
177
+ try { helpers = validateNativeHelpers(value.helpers, { label: "nativeHelpers.helpers" }); } catch (error) { errors.push(...(error.details?.errors ?? [error.message])); }
178
+ if (value.forceArch !== undefined && !["arm64", "x86_64"].includes(value.forceArch)) errors.push("nativeHelpers.forceArch must be arm64 or x86_64");
179
+ if (value.configuration !== undefined && !["release", "debug"].includes(value.configuration)) errors.push("nativeHelpers.configuration must be release or debug");
180
+ 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");
181
+ return app && helpers ? { app, helpers, ...(value.version === undefined ? {} : { version: value.version }), ...(value.forceArch ? { forceArch: value.forceArch } : {}), ...(value.configuration ? { configuration: value.configuration } : {}) } : undefined;
182
+ }
183
+
157
184
  /** Validate a plan strictly; every problem is reported at once as RECIPE_BUILD_PLAN_INVALID. Returns the normalized plan. */
158
185
  export function validateRecipePlan(plan) {
159
186
  const errors = [];
@@ -184,10 +211,14 @@ export function validateRecipePlan(plan) {
184
211
  const cleanup = validateCleanup(plan.cleanup, errors);
185
212
  const stability = validateStability(plan.stability, errors);
186
213
  const stage = validateStage(plan.stage, errors);
214
+ const verifySidecars = validateVerifySidecars(plan.verifySidecars, errors);
215
+ const nativeHelpers = validateNativeHelpersSection(plan.nativeHelpers, errors);
187
216
  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 } : {}) };
217
+ return { version: RECIPE_BUILD_PLAN_VERSION, steps, ...(cleanup ? { cleanup } : {}), ...(stability ? { stability } : {}), ...(stage ? { stage } : {}), ...(verifySidecars ? { verifySidecars } : {}), ...(nativeHelpers ? { nativeHelpers } : {}) };
189
218
  }
190
219
 
220
+ /** This package's own right-release entry: the door's RightKit, whatever the app's node_modules holds. */
221
+ export const OWN_RIGHT_RELEASE = fileURLToPath(new URL("./cli/right-release.mjs", import.meta.url));
191
222
  // cmd.exe would reinterpret these in a shell launch; refuse rather than escape.
192
223
  const SHELL_UNSAFE = /[&|<>^%"!\r\n]/;
193
224
 
@@ -196,7 +227,11 @@ const SHELL_UNSAFE = /[&|<>^%"!\r\n]/;
196
227
  * 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
228
  * only when no argument could be reinterpreted by that shell.
198
229
  */
199
- export function resolveLaunch({ cmd, args = [], platform = process.platform, env = process.env, execPath = process.execPath }) {
230
+ export function resolveLaunch({ cmd, args = [], platform = process.platform, env = process.env, execPath = process.execPath, ownRightRelease = OWN_RIGHT_RELEASE }) {
231
+ // A step's `right-release ...` (also `pnpm exec right-release ...` and `pnpm right-release ...`) runs THIS package's own CLI, never the
232
+ // app's node_modules copy that `pnpm exec` would put first on PATH (the same rule as development.mjs resolveCommand).
233
+ if (cmd === "right-release") return { command: execPath, args: [ownRightRelease, ...args], shell: false };
234
+ 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
235
  if (cmd === "pnpm" && env.npm_execpath) return { command: execPath, args: [env.npm_execpath, ...args], shell: false };
201
236
  if (platform === "win32" && !/\.(?:exe|com)$/i.test(cmd)) {
202
237
  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 +365,7 @@ export function runRecipeBuild(rawPlan, { appRoot, env = process.env, platform =
330
365
  const cleaned = runRecipeCleanup(plan.cleanup, { appRoot, platform, log, spawnSync, ...(fs ? { fs } : {}) });
331
366
  let stable = [];
332
367
  let staged = [];
368
+ let nativeHelpers = null;
333
369
  const stagePost = plan.stage?.post ?? [];
334
370
  const resolveEntry = (entry, appKey) => ({ ...entry, [appKey]: path.resolve(appRoot, entry[appKey]) });
335
371
  for (const [index, step] of plan.steps.entries()) {
@@ -342,6 +378,7 @@ export function runRecipeBuild(rawPlan, { appRoot, env = process.env, platform =
342
378
  // Immediately before the step that embeds the real EXE: it must be exactly the signed in-app copy.
343
379
  verifyReal("after the copy-back, before the build step");
344
380
  }
381
+ if (step.phase === "build" && plan.verifySidecars) verifyUniversalSidecars(plan.verifySidecars, { appRoot, spawnSync, env, log, ...(fs?.readFileSync ? { readFile: fs.readFileSync } : {}) });
345
382
  const launch = resolveLaunch({ cmd: step.cmd, args: step.args, platform, env });
346
383
  const cwd = path.resolve(appRoot, step.cwd ?? ".");
347
384
  log(`recipe-build: [${step.phase}] ${step.cmd} ${step.args.join(" ")}`.trimEnd());
@@ -355,8 +392,39 @@ export function runRecipeBuild(rawPlan, { appRoot, env = process.env, platform =
355
392
  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
393
  if (step.phase === "build" && stagePost.length) staged.push(...stageArtifacts(stagePost.map((entry) => resolveEntry(entry, "to")), { ...(fs ? { fs } : {}), log }));
357
394
  if (step.phase === "build") stable = waitForStableArtifacts(plan.stability, { appRoot, log, ...(sleep ? { sleep } : {}), ...(now ? { now } : {}), ...(statSync ? { statSync } : {}) });
395
+ // Native Swift helpers are built and embedded into the built .app AFTER the app exists and BEFORE postBuild and signing.
396
+ if (step.phase === "build" && plan.nativeHelpers) {
397
+ const section = plan.nativeHelpers;
398
+ 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 } : {}) });
399
+ }
400
+ }
401
+ return { steps: done, cleaned, stable, staged, nativeHelpers };
402
+ }
403
+
404
+ /**
405
+ * After the prepare steps of a universal macOS build: the stage-sidecar receipt must be for universal-apple-darwin and every
406
+ * externalBin file it lists must be named `<name>-universal-apple-darwin` (the name Tauri's universal build looks for) and hold
407
+ * exactly arm64 and x86_64 (`lipo -archs`). Anything else throws RECIPE_BUILD_SIDECAR_NOT_UNIVERSAL before the Tauri build starts.
408
+ * Options: appRoot, spawnSync, readFile, env, log.
409
+ */
410
+ export function verifyUniversalSidecars(section, { appRoot, spawnSync = nodeSpawnSync, readFile = readFileSync, env = process.env, log = () => {} } = {}) {
411
+ const receiptFile = path.resolve(appRoot, section.receipt);
412
+ let receipt;
413
+ 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`); }
414
+ if (!isObject(receipt) || !Array.isArray(receipt.files)) fail("RECIPE_BUILD_SIDECAR_NOT_UNIVERSAL", `${section.receipt} is not a stage-sidecar receipt`);
415
+ if (receipt.target !== section.target) fail("RECIPE_BUILD_SIDECAR_NOT_UNIVERSAL", `${section.receipt} was staged for ${receipt.target}, not ${section.target}`);
416
+ const checked = [];
417
+ for (const file of receipt.files.filter((item) => item?.externalBin === true)) {
418
+ 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}`);
419
+ const absolute = path.resolve(appRoot, file.path);
420
+ if (path.relative(appRoot, absolute).startsWith("..")) fail("RECIPE_BUILD_SIDECAR_NOT_UNIVERSAL", `externalBin ${file.name} path escapes the app: ${file.path}`);
421
+ const result = spawnSync("lipo", ["-archs", absolute], { cwd: appRoot, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
422
+ const archs = String(result?.stdout ?? "").trim().split(/\s+/).filter(Boolean).sort();
423
+ 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 });
424
+ checked.push(file.path);
358
425
  }
359
- return { steps: done, cleaned, stable, staged };
426
+ log(`recipe-build: universal sidecars verified (${checked.length} externalBin file(s))`);
427
+ return checked;
360
428
  }
361
429
 
362
430
  export function parseRecipeBuildArgs(argv) {
@@ -41,7 +41,7 @@ export const LEGACY_EXECUTABLE_EXTENSIONS = Object.freeze([".mjs", ".js", ".cjs"
41
41
  export const TOP_LEVEL_KEYS = Object.freeze([
42
42
  "$schema", "$comment", "schema", "app", "version", "channel", "distribution", "packageManager", "workdir", "targets", "development",
43
43
  "deps", "checks", "buildInputs", "hostedWorkflows", "devtools", "releaseSwitches", "qa", "nativeAssembly", "legal", "web", "tests",
44
- "sidecars", "releaseDependencies", "appStoreConnect", "storage", "cargoProfileExempt", "runtimeArtifacts",
44
+ "sidecars", "assembly", "releaseDependencies", "appStoreConnect", "storage", "cargoProfileExempt", "runtimeArtifacts",
45
45
  ]);
46
46
  export const TEMPLATE_TOKENS = Object.freeze(["app", "version", "home", "localAppData"]);
47
47
 
package/release.mjs CHANGED
@@ -3,6 +3,7 @@ import { expandPackageRecipeTarget } from "./package-recipe-targets.mjs";
3
3
  import { access, readFile, readdir } from "node:fs/promises";
4
4
  import { createHash } from "node:crypto";
5
5
  import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { buildLaunch } from "./build-launch.mjs";
6
7
  import { createRequire } from "node:module";
7
8
  import path from "node:path";
8
9
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -608,7 +609,7 @@ if (problems.length || prebuildFindings.length) {
608
609
  const releaseLock = acquireReleaseLock(root, config.app ?? path.basename(root), opts.platform, opts.tier);
609
610
  const mode = "package";
610
611
  const command = target.package;
611
- if (!command && opts.platform !== "android" && opts.platform !== "ios" && !packageCommandOptional(target, resolveProjectType({ appRoot: root, config, target }))) fail(`${config.app ?? "app"} has no ${opts.platform} ${mode} command`);
612
+ if (!command && opts.platform !== "android" && opts.platform !== "ios" && !packageCommandOptional(target, resolveProjectType({ appRoot: root, config, target }), { expanded: true })) fail(`${config.app ?? "app"} has no ${opts.platform} ${mode} command`);
612
613
  if (opts.upload && !target.publish && !target.upload) {
613
614
  fail(`${config.app ?? "app"} has no ${opts.platform} upload config`);
614
615
  }
@@ -1141,14 +1142,16 @@ function sweepStaleRustArtifacts() {
1141
1142
  }
1142
1143
 
1143
1144
  async function runInstall(pm, cwd) {
1144
- if (pm === "pnpm") await run("pnpm", ["install", "--frozen-lockfile"], cwd, {}, { install: true });
1145
- else if (pm === "npm") await run("npm", ["ci"], cwd, {}, { install: true });
1145
+ if (pm === "pnpm") { const t = buildLaunch("pnpm", ["install", "--frozen-lockfile"], { cwd, exists: existsSync }); await run(t.command, t.args, cwd, t.env ?? {}, { install: true }); }
1146
+ else if (pm === "npm") { const t = buildLaunch("npm", ["ci"], { cwd, exists: existsSync }); await run(t.command, t.args, cwd, t.env ?? {}, { install: true }); }
1146
1147
  else fail(`unsupported package manager: ${pm}`);
1147
1148
  }
1148
1149
 
1149
1150
  async function runPackageScript(pm, script, cwd) {
1150
1151
  if (script === false) return;
1151
- await run(pm, ["run", script], cwd);
1152
+ // A package script (a check or a web build) goes through the one door (build-launch.mjs): a broker job on a managed host.
1153
+ const target = buildLaunch(pm, ["run", script], { cwd, exists: existsSync });
1154
+ await run(target.command, target.args, cwd, target.env ?? {});
1152
1155
  }
1153
1156
 
1154
1157
  // A hook may be one command object or an ordered array of them (e.g. create-mac-updater then lsclean).
@@ -1162,9 +1165,16 @@ async function runCommands(value, root, options) {
1162
1165
  for (const command of commandList(value)) await runCommand(command, root, options);
1163
1166
  }
1164
1167
 
1168
+ // Every configured hook (prePackage, the package command and hook, sidecar stages, gradle, the finalizer, postSign, publish) is
1169
+ // arbitrary code that can build, so it goes through the one door (build-launch.mjs): this package's own right-release steps run as
1170
+ // they are (their builds come back through the door), node, pnpm, cargo and rightkit-qa are broker jobs, and anything else is refused
1171
+ // on a broker-managed host (rightkit#1241).
1165
1172
  async function runCommand(command, root, { env = {}, captureStdout = false } = {}) {
1166
1173
  const cwd = path.resolve(root, command.cwd ?? ".");
1167
- return run(command.cmd, command.args ?? [], cwd, { ...(await commandEnv(command, root)), ...env }, {
1174
+ const commandEnvironment = { ...(await commandEnv(command, root)), ...env };
1175
+ const target = buildLaunch(command.cmd, command.args ?? [], { env: { ...process.env, ...commandEnvironment }, cwd, exists: existsSync });
1176
+ // A direct (Linux) launch carries PATH without RustRight shim directories (build-launch `env`).
1177
+ return run(target.command, target.args, cwd, { ...commandEnvironment, ...(target.env ?? {}) }, {
1168
1178
  ...command,
1169
1179
  captureStdout,
1170
1180
  timeoutMs: commandTimeoutMs(command),
@@ -26,13 +26,13 @@
26
26
  "@rightkit/logs": "0.1.6",
27
27
  "@rightkit/media-ui": "0.3.4",
28
28
  "@rightkit/onboarding": "0.6.3",
29
- "@rightkit/outdated": "0.2.0",
29
+ "@rightkit/outdated": "0.3.0",
30
30
  "@rightkit/pdf-ui": "0.2.0",
31
31
  "@rightkit/platform-ui": "0.4.0",
32
32
  "@rightkit/qa": "0.6.1",
33
33
  "@rightkit/qa-chrome": "0.1.12",
34
34
  "@rightkit/react-shell": "0.7.2",
35
- "@rightkit/release": "0.6.7",
35
+ "@rightkit/release": "0.6.10",
36
36
  "@rightkit/settings-ui": "0.7.0",
37
37
  "@rightkit/tauri": "0.3.0",
38
38
  "@rightkit/tauri-shell": "0.1.3",
@@ -374,7 +374,9 @@
374
374
  "0.6.3",
375
375
  "0.6.4",
376
376
  "0.6.5",
377
- "0.6.6"
377
+ "0.6.6",
378
+ "0.6.7",
379
+ "0.6.8"
378
380
  ],
379
381
  "@rightkit/settings-ui": [
380
382
  "0.1.0",
@@ -424,6 +426,9 @@
424
426
  ],
425
427
  "@rightkit/updates": [
426
428
  "0.2.4"
429
+ ],
430
+ "@rightkit/outdated": [
431
+ "0.2.0"
427
432
  ]
428
433
  },
429
434
  "cargo": {
@@ -440,6 +445,7 @@
440
445
  "rightkit-pdf": "0.1.0",
441
446
  "rightkit-playback": "0.1.0",
442
447
  "rightkit-process": "0.1.0",
448
+ "rightkit-qa": "0.9.1",
443
449
  "rightkit-search": "0.1.0",
444
450
  "rightkit-tauri": "0.1.0"
445
451
  },
@@ -454,7 +460,7 @@
454
460
  "rightkit-browser": "0.3.6",
455
461
  "rightkit-cache": "0.2.3",
456
462
  "rightkit-channel-memory": "0.2.1",
457
- "rightkit-child-process": "0.5.6",
463
+ "rightkit-child-process": "0.5.7",
458
464
  "rightkit-compose": "0.2.0",
459
465
  "rightkit-contracts": "0.1.7",
460
466
  "rightkit-crash": "0.3.6",
@@ -490,7 +496,6 @@
490
496
  "rightkit-pdf": "0.3.3",
491
497
  "rightkit-platform-rules": "0.1.0",
492
498
  "rightkit-playback": "0.3.0",
493
- "rightkit-qa": "0.9.1",
494
499
  "rightkit-qa-driver": "0.7.0",
495
500
  "rightkit-record": "0.3.3",
496
501
  "rightkit-review": "0.4.0",
@@ -579,7 +584,7 @@
579
584
  "rightkit-platform-rules": "0.1.0",
580
585
  "rightkit-playback": "0.3.0",
581
586
  "rightkit-process": "0.4.0",
582
- "rightkit-qa": "0.9.0",
587
+ "rightkit-qa": "0.9.1",
583
588
  "rightkit-qa-driver": "0.7.0",
584
589
  "rightkit-qts": "0.2.6",
585
590
  "rightkit-qts-ggml": "0.1.2",
@@ -985,7 +990,8 @@
985
990
  "0.5.2",
986
991
  "0.5.3",
987
992
  "0.5.4",
988
- "0.5.5"
993
+ "0.5.5",
994
+ "0.5.6"
989
995
  ],
990
996
  "rightkit-hotkeys": [
991
997
  "0.1.0",