@rightkit/release 0.6.4 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/release.mjs CHANGED
@@ -59,6 +59,8 @@ import { releaseSwitchInputs } from "./ios-build-context.mjs";
59
59
  import { productionBuildEnv, productionModeProblems } from "./production-mode.mjs";
60
60
  import { evaluatePrebuildGates } from "./prebuild-gates.mjs";
61
61
  import { releaseConfigFieldProblems } from "./release-config.mjs";
62
+ import { describeRegistryError, formatNotRun, runAdmission } from "./admission.mjs";
63
+ import { sccacheRefusal } from "./admission-host.mjs";
62
64
 
63
65
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
64
66
  const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
@@ -132,6 +134,8 @@ const opts = {
132
134
  skipChecks: false,
133
135
  doctor: false,
134
136
  gates: false,
137
+ inspect: false,
138
+ strict: false,
135
139
  tier: null,
136
140
  resume: false,
137
141
  resumeFrom: null,
@@ -139,19 +143,37 @@ const opts = {
139
143
 
140
144
  // E01: every independent problem of one run is collected and reported together; the doctor and the build share it.
141
145
  // `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.
146
+ // but reported as `not run because <check> failed` (one line, not a finding: the failed check is the one blocking problem of that
147
+ // cause); a throw inside a check is a finding, never a crash. `fail` stays for what cannot continue (it prints what was
148
+ // collected first). The build stops at one checkpoint (before the release lock, install, checks and compile) when any finding
149
+ // exists, so no build step or other side effect ever follows a failed check.
145
150
  const problems = []; // { name, detail }
151
+ const notRunLines = []; // what a failed check kept from running; reported beside the findings, never counted as one
146
152
  const failedChecks = new Set();
153
+ // The three rows only the doctor owns (admission.mjs skips them in its own pass) are not admission findings, so nothing else puts
154
+ // their id before the text. The doctor does, as it does for every admission row: `right-release: <row>: <text>`, the text the
155
+ // build's controller prints after `right-release build: <row>: `.
156
+ const DOCTOR_OWNED_ROWS = new Set(["rightkit-registry", "build-inputs", "dirty-inputs"]);
147
157
  function reject(name, detail) {
148
158
  failedChecks.add(name);
149
- problems.push({ name, detail });
159
+ problems.push({ name, detail: opts.doctor && DOCTOR_OWNED_ROWS.has(name) ? `${name}: ${detail}` : detail });
160
+ }
161
+ // A validator that runs inside a check() refuses with this instead of calling fail(): the check records it as a finding and the
162
+ // doctor goes on to the next rule. `rowId` names the rule; `problems` lists every finding the rule has when it has more than one.
163
+ class ReleaseRefusal extends Error {
164
+ constructor(message, rowId, found = [message]) {
165
+ super(message);
166
+ this.name = "ReleaseRefusal";
167
+ this.rowId = rowId;
168
+ this.problems = found;
169
+ }
150
170
  }
151
171
  function rejectBlocked(name, blockers, what = name) {
152
172
  const blocker = blockers.find((candidate) => failedChecks.has(candidate));
153
173
  if (!blocker) return false;
154
- reject(name, `${what} BLOCKED by ${blocker}`);
174
+ // The check counts as failed for the checks that read it (they are not run either), but it adds no problem of its own.
175
+ failedChecks.add(name);
176
+ notRunLines.push(`${what} not run because ${blocker} failed`);
155
177
  return true;
156
178
  }
157
179
  async function check(name, after, run) {
@@ -159,12 +181,14 @@ async function check(name, after, run) {
159
181
  try {
160
182
  return { ok: true, value: await run() };
161
183
  } catch (error) {
162
- reject(name, error instanceof Error ? error.message : `${name} threw a non-Error value: ${String(error)}`);
184
+ if (error instanceof ReleaseRefusal) for (const detail of error.problems) reject(name, detail);
185
+ else reject(name, error instanceof Error ? error.message : `${name} threw a non-Error value: ${String(error)}`);
163
186
  return { ok: false };
164
187
  }
165
188
  }
166
189
  const printProblems = () => {
167
190
  for (const problem of problems.splice(0)) console.error(`right-release: ${problem.detail}`);
191
+ for (const line of notRunLines.splice(0)) console.error(`right-release: ${line}`);
168
192
  };
169
193
 
170
194
  for (let i = 0; i < args.length; i++) {
@@ -180,7 +204,10 @@ for (let i = 0; i < args.length; i++) {
180
204
  else if (arg === "--skip-hardening") fail("--skip-hardening is forbidden; use exact artifact/source-bound hardeningAllowances");
181
205
  else if (arg === "--doctor") opts.doctor = true;
182
206
  else if (arg === "--gates") opts.gates = true;
207
+ // The doctor applies the build's admission by default; --admission is the old spelling of that, kept so scripts still run.
183
208
  else if (arg === "--admission") opts.admission = true;
209
+ else if (arg === "--inspect") opts.inspect = true;
210
+ else if (arg === "--strict") opts.strict = true;
184
211
  else if (arg === "--resume") opts.resume = true;
185
212
  else if (arg === "--resume-from") { opts.resume = true; opts.resumeFrom = args[++i]; }
186
213
  else if (arg === "--tier") opts.tier = args[++i];
@@ -194,6 +221,8 @@ for (let i = 0; i < args.length; i++) {
194
221
  }
195
222
 
196
223
  if (opts.gates && !opts.doctor) usage(2, "--gates is valid only with --doctor");
224
+ if ((opts.inspect || opts.strict) && !opts.doctor) usage(2, `${opts.inspect ? "--inspect" : "--strict"} is valid only with --doctor`);
225
+ if (opts.inspect && opts.admission) usage(2, "--inspect and --admission contradict each other: --inspect lists newer RightKit versions as information, --admission refuses them");
197
226
  if (opts.upload) fail("combined build+upload was removed; run right-release build, then right-release upload --release <id> --tier patch|update");
198
227
  if (opts.tier && !TIERS.has(opts.tier)) usage(2, `invalid --tier: ${opts.tier} (expected patch|update)`);
199
228
  const MAC_RESUME_PHASES = ["prepackage", "sign", "package", "post-sign", "harden"];
@@ -272,14 +301,13 @@ try {
272
301
  ownership: RIGHTKIT_OWNERSHIP,
273
302
  releaseVersion: VERSION,
274
303
  boundSnapshot,
275
- // The doctor reports newer versions; run as the admission gate (--admission) it refuses a pin behind latest.
276
- admission: opts.doctor && opts.admission,
304
+ // The doctor is the build's own admission: a pin behind latest is refused, as the build refuses it. --inspect is the
305
+ // informational view (newer versions are listed, not refused).
306
+ admission: opts.doctor && !opts.inspect,
277
307
  });
278
308
  } catch (error) {
279
309
  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)}`);
310
+ reject("rightkit-registry", describeRegistryError(error, config.app ?? path.basename(root)));
283
311
  }
284
312
  if (rightKitResolution) {
285
313
  const { newer, tasks, warnings, notes } = rightKitResolution.freshness;
@@ -291,12 +319,18 @@ if (rightKitResolution) {
291
319
  }
292
320
  if (rightKitResolution) rightKitVerifiedNpm = rightKitResolution.verified.npm;
293
321
  // The contracts below are independent of each other; the two that read the proven RightKit versions need the registry resolution.
294
- // A SwiftPM app or a native iOS app without a package.json has no Node package contract to validate.
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
- });
322
+ // A SwiftPM app or a native iOS app without a package.json has no Node package contract to validate. Each package rule is its
323
+ // own finding (a failed one blocks only the rules that read its result), so one run lists every violation.
324
+ if (!((nativeIos || resolveProjectType({ appRoot: root, config, target: config.targets?.[opts.platform] }) === "swiftpm") && !existsSync(path.join(root, "package.json")))) {
325
+ const packageJson = await check("package-json", [], async () => JSON.parse(await readFile(path.join(root, "package.json"), "utf8")));
326
+ const packageLabel = () => config.app ?? packageJson.value?.name ?? "app";
327
+ await check("qa-backdoor-contract", ["package-json"], () => assertQaBackdoorContract(root, packageJson.value.scripts ?? {}));
328
+ await check("hosted-workflows", [], () => validateHostedWorkflowFiles(root, packageLabel(), config.hostedWorkflows, releaseArtifactPaths(config)));
329
+ await check("package-manager", ["package-json"], () => validatePackageManagerPin(packageJson.value, packageLabel()));
330
+ await check("package-workspace-scripts", ["package-json"], () => validateNoParentWorkspaceScripts(packageJson.value, packageLabel()));
331
+ await check("rightkit-pin-specs", ["package-json", "rightkit-registry"], () => validateRightKitPinSpecs(packageJson.value, packageLabel()));
332
+ await check("rightkit-release-supported", ["package-json", "rightkit-registry"], () => validateRightKitReleaseSupported(packageJson.value, packageLabel()));
333
+ }
300
334
  await check("rightkit-cargo-contract", ["rightkit-registry"], () => {
301
335
  validateRightKitCargoContract(root, mergeVerifiedVersions(RIGHTKIT_CARGO_ALLOWED, rightKitResolution.verified.cargo), config.app ?? path.basename(root));
302
336
  });
@@ -306,7 +340,7 @@ await check("cargo-profile-contract", [], () => assertCargoProfileContract(root,
306
340
  await check("qa-adoption", [], () => assertQaAdoption({ appRoot: root, app: config.app }));
307
341
  let target = config.targets?.[opts.platform];
308
342
  // Declarative winPackage / macPackage (#345) expand to the same hook shape every gate below already checks.
309
- // When it fails, `target` is not usable: every check that reads it is reported as BLOCKED by `target`.
343
+ // When it fails, `target` is not usable: every check that reads it is reported as not run because `target` failed.
310
344
  await check("target", [], () => {
311
345
  let expanded;
312
346
  try { expanded = expandPackageRecipeTarget(target, { platform: opts.platform, appRoot: root, config }); } catch (error) { throw new Error(`${config.app ?? "app"} ${opts.platform} ${error.message}`); }
@@ -324,7 +358,7 @@ let androidTarget; // validated Android AAB target (android only; see android-re
324
358
  let iosSpec; // validated iOS iosPackage spec (ios only; package-recipe-targets.mjs expands targets.ios, ios-package-recipe.mjs runs it)
325
359
  const app = config.app ?? "app";
326
360
  // 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.
361
+ // another (mac signing reads the sidecar config, the iOS prepare check reads the iOS spec) is not run (reported as such), never run on it.
328
362
  if (!rejectBlocked("target-checks", ["target"], "the target checks (signed, mediaSidecar, externalBin, publishBlocked and the platform signing and package contract)")) {
329
363
  if (target.signed !== true) reject("target-signed", `${app} ${opts.platform} must declare signed: true; unsigned release targets are forbidden`);
330
364
  if (target.mediaSidecar !== undefined) {
@@ -348,7 +382,7 @@ if (!rejectBlocked("target-checks", ["target"], "the target checks (signed, medi
348
382
  await check("win-msix", [], () => assertValidMsixTarget(target, app));
349
383
  } else {
350
384
  // 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");
385
+ rejectBlocked("win-contract-rules", ["win-signing-contract"], "the win NSIS and portable rules (sign.files, distinct raw EXE and installer files)");
352
386
  if (!target.prePackage?.cmd) reject("win-prepackage", `${app} win must declare prePackage for the raw EXE build`);
353
387
  const rawFiles = target.sign?.prePackageFiles;
354
388
  if (!rawFiles?.length) reject("win-sign-prepackage-files", `${app} win must declare at least one sign.prePackageFiles raw EXE`);
@@ -469,6 +503,17 @@ if (opts.doctor) {
469
503
  const dirtyInputs = dirtyBuildInputs(doctorInvocation.repoRoot, inputs.value.buildInputs, doctorInvocation.commit);
470
504
  if (dirtyInputs.length > 0) throw new Error(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
471
505
  });
506
+ // The build's own admission rows (admission.mjs): the files the build would read and this machine, run with the texts and the
507
+ // rows the build runs before it takes its lock. The registry, the build inputs and the dirty inputs are this doctor's own steps.
508
+ let notRun = [];
509
+ const admission = await check("admission", ["target"], () => runAdmission({
510
+ root, repoRoot: doctorInvocation.repoRoot, configPath, config, platform: opts.platform, target, tiers: ["files", "host"],
511
+ mode: "doctor", skipDoctorOwned: true, skipChecks: opts.skipChecks, commit: doctorInvocation.commit,
512
+ }));
513
+ if (admission.ok) {
514
+ for (const finding of admission.value.findings) reject(finding.row, `${finding.row}: ${finding.detail}`);
515
+ notRun = admission.value.notRun;
516
+ }
472
517
  console.log(`right-release ${VERSION}`);
473
518
  console.log(`config: ${configPath}`);
474
519
  console.log(`app: ${config.app}`);
@@ -534,7 +579,9 @@ if (opts.doctor) {
534
579
  if (gateFindings.length) {
535
580
  console.log(gateFindings.map((finding) => ` [FAIL] gate:${finding.name}: ${finding.detail}`).join("\n"));
536
581
  }
537
- const total = failures.length + gateFindings.length + configProblems;
582
+ // A row that could not run on this host is not a pass; --strict makes it a blocking problem.
583
+ for (const line of formatNotRun({ notRun })) console.error(`right-release: ${line}`);
584
+ const total = failures.length + gateFindings.length + configProblems + (opts.strict ? notRun.length : 0);
538
585
  if (total > 0) {
539
586
  console.error(`right-release: preflight found ${total} blocking problem(s); the build would fail on these`);
540
587
  process.exit(1);
@@ -876,10 +923,7 @@ function assertSharedSccachePrerequisite() {
876
923
  const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
877
924
  const version = output.match(/\b(\d+)\.(\d+)\.(\d+)\b/);
878
925
  const valid = result.status === 0 && version && isAtLeastVersion(version.slice(1).map(Number), [0, 15, 0]);
879
- if (!valid) {
880
- const found = version ? ` (found ${version[0]})` : "";
881
- fail(`shared cache mode requires sccache >= 0.15.0${found}. Install with: cargo install sccache --locked; then verify: sccache --version`);
882
- }
926
+ if (!valid) fail(sccacheRefusal(version?.[0]));
883
927
  return version[0];
884
928
  }
885
929
 
@@ -1135,38 +1179,49 @@ function commandTimeoutMs(command) {
1135
1179
  : undefined;
1136
1180
  }
1137
1181
 
1138
- async function validateRightKitPackageContract(root, appName, hostedWorkflows, artifacts = []) {
1139
- const packageJsonPath = path.join(root, "package.json");
1140
- const pkg = JSON.parse(await readFile(packageJsonPath, "utf8"));
1141
- const scripts = pkg.scripts ?? {};
1142
- assertQaBackdoorContract(root, scripts);
1182
+ // The workflow files of the repository, when it has any, against the hostedWorkflows policy.
1183
+ async function validateHostedWorkflowFiles(root, appName, hostedWorkflows, artifacts = []) {
1143
1184
  const workflowDir = path.join(root, ".github", "workflows");
1144
- if (existsSync(workflowDir)) {
1145
- const workflows = (await readdir(workflowDir)).sort();
1146
- if (workflows.length) {
1147
- await validateHostedWorkflows(root, appName ?? pkg.name ?? "app", workflows, hostedWorkflows, artifacts);
1148
- }
1149
- }
1185
+ if (!existsSync(workflowDir)) return;
1186
+ const workflows = (await readdir(workflowDir)).sort();
1187
+ if (workflows.length) await validateHostedWorkflows(root, appName, workflows, hostedWorkflows, artifacts);
1188
+ }
1189
+
1190
+ function validatePackageManagerPin(pkg, appName) {
1150
1191
  if (pkg.packageManager !== RIGHTKIT_VERSIONS.packageManager) {
1151
- fail(`${appName ?? pkg.name ?? "app"} packageManager must be ${RIGHTKIT_VERSIONS.packageManager}, got ${pkg.packageManager ?? "<missing>"}`);
1192
+ throw new ReleaseRefusal(`${appName} packageManager must be ${RIGHTKIT_VERSIONS.packageManager}, got ${pkg.packageManager ?? "<missing>"}`, "package-manager");
1152
1193
  }
1153
- if (JSON.stringify(scripts).includes("../tools/right-release") || JSON.stringify(scripts).includes("../rightkit/packages/release")) {
1154
- fail(`${appName ?? pkg.name ?? "app"} package.json must call the right-release bin, not parent-workspace release source`);
1194
+ }
1195
+
1196
+ function validateNoParentWorkspaceScripts(pkg, appName) {
1197
+ const scripts = JSON.stringify(pkg.scripts ?? {});
1198
+ if (scripts.includes("../tools/right-release") || scripts.includes("../rightkit/packages/release")) {
1199
+ throw new ReleaseRefusal(`${appName} package.json must call the right-release bin, not parent-workspace release source`, "package-workspace-scripts");
1155
1200
  }
1201
+ }
1202
+
1203
+ // Every @rightkit/* dependency, each violation its own line of the finding.
1204
+ function validateRightKitPinSpecs(pkg, appName) {
1205
+ const found = [];
1156
1206
  const rightKitDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
1157
1207
  for (const [name, specifier] of Object.entries(rightKitDeps).filter(([name]) => name.startsWith("@rightkit/"))) {
1158
1208
  if (FORBIDDEN_RIGHTKIT_SPEC.test(String(specifier))) {
1159
- fail(`${appName ?? pkg.name ?? "app"} ${name} must come from the published npm package, not ${specifier}`);
1209
+ found.push(`${appName} ${name} must come from the published npm package, not ${specifier}`);
1210
+ continue;
1160
1211
  }
1161
1212
  const allowed = RIGHTKIT_ALLOWED.get(name);
1162
1213
  if (allowed && !rightKitVerifiedNpm.get(name)?.has(specifier) && !rightKitVersionAllowed(allowed, specifier)) {
1163
- fail(`${appName ?? pkg.name ?? "app"} ${name} must be one of ${[...allowed].join(", ")}, got ${specifier}`);
1214
+ found.push(`${appName} ${name} must be one of ${[...allowed].join(", ")}, got ${specifier}`);
1164
1215
  }
1165
1216
  }
1217
+ if (found.length) throw new ReleaseRefusal(found.join("\n"), "rightkit-pin-specs", found);
1218
+ }
1219
+
1220
+ function validateRightKitReleaseSupported(pkg, appName) {
1166
1221
  const releaseAllowed = RIGHTKIT_ALLOWED.get("@rightkit/release");
1167
1222
  const releaseSpecifier = pkg.devDependencies?.["@rightkit/release"];
1168
1223
  if (!releaseAllowed || !(rightKitVerifiedNpm.get("@rightkit/release")?.has(releaseSpecifier) || rightKitVersionAllowed(releaseAllowed, releaseSpecifier))) {
1169
- fail(`${appName ?? pkg.name ?? "app"} must depend on a supported @rightkit/release version in devDependencies`);
1224
+ throw new ReleaseRefusal(`${appName} must depend on a supported @rightkit/release version in devDependencies`, "rightkit-release-supported");
1170
1225
  }
1171
1226
  }
1172
1227
 
@@ -1345,21 +1400,21 @@ async function signExistingRenderRefusal(root, manifest, source) {
1345
1400
  async function validateDeclaredWorkflowBlock(root, appName, manifest, declaredFiles) {
1346
1401
  const validator = (await loadRightKitCiModule(root, "workflows.mjs", "validateWorkflows"))?.validateWorkflows;
1347
1402
  const renderLanes = await loadRightKitCiRenderer(root);
1348
- if (!validator || !renderLanes) fail(`${appName} right-git-ci-only policy cannot verify .rightgit.json workflows: @rightkit/ci is not available (install @rightkit/ci as a devDependency)`);
1403
+ if (!validator || !renderLanes) throw new ReleaseRefusal(`${appName} right-git-ci-only policy cannot verify .rightgit.json workflows: @rightkit/ci is not available (install @rightkit/ci as a devDependency)`, "hosted-workflows");
1349
1404
  const errors = [];
1350
1405
  validator(manifest, errors);
1351
- if (errors.length > 0) fail(`${appName} right-git-ci-only policy refuses the .rightgit.json workflows block: ${errors.join("; ")}`);
1406
+ if (errors.length > 0) throw new ReleaseRefusal(`${appName} right-git-ci-only policy refuses the .rightgit.json workflows block: ${errors.join("; ")}`, "hosted-workflows");
1352
1407
  let rendered;
1353
1408
  try {
1354
1409
  rendered = renderLanes(manifest, root);
1355
1410
  } catch (error) {
1356
- fail(`${appName} right-git-ci-only policy cannot render the .rightgit.json workflows block: ${error.message}`);
1411
+ throw new ReleaseRefusal(`${appName} right-git-ci-only policy cannot render the .rightgit.json workflows block: ${error.message}`, "hosted-workflows");
1357
1412
  }
1358
1413
  for (const file of declaredFiles) {
1359
1414
  const expected = rendered.find((entry) => entry.filename === file)?.content;
1360
1415
  const actual = await readFile(path.join(root, ".github", "workflows", file), "utf8");
1361
1416
  if (typeof expected !== "string" || actual.replace(/\r\n?/g, "\n") !== expected.replace(/\r\n?/g, "\n")) {
1362
- fail(`${appName} ${file} is not byte-for-byte the @rightkit/ci render of its .rightgit.json workflows entry (run right-git sync)`);
1417
+ throw new ReleaseRefusal(`${appName} ${file} is not byte-for-byte the @rightkit/ci render of its .rightgit.json workflows entry (run right-git sync)`, "hosted-workflows");
1363
1418
  }
1364
1419
  }
1365
1420
  }
@@ -1368,7 +1423,7 @@ async function validateDeclaredWorkflowBlock(root, appName, manifest, declaredFi
1368
1423
  // `contents: read`, no other permissions key exists, no secrets reference, every `uses:` is commit-pinned, and every
1369
1424
  // checkout step sets persist-credentials: false. Steps are split on step-list indentation, not matched as free text.
1370
1425
  function declaredWorkflowPolicyRefusal(appName, workflow, source) {
1371
- const refuse = (why) => fail(`${appName} right-git managed ${workflow} violates the declared-workflow policy: ${why}`);
1426
+ const refuse = (why) => { throw new ReleaseRefusal(`${appName} right-git managed ${workflow} violates the declared-workflow policy: ${why}`, "hosted-workflows"); };
1372
1427
  const lines = source.replace(/\r\n?/g, "\n").split("\n");
1373
1428
  const permissionsAt = lines.flatMap((line, i) => (/^\s*permissions\s*:/.test(line) ? [i] : []));
1374
1429
  if (permissionsAt.length !== 1 || lines[permissionsAt[0]] !== "permissions:" || lines[permissionsAt[0] + 1] !== " contents: read" || /^ {2,}\S/.test(lines[permissionsAt[0] + 2] ?? "")) refuse("permissions must be exactly one top-level `contents: read`");
@@ -1398,18 +1453,18 @@ async function validateHostedWorkflows(root, appName, workflows, policy, artifac
1398
1453
  const texts = new Map();
1399
1454
  for (const file of workflows) texts.set(file, await readFile(path.join(root, ".github", "workflows", file), "utf8"));
1400
1455
  const refusal = declaredUnrelatedRefusal({ declaration: policy, workflows, read: (file) => texts.get(file), artifacts });
1401
- if (refusal) fail(`${appName} ${DECLARED_UNRELATED_POLICY} hosted workflows refused: ${refusal}`);
1456
+ if (refusal) throw new ReleaseRefusal(`${appName} ${DECLARED_UNRELATED_POLICY} hosted workflows refused: ${refusal}`, "hosted-workflows");
1402
1457
  return;
1403
1458
  }
1404
1459
  if (policy !== "right-git-ci-only") {
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)`);
1460
+ throw new ReleaseRefusal(`${appName} hosted workflow files are forbidden: ${workflows.join(", ")} (set hostedWorkflows to "right-git-ci-only", or to { policy: "${DECLARED_UNRELATED_POLICY}", files: { <file>: <reason> } } when they are a pipeline unrelated to the released app)`, "hosted-workflows");
1406
1461
  }
1407
1462
  const manifestPath = path.join(root, ".rightgit.json");
1408
1463
  let manifest;
1409
1464
  try {
1410
1465
  manifest = JSON.parse(await readFile(manifestPath, "utf8"));
1411
1466
  } catch {
1412
- fail(`${appName} right-git-ci-only policy requires a valid .rightgit.json`);
1467
+ throw new ReleaseRefusal(`${appName} right-git-ci-only policy requires a valid .rightgit.json`, "hosted-workflows");
1413
1468
  }
1414
1469
  const lanes = manifest?.lanes;
1415
1470
  // Mirrors the `lanes` enum in @rightkit/ci manifest.schema.json.
@@ -1421,29 +1476,29 @@ async function validateHostedWorkflows(root, appName, workflows, policy, artifac
1421
1476
  || !(lanes.includes("ci") || lanes.includes("swift-check"))
1422
1477
  || new Set(lanes).size !== lanes.length
1423
1478
  || lanes.some((lane) => !safeLanes.has(lane))) {
1424
- fail(`${appName} right-git-ci-only policy requires compile=github-actions-only plus unique .rightgit.json lanes drawn from [${[...safeLanes].map((lane) => `"${lane}"`).join(", ")}] and including "ci" or "swift-check"`);
1479
+ throw new ReleaseRefusal(`${appName} right-git-ci-only policy requires compile=github-actions-only plus unique .rightgit.json lanes drawn from [${[...safeLanes].map((lane) => `"${lane}"`).join(", ")}] and including "ci" or "swift-check"`, "hosted-workflows");
1425
1480
  }
1426
1481
  // Workflows declared under the manifest's `workflows` block (@rightkit/ci workflows.mjs: explicit `file`,
1427
1482
  // else `<name>.yml`) are managed files too, in addition to the lane files.
1428
1483
  const declaredWorkflows = manifest?.workflows;
1429
1484
  if (declaredWorkflows !== undefined && (declaredWorkflows === null || typeof declaredWorkflows !== "object" || Array.isArray(declaredWorkflows))) {
1430
- fail(`${appName} right-git-ci-only policy requires .rightgit.json workflows to be an object`);
1485
+ throw new ReleaseRefusal(`${appName} right-git-ci-only policy requires .rightgit.json workflows to be an object`, "hosted-workflows");
1431
1486
  }
1432
1487
  const declaredFiles = Object.entries(declaredWorkflows ?? {}).map(([name, config]) => {
1433
- if (config === null || typeof config !== "object" || Array.isArray(config)) fail(`${appName} right-git-ci-only policy requires .rightgit.json workflows.${name} to be an object`);
1488
+ if (config === null || typeof config !== "object" || Array.isArray(config)) throw new ReleaseRefusal(`${appName} right-git-ci-only policy requires .rightgit.json workflows.${name} to be an object`, "hosted-workflows");
1434
1489
  const file = typeof config?.file === "string" ? config.file : `${name}.yml`;
1435
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,60}\.yml$/.test(file)) fail(`${appName} right-git-ci-only policy refuses workflow file name ${JSON.stringify(file)} declared by .rightgit.json`);
1490
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,60}\.yml$/.test(file)) throw new ReleaseRefusal(`${appName} right-git-ci-only policy refuses workflow file name ${JSON.stringify(file)} declared by .rightgit.json`, "hosted-workflows");
1436
1491
  return file;
1437
1492
  });
1438
1493
  const expectedWorkflows = [...new Set([...lanes.map((lane) => `${lane}.yml`), ...declaredFiles])].sort();
1439
1494
  if (JSON.stringify(workflows) !== JSON.stringify(expectedWorkflows)) {
1440
- fail(`${appName} right-git-ci-only policy requires workflows declared by .rightgit.json; expected ${expectedWorkflows.join(", ")}, got: ${workflows.join(", ")}`);
1495
+ throw new ReleaseRefusal(`${appName} right-git-ci-only policy requires workflows declared by .rightgit.json; expected ${expectedWorkflows.join(", ")}, got: ${workflows.join(", ")}`, "hosted-workflows");
1441
1496
  }
1442
1497
  if (declaredFiles.length > 0) await validateDeclaredWorkflowBlock(root, appName, manifest, declaredFiles);
1443
1498
  for (const workflow of expectedWorkflows) {
1444
1499
  const source = await readFile(path.join(root, ".github", "workflows", workflow), "utf8");
1445
1500
  if (!source.startsWith("# Managed by right-git")) {
1446
- fail(`${appName} ${workflow} is not marked as right-git managed`);
1501
+ throw new ReleaseRefusal(`${appName} ${workflow} is not marked as right-git managed`, "hosted-workflows");
1447
1502
  }
1448
1503
  if (declaredFiles.includes(workflow)) declaredWorkflowPolicyRefusal(appName, workflow, source);
1449
1504
  const jobSpan = (jobName) => {
@@ -1530,7 +1585,7 @@ async function validateHostedWorkflows(root, appName, workflows, policy, artifac
1530
1585
  const reason = manifest?.signExisting && typeof manifest.signExisting === "object"
1531
1586
  ? (signExistingRefusal(source) ?? await signExistingRenderRefusal(root, manifest, source))
1532
1587
  : "the manifest declares no signExisting";
1533
- if (reason !== null) fail(`${appName} right-git managed sign-existing.yml is not the protected sign-existing shape: ${reason}`);
1588
+ if (reason !== null) throw new ReleaseRefusal(`${appName} right-git managed sign-existing.yml is not the protected sign-existing shape: ${reason}`, "hosted-workflows");
1534
1589
  protectedSignExisting = true;
1535
1590
  }
1536
1591
  // @rightkit/ci `swift.privatePackages` credential: allowed only in the workflow/job pairs and on the
@@ -1573,7 +1628,7 @@ async function validateHostedWorkflows(root, appName, workflows, policy, artifac
1573
1628
  || refusedSecrets.length > 0) {
1574
1629
  const reasons = [...new Set(refusedSecrets)];
1575
1630
  const detail = reasons.length > 10 ? [...reasons.slice(0, 10), `${reasons.length - 10} more`] : reasons;
1576
- fail(`${appName} right-git managed ${workflow} requests release-capable permissions or secrets${detail.length > 0 ? `: ${detail.join("; ")}` : ""}`);
1631
+ throw new ReleaseRefusal(`${appName} right-git managed ${workflow} requests release-capable permissions or secrets${detail.length > 0 ? `: ${detail.join("; ")}` : ""}`, "hosted-workflows");
1577
1632
  }
1578
1633
  }
1579
1634
  }
@@ -15,7 +15,7 @@
15
15
  "stagedNpm": {
16
16
  "@rightkit/app-update": "0.4.2",
17
17
  "@rightkit/ax": "0.3.2",
18
- "@rightkit/ci": "0.3.0",
18
+ "@rightkit/ci": "0.3.1",
19
19
  "@rightkit/doc-render": "0.2.1",
20
20
  "@rightkit/hooks": "0.1.2",
21
21
  "@rightkit/i18n": "0.2.0",
@@ -32,7 +32,7 @@
32
32
  "@rightkit/qa": "0.6.1",
33
33
  "@rightkit/qa-chrome": "0.1.11",
34
34
  "@rightkit/react-shell": "0.7.2",
35
- "@rightkit/release": "0.6.4",
35
+ "@rightkit/release": "0.6.5",
36
36
  "@rightkit/settings-ui": "0.7.0",
37
37
  "@rightkit/tauri": "0.3.0",
38
38
  "@rightkit/tauri-shell": "0.1.3",
@@ -230,7 +230,8 @@
230
230
  "0.6.0",
231
231
  "0.6.1",
232
232
  "0.6.2",
233
- "0.6.3"
233
+ "0.6.3",
234
+ "0.6.4"
234
235
  ],
235
236
  "@rightkit/qa": [
236
237
  "0.1.0",
@@ -391,7 +392,8 @@
391
392
  "0.2.43",
392
393
  "0.2.44",
393
394
  "0.2.45",
394
- "0.2.49"
395
+ "0.2.49",
396
+ "0.3.0"
395
397
  ],
396
398
  "@rightkit/react-shell": [
397
399
  "0.6.4",
@@ -516,7 +518,7 @@
516
518
  "stagedSwift": {
517
519
  "rightkit-swift": {
518
520
  "url": "https://github.com/Orthic-Labs/rightkit",
519
- "version": "0.7.0"
521
+ "version": "0.8.1"
520
522
  }
521
523
  },
522
524
  "registry": {
@@ -531,7 +533,7 @@
531
533
  "rightkit-browser": "0.3.6",
532
534
  "rightkit-cache": "0.2.3",
533
535
  "rightkit-channel-memory": "0.2.1",
534
- "rightkit-child-process": "0.5.5",
536
+ "rightkit-child-process": "0.5.6",
535
537
  "rightkit-compose": "0.2.0",
536
538
  "rightkit-contracts": "0.1.7",
537
539
  "rightkit-control": "0.3.0",
@@ -573,7 +575,7 @@
573
575
  "rightkit-platform-rules": "0.1.0",
574
576
  "rightkit-playback": "0.3.0",
575
577
  "rightkit-process": "0.4.0",
576
- "rightkit-qa": "0.8.0",
578
+ "rightkit-qa": "0.9.0",
577
579
  "rightkit-qa-driver": "0.7.0",
578
580
  "rightkit-qts": "0.2.6",
579
581
  "rightkit-qts-ggml": "0.1.2",
@@ -626,7 +628,7 @@
626
628
  "@rightkit/qa": "0.6.1",
627
629
  "@rightkit/qa-chrome": "0.1.11",
628
630
  "@rightkit/react-shell": "0.7.2",
629
- "@rightkit/release": "0.6.3",
631
+ "@rightkit/release": "0.6.4",
630
632
  "@rightkit/settings-ui": "0.7.0",
631
633
  "@rightkit/shell": "0.1.1",
632
634
  "@rightkit/tauri": "0.3.0",