@rightkit/release 0.6.3 → 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.
Files changed (48) hide show
  1. package/README.md +67 -5
  2. package/admission-files.mjs +279 -0
  3. package/admission-host.mjs +403 -0
  4. package/admission.mjs +146 -0
  5. package/app-check.mjs +869 -0
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/rightkit-gate.mjs +68 -29
  8. package/build-release.mjs +81 -21
  9. package/cli/right-release.mjs +39 -5
  10. package/deterministic-scans.mjs +42 -15
  11. package/development.mjs +3 -2
  12. package/hosted-workflow-policy.mjs +111 -0
  13. package/inventory-check.mjs +12 -1
  14. package/ios-dev.mjs +2 -2
  15. package/ios-host.mjs +7 -7
  16. package/ios-package-recipe.mjs +2 -2
  17. package/local-publish.mjs +1 -1
  18. package/local-qualify.mjs +3 -3
  19. package/local-runtime.mjs +1 -1
  20. package/ownership-dependency-allowlist.json +2 -0
  21. package/package.json +5 -3
  22. package/pipeline-admission.mjs +5 -4
  23. package/pipeline-receipts.mjs +302 -302
  24. package/pipeline-scan.mjs +3 -3
  25. package/pipeline-tools.mjs +175 -171
  26. package/prebuild-gates.mjs +191 -0
  27. package/progress-control.mjs +1 -1
  28. package/publish-update.mjs +4 -3
  29. package/qa-admission-policy.json +5 -0
  30. package/release-config.mjs +353 -330
  31. package/release-qa-audit.mjs +37 -11
  32. package/release-qa-command.mjs +46 -5
  33. package/release-qa-fixtures.mjs +127 -0
  34. package/release-qa-floor.json +6 -1
  35. package/release-qa-hardening.mjs +173 -0
  36. package/release-qa-policy.mjs +168 -0
  37. package/release-qa-receipt.mjs +21 -1
  38. package/release-switch-scan.mjs +137 -6
  39. package/release.mjs +333 -231
  40. package/rightapps-register.mjs +3 -1
  41. package/rightkit-drift.mjs +1 -1
  42. package/rightkit-ownership.json +15 -5
  43. package/rightkit-versions.json +50 -37
  44. package/stage-sidecar.mjs +17 -15
  45. package/suite-api-literal.mjs +109 -0
  46. package/suite-api.generated.mjs +78 -0
  47. package/swap-helper-bundle.mjs +2 -2
  48. package/win-package-recipe.mjs +7 -4
package/release.mjs CHANGED
@@ -11,6 +11,7 @@ import { validateRightKitCargoContract } from "./cargo-contract.mjs";
11
11
  import { assertCargoProfileContract } from "./cargo-profile-contract.mjs";
12
12
  import { assertQaAdoption, assertQaBackdoorContract } from "./qa-contract.mjs";
13
13
  import { assertLegalReleaseContract } from "./legal-contract.mjs";
14
+ import { DECLARED_UNRELATED_POLICY, declaredUnrelatedRefusal, releaseArtifactPaths } from "./hosted-workflow-policy.mjs";
14
15
  import { assertNativeIosPrepareHasManifest, assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
15
16
  import { declaredCargoManifest, hasDefaultTauriManifest, resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
16
17
  import {
@@ -49,13 +50,17 @@ import { assertNoQaControl, assertNoWebBundleMocks } from "./qa-control-scan.mjs
49
50
  import { assertReleaseDependencies } from "./release-dependencies.mjs";
50
51
  import { assertNoForbiddenBuildFeatures } from "./qa-build-features.mjs";
51
52
  import { expandRecipeBuildCommands } from "./recipe-build.mjs";
52
- import { assertNoReleaseSwitches, commandTokens, localCrateDirsFromTree, resolveFeatureClosure, shippedRustTriples } from "./release-switch-scan.mjs";
53
+ import { assertNoReleaseSwitches, commandTokens, localCrateDirsFromTree, resolveFeatureClosure, shippedRustTriples, validateNonShipped } from "./release-switch-scan.mjs";
53
54
  import { iosOwnershipScope, preflightOwnershipScope } from "./ownership-scope.mjs";
54
55
  import { devtoolsEvidence, resolveDevtoolsAllowance } from "./devtools-policy.mjs";
55
56
  import { runAndroidRelease, validateAndroidTarget } from "./android-release.mjs";
56
57
  import { runIosPackageRecipe } from "./ios-package-recipe.mjs";
57
58
  import { releaseSwitchInputs } from "./ios-build-context.mjs";
58
59
  import { productionBuildEnv, productionModeProblems } from "./production-mode.mjs";
60
+ import { evaluatePrebuildGates } from "./prebuild-gates.mjs";
61
+ import { releaseConfigFieldProblems } from "./release-config.mjs";
62
+ import { describeRegistryError, formatNotRun, runAdmission } from "./admission.mjs";
63
+ import { sccacheRefusal } from "./admission-host.mjs";
59
64
 
60
65
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
61
66
  const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
@@ -128,11 +133,64 @@ const opts = {
128
133
  dryRun: false,
129
134
  skipChecks: false,
130
135
  doctor: false,
136
+ gates: false,
137
+ inspect: false,
138
+ strict: false,
131
139
  tier: null,
132
140
  resume: false,
133
141
  resumeFrom: null,
134
142
  };
135
143
 
144
+ // E01: every independent problem of one run is collected and reported together; the doctor and the build share it.
145
+ // `check` / `reject` record a finding and continue; a check whose input comes from a failed one is not run on that bad input
146
+ // but reported as `not run because <check> failed` (one line, not a finding: the failed check is the one blocking problem of that
147
+ // cause); a throw inside a check is a finding, never a crash. `fail` stays for what cannot continue (it prints what was
148
+ // collected first). The build stops at one checkpoint (before the release lock, install, checks and compile) when any finding
149
+ // exists, so no build step or other side effect ever follows a failed check.
150
+ const problems = []; // { name, detail }
151
+ const notRunLines = []; // what a failed check kept from running; reported beside the findings, never counted as one
152
+ const failedChecks = new Set();
153
+ // The three rows only the doctor owns (admission.mjs skips them in its own pass) are not admission findings, so nothing else puts
154
+ // their id before the text. The doctor does, as it does for every admission row: `right-release: <row>: <text>`, the text the
155
+ // build's controller prints after `right-release build: <row>: `.
156
+ const DOCTOR_OWNED_ROWS = new Set(["rightkit-registry", "build-inputs", "dirty-inputs"]);
157
+ function reject(name, detail) {
158
+ failedChecks.add(name);
159
+ problems.push({ name, detail: opts.doctor && DOCTOR_OWNED_ROWS.has(name) ? `${name}: ${detail}` : detail });
160
+ }
161
+ // A validator that runs inside a check() refuses with this instead of calling fail(): the check records it as a finding and the
162
+ // doctor goes on to the next rule. `rowId` names the rule; `problems` lists every finding the rule has when it has more than one.
163
+ class ReleaseRefusal extends Error {
164
+ constructor(message, rowId, found = [message]) {
165
+ super(message);
166
+ this.name = "ReleaseRefusal";
167
+ this.rowId = rowId;
168
+ this.problems = found;
169
+ }
170
+ }
171
+ function rejectBlocked(name, blockers, what = name) {
172
+ const blocker = blockers.find((candidate) => failedChecks.has(candidate));
173
+ if (!blocker) return false;
174
+ // The check counts as failed for the checks that read it (they are not run either), but it adds no problem of its own.
175
+ failedChecks.add(name);
176
+ notRunLines.push(`${what} not run because ${blocker} failed`);
177
+ return true;
178
+ }
179
+ async function check(name, after, run) {
180
+ if (rejectBlocked(name, after)) return { ok: false };
181
+ try {
182
+ return { ok: true, value: await run() };
183
+ } catch (error) {
184
+ if (error instanceof ReleaseRefusal) for (const detail of error.problems) reject(name, detail);
185
+ else reject(name, error instanceof Error ? error.message : `${name} threw a non-Error value: ${String(error)}`);
186
+ return { ok: false };
187
+ }
188
+ }
189
+ const printProblems = () => {
190
+ for (const problem of problems.splice(0)) console.error(`right-release: ${problem.detail}`);
191
+ for (const line of notRunLines.splice(0)) console.error(`right-release: ${line}`);
192
+ };
193
+
136
194
  for (let i = 0; i < args.length; i++) {
137
195
  const arg = args[i];
138
196
  if (arg === "--") continue;
@@ -145,7 +203,11 @@ for (let i = 0; i < args.length; i++) {
145
203
  else if (arg === "--skip-checks") opts.skipChecks = true;
146
204
  else if (arg === "--skip-hardening") fail("--skip-hardening is forbidden; use exact artifact/source-bound hardeningAllowances");
147
205
  else if (arg === "--doctor") opts.doctor = true;
206
+ else if (arg === "--gates") opts.gates = true;
207
+ // The doctor applies the build's admission by default; --admission is the old spelling of that, kept so scripts still run.
148
208
  else if (arg === "--admission") opts.admission = true;
209
+ else if (arg === "--inspect") opts.inspect = true;
210
+ else if (arg === "--strict") opts.strict = true;
149
211
  else if (arg === "--resume") opts.resume = true;
150
212
  else if (arg === "--resume-from") { opts.resume = true; opts.resumeFrom = args[++i]; }
151
213
  else if (arg === "--tier") opts.tier = args[++i];
@@ -158,6 +220,9 @@ for (let i = 0; i < args.length; i++) {
158
220
  else usage(2, `unknown argument: ${arg}`);
159
221
  }
160
222
 
223
+ if (opts.gates && !opts.doctor) usage(2, "--gates is valid only with --doctor");
224
+ if ((opts.inspect || opts.strict) && !opts.doctor) usage(2, `${opts.inspect ? "--inspect" : "--strict"} is valid only with --doctor`);
225
+ if (opts.inspect && opts.admission) usage(2, "--inspect and --admission contradict each other: --inspect lists newer RightKit versions as information, --admission refuses them");
161
226
  if (opts.upload) fail("combined build+upload was removed; run right-release build, then right-release upload --release <id> --tier patch|update");
162
227
  if (opts.tier && !TIERS.has(opts.tier)) usage(2, `invalid --tier: ${opts.tier} (expected patch|update)`);
163
228
  const MAC_RESUME_PHASES = ["prepackage", "sign", "package", "post-sign", "harden"];
@@ -179,9 +244,12 @@ if (opts.doctor) {
179
244
  if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
180
245
  doctorInvocation = { repoRoot, commit };
181
246
  }
182
- const config = (await import(pathToFileURL(configPath))).default;
247
+ let config;
248
+ try { config = (await import(pathToFileURL(configPath))).default; } catch (error) { fail(`release config could not be loaded (${configPath}): ${error instanceof Error ? error.message : String(error)}`); }
183
249
  if (!config) usage(2, `config did not export default: ${configPath}`);
184
250
  if (config.schema !== 1) fail(`unsupported config schema: ${config.schema ?? "<missing>"} (expected 1)`);
251
+ // Field-level config refusals (updater.keyFile: keys come from the OS credential provider) are listed with every other finding.
252
+ for (const refusal of releaseConfigFieldProblems(config)) reject(refusal.name, refusal.detail);
185
253
 
186
254
  // Hosted-workflow policy patterns: defined before the top-level await below uses them.
187
255
  const SIGN_EXISTING_SECRETS = new Set(["APPLE_CERTIFICATE_BASE64", "APPLE_CERTIFICATE_PASSWORD", "APPLE_KEYCHAIN_PASSWORD"]);
@@ -205,7 +273,7 @@ const nativeFinalizer = resolveNativeFinalizerConfig(nativeAssembly);
205
273
  // (build-release.mjs). This worker, a resume and the upload consume only that admitted snapshot, through the release's own
206
274
  // state root and admission record; a snapshot from anywhere else is refused. The standalone doctor resolves live.
207
275
  const rightKitSnapshotFile = process.env[SNAPSHOT_ENV] || null;
208
- let rightKitResolution;
276
+ let rightKitResolution = null; // null when the registry resolution failed (recorded as the `rightkit-registry` finding)
209
277
  try {
210
278
  const lockInventory = () => {
211
279
  try {
@@ -233,14 +301,15 @@ try {
233
301
  ownership: RIGHTKIT_OWNERSHIP,
234
302
  releaseVersion: VERSION,
235
303
  boundSnapshot,
236
- // The doctor reports newer versions; run as the admission gate (--admission) it refuses a pin behind latest.
237
- admission: opts.doctor && opts.admission,
304
+ // The doctor is the build's own admission: a pin behind latest is refused, as the build refuses it. --inspect is the
305
+ // informational view (newer versions are listed, not refused).
306
+ admission: opts.doctor && !opts.inspect,
238
307
  });
239
308
  } catch (error) {
240
- if (!(error instanceof RightKitRegistryError)) throw error;
241
- fail(`${config.app ?? path.basename(root)} ${error.recoverable ? "RECOVERABLE " : ""}${error.message}`);
309
+ rightKitResolution = null;
310
+ reject("rightkit-registry", describeRegistryError(error, config.app ?? path.basename(root)));
242
311
  }
243
- {
312
+ if (rightKitResolution) {
244
313
  const { newer, tasks, warnings, notes } = rightKitResolution.freshness;
245
314
  const out = opts.doctor ? console.log : console.error;
246
315
  for (const line of newer) out(`right-release: newer RightKit version (informational): ${line}`);
@@ -248,88 +317,133 @@ try {
248
317
  for (const line of warnings) console.error(`right-release: WARNING ${line}`);
249
318
  for (const line of notes) console.error(`right-release: note ${line}`);
250
319
  }
251
- rightKitVerifiedNpm = rightKitResolution.verified.npm;
252
- // A SwiftPM app or a native iOS app without a package.json has no Node package contract to validate.
320
+ if (rightKitResolution) rightKitVerifiedNpm = rightKitResolution.verified.npm;
321
+ // The contracts below are independent of each other; the two that read the proven RightKit versions need the registry resolution.
322
+ // A SwiftPM app or a native iOS app without a package.json has no Node package contract to validate. Each package rule is its
323
+ // own finding (a failed one blocks only the rules that read its result), so one run lists every violation.
253
324
  if (!((nativeIos || resolveProjectType({ appRoot: root, config, target: config.targets?.[opts.platform] }) === "swiftpm") && !existsSync(path.join(root, "package.json")))) {
254
- await validateRightKitPackageContract(root, config.app, config.hostedWorkflows);
255
- }
256
- validateRightKitCargoContract(root, mergeVerifiedVersions(RIGHTKIT_CARGO_ALLOWED, rightKitResolution.verified.cargo), config.app ?? path.basename(root));
325
+ const packageJson = await check("package-json", [], async () => JSON.parse(await readFile(path.join(root, "package.json"), "utf8")));
326
+ const packageLabel = () => config.app ?? packageJson.value?.name ?? "app";
327
+ await check("qa-backdoor-contract", ["package-json"], () => assertQaBackdoorContract(root, packageJson.value.scripts ?? {}));
328
+ await check("hosted-workflows", [], () => validateHostedWorkflowFiles(root, packageLabel(), config.hostedWorkflows, releaseArtifactPaths(config)));
329
+ await check("package-manager", ["package-json"], () => validatePackageManagerPin(packageJson.value, packageLabel()));
330
+ await check("package-workspace-scripts", ["package-json"], () => validateNoParentWorkspaceScripts(packageJson.value, packageLabel()));
331
+ await check("rightkit-pin-specs", ["package-json", "rightkit-registry"], () => validateRightKitPinSpecs(packageJson.value, packageLabel()));
332
+ await check("rightkit-release-supported", ["package-json", "rightkit-registry"], () => validateRightKitReleaseSupported(packageJson.value, packageLabel()));
333
+ }
334
+ await check("rightkit-cargo-contract", ["rightkit-registry"], () => {
335
+ validateRightKitCargoContract(root, mergeVerifiedVersions(RIGHTKIT_CARGO_ALLOWED, rightKitResolution.verified.cargo), config.app ?? path.basename(root));
336
+ });
257
337
  // One standard dev profile and panic = "unwind" in every app (templates/cargo-profiles.toml).
258
- assertCargoProfileContract(root, config.app ?? path.basename(root));
338
+ await check("cargo-profile-contract", [], () => assertCargoProfileContract(root, config.app ?? path.basename(root)));
259
339
  // RightKit QA is mandatory for every app (also SwiftPM apps without a package.json): no QA lane refuses the release.
260
- assertQaAdoption({ appRoot: root, app: config.app });
340
+ await check("qa-adoption", [], () => assertQaAdoption({ appRoot: root, app: config.app }));
261
341
  let target = config.targets?.[opts.platform];
262
342
  // Declarative winPackage / macPackage (#345) expand to the same hook shape every gate below already checks.
263
- try { target = expandPackageRecipeTarget(target, { platform: opts.platform, appRoot: root, config }); } catch (error) { fail(`${config.app ?? "app"} ${opts.platform} ${error.message}`); }
264
- if (!target) fail(`${config.app ?? "app"} has no ${opts.platform} release target`);
265
- const legalContract = config.legal
343
+ // When it fails, `target` is not usable: every check that reads it is reported as not run because `target` failed.
344
+ await check("target", [], () => {
345
+ let expanded;
346
+ try { expanded = expandPackageRecipeTarget(target, { platform: opts.platform, appRoot: root, config }); } catch (error) { throw new Error(`${config.app ?? "app"} ${opts.platform} ${error.message}`); }
347
+ if (!expanded) throw new Error(`${config.app ?? "app"} has no ${opts.platform} release target`);
348
+ target = expanded;
349
+ });
350
+ const legalCheck = await check("legal-contract", [], () => (config.legal
266
351
  ? assertLegalReleaseContract(root, config.legal, config.app ?? path.basename(root), opts.platform)
267
- : null;
268
- if (target.signed !== true) {
269
- fail(`${config.app ?? "app"} ${opts.platform} must declare signed: true; unsigned release targets are forbidden`);
270
- }
352
+ : null));
353
+ const legalContract = legalCheck.ok ? legalCheck.value : null;
271
354
  let macSigning; // validated macOS sign.prePackageFiles / sign.nestedCode (mac only)
272
355
  let mediaSidecarConfig = null; // validated targets.<mac|win>.mediaSidecar (rightkit-ffmpeg decode sidecar)
273
- if (target.mediaSidecar !== undefined) {
274
- if (opts.platform !== "mac" && opts.platform !== "win") fail(`${config.app ?? "app"} ${opts.platform} does not support mediaSidecar (mac and win only)`);
275
- try { mediaSidecarConfig = validateMediaSidecarConfig(target.mediaSidecar, { platform: opts.platform }); } catch (error) { fail(`${config.app ?? "app"} ${opts.platform} ${error.message}`); }
276
- }
277
356
  let externalBinConfig = null; // validated targets.<mac|win>.externalBin (generic Tauri externalBin sidecars, #270)
278
- if (target.externalBin !== undefined) {
279
- if (opts.platform !== "mac" && opts.platform !== "win") fail(`${config.app ?? "app"} ${opts.platform} does not support externalBin (mac and win only)`);
280
- try { externalBinConfig = validateExternalBinConfig(target.externalBin, { platform: opts.platform, reservedNames: mediaSidecarConfig?.tools ?? [] }); } catch (error) { fail(`${config.app ?? "app"} ${opts.platform} ${error.message}`); }
281
- }
282
- if (opts.platform === "win") {
283
- const isNsis = target.signingContract === WINDOWS_NSIS_SIGNING_CONTRACT;
284
- const isPortable = target.signingContract === WINDOWS_PORTABLE_SIGNING_CONTRACT;
285
- const isMsix = target.signingContract === MSIX_SIGNING_CONTRACT;
286
- if (!isNsis && !isPortable && !isMsix) fail(`${config.app ?? "app"} win must declare signingContract: ${WINDOWS_NSIS_SIGNING_CONTRACT}, ${WINDOWS_PORTABLE_SIGNING_CONTRACT} or ${MSIX_SIGNING_CONTRACT}`);
287
- if (isMsix) {
288
- try { assertValidMsixTarget(target, config.app ?? "app"); } catch (error) { fail(error.message); }
289
- } else {
290
- if (!target.prePackage?.cmd) fail(`${config.app ?? "app"} win must declare prePackage for the raw EXE build`);
291
- if (!target.sign?.prePackageFiles?.length) fail(`${config.app ?? "app"} win must declare at least one sign.prePackageFiles raw EXE`);
292
- if (target.sign.prePackageFiles.some((file) => typeof file !== "string") || target.sign.nestedCode !== undefined) fail(`${config.app ?? "app"} win sign.prePackageFiles entries must be path strings; object entries and sign.nestedCode are macOS-only`);
293
- if (isNsis && target.sign.prePackageFiles.length !== 1) fail(`${config.app ?? "app"} win NSIS must declare exactly one sign.prePackageFiles raw EXE`);
294
- if (isNsis && !target.sign?.files?.length) fail(`${config.app ?? "app"} win NSIS must declare sign.files for installer signing`);
295
- if (isNsis && target.sign.prePackageFiles.some((file) => target.sign.files.includes(file))) fail(`${config.app ?? "app"} win raw EXE and installer signing files must be distinct`);
296
- if (isPortable && target.sign?.files?.length) fail(`${config.app ?? "app"} win portable release must sign every executable through sign.prePackageFiles and omit sign.files`);
297
- }
298
- }
299
- if (opts.platform === "mac") {
300
- if (target.signingContract !== MACOS_PORTABLE_SIGNING_CONTRACT) fail(`${config.app ?? "app"} mac must declare signingContract: ${MACOS_PORTABLE_SIGNING_CONTRACT}`);
301
- if (!target.prePackage?.cmd) fail(`${config.app ?? "app"} mac must declare prePackage to materialize Developer ID signing files`);
302
- if (!target.sign?.prePackageFiles?.length) fail(`${config.app ?? "app"} mac must declare at least one sign.prePackageFiles`);
303
- const macSignConfig = mediaSidecarConfig || externalBinConfig
304
- ? { ...target.sign, nestedCode: { ...(mediaSidecarConfig ? mediaSidecarNestedCode(mediaSidecarConfig) : {}), ...(externalBinConfig ? externalBinNestedCode(externalBinConfig) : {}), ...(target.sign.nestedCode ?? {}) } }
305
- : target.sign;
306
- try { macSigning = validateMacSigningConfig(macSignConfig, { root }); } catch (error) { fail(`${config.app ?? "app"} mac ${error.message}`); }
307
- if (!target.notarize?.file) fail(`${config.app ?? "app"} mac must declare notarize.file`);
308
- if (target.notarize.appBeforePackage && !target.notarize.app) fail(`${config.app ?? "app"} mac notarize.appBeforePackage requires notarize.app`);
309
- if (target.notarize.staple === false && (target.notarize.app || /\.(dmg|pkg)$/i.test(target.notarize.file))) {
310
- fail(`${config.app ?? "app"} mac notarize.staple: false applies only to an archive of bare executables (no notarize.app, not a .dmg/.pkg)`);
311
- }
312
- const swiftDmgError = swiftMacDmgConfigError({ target, projectType: resolveProjectType({ appRoot: root, config, target }), appName: config.app ?? "app" });
313
- if (swiftDmgError) fail(swiftDmgError);
314
- if (target.dmg && typeof target.dmg === "object" && target.dmg.spec !== undefined) {
315
- try {
316
- if (typeof target.dmg.spec !== "string" || !target.dmg.spec) throw new Error("dmg.spec must be a path string");
317
- const specOptions = readDmgSpec(path.resolve(root, target.dmg.spec), { app: target.notarize.app });
318
- for (const asset of [specOptions.background, specOptions.volumeIcon]) if (asset && !existsSync(asset)) throw new Error(`referenced file not found: ${asset}`);
319
- } catch (error) { fail(`${config.app ?? "app"} mac targets.mac.dmg.spec: ${error.message}`); }
320
- }
321
- }
322
357
  let androidTarget; // validated Android AAB target (android only; see android-release.mjs)
323
- if (opts.platform === "android") {
324
- try { androidTarget = validateAndroidTarget(target, { root }); } catch (error) { fail(`${config.app ?? "app"} android ${error.message}`); }
325
- }
326
358
  let iosSpec; // validated iOS iosPackage spec (ios only; package-recipe-targets.mjs expands targets.ios, ios-package-recipe.mjs runs it)
327
- if (opts.platform === "ios") {
328
- if (target.recipe?.kind !== "iosPackage") fail(`${config.app ?? "app"} ios must declare targets.ios.iosPackage; there is no hand-written iOS package script`);
329
- iosSpec = target.recipe.spec;
330
- if (nativeIos) { try { assertNativeIosPrepareHasManifest(iosSpec, config, root); } catch (error) { fail(`${config.app ?? "app"} ios ${error.message}`); } }
359
+ const app = config.app ?? "app";
360
+ // Everything below reads the expanded target. Each independent refusal is its own finding; a check that needs the output of
361
+ // another (mac signing reads the sidecar config, the iOS prepare check reads the iOS spec) is not run (reported as such), never run on it.
362
+ if (!rejectBlocked("target-checks", ["target"], "the target checks (signed, mediaSidecar, externalBin, publishBlocked and the platform signing and package contract)")) {
363
+ if (target.signed !== true) reject("target-signed", `${app} ${opts.platform} must declare signed: true; unsigned release targets are forbidden`);
364
+ if (target.mediaSidecar !== undefined) {
365
+ if (opts.platform !== "mac" && opts.platform !== "win") reject("media-sidecar", `${app} ${opts.platform} does not support mediaSidecar (mac and win only)`);
366
+ else await check("media-sidecar", [], () => {
367
+ try { mediaSidecarConfig = validateMediaSidecarConfig(target.mediaSidecar, { platform: opts.platform }); } catch (error) { throw new Error(`${app} ${opts.platform} ${error.message}`); }
368
+ });
369
+ }
370
+ if (target.externalBin !== undefined) {
371
+ if (opts.platform !== "mac" && opts.platform !== "win") reject("external-bin", `${app} ${opts.platform} does not support externalBin (mac and win only)`);
372
+ else await check("external-bin", ["media-sidecar"], () => {
373
+ try { externalBinConfig = validateExternalBinConfig(target.externalBin, { platform: opts.platform, reservedNames: mediaSidecarConfig?.tools ?? [] }); } catch (error) { throw new Error(`${app} ${opts.platform} ${error.message}`); }
374
+ });
375
+ }
376
+ if (opts.platform === "win") {
377
+ const isNsis = target.signingContract === WINDOWS_NSIS_SIGNING_CONTRACT;
378
+ const isPortable = target.signingContract === WINDOWS_PORTABLE_SIGNING_CONTRACT;
379
+ const isMsix = target.signingContract === MSIX_SIGNING_CONTRACT;
380
+ if (!isNsis && !isPortable && !isMsix) reject("win-signing-contract", `${app} win must declare signingContract: ${WINDOWS_NSIS_SIGNING_CONTRACT}, ${WINDOWS_PORTABLE_SIGNING_CONTRACT} or ${MSIX_SIGNING_CONTRACT}`);
381
+ if (isMsix) {
382
+ await check("win-msix", [], () => assertValidMsixTarget(target, app));
383
+ } else {
384
+ // The NSIS and portable rules below depend on which contract the target declares.
385
+ rejectBlocked("win-contract-rules", ["win-signing-contract"], "the win NSIS and portable rules (sign.files, distinct raw EXE and installer files)");
386
+ if (!target.prePackage?.cmd) reject("win-prepackage", `${app} win must declare prePackage for the raw EXE build`);
387
+ const rawFiles = target.sign?.prePackageFiles;
388
+ if (!rawFiles?.length) reject("win-sign-prepackage-files", `${app} win must declare at least one sign.prePackageFiles raw EXE`);
389
+ else {
390
+ if (rawFiles.some((file) => typeof file !== "string") || target.sign.nestedCode !== undefined) reject("win-sign-entries", `${app} win sign.prePackageFiles entries must be path strings; object entries and sign.nestedCode are macOS-only`);
391
+ if (isNsis && rawFiles.length !== 1) reject("win-nsis-raw-exe", `${app} win NSIS must declare exactly one sign.prePackageFiles raw EXE`);
392
+ }
393
+ if (isNsis && !target.sign?.files?.length) reject("win-nsis-sign-files", `${app} win NSIS must declare sign.files for installer signing`);
394
+ if (isNsis && rawFiles?.length && target.sign?.files?.length && rawFiles.some((file) => target.sign.files.includes(file))) reject("win-distinct-files", `${app} win raw EXE and installer signing files must be distinct`);
395
+ if (isPortable && target.sign?.files?.length) reject("win-portable-sign-files", `${app} win portable release must sign every executable through sign.prePackageFiles and omit sign.files`);
396
+ }
397
+ }
398
+ if (opts.platform === "mac") {
399
+ if (target.signingContract !== MACOS_PORTABLE_SIGNING_CONTRACT) reject("mac-signing-contract", `${app} mac must declare signingContract: ${MACOS_PORTABLE_SIGNING_CONTRACT}`);
400
+ if (!target.prePackage?.cmd) reject("mac-prepackage", `${app} mac must declare prePackage to materialize Developer ID signing files`);
401
+ if (!target.sign?.prePackageFiles?.length) reject("mac-sign-prepackage-files", `${app} mac must declare at least one sign.prePackageFiles`);
402
+ else if (!rejectBlocked("mac-signing", ["media-sidecar", "external-bin"], `${app} mac signing config validation (it reads the sidecar and externalBin configs)`)) {
403
+ await check("mac-signing", [], () => {
404
+ const macSignConfig = mediaSidecarConfig || externalBinConfig
405
+ ? { ...target.sign, nestedCode: { ...(mediaSidecarConfig ? mediaSidecarNestedCode(mediaSidecarConfig) : {}), ...(externalBinConfig ? externalBinNestedCode(externalBinConfig) : {}), ...(target.sign.nestedCode ?? {}) } }
406
+ : target.sign;
407
+ try { macSigning = validateMacSigningConfig(macSignConfig, { root }); } catch (error) { throw new Error(`${app} mac ${error.message}`); }
408
+ });
409
+ }
410
+ if (!target.notarize?.file) reject("mac-notarize-file", `${app} mac must declare notarize.file`);
411
+ else {
412
+ if (target.notarize.appBeforePackage && !target.notarize.app) reject("mac-notarize-app", `${app} mac notarize.appBeforePackage requires notarize.app`);
413
+ if (target.notarize.staple === false && (target.notarize.app || /\.(dmg|pkg)$/i.test(target.notarize.file))) {
414
+ reject("mac-notarize-staple", `${app} mac notarize.staple: false applies only to an archive of bare executables (no notarize.app, not a .dmg/.pkg)`);
415
+ }
416
+ }
417
+ await check("mac-swift-dmg", [], () => {
418
+ const swiftDmgError = swiftMacDmgConfigError({ target, projectType: resolveProjectType({ appRoot: root, config, target }), appName: app });
419
+ if (swiftDmgError) throw new Error(swiftDmgError);
420
+ });
421
+ if (target.dmg && typeof target.dmg === "object" && target.dmg.spec !== undefined && !rejectBlocked("mac-dmg-spec", ["mac-notarize-file"], `${app} mac targets.mac.dmg.spec validation (it reads notarize.app)`)) {
422
+ await check("mac-dmg-spec", [], () => {
423
+ try {
424
+ if (typeof target.dmg.spec !== "string" || !target.dmg.spec) throw new Error("dmg.spec must be a path string");
425
+ const specOptions = readDmgSpec(path.resolve(root, target.dmg.spec), { app: target.notarize.app });
426
+ for (const asset of [specOptions.background, specOptions.volumeIcon]) if (asset && !existsSync(asset)) throw new Error(`referenced file not found: ${asset}`);
427
+ } catch (error) { throw new Error(`${app} mac targets.mac.dmg.spec: ${error.message}`); }
428
+ });
429
+ }
430
+ }
431
+ if (opts.platform === "android") {
432
+ await check("android-target", [], () => {
433
+ try { androidTarget = validateAndroidTarget(target, { root }); } catch (error) { throw new Error(`${app} android ${error.message}`); }
434
+ });
435
+ }
436
+ if (opts.platform === "ios") {
437
+ if (target.recipe?.kind !== "iosPackage") reject("ios-target", `${app} ios must declare targets.ios.iosPackage; there is no hand-written iOS package script`);
438
+ else {
439
+ iosSpec = target.recipe.spec;
440
+ if (nativeIos) await check("ios-prepare-manifest", [], () => {
441
+ try { assertNativeIosPrepareHasManifest(iosSpec, config, root); } catch (error) { throw new Error(`${app} ios ${error.message}`); }
442
+ });
443
+ }
444
+ }
445
+ if (opts.upload && target.publishBlocked) reject("publish-blocked", `${app} ${opts.platform} publish blocked: ${target.publishBlocked}`);
331
446
  }
332
- if (opts.upload && target.publishBlocked) fail(`${config.app ?? "app"} ${opts.platform} publish blocked: ${target.publishBlocked}`);
333
447
  /**
334
448
  * Assemble preflight inputs from the app's own files. Kept here (not in
335
449
  * preflight.mjs) so the check module stays free of release.mjs's config shape.
@@ -377,16 +491,28 @@ function releasePreflight({ config, configPath, root, repoRoot, platform, native
377
491
  }
378
492
 
379
493
  if (opts.doctor) {
380
- const { buildInputs, nativeLayout: resolvedNativeLayout, cargoLockPaths: pinnedCargoLocks } = resolveReleaseBuildInputs({
494
+ // Every check below is independent unless it names what it needs; a failed input blocks (never feeds) what reads it.
495
+ const inputs = await check("build-inputs", ["target"], () => resolveReleaseBuildInputs({
381
496
  repoRoot: doctorInvocation.repoRoot,
382
497
  appRoot: root,
383
498
  configPath,
384
499
  config,
385
500
  target,
501
+ }));
502
+ await check("dirty-inputs", ["build-inputs"], () => {
503
+ const dirtyInputs = dirtyBuildInputs(doctorInvocation.repoRoot, inputs.value.buildInputs, doctorInvocation.commit);
504
+ if (dirtyInputs.length > 0) throw new Error(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
386
505
  });
387
- const dirtyInputs = dirtyBuildInputs(doctorInvocation.repoRoot, buildInputs, doctorInvocation.commit);
388
- if (dirtyInputs.length > 0) {
389
- fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
506
+ // The build's own admission rows (admission.mjs): the files the build would read and this machine, run with the texts and the
507
+ // rows the build runs before it takes its lock. The registry, the build inputs and the dirty inputs are this doctor's own steps.
508
+ let notRun = [];
509
+ const admission = await check("admission", ["target"], () => runAdmission({
510
+ root, repoRoot: doctorInvocation.repoRoot, configPath, config, platform: opts.platform, target, tiers: ["files", "host"],
511
+ mode: "doctor", skipDoctorOwned: true, skipChecks: opts.skipChecks, commit: doctorInvocation.commit,
512
+ }));
513
+ if (admission.ok) {
514
+ for (const finding of admission.value.findings) reject(finding.row, `${finding.row}: ${finding.detail}`);
515
+ notRun = admission.value.notRun;
390
516
  }
391
517
  console.log(`right-release ${VERSION}`);
392
518
  console.log(`config: ${configPath}`);
@@ -394,7 +520,7 @@ if (opts.doctor) {
394
520
  console.log(`platform: ${opts.platform}`);
395
521
  console.log(`tier: ${opts.tier ?? "<required for release/publish>"}`);
396
522
  console.log(`packageManager: ${config.packageManager}`);
397
- console.log(`rightkit-registry: ${rightKitResolution.summary}`);
523
+ if (rightKitResolution) console.log(`rightkit-registry: ${rightKitResolution.summary}`);
398
524
  console.log(`workdir: ${workdir}`);
399
525
  console.log(`hardeningscan: ${HARDENING_SCAN}`);
400
526
  if (sccacheVersion) console.log(`sccache: ${sccacheVersion}`);
@@ -403,125 +529,81 @@ if (opts.doctor) {
403
529
  console.log(`legalAcceptance: ${legalContract.acceptanceVersion}`);
404
530
  console.log(`legalManifestSha256: ${legalContract.manifestSha256}`);
405
531
  }
406
- if (target.sign?.files?.length) console.log(`sign: ${target.sign.files.join(", ")}`);
407
- if (target.publishBlocked) console.log(`publishBlocked: ${target.publishBlocked}`);
532
+ if (!failedChecks.has("target")) {
533
+ if (target.sign?.files?.length) console.log(`sign: ${target.sign.files.join(", ")}`);
534
+ if (target.publishBlocked) console.log(`publishBlocked: ${target.publishBlocked}`);
535
+ }
408
536
  const managedRoot = process.env.RIGHTKIT_MANAGED_ROOT;
409
- const installerCopyFindings = findInstallerCopyScripts({
410
- appRoot: root,
411
- workspaceRoot: process.env.RIGHTKIT_WORKSPACE_ROOT ?? path.resolve(doctorInvocation.repoRoot, ".."),
537
+ await check("artifact-storage", [], () => {
538
+ const installerCopyFindings = findInstallerCopyScripts({
539
+ appRoot: root,
540
+ workspaceRoot: process.env.RIGHTKIT_WORKSPACE_ROOT ?? path.resolve(doctorInvocation.repoRoot, ".."),
541
+ });
542
+ const storageFindings = [
543
+ ...installerCopyFindings,
544
+ ...findLargeRepositorySubdirectories({ appRoot: root, managedRoot, declared: config.storage?.declaredStores ?? [], thresholdBytes: Number(process.env.RIGHTKIT_ARTIFACT_STORAGE_THRESHOLD_BYTES ?? DEFAULT_STORAGE_THRESHOLD_BYTES) }),
545
+ ];
546
+ if (storageFindings.length) {
547
+ console.log("artifact-storage: report-only storage findings detected:");
548
+ for (const finding of storageFindings) console.log(` - ${finding.path}: ${finding.reason}`);
549
+ throw new Error(`artifact-storage found ${storageFindings.length} repository storage finding(s); move outputs to RIGHT_RELEASE_ARTIFACT_DIR`);
550
+ } else console.log("artifact-storage: no installer-copy scripts detected");
412
551
  });
413
- const storageFindings = [
414
- ...installerCopyFindings,
415
- ...findLargeRepositorySubdirectories({ appRoot: root, managedRoot, declared: config.storage?.declaredStores ?? [], thresholdBytes: Number(process.env.RIGHTKIT_ARTIFACT_STORAGE_THRESHOLD_BYTES ?? DEFAULT_STORAGE_THRESHOLD_BYTES) }),
416
- ];
417
- if (storageFindings.length) {
418
- console.log("artifact-storage: report-only storage findings detected:");
419
- for (const finding of storageFindings) console.log(` - ${finding.path}: ${finding.reason}`);
420
- fail(`artifact-storage found ${storageFindings.length} repository storage finding(s); move outputs to RIGHT_RELEASE_ARTIFACT_DIR`);
421
- } else console.log("artifact-storage: no installer-copy scripts detected");
422
552
 
423
553
  // Config above, MACHINE below. Printing config never told anyone whether this
424
- // box could finish a build; these checks do.
425
- const checks = releasePreflight({ config, configPath, root, repoRoot: doctorInvocation.repoRoot, platform: opts.platform, nativeLayout: resolvedNativeLayout, pinnedCargoLocks });
426
- console.log("preflight:");
427
- console.log(formatPreflight(checks));
428
- const failures = preflightFailures(checks);
429
- if (failures.length > 0) {
430
- fail(`preflight found ${failures.length} blocking problem(s); the build would fail on these`);
554
+ // box could finish a build; these checks do. The machine preflight reads the resolved build inputs, so it is
555
+ // BLOCKED when those could not be resolved.
556
+ let failures = [];
557
+ const machine = await check("preflight", ["target", "build-inputs"], () => {
558
+ const { nativeLayout: resolvedNativeLayout, cargoLockPaths: pinnedCargoLocks } = inputs.value;
559
+ return releasePreflight({ config, configPath, root, repoRoot: doctorInvocation.repoRoot, platform: opts.platform, nativeLayout: resolvedNativeLayout, pinnedCargoLocks });
560
+ });
561
+ if (machine.ok) {
562
+ console.log("preflight:");
563
+ console.log(formatPreflight(machine.value));
564
+ failures = preflightFailures(machine.value);
565
+ }
566
+ // --gates: run the build's pre-build source gates (release-switch scan, feature closure, release dependencies,
567
+ // devtools, production mode) over this app and report each finding as a FAIL row. They read the expanded target
568
+ // (and, on iOS, the iOS spec), so a failed one of those blocks them.
569
+ let gateFindings = [];
570
+ if (opts.gates) {
571
+ const blocker = ["target", ...(opts.platform === "ios" ? ["ios-target"] : [])].find((name) => failedChecks.has(name));
572
+ gateFindings = blocker
573
+ ? [{ name: "prebuild-gates", detail: `BLOCKED by ${blocker}` }]
574
+ : evaluatePrebuildGates({ root, config, target, platform: opts.platform, iosSpec, nativeIos, nativeAssembly, nativeFinalizer, strict: !opts.dryRun }).findings;
575
+ }
576
+ const configProblems = problems.length;
577
+ // The config findings (stderr, as every early refusal always was) and the gate rows (stdout) of this one run.
578
+ printProblems();
579
+ if (gateFindings.length) {
580
+ console.log(gateFindings.map((finding) => ` [FAIL] gate:${finding.name}: ${finding.detail}`).join("\n"));
581
+ }
582
+ // A row that could not run on this host is not a pass; --strict makes it a blocking problem.
583
+ for (const line of formatNotRun({ notRun })) console.error(`right-release: ${line}`);
584
+ const total = failures.length + gateFindings.length + configProblems + (opts.strict ? notRun.length : 0);
585
+ if (total > 0) {
586
+ console.error(`right-release: preflight found ${total} blocking problem(s); the build would fail on these`);
587
+ process.exit(1);
431
588
  }
432
589
  process.exit(0);
433
590
  }
434
591
 
435
592
  let prePackScanned = false; // set once a pre-pack payload scan has seen a native binary
436
- // Source-level layer of "no test-automation surface ships": refuse a release or update build whose build command
437
- // or app Cargo manifest enables devtools, qa-native, qa-control or dev-native (also in --dry-run).
438
- // DevTools: refused for every app except an allow-listed one that declares `devtools: "content-only"` (devtools-policy.mjs).
439
- let devtoolsAllowance = { mode: "denied" };
440
- // The Rust a build compiles is checked from the manifest it compiles: targets.<os>.cargoManifest, else the declared
441
- // nativeAssembly.cargoManifest. iosPackage.prepare commands run cargo for a native iOS app, so they are scanned like any build command.
442
- const switchInputs = releaseSwitchInputs({ appRoot: root, config, target, prepare: iosSpec?.prepare ?? [], nativeIos });
443
- if (switchInputs.problems.length) fail(`${config.app ?? "app"} ios release-switch scan cannot see what is built:\n- ${switchInputs.problems.join("\n- ")}`);
444
- try {
445
- devtoolsAllowance = resolveDevtoolsAllowance({ config, root });
446
- if (devtoolsAllowance.mode === "content-only") console.log(`right-release: devtools allowance recorded: ${JSON.stringify(devtoolsEvidence(devtoolsAllowance))}`);
447
- assertNoForbiddenBuildFeatures({ root, tier: "release/update", devtools: devtoolsAllowance, cargoManifest: switchInputs.cargoManifest, manifest: config.releaseSwitches?.manifest, commands: [
448
- ...switchInputs.commands,
449
- { label: "target.prePackage", command: target.prePackage },
450
- { label: "target.package", command: target.package },
451
- { label: "nativeAssembly.packageHook", command: nativeAssembly?.packageHook },
452
- { label: "nativeAssembly.finalizer", command: nativeFinalizer },
453
- ...commandList(target.postSign).map((command, index) => ({ label: Array.isArray(target.postSign) ? `target.postSign[${index}]` : "target.postSign", command })),
454
- { label: "target.gradle", command: target.gradle },
455
- ] });
456
- } catch (error) { fail(error.message); }
457
- // M12: the frontend build resolves in production mode only (development-only conditional exports such as
458
- // `@rightkit/license/testing` never resolve). `run` pins NODE_ENV=production for build children; this refuses a
459
- // Vite config that lists the `development` resolve condition explicitly, which the pin cannot override.
460
- {
461
- const modeProblems = productionModeProblems({ appRoot: root });
462
- if (modeProblems.length) fail(`${config.app ?? "app"} release frontend is not production-only:\n- ${modeProblems.join("\n- ")}`);
463
- }
464
-
465
- // D4 (M21/M23) source layer: every environment/argument read in app source is registered in the app's switches.json and
466
- // every S1-S4 selector is compiled out of release. The inventory is `config.releaseSwitches.inventory` (relative to the
467
- // app root) or `switches.json`; a release without one fails (a dry run only checks one that exists).
468
- // The resolved feature graph (`cargo tree -e features`) is the AUTHORITATIVE test-mode check: it runs here, in the app
469
- // repository, for the release target, and a release that cannot run it fails (a dry run reports and continues).
470
- // The binary marker/name scan below is secondary evidence.
471
- const releaseSwitchInventoryPath = path.resolve(root, config.releaseSwitches?.inventory ?? "switches.json");
472
- const releaseSwitchInventoryText = existsSync(releaseSwitchInventoryPath) ? readFileSync(releaseSwitchInventoryPath, "utf8") : null;
473
- try {
474
- // A `node recipe-build.mjs --plan <JSON>` prePackage hides each step's `--features` in one argument: the closure and the
475
- // command token scan see every prepare / build / postBuild step (a plan that cannot be read or validated fails closed).
476
- const closureCommands = [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, ...commandList(target.postSign), target.gradle, ...switchInputs.cargoCommands].filter(Boolean).flatMap((c) => expandRecipeBuildCommands(c, { root }));
477
- assertReleaseDependencies({ root, config, target, commands: closureCommands });
478
- let treeText;
479
- try {
480
- const closure = resolveFeatureClosure({
481
- root,
482
- manifest: config.releaseSwitches?.manifest,
483
- cargoManifest: switchInputs.cargoManifest,
484
- rust: config.releaseSwitches?.rust,
485
- features: config.releaseSwitches?.features ?? [],
486
- // The shipped triples (never "all": the build broker refuses it); evaluated on the raw config target.
487
- triples: () => shippedRustTriples({ platform: opts.platform, target: config.targets?.[opts.platform] }),
488
- commandTexts: closureCommands,
489
- });
490
- if (closure.skipped) console.log(`right-release: feature closure skipped (${closure.skipped})`);
491
- else treeText = closure.treeText;
492
- } catch (error) {
493
- if (!opts.dryRun) throw error;
494
- console.log(`right-release: dry run: ${error.message}`);
495
- }
496
- // remoteHost: rightkit-daemon `remote` is refused in the closure unless the app declares releaseSwitches.remoteHost: true.
497
- // remoteLoopback: the daemon `remote-loopback` feature is a separate declaration (releaseSwitches.remoteLoopback: true); remoteHost does not cover it.
498
- const closureInput = { treeText, devtools: devtoolsAllowance, remoteHost: config.releaseSwitches?.remoteHost, remoteLoopback: config.releaseSwitches?.remoteLoopback, commands: closureCommands.map((c) => commandTokens(c)) };
499
- if (releaseSwitchInventoryText != null) {
500
- // iOS: only what the iPhone app compiles — the Xcode project and its local packages, plus the local crates in the
501
- // resolved aarch64-apple-ios cargo graph (feature-aware, unlike the ownership closure). Other targets walk the app root.
502
- let sourceRoots;
503
- if (opts.platform === "ios") {
504
- const repoTop = git(root, ["rev-parse", "--show-toplevel"]);
505
- // Swift side only: the Rust side is the resolved cargo graph below. The ownership scope also folds in the whole
506
- // nativeAssembly workspace, which would scan crates (desktop tools, the TUI) the iPhone never links.
507
- const swiftSide = (iosOwnershipScope({ repoRoot: repoTop, appRoot: root, target: config.targets?.ios }) ?? []).filter((dir) => dir !== ".rightkit").map((dir) => path.join(repoTop, dir));
508
- sourceRoots = [...new Set([...swiftSide, ...localCrateDirsFromTree(treeText, repoTop)])].sort();
509
- // A prepare step can stage sources from anywhere (a web bundle, a generated resource) into the app; its working directory must be
510
- // inside the scanned roots, or the release fails instead of shipping what the scan never saw. Add the directory to ownershipScope.
511
- for (const step of config.targets?.ios?.iosPackage?.prepare ?? []) {
512
- const cwd = path.resolve(root, step?.cwd ?? ".");
513
- if (!sourceRoots.some((dir) => cwd === dir || cwd.startsWith(dir + path.sep))) throw new Error(`source switch inventory: targets.ios.iosPackage.prepare runs in ${path.relative(repoTop, cwd) || "."}, outside the iOS scan roots (${sourceRoots.map((dir) => path.relative(repoTop, dir) || ".").join(", ")}); list that directory in targets.ios.ownershipScope`);
514
- }
515
- console.log(`right-release: source switch scan (ios): ${sourceRoots.map((dir) => path.relative(repoTop, dir) || ".").join(", ")}`);
516
- }
517
- assertNoReleaseSwitches({ ...closureInput, sourceRoot: root, sourceRoots, inventoryText: releaseSwitchInventoryText, appForbid: config.releaseSwitches?.forbid ?? [] }, "source switch inventory");
518
- } else if (treeText != null || closureInput.commands.length) {
519
- assertNoReleaseSwitches({ ...closureInput, appForbid: config.releaseSwitches?.forbid ?? [] }, "feature closure");
520
- }
521
- if (releaseSwitchInventoryText == null && !opts.dryRun) {
522
- throw new Error(`source switch inventory: ${path.relative(root, releaseSwitchInventoryPath) || releaseSwitchInventoryPath} is missing; a release build needs the app's switches.json (see packages/release/README.md, "switches.json")`);
523
- }
524
- } catch (error) { fail(error.message); }
593
+ // One checkpoint for the build: every config finding of this run and every source-gate finding are reported together, and
594
+ // nothing below (lock, install, checks, compile, signing) runs while any exists.
595
+ // Source-level gates (release-switch scan, devtools, feature closure, release dependencies, production mode) live in
596
+ // prebuild-gates.mjs so `doctor --gates` runs the exact same code in the same order. They read the expanded target (and the
597
+ // iOS spec), so a failed one of those blocks them.
598
+ const prebuildBlocker = ["target", ...(opts.platform === "ios" ? ["ios-target"] : [])].find((name) => failedChecks.has(name));
599
+ const { findings: prebuildFindings, devtoolsAllowance, inventoryText: releaseSwitchInventoryText } = prebuildBlocker
600
+ ? { findings: [{ name: "prebuild-gates", detail: `BLOCKED by ${prebuildBlocker}` }], devtoolsAllowance: { mode: "denied" }, inventoryText: null }
601
+ : evaluatePrebuildGates({ root, config, target, platform: opts.platform, iosSpec, nativeIos, nativeAssembly, nativeFinalizer, strict: !opts.dryRun });
602
+ if (problems.length || prebuildFindings.length) {
603
+ printProblems();
604
+ for (const finding of prebuildFindings) console.error(`right-release: ${finding.detail.startsWith("BLOCKED by ") ? `gate ${finding.name}: ` : ""}${finding.detail}`);
605
+ process.exit(1);
606
+ }
525
607
 
526
608
  const releaseLock = acquireReleaseLock(root, config.app ?? path.basename(root), opts.platform, opts.tier);
527
609
  const mode = "package";
@@ -700,7 +782,8 @@ if (opts.platform === "win" && target.signingContract === WINDOWS_NSIS_SIGNING_C
700
782
  if (target.postSign) await runCommands(target.postSign, root);
701
783
  const updaterFiles = [...new Set((target.updater?.artifacts ?? []).map((artifact) => resolveReleaseArtifactPath(root, artifact.file)))];
702
784
  if (!updaterFiles.length) fail(`${config.app} win must declare updater.artifacts for post-code-signing minisign`);
703
- await run("node", [SIGN_UPDATER, ...(opts.dryRun ? ["--dry-run"] : []), ...(target.updater?.keyFile ? ["--key-file", String(target.updater.keyFile)] : []), ...updaterFiles.flatMap((file) => ["--file", file])], root);
785
+ // The key comes from the OS credential provider inside sign-updater; targets.win.updater.keyFile is refused at config validation and never forwarded.
786
+ await run("node", [SIGN_UPDATER, ...(opts.dryRun ? ["--dry-run"] : []), ...updaterFiles.flatMap((file) => ["--file", file])], root);
704
787
  }
705
788
 
706
789
  if (target.postSign && opts.platform !== "win" && (opts.platform !== "mac" || runMacPhase("post-sign"))) {
@@ -822,7 +905,9 @@ function resolveReleaseArtifactPath(root, declared) {
822
905
  }
823
906
 
824
907
  function fail(message) {
908
+ printProblems();
825
909
  console.error(`right-release: ${message}`);
910
+ if (opts.doctor) console.error("right-release: doctor stopped here: this problem prevents the checks after it from running; fix it and run the doctor again to see the rest");
826
911
  process.exit(1);
827
912
  }
828
913
 
@@ -838,10 +923,7 @@ function assertSharedSccachePrerequisite() {
838
923
  const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
839
924
  const version = output.match(/\b(\d+)\.(\d+)\.(\d+)\b/);
840
925
  const valid = result.status === 0 && version && isAtLeastVersion(version.slice(1).map(Number), [0, 15, 0]);
841
- if (!valid) {
842
- const found = version ? ` (found ${version[0]})` : "";
843
- fail(`shared cache mode requires sccache >= 0.15.0${found}. Install with: cargo install sccache --locked; then verify: sccache --version`);
844
- }
926
+ if (!valid) fail(sccacheRefusal(version?.[0]));
845
927
  return version[0];
846
928
  }
847
929
 
@@ -1097,38 +1179,49 @@ function commandTimeoutMs(command) {
1097
1179
  : undefined;
1098
1180
  }
1099
1181
 
1100
- async function validateRightKitPackageContract(root, appName, hostedWorkflows) {
1101
- const packageJsonPath = path.join(root, "package.json");
1102
- const pkg = JSON.parse(await readFile(packageJsonPath, "utf8"));
1103
- const scripts = pkg.scripts ?? {};
1104
- assertQaBackdoorContract(root, scripts);
1182
+ // The workflow files of the repository, when it has any, against the hostedWorkflows policy.
1183
+ async function validateHostedWorkflowFiles(root, appName, hostedWorkflows, artifacts = []) {
1105
1184
  const workflowDir = path.join(root, ".github", "workflows");
1106
- if (existsSync(workflowDir)) {
1107
- const workflows = (await readdir(workflowDir)).sort();
1108
- if (workflows.length) {
1109
- await validateHostedWorkflows(root, appName ?? pkg.name ?? "app", workflows, hostedWorkflows);
1110
- }
1111
- }
1185
+ if (!existsSync(workflowDir)) return;
1186
+ const workflows = (await readdir(workflowDir)).sort();
1187
+ if (workflows.length) await validateHostedWorkflows(root, appName, workflows, hostedWorkflows, artifacts);
1188
+ }
1189
+
1190
+ function validatePackageManagerPin(pkg, appName) {
1112
1191
  if (pkg.packageManager !== RIGHTKIT_VERSIONS.packageManager) {
1113
- fail(`${appName ?? pkg.name ?? "app"} packageManager must be ${RIGHTKIT_VERSIONS.packageManager}, got ${pkg.packageManager ?? "<missing>"}`);
1192
+ throw new ReleaseRefusal(`${appName} packageManager must be ${RIGHTKIT_VERSIONS.packageManager}, got ${pkg.packageManager ?? "<missing>"}`, "package-manager");
1114
1193
  }
1115
- if (JSON.stringify(scripts).includes("../tools/right-release") || JSON.stringify(scripts).includes("../rightkit/packages/release")) {
1116
- fail(`${appName ?? pkg.name ?? "app"} package.json must call the right-release bin, not parent-workspace release source`);
1194
+ }
1195
+
1196
+ function validateNoParentWorkspaceScripts(pkg, appName) {
1197
+ const scripts = JSON.stringify(pkg.scripts ?? {});
1198
+ if (scripts.includes("../tools/right-release") || scripts.includes("../rightkit/packages/release")) {
1199
+ throw new ReleaseRefusal(`${appName} package.json must call the right-release bin, not parent-workspace release source`, "package-workspace-scripts");
1117
1200
  }
1201
+ }
1202
+
1203
+ // Every @rightkit/* dependency, each violation its own line of the finding.
1204
+ function validateRightKitPinSpecs(pkg, appName) {
1205
+ const found = [];
1118
1206
  const rightKitDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
1119
1207
  for (const [name, specifier] of Object.entries(rightKitDeps).filter(([name]) => name.startsWith("@rightkit/"))) {
1120
1208
  if (FORBIDDEN_RIGHTKIT_SPEC.test(String(specifier))) {
1121
- fail(`${appName ?? pkg.name ?? "app"} ${name} must come from the published npm package, not ${specifier}`);
1209
+ found.push(`${appName} ${name} must come from the published npm package, not ${specifier}`);
1210
+ continue;
1122
1211
  }
1123
1212
  const allowed = RIGHTKIT_ALLOWED.get(name);
1124
1213
  if (allowed && !rightKitVerifiedNpm.get(name)?.has(specifier) && !rightKitVersionAllowed(allowed, specifier)) {
1125
- fail(`${appName ?? pkg.name ?? "app"} ${name} must be one of ${[...allowed].join(", ")}, got ${specifier}`);
1214
+ found.push(`${appName} ${name} must be one of ${[...allowed].join(", ")}, got ${specifier}`);
1126
1215
  }
1127
1216
  }
1217
+ if (found.length) throw new ReleaseRefusal(found.join("\n"), "rightkit-pin-specs", found);
1218
+ }
1219
+
1220
+ function validateRightKitReleaseSupported(pkg, appName) {
1128
1221
  const releaseAllowed = RIGHTKIT_ALLOWED.get("@rightkit/release");
1129
1222
  const releaseSpecifier = pkg.devDependencies?.["@rightkit/release"];
1130
1223
  if (!releaseAllowed || !(rightKitVerifiedNpm.get("@rightkit/release")?.has(releaseSpecifier) || rightKitVersionAllowed(releaseAllowed, releaseSpecifier))) {
1131
- fail(`${appName ?? pkg.name ?? "app"} must depend on a supported @rightkit/release version in devDependencies`);
1224
+ throw new ReleaseRefusal(`${appName} must depend on a supported @rightkit/release version in devDependencies`, "rightkit-release-supported");
1132
1225
  }
1133
1226
  }
1134
1227
 
@@ -1307,21 +1400,21 @@ async function signExistingRenderRefusal(root, manifest, source) {
1307
1400
  async function validateDeclaredWorkflowBlock(root, appName, manifest, declaredFiles) {
1308
1401
  const validator = (await loadRightKitCiModule(root, "workflows.mjs", "validateWorkflows"))?.validateWorkflows;
1309
1402
  const renderLanes = await loadRightKitCiRenderer(root);
1310
- if (!validator || !renderLanes) fail(`${appName} right-git-ci-only policy cannot verify .rightgit.json workflows: @rightkit/ci is not available (install @rightkit/ci as a devDependency)`);
1403
+ if (!validator || !renderLanes) throw new ReleaseRefusal(`${appName} right-git-ci-only policy cannot verify .rightgit.json workflows: @rightkit/ci is not available (install @rightkit/ci as a devDependency)`, "hosted-workflows");
1311
1404
  const errors = [];
1312
1405
  validator(manifest, errors);
1313
- if (errors.length > 0) fail(`${appName} right-git-ci-only policy refuses the .rightgit.json workflows block: ${errors.join("; ")}`);
1406
+ if (errors.length > 0) throw new ReleaseRefusal(`${appName} right-git-ci-only policy refuses the .rightgit.json workflows block: ${errors.join("; ")}`, "hosted-workflows");
1314
1407
  let rendered;
1315
1408
  try {
1316
1409
  rendered = renderLanes(manifest, root);
1317
1410
  } catch (error) {
1318
- fail(`${appName} right-git-ci-only policy cannot render the .rightgit.json workflows block: ${error.message}`);
1411
+ throw new ReleaseRefusal(`${appName} right-git-ci-only policy cannot render the .rightgit.json workflows block: ${error.message}`, "hosted-workflows");
1319
1412
  }
1320
1413
  for (const file of declaredFiles) {
1321
1414
  const expected = rendered.find((entry) => entry.filename === file)?.content;
1322
1415
  const actual = await readFile(path.join(root, ".github", "workflows", file), "utf8");
1323
1416
  if (typeof expected !== "string" || actual.replace(/\r\n?/g, "\n") !== expected.replace(/\r\n?/g, "\n")) {
1324
- fail(`${appName} ${file} is not byte-for-byte the @rightkit/ci render of its .rightgit.json workflows entry (run right-git sync)`);
1417
+ throw new ReleaseRefusal(`${appName} ${file} is not byte-for-byte the @rightkit/ci render of its .rightgit.json workflows entry (run right-git sync)`, "hosted-workflows");
1325
1418
  }
1326
1419
  }
1327
1420
  }
@@ -1330,7 +1423,7 @@ async function validateDeclaredWorkflowBlock(root, appName, manifest, declaredFi
1330
1423
  // `contents: read`, no other permissions key exists, no secrets reference, every `uses:` is commit-pinned, and every
1331
1424
  // checkout step sets persist-credentials: false. Steps are split on step-list indentation, not matched as free text.
1332
1425
  function declaredWorkflowPolicyRefusal(appName, workflow, source) {
1333
- const refuse = (why) => fail(`${appName} right-git managed ${workflow} violates the declared-workflow policy: ${why}`);
1426
+ const refuse = (why) => { throw new ReleaseRefusal(`${appName} right-git managed ${workflow} violates the declared-workflow policy: ${why}`, "hosted-workflows"); };
1334
1427
  const lines = source.replace(/\r\n?/g, "\n").split("\n");
1335
1428
  const permissionsAt = lines.flatMap((line, i) => (/^\s*permissions\s*:/.test(line) ? [i] : []));
1336
1429
  if (permissionsAt.length !== 1 || lines[permissionsAt[0]] !== "permissions:" || lines[permissionsAt[0] + 1] !== " contents: read" || /^ {2,}\S/.test(lines[permissionsAt[0] + 2] ?? "")) refuse("permissions must be exactly one top-level `contents: read`");
@@ -1353,16 +1446,25 @@ function declaredWorkflowPolicyRefusal(appName, workflow, source) {
1353
1446
  if (/^\s*(?:- )?uses:/m.test(lines.filter((l) => !steps.some((b) => b.includes(l))).join("\n"))) refuse("a uses: sits outside a step list");
1354
1447
  }
1355
1448
 
1356
- async function validateHostedWorkflows(root, appName, workflows, policy) {
1449
+ async function validateHostedWorkflows(root, appName, workflows, policy, artifacts = []) {
1450
+ // A repository that ships more than the released app (a monorepo's web or server pipeline) declares each workflow and why;
1451
+ // none may reach a secret but GITHUB_TOKEN or run release machinery (hosted-workflow-policy.mjs).
1452
+ if (policy !== null && typeof policy === "object" && policy.policy === DECLARED_UNRELATED_POLICY) {
1453
+ const texts = new Map();
1454
+ for (const file of workflows) texts.set(file, await readFile(path.join(root, ".github", "workflows", file), "utf8"));
1455
+ const refusal = declaredUnrelatedRefusal({ declaration: policy, workflows, read: (file) => texts.get(file), artifacts });
1456
+ if (refusal) throw new ReleaseRefusal(`${appName} ${DECLARED_UNRELATED_POLICY} hosted workflows refused: ${refusal}`, "hosted-workflows");
1457
+ return;
1458
+ }
1357
1459
  if (policy !== "right-git-ci-only") {
1358
- fail(`${appName} hosted workflow files are forbidden: ${workflows.join(", ")}`);
1460
+ throw new ReleaseRefusal(`${appName} hosted workflow files are forbidden: ${workflows.join(", ")} (set hostedWorkflows to "right-git-ci-only", or to { policy: "${DECLARED_UNRELATED_POLICY}", files: { <file>: <reason> } } when they are a pipeline unrelated to the released app)`, "hosted-workflows");
1359
1461
  }
1360
1462
  const manifestPath = path.join(root, ".rightgit.json");
1361
1463
  let manifest;
1362
1464
  try {
1363
1465
  manifest = JSON.parse(await readFile(manifestPath, "utf8"));
1364
1466
  } catch {
1365
- fail(`${appName} right-git-ci-only policy requires a valid .rightgit.json`);
1467
+ throw new ReleaseRefusal(`${appName} right-git-ci-only policy requires a valid .rightgit.json`, "hosted-workflows");
1366
1468
  }
1367
1469
  const lanes = manifest?.lanes;
1368
1470
  // Mirrors the `lanes` enum in @rightkit/ci manifest.schema.json.
@@ -1374,29 +1476,29 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
1374
1476
  || !(lanes.includes("ci") || lanes.includes("swift-check"))
1375
1477
  || new Set(lanes).size !== lanes.length
1376
1478
  || lanes.some((lane) => !safeLanes.has(lane))) {
1377
- fail(`${appName} right-git-ci-only policy requires compile=github-actions-only plus unique .rightgit.json lanes drawn from [${[...safeLanes].map((lane) => `"${lane}"`).join(", ")}] and including "ci" or "swift-check"`);
1479
+ throw new ReleaseRefusal(`${appName} right-git-ci-only policy requires compile=github-actions-only plus unique .rightgit.json lanes drawn from [${[...safeLanes].map((lane) => `"${lane}"`).join(", ")}] and including "ci" or "swift-check"`, "hosted-workflows");
1378
1480
  }
1379
1481
  // Workflows declared under the manifest's `workflows` block (@rightkit/ci workflows.mjs: explicit `file`,
1380
1482
  // else `<name>.yml`) are managed files too, in addition to the lane files.
1381
1483
  const declaredWorkflows = manifest?.workflows;
1382
1484
  if (declaredWorkflows !== undefined && (declaredWorkflows === null || typeof declaredWorkflows !== "object" || Array.isArray(declaredWorkflows))) {
1383
- fail(`${appName} right-git-ci-only policy requires .rightgit.json workflows to be an object`);
1485
+ throw new ReleaseRefusal(`${appName} right-git-ci-only policy requires .rightgit.json workflows to be an object`, "hosted-workflows");
1384
1486
  }
1385
1487
  const declaredFiles = Object.entries(declaredWorkflows ?? {}).map(([name, config]) => {
1386
- if (config === null || typeof config !== "object" || Array.isArray(config)) fail(`${appName} right-git-ci-only policy requires .rightgit.json workflows.${name} to be an object`);
1488
+ if (config === null || typeof config !== "object" || Array.isArray(config)) throw new ReleaseRefusal(`${appName} right-git-ci-only policy requires .rightgit.json workflows.${name} to be an object`, "hosted-workflows");
1387
1489
  const file = typeof config?.file === "string" ? config.file : `${name}.yml`;
1388
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,60}\.yml$/.test(file)) fail(`${appName} right-git-ci-only policy refuses workflow file name ${JSON.stringify(file)} declared by .rightgit.json`);
1490
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,60}\.yml$/.test(file)) throw new ReleaseRefusal(`${appName} right-git-ci-only policy refuses workflow file name ${JSON.stringify(file)} declared by .rightgit.json`, "hosted-workflows");
1389
1491
  return file;
1390
1492
  });
1391
1493
  const expectedWorkflows = [...new Set([...lanes.map((lane) => `${lane}.yml`), ...declaredFiles])].sort();
1392
1494
  if (JSON.stringify(workflows) !== JSON.stringify(expectedWorkflows)) {
1393
- fail(`${appName} right-git-ci-only policy requires workflows declared by .rightgit.json; expected ${expectedWorkflows.join(", ")}, got: ${workflows.join(", ")}`);
1495
+ throw new ReleaseRefusal(`${appName} right-git-ci-only policy requires workflows declared by .rightgit.json; expected ${expectedWorkflows.join(", ")}, got: ${workflows.join(", ")}`, "hosted-workflows");
1394
1496
  }
1395
1497
  if (declaredFiles.length > 0) await validateDeclaredWorkflowBlock(root, appName, manifest, declaredFiles);
1396
1498
  for (const workflow of expectedWorkflows) {
1397
1499
  const source = await readFile(path.join(root, ".github", "workflows", workflow), "utf8");
1398
1500
  if (!source.startsWith("# Managed by right-git")) {
1399
- fail(`${appName} ${workflow} is not marked as right-git managed`);
1501
+ throw new ReleaseRefusal(`${appName} ${workflow} is not marked as right-git managed`, "hosted-workflows");
1400
1502
  }
1401
1503
  if (declaredFiles.includes(workflow)) declaredWorkflowPolicyRefusal(appName, workflow, source);
1402
1504
  const jobSpan = (jobName) => {
@@ -1483,7 +1585,7 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
1483
1585
  const reason = manifest?.signExisting && typeof manifest.signExisting === "object"
1484
1586
  ? (signExistingRefusal(source) ?? await signExistingRenderRefusal(root, manifest, source))
1485
1587
  : "the manifest declares no signExisting";
1486
- if (reason !== null) fail(`${appName} right-git managed sign-existing.yml is not the protected sign-existing shape: ${reason}`);
1588
+ if (reason !== null) throw new ReleaseRefusal(`${appName} right-git managed sign-existing.yml is not the protected sign-existing shape: ${reason}`, "hosted-workflows");
1487
1589
  protectedSignExisting = true;
1488
1590
  }
1489
1591
  // @rightkit/ci `swift.privatePackages` credential: allowed only in the workflow/job pairs and on the
@@ -1526,7 +1628,7 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
1526
1628
  || refusedSecrets.length > 0) {
1527
1629
  const reasons = [...new Set(refusedSecrets)];
1528
1630
  const detail = reasons.length > 10 ? [...reasons.slice(0, 10), `${reasons.length - 10} more`] : reasons;
1529
- fail(`${appName} right-git managed ${workflow} requests release-capable permissions or secrets${detail.length > 0 ? `: ${detail.join("; ")}` : ""}`);
1631
+ throw new ReleaseRefusal(`${appName} right-git managed ${workflow} requests release-capable permissions or secrets${detail.length > 0 ? `: ${detail.join("; ")}` : ""}`, "hosted-workflows");
1530
1632
  }
1531
1633
  }
1532
1634
  }