@rightkit/release 0.6.4 → 0.6.5

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,403 @@
1
+ // The host tier of the admission (admission.mjs): refusals that depend on this machine, not on the committed files. Each row asks
2
+ // the same question the build would ask later (a missing tool, a credential provider, a signing identity), through an injectable
3
+ // env, spawn and file system, and says NOT RUN with its reason when the probe cannot run on this host (a Windows probe on a Mac).
4
+ // NOT RUN is never a pass. Real-credential acceptance runs on the Mac and the Dell; the unit tests use fakes.
5
+ import { readFileSync } from "node:fs";
6
+ import path from "node:path";
7
+ import { brokerManagedHost } from "./cargo-contract.mjs";
8
+ import { ManagedHostEnvSecretError, isManagedHost } from "./managed-host-secrets.mjs";
9
+ import { resolveMacosDeveloperIdIdentity } from "./macos-signing-identity.mjs";
10
+ import { notarytoolAuthArgs } from "./notary-auth.mjs";
11
+ import { requiredBuildExecutables } from "./project-type.mjs";
12
+ import { resolveReleaseCacheMode } from "./release-cache-mode.mjs";
13
+ import { runCargoSync } from "./managed-cargo.mjs";
14
+ import { planMediaSidecar, validateMediaSidecarConfig } from "./media-sidecar.mjs";
15
+ import { cargoFfmpegCrate, loadSidecarCatalog } from "./media-sidecar-catalog.mjs";
16
+ import { releaseProgressLimits } from "./progress-control.mjs";
17
+ import { runConfiguredChecks } from "./release-checks.mjs";
18
+ import { MSIX_SIGNING_CONTRACT } from "./msix-config.mjs";
19
+
20
+ const WINDOWS_PORTABLE_SIGNING_CONTRACT = "windows-raw-exe-authenticode-before-portable-v1";
21
+ const WINDOWS_NSIS_SIGNING_CONTRACT = "windows-raw-exe-authenticode-before-nsis-v1";
22
+ const UPDATER_ENV = ["TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD"];
23
+ const NOTARY_ENV = ["APPLE_API_KEY_PATH", "APPLE_API_KEY", "APPLE_API_ISSUER"];
24
+ class CheckStopped extends Error {}
25
+ const asList = (value) => (Array.isArray(value) ? value : []);
26
+ const filled = (value) => String(value ?? "").trim() !== "";
27
+ const pathFor = (hostPlatform) => (hostPlatform === "win32" ? path.win32 : path.posix);
28
+ const tail = (text, lines = 12) => String(text ?? "").trim().split(/\r?\n/).slice(-lines).join("\n");
29
+
30
+ let bundledFeed;
31
+ const feedOf = (ctx) => ctx.feed ?? (bundledFeed ??= JSON.parse(readFileSync(new URL("./rightkit-versions.json", import.meta.url), "utf8")));
32
+
33
+ /** The first match of `name` on the env's PATH (Windows: with PATHEXT), or null. The probes spawn this path, never a bare name. */
34
+ export function resolveExecutable(name, ctx) {
35
+ const join = pathFor(ctx.hostPlatform).join;
36
+ const key = Object.keys(ctx.env).find((candidate) => candidate.toLowerCase() === "path") ?? "PATH";
37
+ const dirs = String(ctx.env[key] ?? "").split(ctx.hostPlatform === "win32" ? ";" : ":").filter(Boolean);
38
+ const extensions = ctx.hostPlatform === "win32" ? ["", ...String(ctx.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""];
39
+ for (const dir of dirs) for (const extension of extensions) {
40
+ const candidate = join(dir, `${name}${extension}`);
41
+ if (ctx.exists(candidate)) return candidate;
42
+ }
43
+ return null;
44
+ }
45
+
46
+ // A version probe that does not answer in this long (a package manager waiting for a download prompt) is a failed probe, not a hang.
47
+ // Only the version probes have this cap. The build runs a declared check with no limit of its own (release.mjs runPackageScript);
48
+ // the controller's liveness budget for the whole release step is releaseProgressLimits: 4 hours absolute and 10 minutes without
49
+ // output, file or CPU progress. The doctor holds a declared check to the absolute limit; it captures the output and cannot see progress.
50
+ const PROBE_TIMEOUT_MS = 60_000;
51
+ // The build streams a check's output to its console; here it is captured, so the buffer is large enough for any real lint or test run.
52
+ const CAPTURE_MAX_BUFFER = 256 * 1024 * 1024;
53
+
54
+ function probe(ctx, name, args, { cwd = ctx.root, timeout = PROBE_TIMEOUT_MS, maxBuffer } = {}) {
55
+ const file = resolveExecutable(name, ctx);
56
+ if (!file) return { found: false };
57
+ // Windows starts a .cmd or .bat through the shell, which does not quote a path with spaces by itself.
58
+ const shell = ctx.hostPlatform === "win32" && !/\.(?:exe|com)$/i.test(file);
59
+ try {
60
+ const result = ctx.spawn(shell ? `"${file}"` : file, args, { cwd, env: ctx.env, encoding: "utf8", windowsHide: true, shell, ...(timeout ? { timeout } : {}), ...(maxBuffer ? { maxBuffer } : {}) });
61
+ return { found: true, file, status: result.status ?? null, stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? ""), error: result.error };
62
+ } catch (error) {
63
+ return { found: true, file, status: null, stdout: "", stderr: "", error };
64
+ }
65
+ }
66
+ const failed = (result) => result.error || result.status !== 0;
67
+ const why = (result) => (result.error ? result.error.message : tail(result.stderr || result.stdout, 3));
68
+
69
+ // A limit in the unit it is meant in: whole minutes as minutes (the 240-minute default), anything else in seconds, so a small
70
+ // RIGHT_RELEASE_ABSOLUTE_MS never reads "0 minutes".
71
+ function durationText(ms) {
72
+ if (ms >= 60_000 && ms % 60_000 === 0) return ms === 60_000 ? "1 minute" : `${ms / 60_000} minutes`;
73
+ if (ms < 1000) return `${ms} milliseconds`;
74
+ const seconds = Math.round(ms / 1000);
75
+ return seconds === 1 ? "1 second" : `${seconds} seconds`;
76
+ }
77
+
78
+ /**
79
+ * Why a command that ran here did not run to its end, or null. A command stopped by this tool's own buffer or clock did not fail:
80
+ * the build, which streams the output and sets no limit, would have run it on, so it is NOT RUN here and never a finding.
81
+ */
82
+ function stoppedByThisTool(result, limitMs) {
83
+ const code = result.error?.code;
84
+ if (code === "ENOBUFS") return `wrote more than ${CAPTURE_MAX_BUFFER / 1024 / 1024} MiB of output, more than this tool captures, and was stopped before it finished`;
85
+ if (code === "ETIMEDOUT") return limitMs ? `ran past ${durationText(limitMs)}, the build's absolute limit for a release step (RIGHT_RELEASE_ABSOLUTE_MS), and was stopped` : "did not finish in the time this tool allows and was stopped";
86
+ return null;
87
+ }
88
+ // The row outcome for findings and for commands that could not run to their end: findings when there are any (NOT RUN rides along),
89
+ // otherwise NOT RUN, otherwise the pass (an empty list).
90
+ const verdict = (found, notRun) => (notRun.length === 0 ? found : found.length === 0 ? { notRun: notRun.join("\n") } : { findings: found, notRun: notRun.join("\n") });
91
+
92
+ function readJson(ctx, file) {
93
+ try { return JSON.parse(ctx.fs.readFileSync(file, "utf8")); } catch { return null; }
94
+ }
95
+
96
+ // The pin the app declares (package.json packageManager), or the feed's when it declares none.
97
+ function packageManagerPin(ctx) {
98
+ const declared = readJson(ctx, path.join(ctx.root, "package.json"))?.packageManager;
99
+ return typeof declared === "string" && declared ? { pin: declared, source: "package.json packageManager" } : { pin: feedOf(ctx).packageManager, source: "the RightKit feed (rightkit-versions.json)" };
100
+ }
101
+
102
+ // rust-toolchain(.toml) of the app, or of the nearest directory above it up to the repository: the channel rustup selects.
103
+ function rustToolchainChannel(ctx) {
104
+ const join = pathFor(ctx.hostPlatform).join;
105
+ for (let dir = ctx.root; ; dir = path.dirname(dir)) {
106
+ for (const name of ["rust-toolchain.toml", "rust-toolchain"]) {
107
+ const file = join(dir, name);
108
+ if (!ctx.exists(file)) continue;
109
+ const text = ctx.fs.readFileSync(file, "utf8");
110
+ const channel = name.endsWith(".toml") ? /^\s*channel\s*=\s*"([^"]+)"/m.exec(text)?.[1] : text.trim().split(/\r?\n/)[0]?.trim();
111
+ return channel ? { channel, file } : null;
112
+ }
113
+ if (dir === ctx.repoRoot || path.dirname(dir) === dir) return null;
114
+ }
115
+ }
116
+
117
+ /** The refusal for a shared cache without sccache >= 0.15.0: the doctor, the build's admission and the build itself say it alike. */
118
+ export function sccacheRefusal(found) {
119
+ return `shared cache mode requires sccache >= 0.15.0${found ? ` (found ${found})` : ""}. Install with: cargo install sccache --locked; then verify: sccache --version`;
120
+ }
121
+
122
+ const signsUpdater = (ctx) => (ctx.platform === "win" || ctx.platform === "mac") && asList(ctx.target?.updater?.artifacts).length > 0 && ctx.target.updater?.kind !== "appinstaller";
123
+
124
+ // The names sign-windows.mjs reads: each from the process environment first, then from the user's environment (Windows only).
125
+ const SIGNING_ENV_NAMES = [
126
+ "AZURE_SIGNTOOL_PATH", "SIGNTOOL_PATH", "LOCALAPPDATA", "ProgramFiles(x86)", "AZURE_CODESIGN_DLIB_PATH", "AZURE_ARTIFACT_SIGNING_DLIB_PATH",
127
+ "AZURE_ARTIFACT_SIGNING_METADATA", "AZURE_SIGNING_METADATA", "AZURE_ARTIFACT_SIGNING_ENDPOINT", "AZURE_SIGNING_ENDPOINT", "AZURE_ENDPOINT",
128
+ "AZURE_ARTIFACT_SIGNING_ACCOUNT", "AZURE_SIGNING_ACCOUNT_NAME", "AZURE_ACCOUNT", "AZURE_ARTIFACT_SIGNING_PROFILE", "AZURE_CERTIFICATE_PROFILE_NAME", "AZURE_PROFILE",
129
+ ];
130
+
131
+ function windowsEnv(ctx) {
132
+ let user = null;
133
+ // One PowerShell start answers every name the first time the process environment lacks one.
134
+ const userScope = () => {
135
+ if (user) return user;
136
+ user = new Map();
137
+ const list = SIGNING_ENV_NAMES.map((name) => `'${name.replace(/'/g, "''")}'`).join(",");
138
+ const result = ctx.spawn("powershell", ["-NoProfile", "-Command", `@(${list}) | ForEach-Object { $_ + '=' + [Environment]::GetEnvironmentVariable($_,'User') }`], { encoding: "utf8", windowsHide: true, timeout: PROBE_TIMEOUT_MS });
139
+ if (result.status === 0) for (const line of String(result.stdout ?? "").split(/\r?\n/)) { const at = line.indexOf("="); if (at > 0) user.set(line.slice(0, at), line.slice(at + 1).trim()); }
140
+ return user;
141
+ };
142
+ return (name) => (filled(ctx.env[name]) ? ctx.env[name] : userScope().get(name) ?? "");
143
+ }
144
+
145
+ export const HOST_ROWS = Object.freeze([
146
+ {
147
+ // build-release.mjs assertExecutables / collectToolVersions: the tools the build runs, at the versions it pins.
148
+ id: "host-tools",
149
+ tier: "host",
150
+ run(ctx) {
151
+ const { config, root } = ctx;
152
+ const found = [];
153
+ const packageManager = config.packageManager ?? "pnpm";
154
+ const hasPackageJson = ctx.exists(path.join(root, "package.json"));
155
+ const nativeIos = ctx.nativeIos();
156
+ const required = requiredBuildExecutables({ projectType: ctx.projectType(), packageManager, usesPackageManager: nativeIos ? hasPackageJson : Boolean(config.packageManager) || hasPackageJson, usesCargo: !nativeIos });
157
+ const present = new Set();
158
+ for (const name of required) {
159
+ if (name === "git") continue;
160
+ if (resolveExecutable(name, ctx)) present.add(name);
161
+ else found.push(`required executable not found: ${name}`);
162
+ }
163
+ const engines = /^>=\s*v?(\d+)/.exec(readJson(ctx, path.join(root, "package.json"))?.engines?.node ?? "");
164
+ const nodeMajor = Number((ctx.nodeVersion ?? process.version).replace(/^v/, "").split(".")[0]);
165
+ if (engines && nodeMajor < Number(engines[1])) found.push(`node ${ctx.nodeVersion ?? process.version} is older than the package.json engines.node ${readJson(ctx, path.join(root, "package.json")).engines.node}`);
166
+ if (present.has("pnpm")) {
167
+ const { pin, source } = packageManagerPin(ctx);
168
+ const feedPin = feedOf(ctx).packageManager;
169
+ // The doctor reports the app's pin against the feed with the package rules; the build has no such rule before the worker.
170
+ if (ctx.mode !== "doctor" && pin !== feedPin && source.startsWith("package.json")) found.push(`${config.app ?? "app"} packageManager must be ${feedPin}, got ${pin}`);
171
+ const expected = /^pnpm@(\d+\.\d+\.\d+)/.exec(pin)?.[1];
172
+ const result = probe(ctx, "pnpm", ["--version"]);
173
+ if (failed(result)) found.push(`required command failed: pnpm --version failed: ${why(result)}`);
174
+ else if (expected && result.stdout.trim() !== expected) found.push(`pnpm ${result.stdout.trim()} is installed but ${source} pins pnpm@${expected}; install the pinned version (corepack prepare pnpm@${expected} --activate)`);
175
+ }
176
+ if (present.has("cargo")) {
177
+ const result = probe(ctx, "cargo", ["--version"]);
178
+ if (failed(result)) found.push(`required command failed: cargo --version failed: ${why(result)}`);
179
+ }
180
+ if (present.has("rustc")) {
181
+ const result = probe(ctx, "rustc", ["-vV"]);
182
+ if (failed(result)) found.push(`required command failed: rustc -vV failed: ${why(result)}`);
183
+ else {
184
+ const release = /^release:\s*(\S+)/m.exec(result.stdout)?.[1];
185
+ const pinned = rustToolchainChannel(ctx);
186
+ if (pinned && release && /^\d+\.\d+(?:\.\d+)?$/.test(pinned.channel) && release !== pinned.channel && !release.startsWith(`${pinned.channel}.`)) {
187
+ found.push(`rustc ${release} is installed but ${path.relative(ctx.root, pinned.file).replaceAll("\\", "/") || pinned.file} selects ${pinned.channel}; install it (rustup toolchain install ${pinned.channel})`);
188
+ }
189
+ }
190
+ }
191
+ return found;
192
+ },
193
+ },
194
+ {
195
+ // sign-updater.mjs / notary-auth.mjs / managed-host-secrets.mjs: signing secrets are never taken from the shell environment.
196
+ id: "host-secret-env",
197
+ tier: "host",
198
+ run(ctx) {
199
+ const { env, platform, target } = ctx;
200
+ const managed = isManagedHost(env);
201
+ const found = [];
202
+ const updater = signsUpdater(ctx);
203
+ // notary-auth.mjs reads APPLE_API_* only when no keychain profile is named.
204
+ const notarizes = platform === "mac" && Boolean(target?.notarize) && target.notarize.profile === undefined;
205
+ if (!updater && !notarizes) return { na: "the target signs no updater artifact and names its notary profile" };
206
+ if (updater) {
207
+ for (const name of UPDATER_ENV) {
208
+ for (const key of Object.keys(env).filter((candidate) => candidate.toUpperCase() === name && filled(env[candidate]))) {
209
+ found.push(managed ? new ManagedHostEnvSecretError(name).message : `updater signing key/password must come from the OS credential provider (${key} is set in the environment; unset it)`);
210
+ }
211
+ }
212
+ }
213
+ if (notarizes && managed) for (const name of NOTARY_ENV) if (filled(env[name])) found.push(new ManagedHostEnvSecretError(name).message);
214
+ return found;
215
+ },
216
+ },
217
+ {
218
+ // protected-credentials.mjs execWithUpdaterCredential: the Windows updater key is read by rightkit-credentials, not by this tool.
219
+ id: "host-updater-credential",
220
+ tier: "host",
221
+ run(ctx) {
222
+ if (ctx.platform !== "win" || !signsUpdater(ctx)) return { na: "the target does not sign updater artifacts on Windows" };
223
+ if (ctx.hostPlatform !== "win32") return { notRun: "the updater credential provider is probed on the Windows host that signs" };
224
+ const result = probe(ctx, "rightkit-credentials", ["--version"]);
225
+ if (!result.found || result.error?.code === "ENOENT") return ["OS credential provider unavailable: install rightkit-credentials >=0.2.2 on PATH before updater signing"];
226
+ return undefined;
227
+ },
228
+ },
229
+ {
230
+ // sign-windows.mjs: signtool, the Azure Artifact Signing dlib and the account that signs.
231
+ id: "host-signing-win",
232
+ tier: "host",
233
+ run(ctx) {
234
+ const { platform, target } = ctx;
235
+ const contracts = [WINDOWS_NSIS_SIGNING_CONTRACT, WINDOWS_PORTABLE_SIGNING_CONTRACT, MSIX_SIGNING_CONTRACT];
236
+ if (platform !== "win" || !contracts.includes(target?.signingContract)) return { na: "not a Windows target signed through sign-windows.mjs" };
237
+ if (ctx.hostPlatform !== "win32") return { notRun: "the Windows signing prerequisites (signtool, Azure Artifact Signing) are probed on the Windows host that signs" };
238
+ const env = windowsEnv(ctx);
239
+ const join = path.win32.join;
240
+ const found = [];
241
+ const firstExisting = (candidates) => candidates.find((candidate) => candidate && ctx.exists(candidate));
242
+ const kits = (bin) => {
243
+ if (!ctx.exists(bin)) return [];
244
+ return ctx.fs.readdirSync(bin, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(bin, entry.name, "x64", "signtool.exe")).sort().reverse();
245
+ };
246
+ const signtool = firstExisting([
247
+ env("AZURE_SIGNTOOL_PATH"), env("SIGNTOOL_PATH"),
248
+ ...kits(join(env("LOCALAPPDATA") || "", "AzureArtifactSigningTools", "Microsoft.Windows.SDK.BuildTools", "bin")),
249
+ ...kits(join(env("ProgramFiles(x86)") || "C:\\Program Files (x86)", "Windows Kits", "10", "bin")),
250
+ ]);
251
+ if (!signtool) found.push("signtool.exe not found; set AZURE_SIGNTOOL_PATH or install Windows SDK Build Tools");
252
+ const dlib = firstExisting([
253
+ env("AZURE_CODESIGN_DLIB_PATH"),
254
+ env("AZURE_ARTIFACT_SIGNING_DLIB_PATH"),
255
+ join(env("LOCALAPPDATA") || "", "AzureArtifactSigningTools", "Microsoft.ArtifactSigning.Client", "bin", "x64", "Azure.CodeSigning.Dlib.dll"),
256
+ "C:\\Program Files\\Microsoft Azure Artifact Signing Client Tools\\x64\\Azure.CodeSigning.Dlib.dll",
257
+ "C:\\Program Files (x86)\\Microsoft\\ArtifactSigningClientTools\\bin\\x64\\Azure.CodeSigning.Dlib.dll",
258
+ ]);
259
+ if (!dlib) found.push("Azure.CodeSigning.Dlib.dll not found; set AZURE_CODESIGN_DLIB_PATH or install Azure Artifact Signing Client Tools");
260
+ const metadata = env("AZURE_ARTIFACT_SIGNING_METADATA") || env("AZURE_SIGNING_METADATA");
261
+ if (metadata) {
262
+ if (!ctx.exists(metadata)) found.push(`metadata file not found: ${metadata}`);
263
+ } else {
264
+ const endpoint = env("AZURE_ARTIFACT_SIGNING_ENDPOINT") || env("AZURE_SIGNING_ENDPOINT") || env("AZURE_ENDPOINT");
265
+ const account = env("AZURE_ARTIFACT_SIGNING_ACCOUNT") || env("AZURE_SIGNING_ACCOUNT_NAME") || env("AZURE_ACCOUNT");
266
+ const profile = env("AZURE_ARTIFACT_SIGNING_PROFILE") || env("AZURE_CERTIFICATE_PROFILE_NAME") || env("AZURE_PROFILE");
267
+ if (!endpoint || !account || !profile) found.push("set AZURE_ARTIFACT_SIGNING_ENDPOINT, AZURE_ARTIFACT_SIGNING_ACCOUNT, and AZURE_ARTIFACT_SIGNING_PROFILE");
268
+ }
269
+ return found;
270
+ },
271
+ },
272
+ {
273
+ // macos-signing-identity.mjs / notary-auth.mjs: one Developer ID identity, and a notary profile the Keychain holds.
274
+ id: "host-signing-mac",
275
+ tier: "host",
276
+ run(ctx) {
277
+ const { platform, target, env } = ctx;
278
+ if (platform !== "mac") return { na: "not a macOS target" };
279
+ const found = [];
280
+ const onMac = ctx.hostPlatform === "darwin";
281
+ if (onMac) { try { resolveMacosDeveloperIdIdentity({ env, run: ctx.spawn }); } catch (error) { found.push(error.message); } }
282
+ let inconclusive = null;
283
+ if (target?.notarize) {
284
+ let args = null;
285
+ // How the profile is chosen needs only the config and the environment, so it is judged on any host. A managed host
286
+ // refusing the APPLE_API_* variables is the host-secret-env finding.
287
+ try { args = notarytoolAuthArgs({ profile: target.notarize.profile, env }); } catch (error) { if (!(error instanceof ManagedHostEnvSecretError)) found.push(error.message); }
288
+ if (onMac && args?.[0] === "--keychain-profile") {
289
+ const profile = args[1];
290
+ const stored = ctx.spawn("security", ["find-generic-password", "-s", `com.apple.gke.notary.tool.saved-creds.${profile}`], { encoding: "utf8", windowsHide: true });
291
+ if (stored.status !== 0) {
292
+ // The Keychain item name is notarytool's own; ask notarytool when it is not found under that name.
293
+ const asked = ctx.spawn("xcrun", ["notarytool", "history", "--keychain-profile", profile, "--output-format", "json"], { encoding: "utf8", windowsHide: true });
294
+ const output = `${asked.stdout ?? ""}\n${asked.stderr ?? ""}`;
295
+ if (asked.status === 0) { /* the profile answers */ }
296
+ else if (/no keychain password item found/i.test(output)) found.push(`notarytool keychain profile ${profile} is not stored in the Keychain; store it once with: xcrun notarytool store-credentials ${profile}`);
297
+ else inconclusive = `notarytool could not be asked about keychain profile ${profile} (${tail(output, 2) || "no answer"}); check it with: xcrun notarytool history --keychain-profile ${profile}`;
298
+ }
299
+ }
300
+ }
301
+ if (found.length > 0) return found;
302
+ if (!onMac) return { notRun: "the Developer ID identity and notary profile are probed on the Mac that signs" };
303
+ return inconclusive ? { notRun: inconclusive } : undefined;
304
+ },
305
+ },
306
+ {
307
+ // build-release.mjs preflight op: target.preflight.env / executables / commands, which the app declares as host preconditions.
308
+ id: "host-preflight-env",
309
+ tier: "host",
310
+ run(ctx) {
311
+ const preflight = ctx.target?.preflight;
312
+ if (!preflight || (!asList(preflight.env).length && !asList(preflight.executables).length && !asList(preflight.commands).length)) return { na: "the target declares no preflight requirements" };
313
+ const found = [];
314
+ const notRun = [];
315
+ for (const name of asList(preflight.executables)) if (!resolveExecutable(name, ctx)) found.push(`required executable not found: ${name}`);
316
+ for (const name of asList(preflight.env)) if (!ctx.env[name]) found.push(`missing required environment variable: ${name}`);
317
+ for (const command of asList(preflight.commands)) {
318
+ // The build runs the command with inherited output and no time limit (build-release.mjs runChecked); the output is captured here.
319
+ const result = ctx.spawn(command.cmd, command.args ?? [], { cwd: path.resolve(ctx.root, command.cwd ?? "."), env: ctx.env, encoding: "utf8", windowsHide: true, shell: false, maxBuffer: CAPTURE_MAX_BUFFER });
320
+ const stopped = stoppedByThisTool(result);
321
+ if (stopped) notRun.push(`preflight command ${command.cmd} ${stopped}`);
322
+ else if (result.error || result.status !== 0) found.push(`${command.cmd} exited ${result.status ?? result.error?.message}`);
323
+ }
324
+ return verdict(found, notRun);
325
+ },
326
+ },
327
+ {
328
+ // build-release.mjs: on mac and win the build defaults to the shared cache, which needs sccache >= 0.15.0; a broker host does not.
329
+ id: "host-cache-mode",
330
+ tier: "host",
331
+ run(ctx) {
332
+ if (ctx.projectType() === "swiftpm" || ctx.nativeIos()) return { na: "SwiftPM and native iOS builds plan their own cache" };
333
+ let mode;
334
+ try { mode = resolveReleaseCacheMode({ requested: ctx.env.RIGHT_RELEASE_CACHE_MODE, platform: ctx.platform, env: ctx.env }); } catch (error) { return [error.message]; }
335
+ if (brokerManagedHost(ctx.env, ctx.hostPlatform, ctx.exists)) return { na: "broker-managed host: the broker owns CARGO_TARGET_DIR and the shared-cache checks are skipped" };
336
+ if (mode !== "shared") return { na: `cache mode ${mode} needs no sccache` };
337
+ const result = probe(ctx, "sccache", ["--version"]);
338
+ const version = /sccache\s+(\d+)\.(\d+)\.(\d+)/i.exec(result.found && !failed(result) ? result.stdout : "");
339
+ if (!version || (Number(version[1]) === 0 && Number(version[2]) < 15)) return [sccacheRefusal(version ? version.slice(1).join(".") : null)];
340
+ return undefined;
341
+ },
342
+ },
343
+ {
344
+ // release.mjs prepareMediaSidecar: with no catalog named in the config, the rightkit-ffmpeg crate the app compiled against is found
345
+ // with `cargo metadata --offline` (the call the worker makes, through the same managed Cargo), and its catalog plans. A catalog
346
+ // the config names, and the missing Cargo manifest, are the media-sidecar-plan files row.
347
+ id: "media-sidecar-catalog",
348
+ tier: "host",
349
+ after: ["build-inputs", "media-sidecar-plan"],
350
+ run(ctx) {
351
+ if (ctx.target.mediaSidecar === undefined || (ctx.platform !== "mac" && ctx.platform !== "win")) return { na: "the target declares no mediaSidecar" };
352
+ let sidecar;
353
+ try { sidecar = validateMediaSidecarConfig(ctx.target.mediaSidecar, { platform: ctx.platform }); } catch { return { skipped: "an invalid mediaSidecar configuration is reported by the media-sidecar rule" }; }
354
+ if (sidecar.catalog) return { na: "the config names the catalog; the media-sidecar-plan row reads it" };
355
+ const inputs = ctx.buildInputs();
356
+ if (!inputs.ok) return { skipped: "build-inputs is reported on its own" };
357
+ const layout = inputs.value.nativeLayout;
358
+ if (!layout) return { skipped: "media-sidecar-plan reports the missing native Cargo manifest" };
359
+ const cargo = (args, options) => runCargoSync(args, { ...options, env: ctx.env }, { platform: ctx.hostPlatform, exists: ctx.exists, spawn: ctx.spawn });
360
+ try {
361
+ const loaded = loadSidecarCatalog({ config: sidecar, root: ctx.root, manifestPath: layout.manifestPath, read: (file) => ctx.fs.readFileSync(file), crateFor: (manifestPath) => cargoFfmpegCrate(manifestPath, { run: cargo }) });
362
+ planMediaSidecar({ config: sidecar, ...loaded, root: ctx.root });
363
+ } catch (error) { return [`${ctx.config?.app ?? "app"} ${ctx.platform} mediaSidecar: [${error.code ?? "ERROR"}] ${error.message}`]; }
364
+ return undefined;
365
+ },
366
+ },
367
+ {
368
+ // release-checks.mjs runs config.checks (lint, typecheck) after the install. The doctor runs them when the dependencies are
369
+ // installed; the build runs them after its own install, so here they are skipped.
370
+ id: "declared-checks-run",
371
+ tier: "host",
372
+ needsTarget: false,
373
+ async run(ctx) {
374
+ const checks = asList(ctx.config.checks).filter((script) => typeof script === "string");
375
+ if (checks.length === 0) return { na: "the config declares no checks" };
376
+ if (ctx.skipChecks) return { na: "the declared checks are skipped" };
377
+ if (ctx.mode !== "doctor") return { skipped: "the build runs the declared checks after its install" };
378
+ const workdir = path.resolve(ctx.root, ctx.config.workdir ?? ".");
379
+ if (!ctx.exists(path.join(workdir, "node_modules"))) return { notRun: `not installed: no node_modules in ${workdir}, so ${checks.join(", ")} cannot run` };
380
+ const packageManager = ctx.config.packageManager ?? "pnpm";
381
+ const { absoluteMs } = releaseProgressLimits(ctx.env);
382
+ const found = [];
383
+ const notRun = [];
384
+ for (const script of checks) {
385
+ try {
386
+ await runConfiguredChecks({
387
+ packageManager, checks: [script], workdir,
388
+ run: async (pm, name, cwd) => {
389
+ // The build runs the script with inherited output and no limit but the release step's (release.mjs runPackageScript): a slow
390
+ // check that passes is green here too, and a check this tool had to stop is not a failed check.
391
+ const result = probe({ ...ctx, root: cwd }, pm, ["run", name], { cwd, timeout: absoluteMs, maxBuffer: CAPTURE_MAX_BUFFER });
392
+ if (!result.found) throw new Error(`required executable not found: ${pm}`);
393
+ const stopped = stoppedByThisTool(result, absoluteMs);
394
+ if (stopped) throw new CheckStopped(`check ${JSON.stringify(name)} ${stopped}; the build runs it to its end, so run it yourself: ${pm} run ${name}`);
395
+ if (failed(result)) throw new Error(`check ${JSON.stringify(name)} failed (${pm} run ${name} exited ${result.status ?? result.error?.message ?? result.signal}):\n${tail(`${result.stdout}\n${result.stderr}`)}`);
396
+ },
397
+ });
398
+ } catch (error) { (error instanceof CheckStopped ? notRun : found).push(error.message); }
399
+ }
400
+ return verdict(found, notRun);
401
+ },
402
+ },
403
+ ]);
package/admission.mjs ADDED
@@ -0,0 +1,146 @@
1
+ // Admission: the build's deterministic refusals as rows, run by the doctor and by the start of `right-release build` alike.
2
+ // A row is data, { id, tier, run(ctx) }:
3
+ // files reads the release config and the committed files only (no tool, no network, no credential)
4
+ // host asks this machine (tools, environment, signing prerequisites); injectable env and spawn
5
+ // network asks a registry
6
+ // `run` returns nothing (pass), an array of finding texts, or { notRun } (the probe cannot run on this host: never a pass),
7
+ // { na } (the row does not apply to this target) or { skipped } (another step runs it). { findings, notRun } is both at once: the
8
+ // row fails, and part of it also could not run. A throw is a finding, never a crash.
9
+ // The doctor and the build call the same rows with the same text, so a refusal found in one is found in the other; the build
10
+ // calls them before it takes a lock, writes a state directory, installs or starts a worker. See docs/PIPELINE-ARCHITECTURE.md.
11
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
12
+ import path from "node:path";
13
+ import { spawnSync } from "./background-process.mjs";
14
+ import { expandPackageRecipeTarget } from "./package-recipe-targets.mjs";
15
+ import { isNativeIosTarget, resolveReleaseBuildInputs } from "./release-invocation.mjs";
16
+ import { resolveProjectType } from "./project-type.mjs";
17
+ import { RightKitRegistryError } from "./rightkit-registry-resolver.mjs";
18
+ import { FILES_ROWS } from "./admission-files.mjs";
19
+ import { HOST_ROWS } from "./admission-host.mjs";
20
+
21
+ export const TIERS = Object.freeze(["files", "host", "network"]);
22
+
23
+ /** The registry finding, worded for the doctor and the build alike. */
24
+ export function describeRegistryError(error, app) {
25
+ return error instanceof RightKitRegistryError
26
+ ? `${app} ${error.recoverable ? "RECOVERABLE " : ""}${error.message}`
27
+ : `RightKit registry resolution threw: ${error instanceof Error ? error.message : String(error)}`;
28
+ }
29
+
30
+ // The RightKit pins against the registries (rightkit-registry-resolver.mjs). `ctx.registry()` is the caller's resolver: the build
31
+ // hands it the same call it used to make after the lock, so a fresh build resolves once, before any side effect. The doctor
32
+ // resolves in its own top-level step (its package rules read the result), so it skips this row.
33
+ const NETWORK_ROWS = Object.freeze([{
34
+ id: "rightkit-registry",
35
+ tier: "network",
36
+ doctorOwned: true,
37
+ async run(ctx) {
38
+ if (!ctx.registry) return { na: "no registry resolver was supplied" };
39
+ try { ctx.out.registry = await ctx.registry(ctx); } catch (error) { return [describeRegistryError(error, ctx.config?.app ?? path.basename(ctx.root))]; }
40
+ return undefined;
41
+ },
42
+ }]);
43
+
44
+ export const ADMISSION_ROWS = Object.freeze([...FILES_ROWS, ...HOST_ROWS, ...NETWORK_ROWS]);
45
+
46
+ const memo = (fn) => { let box; return () => (box ??= { value: fn() }).value; };
47
+
48
+ function gitHead(repoRoot, spawn) {
49
+ const result = spawn("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8", windowsHide: true });
50
+ if (result.status !== 0) throw new Error(`git rev-parse HEAD failed: ${String(result.stderr ?? "").trim()}`);
51
+ return String(result.stdout).trim();
52
+ }
53
+
54
+ /**
55
+ * Run the rows of `tiers` over one app and platform. `target` is the expanded target the worker validates; without it the
56
+ * target is expanded here and a failure is the `target` finding the doctor reports, with the rows that read it BLOCKED.
57
+ * Returns { findings: [{ row, detail }], rows: [{ id, tier, status, detail }], notRun: [{ id, reason }], out }.
58
+ * `mode` is "doctor" or "build": declared-checks-run executes only in the doctor (the build runs the checks after install).
59
+ */
60
+ export async function runAdmission({
61
+ root, repoRoot = root, configPath, config, platform, target, tiers = TIERS, env = process.env, spawn = spawnSync, registry,
62
+ log = () => {}, hostPlatform = process.platform, mode = "build", skipChecks = false, skipDoctorOwned = false, commit,
63
+ fs = { existsSync, readdirSync, readFileSync, statSync }, feed, rows = ADMISSION_ROWS, resolveInputs = resolveBuildInputs,
64
+ } = {}) {
65
+ const findings = [];
66
+ const results = [];
67
+ const out = {};
68
+ let targetProblem = null;
69
+ if (!target) {
70
+ const app = config?.app ?? "app";
71
+ try {
72
+ target = expandPackageRecipeTarget(config?.targets?.[platform], { platform, appRoot: root, config });
73
+ if (!target) targetProblem = `${app} has no ${platform} release target`;
74
+ } catch (error) {
75
+ targetProblem = `${app} ${platform} ${error.message}`;
76
+ }
77
+ }
78
+ const exists = fs.existsSync;
79
+ const projectType = memo(() => resolveProjectType({ appRoot: root, config, target }));
80
+ const ctx = {
81
+ root, repoRoot, configPath, config, platform, target, env, spawn, registry, log, hostPlatform, mode, skipChecks, fs, exists, feed, out,
82
+ // The target as the config declares it: the build controller judges a few rules on it, the worker on the expanded one.
83
+ rawTarget: config?.targets?.[platform],
84
+ projectType,
85
+ nativeIos: memo(() => platform === "ios" && isNativeIosTarget({ appRoot: root, target })),
86
+ commit: memo(() => commit ?? gitHead(repoRoot, spawn)),
87
+ };
88
+ // The canonical build-input inventory, resolved once: { ok, value } or { ok: false, error }.
89
+ ctx.buildInputs = memo(() => resolveInputs(ctx));
90
+ const failed = new Set();
91
+ if (targetProblem && tiers.some((tier) => tier !== "network")) {
92
+ findings.push({ row: "target", detail: targetProblem });
93
+ results.push({ id: "target", tier: "files", status: "fail", detail: targetProblem });
94
+ failed.add("target");
95
+ }
96
+ for (const row of rows) {
97
+ if (!tiers.includes(row.tier)) continue;
98
+ if (skipDoctorOwned && row.doctorOwned) continue;
99
+ const blocker = [...(row.needsTarget === false ? [] : targetProblem ? ["target"] : []), ...(row.after ?? [])].find((id) => failed.has(id));
100
+ if (blocker) {
101
+ results.push({ id: row.id, tier: row.tier, status: "blocked", detail: `BLOCKED by ${blocker}` });
102
+ failed.add(row.id);
103
+ continue;
104
+ }
105
+ let outcome;
106
+ try { outcome = await row.run(ctx); } catch (error) { outcome = [error instanceof Error ? error.message : `${row.id} threw a non-Error value: ${String(error)}`]; }
107
+ const found = Array.isArray(outcome) ? outcome : Array.isArray(outcome?.findings) ? outcome.findings : [];
108
+ if (found.length > 0) {
109
+ for (const detail of found) findings.push({ row: row.id, detail });
110
+ results.push({ id: row.id, tier: row.tier, status: "fail", detail: found.join("\n"), ...(outcome.notRun ? { alsoNotRun: outcome.notRun } : {}) });
111
+ failed.add(row.id);
112
+ } else if (outcome?.notRun) results.push({ id: row.id, tier: row.tier, status: "not-run", detail: outcome.notRun });
113
+ else if (outcome?.na) results.push({ id: row.id, tier: row.tier, status: "n/a", detail: outcome.na });
114
+ else if (outcome?.skipped) results.push({ id: row.id, tier: row.tier, status: "skipped", detail: outcome.skipped });
115
+ else results.push({ id: row.id, tier: row.tier, status: "ok", detail: "" });
116
+ }
117
+ const notRun = results.flatMap((row) => (row.status === "not-run" ? [{ id: row.id, reason: row.detail }] : row.alsoNotRun ? [{ id: row.id, reason: row.alsoNotRun }] : []));
118
+ return { findings, rows: results, notRun, out };
119
+ }
120
+
121
+ function resolveBuildInputs(ctx) {
122
+ try {
123
+ return { ok: true, value: resolveReleaseBuildInputs({ repoRoot: ctx.repoRoot, appRoot: ctx.root, configPath: ctx.configPath, config: ctx.config, target: ctx.target, env: ctx.env }) };
124
+ } catch (error) {
125
+ return { ok: false, error };
126
+ }
127
+ }
128
+
129
+ const SYMBOL = { ok: "ok ", fail: "FAIL ", "not-run": "NOT RUN", "n/a": "n/a ", skipped: "skipped", blocked: "BLOCKED" };
130
+
131
+ /** The rows as lines for a dry run or a verbose doctor: one per row, the finding text under a failed one. */
132
+ export function formatAdmission(result) {
133
+ const counts = (status) => result.rows.filter((row) => row.status === status).length;
134
+ const lines = [`admission: ${result.rows.length} rows, ${counts("fail")} failed, ${result.notRun.length} NOT RUN`];
135
+ for (const row of result.rows) {
136
+ const head = ` [${SYMBOL[row.status]}] ${row.id}`;
137
+ lines.push(row.status === "ok" ? head : `${head}: ${row.detail.replaceAll("\n", "\n ")}`);
138
+ if (row.alsoNotRun) lines.push(` NOT RUN: ${row.alsoNotRun.replaceAll("\n", "\n ")}`);
139
+ }
140
+ return lines;
141
+ }
142
+
143
+ /** Lines naming the rows that could not run on this host. They are not passes. */
144
+ export function formatNotRun(result) {
145
+ return result.notRun.map((row) => `NOT RUN ${row.id}: ${row.reason}`);
146
+ }