@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.
- package/README.md +30 -12
- package/admission-files.mjs +4 -1
- package/admission.mjs +2 -2
- package/advisory-exceptions.mjs +41 -5
- package/android-play-upload.mjs +16 -15
- package/app-check.mjs +105 -27
- package/assemble-app-command.mjs +151 -0
- package/assembled-app.mjs +445 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/bin/rightkit-gate.mjs +1608 -345
- package/broker-pins.mjs +152 -0
- package/build-launch.mjs +88 -0
- package/build-ownership.mjs +1231 -0
- package/build-release.mjs +82 -28
- package/cargo-contract.mjs +3 -1
- package/cargo-ledger.mjs +62 -0
- package/cargo-target-shim.mjs +238 -0
- package/class-a-launch-guard.mjs +126 -0
- package/cli/right-release.mjs +31 -5
- package/clippy-equivalence.mjs +202 -0
- package/clippy-route.mjs +543 -0
- package/codesign-shim.mjs +11 -1
- package/committed-source.mjs +39 -5
- package/crate-workspaces.mjs +87 -0
- package/create-mac-updater.mjs +14 -2
- package/deterministic-scans.mjs +997 -71
- package/development-signing.mjs +4 -1
- package/development.mjs +120 -21
- package/direct-receipt.mjs +135 -0
- package/door-policy.mjs +71 -6
- package/door.mjs +6 -6
- package/freshness-census.mjs +257 -0
- package/gate-executor.mjs +14 -2
- package/gate-stages.mjs +91 -15
- package/heavy-command.mjs +3 -2
- package/hook-retirement.mjs +65 -0
- package/init-command.mjs +1 -1
- package/install-app.mjs +11 -2
- package/install-assembled-app.mjs +423 -0
- package/inventory-check.mjs +11 -8
- package/ios-dev.mjs +70 -3
- package/ios-upload.mjs +20 -15
- package/linux-artifacts.mjs +93 -0
- package/local-publish.mjs +224 -53
- package/local-qualify.mjs +5 -3
- package/local-runtime.mjs +5 -4
- package/mac-package-recipe.mjs +4 -1
- package/managed-cargo.mjs +47 -5
- package/managed-root-guard.mjs +34 -0
- package/mirror-root-artifact.mjs +18 -5
- package/native-helpers.mjs +355 -0
- package/ownership-class-rules.mjs +1 -1
- package/ownership-evidence.mjs +37 -13
- package/ownership-hooks.mjs +20 -59
- package/ownership-lockfiles.mjs +14 -1
- package/ownership-policy-bundle.mjs +3 -2
- package/ownership-staged.mjs +2 -1
- package/ownership.mjs +24 -0
- package/package-recipe-targets.mjs +84 -10
- package/package.json +4 -3
- package/pipeline-admission.mjs +4 -4
- package/pipeline-receipts.mjs +302 -302
- package/preflight.mjs +27 -4
- package/project-type.mjs +2 -2
- package/provenance-verify.mjs +115 -0
- package/publish-cargo.mjs +12 -9
- package/qa-admission-policy.json +10 -1
- package/recipe-build.mjs +106 -14
- package/release-artifact-path.mjs +341 -0
- package/release-config.mjs +1 -1
- package/release-invocation.mjs +8 -1
- package/release-qa-command.mjs +25 -9
- package/release-source-brand.mjs +17 -0
- package/release-source-identity.mjs +171 -0
- package/release.mjs +59 -22
- package/rightkit-versions.json +73 -47
- package/runtime-budgets.mjs +6 -2
- package/scan-digest.mjs +71 -5
- package/sign-updater.mjs +6 -3
- package/snapshot-broker.mjs +172 -0
- package/snapshot-check.mjs +312 -0
- package/snapshot-git.mjs +166 -0
- package/snapshot-request.mjs +363 -0
- package/snapshot-root-config.mjs +76 -0
- package/snapshot.mjs +969 -0
- package/source-split.mjs +73 -0
- package/stage-commands.mjs +60 -0
- package/stage-ledger.mjs +204 -0
- package/stage-sidecar-command.mjs +5 -1
- package/stage-sidecar.mjs +157 -27
- package/swap-helper-bundle.mjs +5 -2
- package/test-command.mjs +17 -4
- package/unit-gate.mjs +301 -0
- package/web-build.mjs +40 -6
- 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
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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", "--",
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/qa-admission-policy.json
CHANGED
|
@@ -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
|
-
/**
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
185
|
-
const
|
|
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
|
-
|
|
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) {
|