@rightkit/release 0.6.2 → 0.6.4

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 (44) hide show
  1. package/README.md +37 -5
  2. package/bin/darwin/rightkit-swap +0 -0
  3. package/bin/rightkit-gate.mjs +4 -4
  4. package/build-release.mjs +2 -2
  5. package/cli/right-release.mjs +3 -2
  6. package/deterministic-scans.mjs +21 -10
  7. package/development.mjs +3 -2
  8. package/hosted-workflow-policy.mjs +111 -0
  9. package/ios-dev.mjs +2 -2
  10. package/ios-host.mjs +7 -7
  11. package/ios-package-recipe.mjs +2 -2
  12. package/local-publish.mjs +1 -1
  13. package/local-qualify.mjs +3 -3
  14. package/local-runtime.mjs +1 -1
  15. package/ownership-scope.mjs +9 -0
  16. package/package.json +1 -1
  17. package/pipeline-admission.mjs +5 -4
  18. package/pipeline-receipts.mjs +302 -302
  19. package/pipeline-scan.mjs +3 -3
  20. package/pipeline-tools.mjs +175 -171
  21. package/prebuild-gates.mjs +187 -0
  22. package/preflight.mjs +5 -1
  23. package/progress-control.mjs +1 -1
  24. package/publish-update.mjs +4 -3
  25. package/qa-admission-policy.json +5 -0
  26. package/release-config.mjs +353 -330
  27. package/release-qa-audit.mjs +37 -11
  28. package/release-qa-command.mjs +46 -5
  29. package/release-qa-fixtures.mjs +127 -0
  30. package/release-qa-floor.json +6 -1
  31. package/release-qa-hardening.mjs +173 -0
  32. package/release-qa-policy.mjs +168 -0
  33. package/release-qa-receipt.mjs +21 -1
  34. package/release-switch-scan.mjs +137 -6
  35. package/release.mjs +256 -198
  36. package/rightapps-register.mjs +3 -1
  37. package/rightkit-drift.mjs +1 -1
  38. package/rightkit-ownership.json +15 -5
  39. package/rightkit-versions.json +52 -38
  40. package/stage-sidecar.mjs +17 -15
  41. package/suite-api-literal.mjs +109 -0
  42. package/suite-api.generated.mjs +78 -0
  43. package/swap-helper-bundle.mjs +2 -2
  44. 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,15 @@ 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 { iosOwnershipScope } from "./ownership-scope.mjs";
53
+ import { assertNoReleaseSwitches, commandTokens, localCrateDirsFromTree, resolveFeatureClosure, shippedRustTriples, validateNonShipped } from "./release-switch-scan.mjs";
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";
59
62
 
60
63
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
61
64
  const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
@@ -128,11 +131,42 @@ const opts = {
128
131
  dryRun: false,
129
132
  skipChecks: false,
130
133
  doctor: false,
134
+ gates: false,
131
135
  tier: null,
132
136
  resume: false,
133
137
  resumeFrom: null,
134
138
  };
135
139
 
140
+ // E01: every independent problem of one run is collected and reported together; the doctor and the build share it.
141
+ // `check` / `reject` record a finding and continue; a check whose input comes from a failed one is not run on that bad input
142
+ // but recorded as `BLOCKED by <check>`; a throw inside a check is a finding, never a crash. `fail` stays for what cannot
143
+ // continue (it prints what was collected first). The build stops at one checkpoint (before the release lock, install, checks and
144
+ // compile) when any finding exists, so no build step or other side effect ever follows a failed check.
145
+ const problems = []; // { name, detail }
146
+ const failedChecks = new Set();
147
+ function reject(name, detail) {
148
+ failedChecks.add(name);
149
+ problems.push({ name, detail });
150
+ }
151
+ function rejectBlocked(name, blockers, what = name) {
152
+ const blocker = blockers.find((candidate) => failedChecks.has(candidate));
153
+ if (!blocker) return false;
154
+ reject(name, `${what} BLOCKED by ${blocker}`);
155
+ return true;
156
+ }
157
+ async function check(name, after, run) {
158
+ if (rejectBlocked(name, after)) return { ok: false };
159
+ try {
160
+ return { ok: true, value: await run() };
161
+ } catch (error) {
162
+ reject(name, error instanceof Error ? error.message : `${name} threw a non-Error value: ${String(error)}`);
163
+ return { ok: false };
164
+ }
165
+ }
166
+ const printProblems = () => {
167
+ for (const problem of problems.splice(0)) console.error(`right-release: ${problem.detail}`);
168
+ };
169
+
136
170
  for (let i = 0; i < args.length; i++) {
137
171
  const arg = args[i];
138
172
  if (arg === "--") continue;
@@ -145,6 +179,7 @@ for (let i = 0; i < args.length; i++) {
145
179
  else if (arg === "--skip-checks") opts.skipChecks = true;
146
180
  else if (arg === "--skip-hardening") fail("--skip-hardening is forbidden; use exact artifact/source-bound hardeningAllowances");
147
181
  else if (arg === "--doctor") opts.doctor = true;
182
+ else if (arg === "--gates") opts.gates = true;
148
183
  else if (arg === "--admission") opts.admission = true;
149
184
  else if (arg === "--resume") opts.resume = true;
150
185
  else if (arg === "--resume-from") { opts.resume = true; opts.resumeFrom = args[++i]; }
@@ -158,6 +193,7 @@ for (let i = 0; i < args.length; i++) {
158
193
  else usage(2, `unknown argument: ${arg}`);
159
194
  }
160
195
 
196
+ if (opts.gates && !opts.doctor) usage(2, "--gates is valid only with --doctor");
161
197
  if (opts.upload) fail("combined build+upload was removed; run right-release build, then right-release upload --release <id> --tier patch|update");
162
198
  if (opts.tier && !TIERS.has(opts.tier)) usage(2, `invalid --tier: ${opts.tier} (expected patch|update)`);
163
199
  const MAC_RESUME_PHASES = ["prepackage", "sign", "package", "post-sign", "harden"];
@@ -179,9 +215,12 @@ if (opts.doctor) {
179
215
  if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
180
216
  doctorInvocation = { repoRoot, commit };
181
217
  }
182
- const config = (await import(pathToFileURL(configPath))).default;
218
+ let config;
219
+ 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
220
  if (!config) usage(2, `config did not export default: ${configPath}`);
184
221
  if (config.schema !== 1) fail(`unsupported config schema: ${config.schema ?? "<missing>"} (expected 1)`);
222
+ // Field-level config refusals (updater.keyFile: keys come from the OS credential provider) are listed with every other finding.
223
+ for (const refusal of releaseConfigFieldProblems(config)) reject(refusal.name, refusal.detail);
185
224
 
186
225
  // Hosted-workflow policy patterns: defined before the top-level await below uses them.
187
226
  const SIGN_EXISTING_SECRETS = new Set(["APPLE_CERTIFICATE_BASE64", "APPLE_CERTIFICATE_PASSWORD", "APPLE_KEYCHAIN_PASSWORD"]);
@@ -205,7 +244,7 @@ const nativeFinalizer = resolveNativeFinalizerConfig(nativeAssembly);
205
244
  // (build-release.mjs). This worker, a resume and the upload consume only that admitted snapshot, through the release's own
206
245
  // state root and admission record; a snapshot from anywhere else is refused. The standalone doctor resolves live.
207
246
  const rightKitSnapshotFile = process.env[SNAPSHOT_ENV] || null;
208
- let rightKitResolution;
247
+ let rightKitResolution = null; // null when the registry resolution failed (recorded as the `rightkit-registry` finding)
209
248
  try {
210
249
  const lockInventory = () => {
211
250
  try {
@@ -237,10 +276,12 @@ try {
237
276
  admission: opts.doctor && opts.admission,
238
277
  });
239
278
  } catch (error) {
240
- if (!(error instanceof RightKitRegistryError)) throw error;
241
- fail(`${config.app ?? path.basename(root)} ${error.recoverable ? "RECOVERABLE " : ""}${error.message}`);
279
+ rightKitResolution = null;
280
+ reject("rightkit-registry", error instanceof RightKitRegistryError
281
+ ? `${config.app ?? path.basename(root)} ${error.recoverable ? "RECOVERABLE " : ""}${error.message}`
282
+ : `RightKit registry resolution threw: ${error instanceof Error ? error.message : String(error)}`);
242
283
  }
243
- {
284
+ if (rightKitResolution) {
244
285
  const { newer, tasks, warnings, notes } = rightKitResolution.freshness;
245
286
  const out = opts.doctor ? console.log : console.error;
246
287
  for (const line of newer) out(`right-release: newer RightKit version (informational): ${line}`);
@@ -248,88 +289,127 @@ try {
248
289
  for (const line of warnings) console.error(`right-release: WARNING ${line}`);
249
290
  for (const line of notes) console.error(`right-release: note ${line}`);
250
291
  }
251
- rightKitVerifiedNpm = rightKitResolution.verified.npm;
292
+ if (rightKitResolution) rightKitVerifiedNpm = rightKitResolution.verified.npm;
293
+ // The contracts below are independent of each other; the two that read the proven RightKit versions need the registry resolution.
252
294
  // A SwiftPM app or a native iOS app without a package.json has no Node package contract to validate.
253
- 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));
295
+ await check("rightkit-package-contract", ["rightkit-registry"], async () => {
296
+ if (!((nativeIos || resolveProjectType({ appRoot: root, config, target: config.targets?.[opts.platform] }) === "swiftpm") && !existsSync(path.join(root, "package.json")))) {
297
+ await validateRightKitPackageContract(root, config.app, config.hostedWorkflows, releaseArtifactPaths(config));
298
+ }
299
+ });
300
+ await check("rightkit-cargo-contract", ["rightkit-registry"], () => {
301
+ validateRightKitCargoContract(root, mergeVerifiedVersions(RIGHTKIT_CARGO_ALLOWED, rightKitResolution.verified.cargo), config.app ?? path.basename(root));
302
+ });
257
303
  // One standard dev profile and panic = "unwind" in every app (templates/cargo-profiles.toml).
258
- assertCargoProfileContract(root, config.app ?? path.basename(root));
304
+ await check("cargo-profile-contract", [], () => assertCargoProfileContract(root, config.app ?? path.basename(root)));
259
305
  // 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 });
306
+ await check("qa-adoption", [], () => assertQaAdoption({ appRoot: root, app: config.app }));
261
307
  let target = config.targets?.[opts.platform];
262
308
  // 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
309
+ // When it fails, `target` is not usable: every check that reads it is reported as BLOCKED by `target`.
310
+ await check("target", [], () => {
311
+ let expanded;
312
+ try { expanded = expandPackageRecipeTarget(target, { platform: opts.platform, appRoot: root, config }); } catch (error) { throw new Error(`${config.app ?? "app"} ${opts.platform} ${error.message}`); }
313
+ if (!expanded) throw new Error(`${config.app ?? "app"} has no ${opts.platform} release target`);
314
+ target = expanded;
315
+ });
316
+ const legalCheck = await check("legal-contract", [], () => (config.legal
266
317
  ? 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
- }
318
+ : null));
319
+ const legalContract = legalCheck.ok ? legalCheck.value : null;
271
320
  let macSigning; // validated macOS sign.prePackageFiles / sign.nestedCode (mac only)
272
321
  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
322
  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
323
  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
324
  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}`); } }
325
+ const app = config.app ?? "app";
326
+ // Everything below reads the expanded target. Each independent refusal is its own finding; a check that needs the output of
327
+ // another (mac signing reads the sidecar config, the iOS prepare check reads the iOS spec) is BLOCKED by it, never run on it.
328
+ if (!rejectBlocked("target-checks", ["target"], "the target checks (signed, mediaSidecar, externalBin, publishBlocked and the platform signing and package contract)")) {
329
+ if (target.signed !== true) reject("target-signed", `${app} ${opts.platform} must declare signed: true; unsigned release targets are forbidden`);
330
+ if (target.mediaSidecar !== undefined) {
331
+ if (opts.platform !== "mac" && opts.platform !== "win") reject("media-sidecar", `${app} ${opts.platform} does not support mediaSidecar (mac and win only)`);
332
+ else await check("media-sidecar", [], () => {
333
+ try { mediaSidecarConfig = validateMediaSidecarConfig(target.mediaSidecar, { platform: opts.platform }); } catch (error) { throw new Error(`${app} ${opts.platform} ${error.message}`); }
334
+ });
335
+ }
336
+ if (target.externalBin !== undefined) {
337
+ if (opts.platform !== "mac" && opts.platform !== "win") reject("external-bin", `${app} ${opts.platform} does not support externalBin (mac and win only)`);
338
+ else await check("external-bin", ["media-sidecar"], () => {
339
+ try { externalBinConfig = validateExternalBinConfig(target.externalBin, { platform: opts.platform, reservedNames: mediaSidecarConfig?.tools ?? [] }); } catch (error) { throw new Error(`${app} ${opts.platform} ${error.message}`); }
340
+ });
341
+ }
342
+ if (opts.platform === "win") {
343
+ const isNsis = target.signingContract === WINDOWS_NSIS_SIGNING_CONTRACT;
344
+ const isPortable = target.signingContract === WINDOWS_PORTABLE_SIGNING_CONTRACT;
345
+ const isMsix = target.signingContract === MSIX_SIGNING_CONTRACT;
346
+ 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}`);
347
+ if (isMsix) {
348
+ await check("win-msix", [], () => assertValidMsixTarget(target, app));
349
+ } else {
350
+ // The NSIS and portable rules below depend on which contract the target declares.
351
+ if (failedChecks.has("win-signing-contract")) reject("win-contract-rules", "the win NSIS and portable rules (sign.files, distinct raw EXE and installer files) BLOCKED by win-signing-contract");
352
+ if (!target.prePackage?.cmd) reject("win-prepackage", `${app} win must declare prePackage for the raw EXE build`);
353
+ const rawFiles = target.sign?.prePackageFiles;
354
+ if (!rawFiles?.length) reject("win-sign-prepackage-files", `${app} win must declare at least one sign.prePackageFiles raw EXE`);
355
+ else {
356
+ 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`);
357
+ if (isNsis && rawFiles.length !== 1) reject("win-nsis-raw-exe", `${app} win NSIS must declare exactly one sign.prePackageFiles raw EXE`);
358
+ }
359
+ if (isNsis && !target.sign?.files?.length) reject("win-nsis-sign-files", `${app} win NSIS must declare sign.files for installer signing`);
360
+ 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`);
361
+ 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`);
362
+ }
363
+ }
364
+ if (opts.platform === "mac") {
365
+ if (target.signingContract !== MACOS_PORTABLE_SIGNING_CONTRACT) reject("mac-signing-contract", `${app} mac must declare signingContract: ${MACOS_PORTABLE_SIGNING_CONTRACT}`);
366
+ if (!target.prePackage?.cmd) reject("mac-prepackage", `${app} mac must declare prePackage to materialize Developer ID signing files`);
367
+ if (!target.sign?.prePackageFiles?.length) reject("mac-sign-prepackage-files", `${app} mac must declare at least one sign.prePackageFiles`);
368
+ else if (!rejectBlocked("mac-signing", ["media-sidecar", "external-bin"], `${app} mac signing config validation (it reads the sidecar and externalBin configs)`)) {
369
+ await check("mac-signing", [], () => {
370
+ const macSignConfig = mediaSidecarConfig || externalBinConfig
371
+ ? { ...target.sign, nestedCode: { ...(mediaSidecarConfig ? mediaSidecarNestedCode(mediaSidecarConfig) : {}), ...(externalBinConfig ? externalBinNestedCode(externalBinConfig) : {}), ...(target.sign.nestedCode ?? {}) } }
372
+ : target.sign;
373
+ try { macSigning = validateMacSigningConfig(macSignConfig, { root }); } catch (error) { throw new Error(`${app} mac ${error.message}`); }
374
+ });
375
+ }
376
+ if (!target.notarize?.file) reject("mac-notarize-file", `${app} mac must declare notarize.file`);
377
+ else {
378
+ if (target.notarize.appBeforePackage && !target.notarize.app) reject("mac-notarize-app", `${app} mac notarize.appBeforePackage requires notarize.app`);
379
+ if (target.notarize.staple === false && (target.notarize.app || /\.(dmg|pkg)$/i.test(target.notarize.file))) {
380
+ reject("mac-notarize-staple", `${app} mac notarize.staple: false applies only to an archive of bare executables (no notarize.app, not a .dmg/.pkg)`);
381
+ }
382
+ }
383
+ await check("mac-swift-dmg", [], () => {
384
+ const swiftDmgError = swiftMacDmgConfigError({ target, projectType: resolveProjectType({ appRoot: root, config, target }), appName: app });
385
+ if (swiftDmgError) throw new Error(swiftDmgError);
386
+ });
387
+ 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)`)) {
388
+ await check("mac-dmg-spec", [], () => {
389
+ try {
390
+ if (typeof target.dmg.spec !== "string" || !target.dmg.spec) throw new Error("dmg.spec must be a path string");
391
+ const specOptions = readDmgSpec(path.resolve(root, target.dmg.spec), { app: target.notarize.app });
392
+ for (const asset of [specOptions.background, specOptions.volumeIcon]) if (asset && !existsSync(asset)) throw new Error(`referenced file not found: ${asset}`);
393
+ } catch (error) { throw new Error(`${app} mac targets.mac.dmg.spec: ${error.message}`); }
394
+ });
395
+ }
396
+ }
397
+ if (opts.platform === "android") {
398
+ await check("android-target", [], () => {
399
+ try { androidTarget = validateAndroidTarget(target, { root }); } catch (error) { throw new Error(`${app} android ${error.message}`); }
400
+ });
401
+ }
402
+ if (opts.platform === "ios") {
403
+ if (target.recipe?.kind !== "iosPackage") reject("ios-target", `${app} ios must declare targets.ios.iosPackage; there is no hand-written iOS package script`);
404
+ else {
405
+ iosSpec = target.recipe.spec;
406
+ if (nativeIos) await check("ios-prepare-manifest", [], () => {
407
+ try { assertNativeIosPrepareHasManifest(iosSpec, config, root); } catch (error) { throw new Error(`${app} ios ${error.message}`); }
408
+ });
409
+ }
410
+ }
411
+ if (opts.upload && target.publishBlocked) reject("publish-blocked", `${app} ${opts.platform} publish blocked: ${target.publishBlocked}`);
331
412
  }
332
- if (opts.upload && target.publishBlocked) fail(`${config.app ?? "app"} ${opts.platform} publish blocked: ${target.publishBlocked}`);
333
413
  /**
334
414
  * Assemble preflight inputs from the app's own files. Kept here (not in
335
415
  * preflight.mjs) so the check module stays free of release.mjs's config shape.
@@ -348,8 +428,19 @@ function releasePreflight({ config, configPath, root, repoRoot, platform, native
348
428
  ...pinnedCargoLocks,
349
429
  ...(config.cargoLocks ?? []).map((entry) => path.resolve(root, entry)),
350
430
  ].filter((entry, index, entries) => entries.indexOf(entry) === index);
431
+ // Same scope the build uses (rightkit#927): an iOS target is checked over its Xcode project and local packages only.
432
+ // The doctor reports a refused scope as an ownership FAIL (never a crash, never a whole-repo scan); the build still throws.
433
+ let ownershipScope = null;
434
+ let ownershipScopeError = null;
435
+ try {
436
+ ownershipScope = preflightOwnershipScope({ repoRoot, appRoot: root, platform, target: projectTarget, nativeAssembly: config.nativeAssembly });
437
+ } catch (error) {
438
+ ownershipScopeError = error instanceof Error ? error.message : String(error);
439
+ }
351
440
  return collectPreflight({
352
441
  platform,
442
+ ownershipScope,
443
+ ownershipScopeError,
353
444
  appRoot: root,
354
445
  repoRoot,
355
446
  app: config.app,
@@ -366,24 +457,25 @@ function releasePreflight({ config, configPath, root, repoRoot, platform, native
366
457
  }
367
458
 
368
459
  if (opts.doctor) {
369
- const { buildInputs, nativeLayout: resolvedNativeLayout, cargoLockPaths: pinnedCargoLocks } = resolveReleaseBuildInputs({
460
+ // Every check below is independent unless it names what it needs; a failed input blocks (never feeds) what reads it.
461
+ const inputs = await check("build-inputs", ["target"], () => resolveReleaseBuildInputs({
370
462
  repoRoot: doctorInvocation.repoRoot,
371
463
  appRoot: root,
372
464
  configPath,
373
465
  config,
374
466
  target,
467
+ }));
468
+ await check("dirty-inputs", ["build-inputs"], () => {
469
+ const dirtyInputs = dirtyBuildInputs(doctorInvocation.repoRoot, inputs.value.buildInputs, doctorInvocation.commit);
470
+ 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")}`);
375
471
  });
376
- const dirtyInputs = dirtyBuildInputs(doctorInvocation.repoRoot, buildInputs, doctorInvocation.commit);
377
- if (dirtyInputs.length > 0) {
378
- fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
379
- }
380
472
  console.log(`right-release ${VERSION}`);
381
473
  console.log(`config: ${configPath}`);
382
474
  console.log(`app: ${config.app}`);
383
475
  console.log(`platform: ${opts.platform}`);
384
476
  console.log(`tier: ${opts.tier ?? "<required for release/publish>"}`);
385
477
  console.log(`packageManager: ${config.packageManager}`);
386
- console.log(`rightkit-registry: ${rightKitResolution.summary}`);
478
+ if (rightKitResolution) console.log(`rightkit-registry: ${rightKitResolution.summary}`);
387
479
  console.log(`workdir: ${workdir}`);
388
480
  console.log(`hardeningscan: ${HARDENING_SCAN}`);
389
481
  if (sccacheVersion) console.log(`sccache: ${sccacheVersion}`);
@@ -392,125 +484,79 @@ if (opts.doctor) {
392
484
  console.log(`legalAcceptance: ${legalContract.acceptanceVersion}`);
393
485
  console.log(`legalManifestSha256: ${legalContract.manifestSha256}`);
394
486
  }
395
- if (target.sign?.files?.length) console.log(`sign: ${target.sign.files.join(", ")}`);
396
- if (target.publishBlocked) console.log(`publishBlocked: ${target.publishBlocked}`);
487
+ if (!failedChecks.has("target")) {
488
+ if (target.sign?.files?.length) console.log(`sign: ${target.sign.files.join(", ")}`);
489
+ if (target.publishBlocked) console.log(`publishBlocked: ${target.publishBlocked}`);
490
+ }
397
491
  const managedRoot = process.env.RIGHTKIT_MANAGED_ROOT;
398
- const installerCopyFindings = findInstallerCopyScripts({
399
- appRoot: root,
400
- workspaceRoot: process.env.RIGHTKIT_WORKSPACE_ROOT ?? path.resolve(doctorInvocation.repoRoot, ".."),
492
+ await check("artifact-storage", [], () => {
493
+ const installerCopyFindings = findInstallerCopyScripts({
494
+ appRoot: root,
495
+ workspaceRoot: process.env.RIGHTKIT_WORKSPACE_ROOT ?? path.resolve(doctorInvocation.repoRoot, ".."),
496
+ });
497
+ const storageFindings = [
498
+ ...installerCopyFindings,
499
+ ...findLargeRepositorySubdirectories({ appRoot: root, managedRoot, declared: config.storage?.declaredStores ?? [], thresholdBytes: Number(process.env.RIGHTKIT_ARTIFACT_STORAGE_THRESHOLD_BYTES ?? DEFAULT_STORAGE_THRESHOLD_BYTES) }),
500
+ ];
501
+ if (storageFindings.length) {
502
+ console.log("artifact-storage: report-only storage findings detected:");
503
+ for (const finding of storageFindings) console.log(` - ${finding.path}: ${finding.reason}`);
504
+ throw new Error(`artifact-storage found ${storageFindings.length} repository storage finding(s); move outputs to RIGHT_RELEASE_ARTIFACT_DIR`);
505
+ } else console.log("artifact-storage: no installer-copy scripts detected");
401
506
  });
402
- const storageFindings = [
403
- ...installerCopyFindings,
404
- ...findLargeRepositorySubdirectories({ appRoot: root, managedRoot, declared: config.storage?.declaredStores ?? [], thresholdBytes: Number(process.env.RIGHTKIT_ARTIFACT_STORAGE_THRESHOLD_BYTES ?? DEFAULT_STORAGE_THRESHOLD_BYTES) }),
405
- ];
406
- if (storageFindings.length) {
407
- console.log("artifact-storage: report-only storage findings detected:");
408
- for (const finding of storageFindings) console.log(` - ${finding.path}: ${finding.reason}`);
409
- fail(`artifact-storage found ${storageFindings.length} repository storage finding(s); move outputs to RIGHT_RELEASE_ARTIFACT_DIR`);
410
- } else console.log("artifact-storage: no installer-copy scripts detected");
411
507
 
412
508
  // Config above, MACHINE below. Printing config never told anyone whether this
413
- // box could finish a build; these checks do.
414
- const checks = releasePreflight({ config, configPath, root, repoRoot: doctorInvocation.repoRoot, platform: opts.platform, nativeLayout: resolvedNativeLayout, pinnedCargoLocks });
415
- console.log("preflight:");
416
- console.log(formatPreflight(checks));
417
- const failures = preflightFailures(checks);
418
- if (failures.length > 0) {
419
- fail(`preflight found ${failures.length} blocking problem(s); the build would fail on these`);
509
+ // box could finish a build; these checks do. The machine preflight reads the resolved build inputs, so it is
510
+ // BLOCKED when those could not be resolved.
511
+ let failures = [];
512
+ const machine = await check("preflight", ["target", "build-inputs"], () => {
513
+ const { nativeLayout: resolvedNativeLayout, cargoLockPaths: pinnedCargoLocks } = inputs.value;
514
+ return releasePreflight({ config, configPath, root, repoRoot: doctorInvocation.repoRoot, platform: opts.platform, nativeLayout: resolvedNativeLayout, pinnedCargoLocks });
515
+ });
516
+ if (machine.ok) {
517
+ console.log("preflight:");
518
+ console.log(formatPreflight(machine.value));
519
+ failures = preflightFailures(machine.value);
520
+ }
521
+ // --gates: run the build's pre-build source gates (release-switch scan, feature closure, release dependencies,
522
+ // devtools, production mode) over this app and report each finding as a FAIL row. They read the expanded target
523
+ // (and, on iOS, the iOS spec), so a failed one of those blocks them.
524
+ let gateFindings = [];
525
+ if (opts.gates) {
526
+ const blocker = ["target", ...(opts.platform === "ios" ? ["ios-target"] : [])].find((name) => failedChecks.has(name));
527
+ gateFindings = blocker
528
+ ? [{ name: "prebuild-gates", detail: `BLOCKED by ${blocker}` }]
529
+ : evaluatePrebuildGates({ root, config, target, platform: opts.platform, iosSpec, nativeIos, nativeAssembly, nativeFinalizer, strict: !opts.dryRun }).findings;
530
+ }
531
+ const configProblems = problems.length;
532
+ // The config findings (stderr, as every early refusal always was) and the gate rows (stdout) of this one run.
533
+ printProblems();
534
+ if (gateFindings.length) {
535
+ console.log(gateFindings.map((finding) => ` [FAIL] gate:${finding.name}: ${finding.detail}`).join("\n"));
536
+ }
537
+ const total = failures.length + gateFindings.length + configProblems;
538
+ if (total > 0) {
539
+ console.error(`right-release: preflight found ${total} blocking problem(s); the build would fail on these`);
540
+ process.exit(1);
420
541
  }
421
542
  process.exit(0);
422
543
  }
423
544
 
424
545
  let prePackScanned = false; // set once a pre-pack payload scan has seen a native binary
425
- // Source-level layer of "no test-automation surface ships": refuse a release or update build whose build command
426
- // or app Cargo manifest enables devtools, qa-native, qa-control or dev-native (also in --dry-run).
427
- // DevTools: refused for every app except an allow-listed one that declares `devtools: "content-only"` (devtools-policy.mjs).
428
- let devtoolsAllowance = { mode: "denied" };
429
- // The Rust a build compiles is checked from the manifest it compiles: targets.<os>.cargoManifest, else the declared
430
- // nativeAssembly.cargoManifest. iosPackage.prepare commands run cargo for a native iOS app, so they are scanned like any build command.
431
- const switchInputs = releaseSwitchInputs({ appRoot: root, config, target, prepare: iosSpec?.prepare ?? [], nativeIos });
432
- if (switchInputs.problems.length) fail(`${config.app ?? "app"} ios release-switch scan cannot see what is built:\n- ${switchInputs.problems.join("\n- ")}`);
433
- try {
434
- devtoolsAllowance = resolveDevtoolsAllowance({ config, root });
435
- if (devtoolsAllowance.mode === "content-only") console.log(`right-release: devtools allowance recorded: ${JSON.stringify(devtoolsEvidence(devtoolsAllowance))}`);
436
- assertNoForbiddenBuildFeatures({ root, tier: "release/update", devtools: devtoolsAllowance, cargoManifest: switchInputs.cargoManifest, manifest: config.releaseSwitches?.manifest, commands: [
437
- ...switchInputs.commands,
438
- { label: "target.prePackage", command: target.prePackage },
439
- { label: "target.package", command: target.package },
440
- { label: "nativeAssembly.packageHook", command: nativeAssembly?.packageHook },
441
- { label: "nativeAssembly.finalizer", command: nativeFinalizer },
442
- ...commandList(target.postSign).map((command, index) => ({ label: Array.isArray(target.postSign) ? `target.postSign[${index}]` : "target.postSign", command })),
443
- { label: "target.gradle", command: target.gradle },
444
- ] });
445
- } catch (error) { fail(error.message); }
446
- // M12: the frontend build resolves in production mode only (development-only conditional exports such as
447
- // `@rightkit/license/testing` never resolve). `run` pins NODE_ENV=production for build children; this refuses a
448
- // Vite config that lists the `development` resolve condition explicitly, which the pin cannot override.
449
- {
450
- const modeProblems = productionModeProblems({ appRoot: root });
451
- if (modeProblems.length) fail(`${config.app ?? "app"} release frontend is not production-only:\n- ${modeProblems.join("\n- ")}`);
452
- }
453
-
454
- // D4 (M21/M23) source layer: every environment/argument read in app source is registered in the app's switches.json and
455
- // every S1-S4 selector is compiled out of release. The inventory is `config.releaseSwitches.inventory` (relative to the
456
- // app root) or `switches.json`; a release without one fails (a dry run only checks one that exists).
457
- // The resolved feature graph (`cargo tree -e features`) is the AUTHORITATIVE test-mode check: it runs here, in the app
458
- // repository, for the release target, and a release that cannot run it fails (a dry run reports and continues).
459
- // The binary marker/name scan below is secondary evidence.
460
- const releaseSwitchInventoryPath = path.resolve(root, config.releaseSwitches?.inventory ?? "switches.json");
461
- const releaseSwitchInventoryText = existsSync(releaseSwitchInventoryPath) ? readFileSync(releaseSwitchInventoryPath, "utf8") : null;
462
- try {
463
- // A `node recipe-build.mjs --plan <JSON>` prePackage hides each step's `--features` in one argument: the closure and the
464
- // command token scan see every prepare / build / postBuild step (a plan that cannot be read or validated fails closed).
465
- const closureCommands = [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, ...commandList(target.postSign), target.gradle, ...switchInputs.cargoCommands].filter(Boolean).flatMap((c) => expandRecipeBuildCommands(c, { root }));
466
- assertReleaseDependencies({ root, config, target, commands: closureCommands });
467
- let treeText;
468
- try {
469
- const closure = resolveFeatureClosure({
470
- root,
471
- manifest: config.releaseSwitches?.manifest,
472
- cargoManifest: switchInputs.cargoManifest,
473
- rust: config.releaseSwitches?.rust,
474
- features: config.releaseSwitches?.features ?? [],
475
- // The shipped triples (never "all": the build broker refuses it); evaluated on the raw config target.
476
- triples: () => shippedRustTriples({ platform: opts.platform, target: config.targets?.[opts.platform] }),
477
- commandTexts: closureCommands,
478
- });
479
- if (closure.skipped) console.log(`right-release: feature closure skipped (${closure.skipped})`);
480
- else treeText = closure.treeText;
481
- } catch (error) {
482
- if (!opts.dryRun) throw error;
483
- console.log(`right-release: dry run: ${error.message}`);
484
- }
485
- // remoteHost: rightkit-daemon `remote` is refused in the closure unless the app declares releaseSwitches.remoteHost: true.
486
- // remoteLoopback: the daemon `remote-loopback` feature is a separate declaration (releaseSwitches.remoteLoopback: true); remoteHost does not cover it.
487
- const closureInput = { treeText, devtools: devtoolsAllowance, remoteHost: config.releaseSwitches?.remoteHost, remoteLoopback: config.releaseSwitches?.remoteLoopback, commands: closureCommands.map((c) => commandTokens(c)) };
488
- if (releaseSwitchInventoryText != null) {
489
- // iOS: only what the iPhone app compiles — the Xcode project and its local packages, plus the local crates in the
490
- // resolved aarch64-apple-ios cargo graph (feature-aware, unlike the ownership closure). Other targets walk the app root.
491
- let sourceRoots;
492
- if (opts.platform === "ios") {
493
- const repoTop = git(root, ["rev-parse", "--show-toplevel"]);
494
- // Swift side only: the Rust side is the resolved cargo graph below. The ownership scope also folds in the whole
495
- // nativeAssembly workspace, which would scan crates (desktop tools, the TUI) the iPhone never links.
496
- const swiftSide = (iosOwnershipScope({ repoRoot: repoTop, appRoot: root, target: config.targets?.ios }) ?? []).filter((dir) => dir !== ".rightkit").map((dir) => path.join(repoTop, dir));
497
- sourceRoots = [...new Set([...swiftSide, ...localCrateDirsFromTree(treeText, repoTop)])].sort();
498
- // A prepare step can stage sources from anywhere (a web bundle, a generated resource) into the app; its working directory must be
499
- // inside the scanned roots, or the release fails instead of shipping what the scan never saw. Add the directory to ownershipScope.
500
- for (const step of config.targets?.ios?.iosPackage?.prepare ?? []) {
501
- const cwd = path.resolve(root, step?.cwd ?? ".");
502
- 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`);
503
- }
504
- console.log(`right-release: source switch scan (ios): ${sourceRoots.map((dir) => path.relative(repoTop, dir) || ".").join(", ")}`);
505
- }
506
- assertNoReleaseSwitches({ ...closureInput, sourceRoot: root, sourceRoots, inventoryText: releaseSwitchInventoryText, appForbid: config.releaseSwitches?.forbid ?? [] }, "source switch inventory");
507
- } else if (treeText != null || closureInput.commands.length) {
508
- assertNoReleaseSwitches({ ...closureInput, appForbid: config.releaseSwitches?.forbid ?? [] }, "feature closure");
509
- }
510
- if (releaseSwitchInventoryText == null && !opts.dryRun) {
511
- 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")`);
512
- }
513
- } catch (error) { fail(error.message); }
546
+ // One checkpoint for the build: every config finding of this run and every source-gate finding are reported together, and
547
+ // nothing below (lock, install, checks, compile, signing) runs while any exists.
548
+ // Source-level gates (release-switch scan, devtools, feature closure, release dependencies, production mode) live in
549
+ // prebuild-gates.mjs so `doctor --gates` runs the exact same code in the same order. They read the expanded target (and the
550
+ // iOS spec), so a failed one of those blocks them.
551
+ const prebuildBlocker = ["target", ...(opts.platform === "ios" ? ["ios-target"] : [])].find((name) => failedChecks.has(name));
552
+ const { findings: prebuildFindings, devtoolsAllowance, inventoryText: releaseSwitchInventoryText } = prebuildBlocker
553
+ ? { findings: [{ name: "prebuild-gates", detail: `BLOCKED by ${prebuildBlocker}` }], devtoolsAllowance: { mode: "denied" }, inventoryText: null }
554
+ : evaluatePrebuildGates({ root, config, target, platform: opts.platform, iosSpec, nativeIos, nativeAssembly, nativeFinalizer, strict: !opts.dryRun });
555
+ if (problems.length || prebuildFindings.length) {
556
+ printProblems();
557
+ for (const finding of prebuildFindings) console.error(`right-release: ${finding.detail.startsWith("BLOCKED by ") ? `gate ${finding.name}: ` : ""}${finding.detail}`);
558
+ process.exit(1);
559
+ }
514
560
 
515
561
  const releaseLock = acquireReleaseLock(root, config.app ?? path.basename(root), opts.platform, opts.tier);
516
562
  const mode = "package";
@@ -689,7 +735,8 @@ if (opts.platform === "win" && target.signingContract === WINDOWS_NSIS_SIGNING_C
689
735
  if (target.postSign) await runCommands(target.postSign, root);
690
736
  const updaterFiles = [...new Set((target.updater?.artifacts ?? []).map((artifact) => resolveReleaseArtifactPath(root, artifact.file)))];
691
737
  if (!updaterFiles.length) fail(`${config.app} win must declare updater.artifacts for post-code-signing minisign`);
692
- await run("node", [SIGN_UPDATER, ...(opts.dryRun ? ["--dry-run"] : []), ...(target.updater?.keyFile ? ["--key-file", String(target.updater.keyFile)] : []), ...updaterFiles.flatMap((file) => ["--file", file])], root);
738
+ // The key comes from the OS credential provider inside sign-updater; targets.win.updater.keyFile is refused at config validation and never forwarded.
739
+ await run("node", [SIGN_UPDATER, ...(opts.dryRun ? ["--dry-run"] : []), ...updaterFiles.flatMap((file) => ["--file", file])], root);
693
740
  }
694
741
 
695
742
  if (target.postSign && opts.platform !== "win" && (opts.platform !== "mac" || runMacPhase("post-sign"))) {
@@ -811,7 +858,9 @@ function resolveReleaseArtifactPath(root, declared) {
811
858
  }
812
859
 
813
860
  function fail(message) {
861
+ printProblems();
814
862
  console.error(`right-release: ${message}`);
863
+ 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");
815
864
  process.exit(1);
816
865
  }
817
866
 
@@ -1086,7 +1135,7 @@ function commandTimeoutMs(command) {
1086
1135
  : undefined;
1087
1136
  }
1088
1137
 
1089
- async function validateRightKitPackageContract(root, appName, hostedWorkflows) {
1138
+ async function validateRightKitPackageContract(root, appName, hostedWorkflows, artifacts = []) {
1090
1139
  const packageJsonPath = path.join(root, "package.json");
1091
1140
  const pkg = JSON.parse(await readFile(packageJsonPath, "utf8"));
1092
1141
  const scripts = pkg.scripts ?? {};
@@ -1095,7 +1144,7 @@ async function validateRightKitPackageContract(root, appName, hostedWorkflows) {
1095
1144
  if (existsSync(workflowDir)) {
1096
1145
  const workflows = (await readdir(workflowDir)).sort();
1097
1146
  if (workflows.length) {
1098
- await validateHostedWorkflows(root, appName ?? pkg.name ?? "app", workflows, hostedWorkflows);
1147
+ await validateHostedWorkflows(root, appName ?? pkg.name ?? "app", workflows, hostedWorkflows, artifacts);
1099
1148
  }
1100
1149
  }
1101
1150
  if (pkg.packageManager !== RIGHTKIT_VERSIONS.packageManager) {
@@ -1342,9 +1391,18 @@ function declaredWorkflowPolicyRefusal(appName, workflow, source) {
1342
1391
  if (/^\s*(?:- )?uses:/m.test(lines.filter((l) => !steps.some((b) => b.includes(l))).join("\n"))) refuse("a uses: sits outside a step list");
1343
1392
  }
1344
1393
 
1345
- async function validateHostedWorkflows(root, appName, workflows, policy) {
1394
+ async function validateHostedWorkflows(root, appName, workflows, policy, artifacts = []) {
1395
+ // A repository that ships more than the released app (a monorepo's web or server pipeline) declares each workflow and why;
1396
+ // none may reach a secret but GITHUB_TOKEN or run release machinery (hosted-workflow-policy.mjs).
1397
+ if (policy !== null && typeof policy === "object" && policy.policy === DECLARED_UNRELATED_POLICY) {
1398
+ const texts = new Map();
1399
+ for (const file of workflows) texts.set(file, await readFile(path.join(root, ".github", "workflows", file), "utf8"));
1400
+ const refusal = declaredUnrelatedRefusal({ declaration: policy, workflows, read: (file) => texts.get(file), artifacts });
1401
+ if (refusal) fail(`${appName} ${DECLARED_UNRELATED_POLICY} hosted workflows refused: ${refusal}`);
1402
+ return;
1403
+ }
1346
1404
  if (policy !== "right-git-ci-only") {
1347
- fail(`${appName} hosted workflow files are forbidden: ${workflows.join(", ")}`);
1405
+ fail(`${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)`);
1348
1406
  }
1349
1407
  const manifestPath = path.join(root, ".rightgit.json");
1350
1408
  let manifest;