@rightkit/release 0.6.4 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +109 -7
  2. package/admission-files.mjs +279 -0
  3. package/admission-host.mjs +403 -0
  4. package/admission.mjs +146 -0
  5. package/android-play-upload.mjs +4 -4
  6. package/app-check.mjs +1326 -0
  7. package/apple-profile-create.mjs +4 -3
  8. package/apple-profile-renew.mjs +5 -4
  9. package/bin/darwin/rightkit-swap +0 -0
  10. package/bin/rightkit-gate.mjs +495 -69
  11. package/binary-license-notices.mjs +2 -1
  12. package/build-release.mjs +87 -26
  13. package/cache-command.mjs +4 -3
  14. package/cargo-license-notices.mjs +2 -1
  15. package/cargo-manifest-text.mjs +25 -0
  16. package/cargo-profile-contract.mjs +26 -13
  17. package/cli/right-release.mjs +45 -5
  18. package/command-spec.mjs +4 -5
  19. package/deps.mjs +5 -4
  20. package/deterministic-scans.mjs +301 -37
  21. package/development.mjs +5 -5
  22. package/github-release.mjs +5 -3
  23. package/github-upload-gate.mjs +6 -6
  24. package/init-command.mjs +134 -8
  25. package/inventory-check.mjs +12 -1
  26. package/ios-upload.mjs +4 -4
  27. package/mirror-root-artifact.mjs +3 -4
  28. package/npm-license-notices.mjs +2 -1
  29. package/outdated-command.mjs +91 -0
  30. package/ownership-dependency-allowlist.json +2 -0
  31. package/package.json +41 -3
  32. package/pipeline-admission.mjs +12 -0
  33. package/prebuild-gates.mjs +7 -3
  34. package/publish-cargo.mjs +215 -3
  35. package/publish-update.mjs +5 -3
  36. package/release-config.mjs +36 -5
  37. package/release-dependencies-command.mjs +4 -3
  38. package/release-qa-audit.mjs +12 -10
  39. package/release-qa-command.mjs +42 -7
  40. package/release-qa-hardening.mjs +10 -3
  41. package/release-qa-policy.mjs +214 -47
  42. package/release.mjs +117 -62
  43. package/rightkit-registry-resolver.mjs +5 -2
  44. package/rightkit-versions.json +18 -13
  45. package/stage-sidecar-command.mjs +5 -5
  46. package/test-command.mjs +3 -2
  47. package/upload-release.mjs +3 -2
  48. package/web-build.mjs +2 -1
package/release.mjs CHANGED
@@ -58,7 +58,9 @@ import { runIosPackageRecipe } from "./ios-package-recipe.mjs";
58
58
  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
- import { releaseConfigFieldProblems } from "./release-config.mjs";
61
+ import { defaultReleaseConfigFile, importReleaseConfig, 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");
@@ -124,7 +126,7 @@ const SWIFT_PRIVATE_DISPATCH_WORKFLOWS = new Set(["release-candidate.yml", "rege
124
126
 
125
127
  const args = process.argv.slice(2);
126
128
  const opts = {
127
- config: "right-release.config.mjs",
129
+ config: defaultReleaseConfigFile(),
128
130
  platform: process.platform === "win32" ? "win" : process.platform === "darwin" ? "mac" : process.platform,
129
131
  upload: false,
130
132
  install: false,
@@ -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"];
@@ -216,7 +245,7 @@ if (opts.doctor) {
216
245
  doctorInvocation = { repoRoot, commit };
217
246
  }
218
247
  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)}`); }
248
+ try { config = await importReleaseConfig(configPath); } catch (error) { fail(`release config could not be loaded (${configPath}): ${error instanceof Error ? error.message : String(error)}`); }
220
249
  if (!config) usage(2, `config did not export default: ${configPath}`);
221
250
  if (config.schema !== 1) fail(`unsupported config schema: ${config.schema ?? "<missing>"} (expected 1)`);
222
251
  // Field-level config refusals (updater.keyFile: keys come from the OS credential provider) are listed with every other finding.
@@ -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);
@@ -794,7 +841,7 @@ releaseLock.release();
794
841
 
795
842
  function usage(code, message) {
796
843
  if (message) console.error(`right-release: ${message}\n`);
797
- console.error("usage: right-release build --platform mac|win [--config right-release.config.mjs] (build is tier-neutral; tier applies only to: right-release upload --release <id> --platform mac|win --tier patch|update)");
844
+ console.error("usage: right-release build --platform mac|win [--config right-release.config.json] (build is tier-neutral; tier applies only to: right-release upload --release <id> --platform mac|win --tier patch|update)");
798
845
  process.exit(code);
799
846
  }
800
847
 
@@ -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
  }
@@ -29,6 +29,7 @@ import { parse as parseToml } from "smol-toml";
29
29
  import { isExcludedScanDirectory } from "./cargo-contract.mjs";
30
30
  import { resolveSharedCacheRoot } from "./cache-policy.mjs";
31
31
  import { parsePnpmYaml } from "./ownership-lockfiles.mjs";
32
+ import { UPDATE_COMMAND } from "./outdated-command.mjs";
32
33
  import { compareVersions } from "./rightkit-sync.mjs";
33
34
 
34
35
  export const SNAPSHOT_SCHEMA = 1;
@@ -42,7 +43,9 @@ export const REGISTRIES = Object.freeze({
42
43
  const CRATES_IO_SOURCES = new Set(["registry+https://github.com/rust-lang/crates.io-index", "registry+https://index.crates.io/"]);
43
44
  const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
44
45
  const USER_AGENT = "right-release-resolver/1 (https://github.com/bogusyogi/rightkit)";
45
- const FIX = "run `right-update run` (@rightkit/outdated 0.2.0) to move the pin";
46
+ // The updater is @rightkit/outdated, a dependency of this package; `right-release update` runs its bin (pnpm links only an
47
+ // app's direct dependencies' bins, so `pnpm exec right-update` does not exist in an app that has just @rightkit/release).
48
+ export const FIX = `run \`${UPDATE_COMMAND}\` (the pinned @rightkit/outdated updater; it reads .rightkit/update.json) to move the pin`;
46
49
  const DEFAULT_ATTEMPTS = 3;
47
50
  const DEFAULT_TIMEOUT_MS = 15000;
48
51
  const CONCURRENCY = 6;
@@ -589,7 +592,7 @@ function matchesPattern(name, pattern) {
589
592
  }
590
593
  /**
591
594
  * Holds named in the nearest .rightkit/update.json (app root up to the repository boundary). A hold is a migration
592
- * lane task for right-update; it is reported next to a stale pin and never grants permission to seal it.
595
+ * lane task for the updater (right-update); it is reported next to a stale pin and never grants permission to seal it.
593
596
  * Only holds the file itself declares are read (policy.held, policy.tier.held); no built-in pattern is applied here.
594
597
  */
595
598
  export function readUpdateHolds(root) {
@@ -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",
@@ -30,9 +30,9 @@
30
30
  "@rightkit/pdf-ui": "0.2.0",
31
31
  "@rightkit/platform-ui": "0.4.0",
32
32
  "@rightkit/qa": "0.6.1",
33
- "@rightkit/qa-chrome": "0.1.11",
33
+ "@rightkit/qa-chrome": "0.1.12",
34
34
  "@rightkit/react-shell": "0.7.2",
35
- "@rightkit/release": "0.6.4",
35
+ "@rightkit/release": "0.6.6",
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,9 @@
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",
235
+ "0.6.5"
234
236
  ],
235
237
  "@rightkit/qa": [
236
238
  "0.1.0",
@@ -372,7 +374,8 @@
372
374
  "0.1.5",
373
375
  "0.1.6",
374
376
  "0.1.8",
375
- "0.1.10"
377
+ "0.1.10",
378
+ "0.1.11"
376
379
  ],
377
380
  "@rightkit/updates": [
378
381
  "0.2.4"
@@ -391,7 +394,8 @@
391
394
  "0.2.43",
392
395
  "0.2.44",
393
396
  "0.2.45",
394
- "0.2.49"
397
+ "0.2.49",
398
+ "0.3.0"
395
399
  ],
396
400
  "@rightkit/react-shell": [
397
401
  "0.6.4",
@@ -484,7 +488,7 @@
484
488
  "rightkit-pdf": "0.3.3",
485
489
  "rightkit-platform-rules": "0.1.0",
486
490
  "rightkit-playback": "0.3.0",
487
- "rightkit-qa": "0.9.0",
491
+ "rightkit-qa": "0.9.1",
488
492
  "rightkit-qa-driver": "0.7.0",
489
493
  "rightkit-record": "0.3.3",
490
494
  "rightkit-review": "0.4.0",
@@ -516,7 +520,7 @@
516
520
  "stagedSwift": {
517
521
  "rightkit-swift": {
518
522
  "url": "https://github.com/Orthic-Labs/rightkit",
519
- "version": "0.7.0"
523
+ "version": "0.8.1"
520
524
  }
521
525
  },
522
526
  "registry": {
@@ -531,7 +535,7 @@
531
535
  "rightkit-browser": "0.3.6",
532
536
  "rightkit-cache": "0.2.3",
533
537
  "rightkit-channel-memory": "0.2.1",
534
- "rightkit-child-process": "0.5.5",
538
+ "rightkit-child-process": "0.5.6",
535
539
  "rightkit-compose": "0.2.0",
536
540
  "rightkit-contracts": "0.1.7",
537
541
  "rightkit-control": "0.3.0",
@@ -573,7 +577,7 @@
573
577
  "rightkit-platform-rules": "0.1.0",
574
578
  "rightkit-playback": "0.3.0",
575
579
  "rightkit-process": "0.4.0",
576
- "rightkit-qa": "0.8.0",
580
+ "rightkit-qa": "0.9.0",
577
581
  "rightkit-qa-driver": "0.7.0",
578
582
  "rightkit-qts": "0.2.6",
579
583
  "rightkit-qts-ggml": "0.1.2",
@@ -608,7 +612,7 @@
608
612
  "@rightkit/app-shell": "0.6.2",
609
613
  "@rightkit/app-update": "0.4.2",
610
614
  "@rightkit/ax": "0.3.2",
611
- "@rightkit/ci": "0.3.0",
615
+ "@rightkit/ci": "0.3.1",
612
616
  "@rightkit/doc-render": "0.2.1",
613
617
  "@rightkit/git": "0.2.42",
614
618
  "@rightkit/hooks": "0.1.2",
@@ -626,7 +630,7 @@
626
630
  "@rightkit/qa": "0.6.1",
627
631
  "@rightkit/qa-chrome": "0.1.11",
628
632
  "@rightkit/react-shell": "0.7.2",
629
- "@rightkit/release": "0.6.3",
633
+ "@rightkit/release": "0.6.5",
630
634
  "@rightkit/settings-ui": "0.7.0",
631
635
  "@rightkit/shell": "0.1.1",
632
636
  "@rightkit/tauri": "0.3.0",
@@ -760,7 +764,8 @@
760
764
  "0.6.4",
761
765
  "0.7.0",
762
766
  "0.7.1",
763
- "0.8.0"
767
+ "0.8.0",
768
+ "0.9.0"
764
769
  ],
765
770
  "rightkit-sandbox": [
766
771
  "0.1.0",
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
- // `right-release stage-sidecar [--config right-release.config.mjs] [--target <triple>] [--check] [--json]` (bogusyogi/rightkit#345):
2
+ // `right-release stage-sidecar [--config right-release.config.json] [--target <triple>] [--check] [--json]` (bogusyogi/rightkit#345):
3
3
  // stages the config's declarative `sidecars` into src-tauri/binaries with Tauri externalBin naming. `--check` validates
4
4
  // the manifest and prints the plan without building, downloading, copying or deleting anything (for template gates).
5
5
  import path from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
+ import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
7
8
  import { planSidecarStaging, resolveTargetTriple, stageSidecars } from "./stage-sidecar.mjs";
8
9
 
9
- export function parseStageSidecarArgs(argv) {
10
- const opts = { config: "right-release.config.mjs", target: undefined, json: false, check: false };
10
+ export function parseStageSidecarArgs(argv, cwd = process.cwd()) {
11
+ const opts = { config: defaultReleaseConfigFile(cwd), target: undefined, json: false, check: false };
11
12
  for (let i = 0; i < argv.length; i += 1) {
12
13
  const arg = argv[i];
13
14
  if (arg === "--config" || arg === "--target") {
@@ -49,8 +50,7 @@ async function main() {
49
50
  const opts = parseStageSidecarArgs(process.argv.slice(2));
50
51
  const configPath = path.resolve(opts.config);
51
52
  const appRoot = path.dirname(configPath);
52
- const loaded = await import(pathToFileURL(configPath).href);
53
- const config = loaded.default ?? loaded.config ?? loaded;
53
+ const config = await importReleaseConfig(configPath, { lenient: true });
54
54
  if (config?.sidecars === undefined) throw new Error(`${opts.config} declares no sidecars`);
55
55
  if (opts.check) {
56
56
  const target = opts.target ?? process.env.TAURI_ENV_TARGET_TRIPLE ?? nodeHostTriple();
package/test-command.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // `right-release test [--config <file>] [--suite <name>[,<name>]]... [--runner <name>] [--list] [--json] [-- <args>]`
3
- // runs the app's declared test suites from `right-release.config.mjs`, one after another, and stops at the first failure.
3
+ // runs the app's declared test suites from its release config (`right-release.config.json`), one after another, and stops at the first failure.
4
4
  //
5
5
  // tests: {
6
6
  // eventPrefix: "e2e", // optional; events are `<prefix>_suite_started` / `<prefix>_suite_completed`
@@ -26,6 +26,7 @@ import path from "node:path";
26
26
  import { pathToFileURL } from "node:url";
27
27
  import { spawn } from "./background-process.mjs";
28
28
  import { CommandSpecError, loadAppConfig, resolveInvocation, validateStep } from "./command-spec.mjs";
29
+ import { defaultReleaseConfigFile } from "./release-config.mjs";
29
30
 
30
31
  const fail = (message) => { throw new CommandSpecError(message); };
31
32
  const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
@@ -34,7 +35,7 @@ const PREFIX = /^[a-z][a-z0-9_]*$/;
34
35
  const uniq = (...lists) => [...new Set(lists.flatMap((list) => list ?? []))];
35
36
 
36
37
  export function parseTestArgs(argv) {
37
- const opts = { config: "right-release.config.mjs", suites: [], runner: undefined, list: false, json: false, passthrough: [] };
38
+ const opts = { config: defaultReleaseConfigFile(), suites: [], runner: undefined, list: false, json: false, passthrough: [] };
38
39
  for (let i = 0; i < argv.length; i += 1) {
39
40
  const arg = argv[i];
40
41
  if (arg === "--") { opts.passthrough = argv.slice(i + 1); break; }