@rightkit/release 0.6.3 → 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.
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 { 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";
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.
@@ -377,24 +457,25 @@ function releasePreflight({ config, configPath, root, repoRoot, platform, native
377
457
  }
378
458
 
379
459
  if (opts.doctor) {
380
- 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({
381
462
  repoRoot: doctorInvocation.repoRoot,
382
463
  appRoot: root,
383
464
  configPath,
384
465
  config,
385
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")}`);
386
471
  });
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")}`);
390
- }
391
472
  console.log(`right-release ${VERSION}`);
392
473
  console.log(`config: ${configPath}`);
393
474
  console.log(`app: ${config.app}`);
394
475
  console.log(`platform: ${opts.platform}`);
395
476
  console.log(`tier: ${opts.tier ?? "<required for release/publish>"}`);
396
477
  console.log(`packageManager: ${config.packageManager}`);
397
- console.log(`rightkit-registry: ${rightKitResolution.summary}`);
478
+ if (rightKitResolution) console.log(`rightkit-registry: ${rightKitResolution.summary}`);
398
479
  console.log(`workdir: ${workdir}`);
399
480
  console.log(`hardeningscan: ${HARDENING_SCAN}`);
400
481
  if (sccacheVersion) console.log(`sccache: ${sccacheVersion}`);
@@ -403,125 +484,79 @@ if (opts.doctor) {
403
484
  console.log(`legalAcceptance: ${legalContract.acceptanceVersion}`);
404
485
  console.log(`legalManifestSha256: ${legalContract.manifestSha256}`);
405
486
  }
406
- if (target.sign?.files?.length) console.log(`sign: ${target.sign.files.join(", ")}`);
407
- 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
+ }
408
491
  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, ".."),
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");
412
506
  });
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
507
 
423
508
  // 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`);
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);
431
541
  }
432
542
  process.exit(0);
433
543
  }
434
544
 
435
545
  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); }
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
+ }
525
560
 
526
561
  const releaseLock = acquireReleaseLock(root, config.app ?? path.basename(root), opts.platform, opts.tier);
527
562
  const mode = "package";
@@ -700,7 +735,8 @@ if (opts.platform === "win" && target.signingContract === WINDOWS_NSIS_SIGNING_C
700
735
  if (target.postSign) await runCommands(target.postSign, root);
701
736
  const updaterFiles = [...new Set((target.updater?.artifacts ?? []).map((artifact) => resolveReleaseArtifactPath(root, artifact.file)))];
702
737
  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);
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);
704
740
  }
705
741
 
706
742
  if (target.postSign && opts.platform !== "win" && (opts.platform !== "mac" || runMacPhase("post-sign"))) {
@@ -822,7 +858,9 @@ function resolveReleaseArtifactPath(root, declared) {
822
858
  }
823
859
 
824
860
  function fail(message) {
861
+ printProblems();
825
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");
826
864
  process.exit(1);
827
865
  }
828
866
 
@@ -1097,7 +1135,7 @@ function commandTimeoutMs(command) {
1097
1135
  : undefined;
1098
1136
  }
1099
1137
 
1100
- async function validateRightKitPackageContract(root, appName, hostedWorkflows) {
1138
+ async function validateRightKitPackageContract(root, appName, hostedWorkflows, artifacts = []) {
1101
1139
  const packageJsonPath = path.join(root, "package.json");
1102
1140
  const pkg = JSON.parse(await readFile(packageJsonPath, "utf8"));
1103
1141
  const scripts = pkg.scripts ?? {};
@@ -1106,7 +1144,7 @@ async function validateRightKitPackageContract(root, appName, hostedWorkflows) {
1106
1144
  if (existsSync(workflowDir)) {
1107
1145
  const workflows = (await readdir(workflowDir)).sort();
1108
1146
  if (workflows.length) {
1109
- await validateHostedWorkflows(root, appName ?? pkg.name ?? "app", workflows, hostedWorkflows);
1147
+ await validateHostedWorkflows(root, appName ?? pkg.name ?? "app", workflows, hostedWorkflows, artifacts);
1110
1148
  }
1111
1149
  }
1112
1150
  if (pkg.packageManager !== RIGHTKIT_VERSIONS.packageManager) {
@@ -1353,9 +1391,18 @@ function declaredWorkflowPolicyRefusal(appName, workflow, source) {
1353
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");
1354
1392
  }
1355
1393
 
1356
- 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
+ }
1357
1404
  if (policy !== "right-git-ci-only") {
1358
- 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)`);
1359
1406
  }
1360
1407
  const manifestPath = path.join(root, ".rightgit.json");
1361
1408
  let manifest;
@@ -1,4 +1,6 @@
1
- const DEFAULT_API_BASE = "https://api.spoares.com";
1
+ import { RIGHT_SUITE_API_ORIGIN } from "./suite-api.generated.mjs";
2
+
3
+ const DEFAULT_API_BASE = RIGHT_SUITE_API_ORIGIN;
2
4
  const REGISTRATION_ROUTES = new Set([
3
5
  "/v1/admin/apps/patches",
4
6
  "/v1/admin/apps/releases",