@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.
- package/README.md +10 -4
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +1 -1
- package/cli/right-release.mjs +5 -3
- package/development-signing.mjs +135 -4
- package/development.mjs +12 -11
- package/in-place-install.mjs +32 -7
- package/install-app-windows.mjs +108 -17
- package/install-app.mjs +32 -8
- package/install-tauri-devid.mjs +106 -47
- package/mac-bundle-signing.mjs +143 -1
- package/mac-signing-matrix.mjs +73 -6
- package/macos-signing-session.mjs +12 -2
- package/package-recipe-targets.mjs +166 -16
- package/package.json +1 -1
- package/portable-development.mjs +9 -9
- package/qa-build-features.mjs +7 -1
- package/recipe-build.mjs +346 -0
- package/release.mjs +23 -8
- package/rightkit-signing-agent.mjs +10 -4
- package/rightkit-versions.json +9 -5
- package/runtime-budgets.mjs +14 -5
- package/stage-binary.mjs +1 -1
- package/stage-sidecar-command.mjs +36 -4
- package/stage-sidecar.mjs +463 -48
package/recipe-build.mjs
ADDED
|
@@ -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("&", "&"));
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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);
|
package/rightkit-versions.json
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
}
|
package/runtime-budgets.mjs
CHANGED
|
@@ -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] ===
|
|
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");
|