@rightkit/release 0.2.173 → 0.2.174

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.
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env node
2
+ // `right-release recipe-build --plan <json>` (bogusyogi/rightkit#345): the single prePackage command that a declarative
3
+ // winPackage / macPackage target expands to. release.mjs runs one prePackage command; a real Tauri app needs several
4
+ // steps before and after `tauri build` (stage ffmpeg / pdfium / sidecars, then the build, then for macOS embed a dylib
5
+ // before release.mjs signs). This runs them in order from a validated plan, with argv arrays and no shell, and fails
6
+ // closed on the first step that does not exit 0. It never signs; release.mjs signs what it leaves behind.
7
+ //
8
+ // Plan: { version: 1, steps: [{ phase: "prepare" | "build" | "postBuild", cmd, args?, cwd?, env? }], cleanup?, stability? }
9
+ // `cwd` is relative to the app root (default the app root). Phases must appear in order prepare, build, postBuild, and
10
+ // exactly one build step is required.
11
+ //
12
+ // Built-in steps (no app script needed), all paths relative to the app root:
13
+ // cleanup: { remove?: [file or dir], apps?: { dir, remove?: bool, unregister?: bool }, dmgVolumes?: [name prefix] }
14
+ // runs BEFORE the first step. `remove` deletes stale artifacts (a stale raw EXE or installer must never be
15
+ // mistaken for this build's output). `apps` finds stale `*.app` bundles in `dir`, unregisters them with
16
+ // `lsregister -u` and/or deletes them. `dmgVolumes` detaches /Volumes/<name> and /Volumes/<name> <N> (the macOS
17
+ // duplicate suffix) only when `hdiutil info -plist` shows the volume is a mounted disk image; `-force` is used
18
+ // only on retry. The macOS-only parts (apps unregister, dmgVolumes) are skipped off darwin.
19
+ // stability: { files: [file], intervalMs?, checks?, timeoutMs? } runs right AFTER the build step: each file must exist
20
+ // and keep the same size and mtime over `checks` consecutive polls `intervalMs` apart (defaults 2 and 1000 ms,
21
+ // timeout 120 s), so a hung or still-writing Tauri process cannot hand a half-written EXE to signing.
22
+ import { existsSync as nodeExistsSync, readFileSync, readdirSync as nodeReaddirSync, rmSync as nodeRmSync, statSync as nodeStatSync } from "node:fs";
23
+ import path from "node:path";
24
+ import { pathToFileURL } from "node:url";
25
+ import { spawnSync as nodeSpawnSync } from "./background-process.mjs";
26
+
27
+ export const RECIPE_BUILD_PLAN_VERSION = 1;
28
+ export const RECIPE_PHASES = Object.freeze(["prepare", "build", "postBuild"]);
29
+
30
+ export class RecipeBuildError extends Error {
31
+ constructor(code, message, details = {}) {
32
+ super(message);
33
+ this.name = "RecipeBuildError";
34
+ this.code = code;
35
+ this.details = details;
36
+ }
37
+ }
38
+
39
+ const fail = (code, message, details) => { throw new RecipeBuildError(code, message, details); };
40
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
41
+ const STEP_KEYS = new Set(["phase", "cmd", "args", "cwd", "env"]);
42
+ const PLAN_KEYS = new Set(["version", "steps", "cleanup", "stability"]);
43
+ const CLEANUP_KEYS = new Set(["remove", "apps", "dmgVolumes"]);
44
+ const APPS_KEYS = new Set(["dir", "remove", "unregister"]);
45
+ const STABILITY_KEYS = new Set(["files", "intervalMs", "checks", "timeoutMs"]);
46
+ const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
47
+ const unknownKeys = (value, allowed, label, errors) => { for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`${label}: unknown key ${JSON.stringify(key)} (allowed: ${[...allowed].join(", ")})`); };
48
+ const positiveInt = (value) => Number.isInteger(value) && value > 0;
49
+ const badVolumePrefix = (item) => typeof item !== "string" || !item.trim() || item === "." || item === ".." || item.includes("/") || item.includes("\\") || item.includes(":") || item.includes("\0");
50
+
51
+ /** A relative path that stays inside the app root; returns the normalized form or undefined and pushes an error. */
52
+ function insideApp(value, label, errors, { strict = false } = {}) {
53
+ if (typeof value !== "string" || !value.trim()) { errors.push(`${label} must be a non-empty relative path`); return undefined; }
54
+ const normalized = value.replaceAll("\\", "/");
55
+ 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; }
56
+ const resolved = path.posix.normalize(normalized);
57
+ 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; }
58
+ if (resolved === "/" || resolved.startsWith("../") || resolved === "..") { errors.push(`${label} must name a path strictly inside the app, not the app root or an ancestor: ${JSON.stringify(value)}`); return undefined; }
59
+ return normalized;
60
+ }
61
+
62
+ function validateCleanup(value, errors) {
63
+ if (value === undefined) return undefined;
64
+ if (!isObject(value)) { errors.push("cleanup must be an object"); return undefined; }
65
+ unknownKeys(value, CLEANUP_KEYS, "cleanup", errors);
66
+ const out = {};
67
+ if (value.remove !== undefined) {
68
+ if (!Array.isArray(value.remove)) errors.push("cleanup.remove must be a list of relative paths");
69
+ else out.remove = value.remove.map((item, index) => insideApp(item, `cleanup.remove[${index}]`, errors, { strict: true })).filter(Boolean);
70
+ }
71
+ if (value.apps !== undefined) {
72
+ if (!isObject(value.apps)) errors.push("cleanup.apps must be an object");
73
+ else {
74
+ unknownKeys(value.apps, APPS_KEYS, "cleanup.apps", errors);
75
+ 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`);
76
+ const dir = insideApp(value.apps.dir, "cleanup.apps.dir", errors, { strict: true });
77
+ if (dir) out.apps = { dir, remove: value.apps.remove === true, unregister: value.apps.unregister === true };
78
+ }
79
+ }
80
+ if (value.dmgVolumes !== undefined) {
81
+ if (!Array.isArray(value.dmgVolumes) || value.dmgVolumes.some(badVolumePrefix)) errors.push("cleanup.dmgVolumes must be a list of non-empty volume name prefixes (no path separators)");
82
+ else out.dmgVolumes = [...value.dmgVolumes];
83
+ }
84
+ return out;
85
+ }
86
+
87
+ function validateStability(value, errors) {
88
+ if (value === undefined) return undefined;
89
+ if (!isObject(value)) { errors.push("stability must be an object"); return undefined; }
90
+ unknownKeys(value, STABILITY_KEYS, "stability", errors);
91
+ for (const key of ["intervalMs", "checks", "timeoutMs"]) if (value[key] !== undefined && !positiveInt(value[key])) errors.push(`stability.${key} must be a positive integer`);
92
+ if (!Array.isArray(value.files) || !value.files.length) { errors.push("stability.files must be a non-empty list of relative paths"); return undefined; }
93
+ const files = value.files.map((item, index) => insideApp(item, `stability.files[${index}]`, errors, { strict: true })).filter(Boolean);
94
+ return { files, ...(value.intervalMs === undefined ? {} : { intervalMs: value.intervalMs }), ...(value.checks === undefined ? {} : { checks: value.checks }), ...(value.timeoutMs === undefined ? {} : { timeoutMs: value.timeoutMs }) };
95
+ }
96
+
97
+ /** Validate a plan strictly; every problem is reported at once as RECIPE_BUILD_PLAN_INVALID. Returns the normalized plan. */
98
+ export function validateRecipePlan(plan) {
99
+ const errors = [];
100
+ if (!isObject(plan)) fail("RECIPE_BUILD_PLAN_INVALID", "recipe plan must be an object");
101
+ for (const key of Object.keys(plan)) if (!PLAN_KEYS.has(key)) errors.push(`unknown key ${JSON.stringify(key)}`);
102
+ if (plan.version !== RECIPE_BUILD_PLAN_VERSION) errors.push(`version must be ${RECIPE_BUILD_PLAN_VERSION}`);
103
+ if (!Array.isArray(plan.steps) || !plan.steps.length) errors.push("steps must be a non-empty array");
104
+ const steps = [];
105
+ let lastPhase = -1;
106
+ let builds = 0;
107
+ for (const [index, step] of (Array.isArray(plan.steps) ? plan.steps : []).entries()) {
108
+ const label = `steps[${index}]`;
109
+ if (!isObject(step)) { errors.push(`${label} must be an object`); continue; }
110
+ for (const key of Object.keys(step)) if (!STEP_KEYS.has(key)) errors.push(`${label}: unknown key ${JSON.stringify(key)} (allowed: ${[...STEP_KEYS].join(", ")})`);
111
+ const phase = RECIPE_PHASES.indexOf(step.phase);
112
+ if (phase < 0) errors.push(`${label}.phase must be one of ${RECIPE_PHASES.join(", ")}`);
113
+ else if (phase < lastPhase) errors.push(`${label}: phase ${step.phase} comes after a later phase (order: ${RECIPE_PHASES.join(", ")})`);
114
+ else lastPhase = phase;
115
+ if (step.phase === "build") builds += 1;
116
+ if (typeof step.cmd !== "string" || !step.cmd.trim()) errors.push(`${label}.cmd must be a non-empty string`);
117
+ const args = step.args ?? [];
118
+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string")) errors.push(`${label}.args must be a list of strings`);
119
+ const cwd = step.cwd === undefined ? undefined : insideApp(step.cwd, `${label}.cwd`, errors);
120
+ if (step.env !== undefined && (!isObject(step.env) || Object.values(step.env).some((value) => typeof value !== "string"))) errors.push(`${label}.env must map names to strings`);
121
+ steps.push({ phase: step.phase, cmd: step.cmd, args: Array.isArray(args) ? [...args] : [], ...(cwd ? { cwd } : {}), ...(isObject(step.env) ? { env: { ...step.env } } : {}) });
122
+ }
123
+ if (builds !== 1) errors.push(`exactly one build step is required, found ${builds}`);
124
+ const cleanup = validateCleanup(plan.cleanup, errors);
125
+ const stability = validateStability(plan.stability, errors);
126
+ if (errors.length) fail("RECIPE_BUILD_PLAN_INVALID", `invalid recipe plan:\n${errors.map((line) => `- ${line}`).join("\n")}`, { errors });
127
+ return { version: RECIPE_BUILD_PLAN_VERSION, steps, ...(cleanup ? { cleanup } : {}), ...(stability ? { stability } : {}) };
128
+ }
129
+
130
+ // cmd.exe would reinterpret these in a shell launch; refuse rather than escape.
131
+ const SHELL_UNSAFE = /[&|<>^%"!\r\n]/;
132
+
133
+ /**
134
+ * How to launch one step without a shell. `pnpm` runs through the pinned pnpm CLI (npm_execpath, set whenever the
135
+ * release runs under pnpm). On Windows a non-.exe command is a .cmd shim that only a shell can start, so it is allowed
136
+ * only when no argument could be reinterpreted by that shell.
137
+ */
138
+ export function resolveLaunch({ cmd, args = [], platform = process.platform, env = process.env, execPath = process.execPath }) {
139
+ if (cmd === "pnpm" && env.npm_execpath) return { command: execPath, args: [env.npm_execpath, ...args], shell: false };
140
+ if (platform === "win32" && !/\.(?:exe|com)$/i.test(cmd)) {
141
+ 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 });
142
+ return { command: cmd, args, shell: true };
143
+ }
144
+ return { command: cmd, args, shell: false };
145
+ }
146
+
147
+ const defaultSleep = (ms) => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); };
148
+ const defaultFs = { existsSync: nodeExistsSync, readdirSync: nodeReaddirSync, rmSync: nodeRmSync };
149
+
150
+ /**
151
+ * Mount points of mounted disk images in `hdiutil info -plist` output: a mount-point counts only when it follows an
152
+ * `image-path` key (so it belongs to an image). Anything unparsable yields an empty set, so nothing is detached.
153
+ */
154
+ export function diskImageMountPoints(plistText) {
155
+ const mounts = new Set();
156
+ const segments = String(plistText).split("<key>image-path</key>").slice(1);
157
+ for (const segment of segments) {
158
+ for (const match of segment.matchAll(/<key>mount-point<\/key>\s*<string>([^<]*)<\/string>/g)) mounts.add(match[1].replaceAll("&amp;", "&"));
159
+ }
160
+ return mounts;
161
+ }
162
+
163
+ /**
164
+ * Stale-artifact cleanup, run before the build. Returns what it did: { removed, unregistered, detached }.
165
+ * A failing detach or unregister is logged and skipped: the build's own gates decide pass or fail.
166
+ * Options: appRoot, platform, log, spawnSync, fs ({ existsSync, readdirSync, rmSync }), volumesDir.
167
+ */
168
+ export function runRecipeCleanup(cleanup, { appRoot, platform = process.platform, log = () => {}, spawnSync = nodeSpawnSync, fs = defaultFs, volumesDir = "/Volumes" } = {}) {
169
+ const result = { removed: [], unregistered: [], detached: [] };
170
+ if (!cleanup) return result;
171
+ const tool = (cmd, args) => {
172
+ const run = spawnSync(cmd, args, { stdio: "inherit", windowsHide: true });
173
+ const ok = !(run?.error || run?.status !== 0);
174
+ if (!ok) log(`recipe-build: cleanup ${cmd} ${args.join(" ")} did not succeed (${run?.error?.message ?? `exit ${run?.status ?? "unknown"}`}); continuing`);
175
+ return ok;
176
+ };
177
+ if (cleanup.dmgVolumes?.length && platform !== "darwin") log("recipe-build: cleanup dmgVolumes skipped (not macOS)");
178
+ else {
179
+ // Only a volume named exactly `<name>` or `<name> <N>` (the macOS duplicate suffix) that `hdiutil info -plist` reports as
180
+ // a mounted disk image is detached; a real disk, a network share or a similarly named folder is never touched.
181
+ const candidatesFor = (names) => {
182
+ const wanted = cleanup.dmgVolumes ?? [];
183
+ return names.filter((entry) => wanted.some((volName) => entry === volName || (entry.startsWith(`${volName} `) && /^[0-9]+$/.test(entry.slice(volName.length + 1))))).sort();
184
+ };
185
+ let names = [];
186
+ try { names = fs.readdirSync(volumesDir); } catch { names = []; }
187
+ const candidates = candidatesFor(names);
188
+ if (candidates.length) {
189
+ const info = spawnSync("hdiutil", ["info", "-plist"], { encoding: "utf8", windowsHide: true });
190
+ const imageMounts = info?.error || info?.status !== 0 ? null : diskImageMountPoints(String(info.stdout ?? ""));
191
+ if (!imageMounts) log("recipe-build: cleanup dmgVolumes skipped (hdiutil info -plist failed, so no volume is confirmed to be a disk image)");
192
+ else {
193
+ for (const name of candidates) {
194
+ const volume = path.posix.join(volumesDir, name);
195
+ if (!imageMounts.has(volume)) { log(`recipe-build: cleanup leaves ${volume} (not a mounted disk image)`); continue; }
196
+ log(`recipe-build: cleanup detach ${volume}`);
197
+ // No -force first; -force only on retry, and only for a volume confirmed to be an image mount.
198
+ if (tool("hdiutil", ["detach", volume]) || tool("hdiutil", ["detach", "-force", volume])) result.detached.push(name);
199
+ }
200
+ }
201
+ }
202
+ }
203
+ if (cleanup.apps) {
204
+ const dir = path.resolve(appRoot, cleanup.apps.dir);
205
+ let entries = [];
206
+ try { entries = fs.readdirSync(dir).filter((entry) => entry.toLowerCase().endsWith(".app")).sort(); } catch { entries = []; }
207
+ for (const entry of entries) {
208
+ const stale = path.join(dir, entry);
209
+ const root = path.resolve(appRoot);
210
+ const rel = path.relative(stale, root);
211
+ if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)) || (dir === root && entry.toLowerCase() === "notarize.app")) {
212
+ fail("RECIPE_BUILD_CLEANUP_UNSAFE", `cleanup refuses ${stale}: it is, or contains, the app root ${root}`);
213
+ }
214
+ if (cleanup.apps.unregister) {
215
+ if (platform !== "darwin") log("recipe-build: cleanup lsregister skipped (not macOS)");
216
+ else if (tool(LSREGISTER, ["-u", stale])) result.unregistered.push(stale);
217
+ }
218
+ if (cleanup.apps.remove) {
219
+ log(`recipe-build: cleanup remove stale app ${stale}`);
220
+ fs.rmSync(stale, { recursive: true, force: true });
221
+ result.removed.push(stale);
222
+ }
223
+ }
224
+ }
225
+ for (const relative of cleanup.remove ?? []) {
226
+ const stale = path.resolve(appRoot, relative);
227
+ if (!fs.existsSync(stale)) continue;
228
+ log(`recipe-build: cleanup remove stale ${stale}`);
229
+ fs.rmSync(stale, { recursive: true, force: true });
230
+ result.removed.push(stale);
231
+ }
232
+ return result;
233
+ }
234
+
235
+ /**
236
+ * Wait until every file exists and keeps the same size and mtime over `checks` consecutive polls `intervalMs` apart.
237
+ * Throws RECIPE_BUILD_ARTIFACT_UNSTABLE when that does not happen within `timeoutMs`. Returns the absolute files.
238
+ * Options: appRoot, log, statSync, sleep(ms), now().
239
+ */
240
+ export function waitForStableArtifacts(stability, { appRoot, log = () => {}, statSync = nodeStatSync, sleep = defaultSleep, now = Date.now } = {}) {
241
+ if (!stability) return [];
242
+ const { intervalMs = 1000, checks = 2, timeoutMs = 120000 } = stability;
243
+ const signature = (file) => { try { const stat = statSync(file); return `${stat.size}:${stat.mtimeMs}`; } catch { return null; } };
244
+ const files = stability.files.map((relative) => path.resolve(appRoot, relative));
245
+ const deadline = now() + timeoutMs;
246
+ let previous = files.map(signature);
247
+ let stable = 0;
248
+ while (stable < checks) {
249
+ if (now() >= deadline) fail("RECIPE_BUILD_ARTIFACT_UNSTABLE", `artifact missing or still changing after ${timeoutMs} ms: ${files.join(", ")}`, { files });
250
+ sleep(intervalMs);
251
+ const current = files.map(signature);
252
+ stable = current.every((value, index) => value !== null && value === previous[index]) ? stable + 1 : 0;
253
+ previous = current;
254
+ }
255
+ log(`recipe-build: artifacts stable (${files.length} file(s), ${checks} check(s) ${intervalMs} ms apart)`);
256
+ return files;
257
+ }
258
+
259
+ /**
260
+ * Run the plan: cleanup, prepare steps, the build step, stability polling, then postBuild steps, strictly in order.
261
+ * Returns { steps: [{ phase, cmd, status }], cleaned, stable }. The first failure throws RECIPE_BUILD_STEP_FAILED and
262
+ * nothing after it runs.
263
+ * Options: appRoot (absolute), env, platform, log, spawnSync, fs, sleep, now, statSync (injected in tests).
264
+ */
265
+ export function runRecipeBuild(rawPlan, { appRoot, env = process.env, platform = process.platform, log = () => {}, spawnSync = nodeSpawnSync, fs, sleep, now, statSync } = {}) {
266
+ if (typeof appRoot !== "string" || !path.isAbsolute(appRoot)) fail("RECIPE_BUILD_PLAN_INVALID", "appRoot must be an absolute path");
267
+ const plan = validateRecipePlan(rawPlan);
268
+ const done = [];
269
+ const cleaned = runRecipeCleanup(plan.cleanup, { appRoot, platform, log, spawnSync, ...(fs ? { fs } : {}) });
270
+ let stable = [];
271
+ for (const [index, step] of plan.steps.entries()) {
272
+ const launch = resolveLaunch({ cmd: step.cmd, args: step.args, platform, env });
273
+ const cwd = path.resolve(appRoot, step.cwd ?? ".");
274
+ log(`recipe-build: [${step.phase}] ${step.cmd} ${step.args.join(" ")}`.trimEnd());
275
+ const result = spawnSync(launch.command, launch.args, { cwd, env: { ...env, ...(step.env ?? {}) }, stdio: "inherit", shell: launch.shell, windowsHide: true });
276
+ if (result?.error || result?.status !== 0) {
277
+ fail("RECIPE_BUILD_STEP_FAILED", `recipe-build ${step.phase} step ${index} (${step.cmd}) failed (${result?.error?.message ?? `exit ${result?.status ?? result?.signal ?? "unknown"}`}); later steps were not run`, { phase: step.phase, index, cmd: step.cmd, status: result?.status ?? null });
278
+ }
279
+ done.push({ phase: step.phase, cmd: step.cmd, status: 0 });
280
+ if (step.phase === "build") stable = waitForStableArtifacts(plan.stability, { appRoot, log, ...(sleep ? { sleep } : {}), ...(now ? { now } : {}), ...(statSync ? { statSync } : {}) });
281
+ }
282
+ return { steps: done, cleaned, stable };
283
+ }
284
+
285
+ export function parseRecipeBuildArgs(argv) {
286
+ const opts = { plan: undefined, planFile: undefined, appRoot: undefined };
287
+ for (let i = 0; i < argv.length; i += 1) {
288
+ const arg = argv[i];
289
+ if (arg === "--plan" || arg === "--plan-file" || arg === "--app-root") {
290
+ const value = argv[i + 1];
291
+ if (value === undefined || (arg !== "--plan" && value.startsWith("--"))) throw new Error(`${arg} needs a value`);
292
+ opts[arg === "--plan" ? "plan" : arg === "--plan-file" ? "planFile" : "appRoot"] = value;
293
+ i += 1;
294
+ } else throw new Error(`unknown recipe-build argument: ${arg}`);
295
+ }
296
+ if ((opts.plan === undefined) === (opts.planFile === undefined)) throw new Error("recipe-build needs exactly one of --plan <json> or --plan-file <path>");
297
+ return opts;
298
+ }
299
+
300
+ /**
301
+ * The commands a build command really runs. A declarative winPackage / macPackage wraps the Tauri build as
302
+ * `node recipe-build.mjs --plan <JSON>` (or `--plan-file <path>`), which hides each step's `--features` inside one JSON
303
+ * argument. The forbidden-feature scan and the cargo-tree closure must see every step, so this returns
304
+ * `[command, ...every prepare / build / postBuild step as { cmd, args, env? }]` (nested wrappers are expanded too), and
305
+ * `[command]` for any other command. A wrapper whose plan cannot be read or validated throws (fail closed).
306
+ * Options: root (the app root, for --plan-file and --app-root), readFile (injected in tests).
307
+ */
308
+ export function expandRecipeBuildCommands(command, { root = process.cwd(), readFile = readFileSync, depth = 0 } = {}) {
309
+ if (command === undefined || command === null) return [];
310
+ const parts = typeof command === "string"
311
+ ? (command.match(/"[^"]*"|'[^']*'|[^\s"']+/g) ?? []).map((token) => token.replace(/^["']|["']$/g, ""))
312
+ : [command?.cmd, ...(command?.args ?? [])].filter((value) => value !== undefined).map(String);
313
+ const at = parts.findIndex((part) => path.basename(part.replaceAll("\\", "/")) === "recipe-build.mjs" || part === "recipe-build");
314
+ if (at < 0) return [command];
315
+ const rest = parts.slice(at + 1);
316
+ if (!rest.some((arg) => arg === "--plan" || arg === "--plan-file")) return [command];
317
+ if (depth >= 3) fail("RECIPE_BUILD_PLAN_INVALID", "recipe-build plans are nested too deeply to scan");
318
+ let plan;
319
+ try {
320
+ const opts = parseRecipeBuildArgs(rest);
321
+ const text = opts.plan ?? readFile(path.resolve(path.resolve(root, opts.appRoot ?? "."), opts.planFile), "utf8");
322
+ plan = validateRecipePlan(JSON.parse(text));
323
+ } catch (error) {
324
+ if (error instanceof RecipeBuildError) throw error;
325
+ fail("RECIPE_BUILD_PLAN_INVALID", `cannot scan the recipe-build command (${error.message}); every step must be visible to the forbidden-feature scan`);
326
+ }
327
+ const steps = plan.steps.map(({ cmd, args, env }) => ({ cmd, args, ...(env ? { env } : {}) }));
328
+ return [command, ...steps.flatMap((step) => expandRecipeBuildCommands(step, { root, readFile, depth: depth + 1 }))];
329
+ }
330
+
331
+ async function main() {
332
+ const opts = parseRecipeBuildArgs(process.argv.slice(2));
333
+ const appRoot = path.resolve(opts.appRoot ?? process.cwd());
334
+ let text = opts.plan;
335
+ if (text === undefined) {
336
+ const { readFileSync } = await import("node:fs");
337
+ text = readFileSync(path.resolve(appRoot, opts.planFile), "utf8");
338
+ }
339
+ let plan;
340
+ try { plan = JSON.parse(text); } catch (error) { throw new RecipeBuildError("RECIPE_BUILD_PLAN_INVALID", `the plan is not JSON: ${error.message}`); }
341
+ runRecipeBuild(plan, { appRoot, log: (line) => console.log(line) });
342
+ }
343
+
344
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
345
+ main().catch((error) => { console.error(`right-release recipe-build: ${error.code ? `[${error.code}] ` : ""}${error.message}`); process.exitCode = 1; });
346
+ }
package/release.mjs CHANGED
@@ -46,6 +46,7 @@ import { DEFAULT_STORAGE_THRESHOLD_BYTES, findInstallerCopyScripts, findLargeRep
46
46
  import { macosCodesignEnvironment } from "./codesign-environment.mjs";
47
47
  import { assertNoQaControl, assertNoWebBundleMocks } from "./qa-control-scan.mjs";
48
48
  import { assertNoForbiddenBuildFeatures } from "./qa-build-features.mjs";
49
+ import { expandRecipeBuildCommands } from "./recipe-build.mjs";
49
50
  import { assertNoReleaseSwitches, commandTokens, resolveFeatureClosure } from "./release-switch-scan.mjs";
50
51
  import { devtoolsEvidence, resolveDevtoolsAllowance } from "./devtools-policy.mjs";
51
52
  import { runAndroidRelease, validateAndroidTarget } from "./android-release.mjs";
@@ -356,7 +357,7 @@ try {
356
357
  { label: "target.package", command: target.package },
357
358
  { label: "nativeAssembly.packageHook", command: nativeAssembly?.packageHook },
358
359
  { label: "nativeAssembly.finalizer", command: nativeFinalizer },
359
- { label: "target.postSign", command: target.postSign },
360
+ ...commandList(target.postSign).map((command, index) => ({ label: Array.isArray(target.postSign) ? `target.postSign[${index}]` : "target.postSign", command })),
360
361
  { label: "target.gradle", command: target.gradle },
361
362
  ] });
362
363
  } catch (error) { fail(error.message); }
@@ -370,6 +371,9 @@ try {
370
371
  const releaseSwitchInventoryPath = path.resolve(root, config.releaseSwitches?.inventory ?? "switches.json");
371
372
  const releaseSwitchInventoryText = existsSync(releaseSwitchInventoryPath) ? readFileSync(releaseSwitchInventoryPath, "utf8") : null;
372
373
  try {
374
+ // A `node recipe-build.mjs --plan <JSON>` prePackage hides each step's `--features` in one argument: the closure and the
375
+ // command token scan see every prepare / build / postBuild step (a plan that cannot be read or validated fails closed).
376
+ const closureCommands = [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, ...commandList(target.postSign), target.gradle].filter(Boolean).flatMap((c) => expandRecipeBuildCommands(c, { root }));
373
377
  let treeText;
374
378
  try {
375
379
  const closure = resolveFeatureClosure({
@@ -379,7 +383,7 @@ try {
379
383
  rust: config.releaseSwitches?.rust,
380
384
  features: config.releaseSwitches?.features ?? [],
381
385
  triple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget,
382
- commandTexts: [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, target.postSign, target.gradle],
386
+ commandTexts: closureCommands,
383
387
  });
384
388
  if (closure.skipped) console.log(`right-release: feature closure skipped (${closure.skipped})`);
385
389
  else treeText = closure.treeText;
@@ -388,7 +392,7 @@ try {
388
392
  console.log(`right-release: dry run: ${error.message}`);
389
393
  }
390
394
  // remoteHost: rightkit-daemon `remote` is refused in the closure unless the app declares releaseSwitches.remoteHost: true.
391
- const closureInput = { treeText, remoteHost: config.releaseSwitches?.remoteHost, commands: [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, target.postSign, target.gradle].filter(Boolean).map((c) => commandTokens(c)) };
395
+ const closureInput = { treeText, remoteHost: config.releaseSwitches?.remoteHost, commands: closureCommands.map((c) => commandTokens(c)) };
392
396
  if (releaseSwitchInventoryText != null) {
393
397
  assertNoReleaseSwitches({ ...closureInput, sourceRoot: root, inventoryText: releaseSwitchInventoryText, appForbid: config.releaseSwitches?.forbid ?? [] }, "source switch inventory");
394
398
  } else if (treeText != null || closureInput.commands.length) {
@@ -494,7 +498,7 @@ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT)
494
498
  assertNoQaControlBinaries("macOS app staged for the DMG", [stagedApp], { requireBinary: true, prePack: true });
495
499
  }
496
500
  if (runMacPhase("sign")) {
497
- await signMacos(signingEntries, root, "signing", macSigning.nestedCode);
501
+ await signMacos(signingEntries, root, "signing", macSigning.nestedCode, macSigning.nestedSigning);
498
502
  if (sidecarPlan && !opts.dryRun) {
499
503
  const app = signingEntries.find((entry) => /\.app\/?$/.test(entry.file));
500
504
  if (!app) fail("mediaSidecar on macOS needs an .app entry in sign.prePackageFiles");
@@ -563,14 +567,14 @@ if (opts.platform === "win" && target.signingContract === WINDOWS_NSIS_SIGNING_C
563
567
  // and still installs a binary Windows rejects.
564
568
  await verifyWindowsEmbedding({ root, target, files, markers: bundleMarkerReceipts });
565
569
  await signWindows(files, "installer", root);
566
- if (target.postSign) await runCommand(target.postSign, root);
570
+ if (target.postSign) await runCommands(target.postSign, root);
567
571
  const updaterFiles = [...new Set((target.updater?.artifacts ?? []).map((artifact) => resolveReleaseArtifactPath(root, artifact.file)))];
568
572
  if (!updaterFiles.length) fail(`${config.app} win must declare updater.artifacts for post-code-signing minisign`);
569
573
  await run("node", [SIGN_UPDATER, ...(opts.dryRun ? ["--dry-run"] : []), ...(target.updater?.keyFile ? ["--key-file", String(target.updater.keyFile)] : []), ...updaterFiles.flatMap((file) => ["--file", file])], root);
570
574
  }
571
575
 
572
576
  if (target.postSign && opts.platform !== "win" && (opts.platform !== "mac" || runMacPhase("post-sign"))) {
573
- await runCommand(target.postSign, root);
577
+ await runCommands(target.postSign, root);
574
578
  }
575
579
 
576
580
  if (opts.platform === "mac") {
@@ -933,6 +937,17 @@ async function runPackageScript(pm, script, cwd) {
933
937
  await run(pm, ["run", script], cwd);
934
938
  }
935
939
 
940
+ // A hook may be one command object or an ordered array of them (e.g. create-mac-updater then lsclean).
941
+ function commandList(value) {
942
+ if (value == null) return [];
943
+ return Array.isArray(value) ? value : [value];
944
+ }
945
+
946
+ // Runs in order; run() exits the process on the first failure, so the chain fails closed.
947
+ async function runCommands(value, root, options) {
948
+ for (const command of commandList(value)) await runCommand(command, root, options);
949
+ }
950
+
936
951
  async function runCommand(command, root, { env = {}, captureStdout = false } = {}) {
937
952
  const cwd = path.resolve(root, command.cwd ?? ".");
938
953
  return run(command.cmd, command.args ?? [], cwd, { ...(await commandEnv(command, root)), ...env }, {
@@ -1462,12 +1477,12 @@ function macSignInvocation(items, receiptPath, extra = []) {
1462
1477
  return [SIGN_MACOS, ...extra, "--receipt", receiptPath, "--manifest", manifest];
1463
1478
  }
1464
1479
 
1465
- async function signMacos(entries, root, phase = "signing", nestedCode = []) {
1480
+ async function signMacos(entries, root, phase = "signing", nestedCode = [], nestedSigning) {
1466
1481
  const files = entries.map((entry) => entry.file);
1467
1482
  const receipt = phase === "signing" ? macosReceiptPath(root, "signing") : path.resolve(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(root, ".right-release", "receipts"), `macos-${phase}.json`);
1468
1483
  // Directory entries (.app bundles): nested code first (default settings unless sign.nestedCode names it), then the bundle itself.
1469
1484
  let nested = [];
1470
- try { ({ nested } = opts.dryRun ? { nested: [] } : planMacSigning({ entries, nestedCode })); } catch (error) { fail(error.message); }
1485
+ try { ({ nested } = opts.dryRun ? { nested: [] } : planMacSigning({ entries, nestedCode, nestedSigning, root })); } catch (error) { fail(error.message); }
1471
1486
  if (opts.dryRun && (nestedCode.length || entries.some(hasNonDefaultSettings))) {
1472
1487
  console.log(`right-release: dry-run: per-file signing matrix (${nestedCode.length} nestedCode rule(s), ${entries.filter(hasNonDefaultSettings).length} entry setting(s)); nested code is resolved against the built bundle at sign time`);
1473
1488
  }
@@ -11,6 +11,11 @@ import { parseCodesignArgv } from "./codesign-argv.mjs";
11
11
 
12
12
  export const AGENT_LABEL = "dev.rightkit.signing-agent";
13
13
  export const MAX_FRAME = 1024 * 1024;
14
+ // A typed codesign `--identifier` (reverse-DNS characters only; never starts with "-", so it cannot read as an option).
15
+ export const AGENT_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9.-]{0,254}$/;
16
+ // Request schema 2 is codesign-only and is the sole schema that may carry per-item `identifier`. Schema 1 stays valid and
17
+ // byte-compatible for everything else; an agent that predates schema 2 refuses it (and any unknown item field) outright.
18
+ export const CODESIGN_IDENTIFIER_SCHEMA = 2;
14
19
  export const NOTARIZATION_TIMEOUT_MS = 1800000;
15
20
  export const MAX_NOTARIZATION_TIMEOUT_MS = 7200000;
16
21
  export class SigningAgentError extends Error {
@@ -72,7 +77,7 @@ export function validateSigningRequest(request, { roots, stagingRoot, io = fs }
72
77
  const notary = ["notarize", "staple"].includes(request?.type);
73
78
  if (!["codesign", "codesign-argv", "updater", "notarize", "staple"].includes(request?.type)) fail("MAC_SIGNING_AGENT_PROTOCOL", "Unsupported signing request type");
74
79
  shape(request, [...common, ...(notary ? ["path", "profile", "staple", "app", "validateOnly"] : request.type === "codesign-argv" ? ["argv", "targets"] : [request.type === "codesign" ? "items" : "files"])]);
75
- if (request.schema !== 1 || !Number.isInteger(request.timeoutMs) || request.timeoutMs < 1 || request.timeoutMs > (notary ? MAX_NOTARIZATION_TIMEOUT_MS : 180000)) {
80
+ if (!(request.schema === 1 || (request.type === "codesign" && request.schema === CODESIGN_IDENTIFIER_SCHEMA)) || !Number.isInteger(request.timeoutMs) || request.timeoutMs < 1 || request.timeoutMs > (notary ? MAX_NOTARIZATION_TIMEOUT_MS : 180000)) {
76
81
  fail("MAC_SIGNING_AGENT_PROTOCOL", "Invalid schema or signing deadline");
77
82
  }
78
83
  if (!Array.isArray(request.allowedRoots) || !request.allowedRoots.length || request.allowedRoots.length > 32) fail("MAC_SIGNING_AGENT_PATH_DENIED", "Allowed roots required");
@@ -142,7 +147,8 @@ export function validateSigningRequest(request, { roots, stagingRoot, io = fs }
142
147
  }
143
148
  if (!Array.isArray(request.items) || !request.items.length || request.items.length > 4096) fail("MAC_SIGNING_AGENT_PROTOCOL", "Codesign items required");
144
149
  const items = request.items.map((item) => {
145
- shape(item, ["path", "identity", "entitlements", "runtime", "timestamp", "force", "preserveEntitlements"]);
150
+ shape(item, ["path", "identity", "entitlements", "runtime", "timestamp", "force", "preserveEntitlements", ...(request.schema === CODESIGN_IDENTIFIER_SCHEMA ? ["identifier"] : [])]);
151
+ if (item.identifier !== undefined && (typeof item.identifier !== "string" || !AGENT_IDENTIFIER_PATTERN.test(item.identifier))) fail("MAC_SIGNING_AGENT_PROTOCOL", "Invalid codesign identifier");
146
152
  if (typeof item.identity !== "string" || !item.identity.trim() || item.identity === "-" || item.identity.includes("\0") || item.identity.length > 512) fail("MAC_SIGNING_AGENT_PROTOCOL", "Real signing identity required");
147
153
  for (const field of ["runtime", "timestamp", "force", "preserveEntitlements"]) if (item[field] !== undefined && typeof item[field] !== "boolean") fail("MAC_SIGNING_AGENT_PROTOCOL", "Signing options must be boolean");
148
154
  return { ...item, path: checked(item.path), ...(item.entitlements !== undefined ? { entitlements: checked(item.entitlements, { entitlements: true }) } : {}) };
@@ -156,7 +162,7 @@ export function authenticateSigningToken(candidate, token) {
156
162
  }
157
163
  export function executeSigningRequest(request, { run = spawnSync, home = os.userInfo().homedir,
158
164
  username = os.userInfo().username, pnpm, environmentPath, now = Date.now } = {}) {
159
- if (request.type === "health") return { schema: 1, ok: true, service: AGENT_LABEL, pid: process.pid, capabilities: ["codesign", "codesign-argv", "updater", "notarize", "staple"] };
165
+ if (request.type === "health") return { schema: 1, ok: true, service: AGENT_LABEL, pid: process.pid, capabilities: ["codesign", "codesign-argv", "updater", "notarize", "staple", "codesign-identifier"] };
160
166
  const deadline = now() + request.timeoutMs;
161
167
  const env = { HOME: home, PATH: environmentPath, TMPDIR: path.join(signingAgentPaths(home).directory, "tmp") };
162
168
  if (request.type === "codesign-argv") {
@@ -191,7 +197,7 @@ export function executeSigningRequest(request, { run = spawnSync, home = os.user
191
197
  // Preserve caller's inside-out ordering. No arbitrary argv reaches spawn.
192
198
  for (const item of request.items) {
193
199
  const args = [...(item.force ? ["--force"] : []), ...(item.runtime ? ["--options", "runtime"] : []),
194
- ...(item.timestamp ? ["--timestamp"] : []), "--sign", item.identity,
200
+ ...(item.timestamp ? ["--timestamp"] : []), ...(item.identifier !== undefined ? ["--identifier", item.identifier] : []), "--sign", item.identity,
195
201
  ...(item.entitlements ? ["--entitlements", item.entitlements] : []),
196
202
  ...(item.preserveEntitlements ? ["--preserve-metadata=entitlements"] : []), item.path];
197
203
  results.push(invoke("/usr/bin/codesign", args).result);
@@ -31,7 +31,7 @@
31
31
  "@rightkit/qa": "0.4.9",
32
32
  "@rightkit/qa-chrome": "0.1.8",
33
33
  "@rightkit/react-shell": "0.6.8",
34
- "@rightkit/release": "0.2.173",
34
+ "@rightkit/release": "0.2.174",
35
35
  "@rightkit/settings-ui": "0.5.0",
36
36
  "@rightkit/tauri": "0.2.1",
37
37
  "@rightkit/tauri-shell": "0.1.1",
@@ -210,7 +210,8 @@
210
210
  "0.2.163",
211
211
  "0.2.164",
212
212
  "0.2.165",
213
- "0.2.172"
213
+ "0.2.172",
214
+ "0.2.173"
214
215
  ],
215
216
  "@rightkit/qa": [
216
217
  "0.1.0",
@@ -384,7 +385,7 @@
384
385
  "rightkit-automation": "0.4.1",
385
386
  "rightkit-browser": "0.3.1",
386
387
  "rightkit-cache": "0.2.0",
387
- "rightkit-channel-memory": "0.1.0",
388
+ "rightkit-channel-memory": "0.2.1",
388
389
  "rightkit-child-process": "0.4.3",
389
390
  "rightkit-compose": "0.1.0",
390
391
  "rightkit-contracts": "0.1.5",
@@ -456,7 +457,7 @@
456
457
  "rightkit-automation": "0.4.1",
457
458
  "rightkit-browser": "0.3.1",
458
459
  "rightkit-cache": "0.2.0",
459
- "rightkit-channel-memory": "0.1.0",
460
+ "rightkit-channel-memory": "0.2.1",
460
461
  "rightkit-child-process": "0.4.1",
461
462
  "rightkit-compose": "0.1.0",
462
463
  "rightkit-contracts": "0.1.5",
@@ -541,7 +542,7 @@
541
542
  "@rightkit/qa": "0.4.9",
542
543
  "@rightkit/qa-chrome": "0.1.8",
543
544
  "@rightkit/react-shell": "0.6.8",
544
- "@rightkit/release": "0.2.172",
545
+ "@rightkit/release": "0.2.173",
545
546
  "@rightkit/settings-ui": "0.5.0",
546
547
  "@rightkit/shell": "0.1.1",
547
548
  "@rightkit/tauri": "0.2.1",
@@ -812,6 +813,9 @@
812
813
  ],
813
814
  "rightkit-hotkeys": [
814
815
  "0.1.0"
816
+ ],
817
+ "rightkit-channel-memory": [
818
+ "0.1.0"
815
819
  ]
816
820
  }
817
821
  }
@@ -199,7 +199,7 @@ export function renderRuntimeBudgetBody(budget) {
199
199
  const host = `${m.host?.os}/${m.host?.arch}${m.host?.model ? ` (${m.host.model})` : ""}`;
200
200
  const scale = `${count(m.dataScale?.count)} ${m.dataScale?.unit} (${m.dataScale?.description})`;
201
201
  const app = m.scope === "installed" && m.app ? `, app ${m.app.name} ${m.app.version}` : "";
202
- const observed = BUDGET_FIGURES.map((k) => `${FIGURE_LABEL[k]} ${m.observed?.[k] === undefined ? "n/a" : FORMAT[k](m.observed[k])}`).join(", ");
202
+ const observed = BUDGET_FIGURES.map((k) => `${FIGURE_LABEL[k]} ${m.observed?.[k] === undefined || m.observed?.[k] === null ? "n/a (not measurable on this host)" : FORMAT[k](m.observed[k])}`).join(", ");
203
203
  out.push(`- Measured: ${host}, ${budget.crate} ${m.crateVersion} at ${String(m.commit).slice(0, 12)}, ${scale}, ${m.scope}${app}, \`${m.command}\`, ${m.date}, evidence ${String(m.evidence?.sha256 ?? "none").slice(0, 12)}: ${observed}.`);
204
204
  }
205
205
  return out;
@@ -291,6 +291,10 @@ export function measurementStaleness(commit, crateDir, git = defaultGit, deps =
291
291
 
292
292
  // ---------------------------------------------------------------- bench evidence
293
293
 
294
+ // Figures a host cannot measure by design. macOS has no per-process disk-operation count (rightkit-child-process
295
+ // usage.rs: ru_inblock/ru_oublock stay 0 there), so disk_ops_per_s may be "unavailable" on darwin only.
296
+ export const HOST_UNAVAILABLE_FIGURES = Object.freeze({ darwin: Object.freeze(["diskOpsPerSecond"]) });
297
+ export const figureUnavailableOn = (os, key) => (HOST_UNAVAILABLE_FIGURES[os] ?? []).includes(key);
294
298
  const RESULT_NUMBERS = Object.freeze({ resident_bytes: "residentMemoryBytes", idle_cpu_percent: "idleCpuPercent", wakeups_per_s: "wakeupsPerSecond", disk_ops_per_s: "diskOpsPerSecond", scale_count: "scaleCount" });
295
299
  const RESULT_WORDS = Object.freeze(["crate", "version", "scale_unit"]);
296
300
 
@@ -312,7 +316,8 @@ export function parseBudgetResult(text) {
312
316
  for (const key of RESULT_WORDS) { if (!nonEmpty(fields[key])) problems.push(`missing field ${key}`); else value[key] = fields[key]; }
313
317
  for (const [key, name] of Object.entries(RESULT_NUMBERS)) {
314
318
  const n = Number(fields[key]);
315
- if (fields[key] === undefined || fields[key] === "" || !Number.isFinite(n) || n < 0) problems.push(`field ${key} must be a non-negative number`);
319
+ if (fields[key] === "unavailable" && name !== "residentMemoryBytes" && name !== "scaleCount") value[name] = null;
320
+ else if (fields[key] === undefined || fields[key] === "" || !Number.isFinite(n) || n < 0) problems.push(`field ${key} must be a non-negative number`);
316
321
  else value[name] = n;
317
322
  }
318
323
  return problems.length ? { ok: false, problems } : { ok: true, problems: [], value };
@@ -412,6 +417,7 @@ function validateMeasurement(m, index) {
412
417
  }
413
418
  if (!nonEmpty(m.command)) problems.push(`${at} has no command`);
414
419
  for (const key of BUDGET_FIGURES) {
420
+ if (m.observed?.[key] === null && figureUnavailableOn(m.host?.os, key)) continue;
415
421
  if (!isNumber(m.observed?.[key]) || m.observed[key] < 0) problems.push(`${at} observed.${key} must be a non-negative number`);
416
422
  }
417
423
  if (isNumber(m.observed?.residentMemoryBytes) && m.observed.residentMemoryBytes === 0) {
@@ -507,12 +513,12 @@ export function evaluateRuntimeBudget({ crate, crateDir, version, evidence, requ
507
513
  }
508
514
  for (const { m, index } of counted) {
509
515
  for (const key of BUDGET_FIGURES) {
510
- if (ceilings[key] !== undefined && m.observed[key] > ceilings[key]) {
516
+ if (ceilings[key] !== undefined && m.observed[key] !== null && m.observed[key] > ceilings[key]) {
511
517
  problems.push(`measurement[${index}] on ${m.host.os}/${m.host.arch} exceeds the ${FIGURE_LABEL[key]} ceiling: observed ${m.observed[key]} > ${ceilings[key]}`);
512
518
  }
513
519
  }
514
520
  }
515
- const withinCeilings = (m) => BUDGET_FIGURES.every((key) => ceilings[key] !== undefined && m.observed[key] <= ceilings[key]);
521
+ const withinCeilings = (m) => BUDGET_FIGURES.every((key) => ceilings[key] !== undefined && (m.observed[key] === null ? figureUnavailableOn(m.host?.os, key) : m.observed[key] <= ceilings[key]));
516
522
  const atScale = (m) => requiredScale !== null && m.dataScale.unit === requiredScale.unit && m.dataScale.count >= requiredScale.minCount;
517
523
  const staleness = new Map();
518
524
  const stale = (m) => {
@@ -768,7 +774,7 @@ export function appRuntimeBudgetAdoptionCheck({ appRoot, app, version, cargoLock
768
774
  if (evidenceProblems.length) { problems.push(`${crate}: ${evidenceProblems.join("; ")}`); return false; }
769
775
  if (m.app.name !== app) { problems.push(`${crate}: measurement[${index}] is for app ${m.app.name}, not ${app}`); return false; }
770
776
  if (version && m.app.version !== version) { problems.push(`${crate}: measurement[${index}] was taken at ${app} ${m.app.version}, not the current ${version}`); return false; }
771
- const over = BUDGET_FIGURES.filter((k) => m.observed[k] > ceilings[k]);
777
+ const over = BUDGET_FIGURES.filter((k) => m.observed[k] !== null && m.observed[k] > ceilings[k]);
772
778
  if (over.length) { problems.push(`${crate}: measurement[${index}] exceeds ${over.join(", ")}`); return false; }
773
779
  if (m.dataScale.unit !== requiredScale.unit || m.dataScale.count < requiredScale.minCount) { problems.push(`${crate}: measurement[${index}] is below the required scale ${requiredScale.minCount} ${requiredScale.unit}`); return false; }
774
780
  return true;
@@ -813,6 +819,9 @@ export function recordMeasurement({ crateDir, resultFile, scope, command, descri
813
819
  const v = parsed.value;
814
820
  if (v.crate !== pkg.name) throw new Error(`bench output is for ${v.crate}, crate is ${pkg.name}`);
815
821
  if (v.version !== pkg.version) throw new Error(`bench output is for version ${v.version}, crate is ${pkg.version}`);
822
+ for (const key of BUDGET_FIGURES) {
823
+ if (v[key] === null && !figureUnavailableOn(host.os, key)) throw new Error(`bench output rejected: ${key} is unavailable, which is allowed only where the host cannot measure it (${JSON.stringify(HOST_UNAVAILABLE_FIGURES)})`);
824
+ }
816
825
  if (!SCOPES.includes(scope)) throw new Error(`--scope must be one of ${SCOPES.join(", ")}`);
817
826
  if (scope === "installed" && !(nonEmpty(app?.name) && nonEmpty(app?.version))) throw new Error("--scope installed needs --app and --app-version");
818
827
  if (!nonEmpty(command)) throw new Error("--command (the exact bench command) is required");