@rightkit/release 0.6.5 → 0.6.7

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 (73) hide show
  1. package/README.md +196 -11
  2. package/admission-files.mjs +3 -2
  3. package/advisory-db.json +4 -0
  4. package/advisory-db.mjs +124 -0
  5. package/advisory-exceptions.mjs +13 -10
  6. package/android-play-upload.mjs +4 -4
  7. package/app-check.mjs +702 -26
  8. package/apple-profile-create.mjs +4 -3
  9. package/apple-profile-renew.mjs +5 -4
  10. package/bin/darwin/rightkit-swap +0 -0
  11. package/bin/rightkit-agent-hook.mjs +9 -0
  12. package/bin/rightkit-gate.mjs +671 -133
  13. package/binary-license-notices.mjs +2 -1
  14. package/build-release.mjs +20 -12
  15. package/cache-command.mjs +4 -3
  16. package/cargo-license-notices.mjs +2 -1
  17. package/cargo-manifest-text.mjs +25 -0
  18. package/cargo-profile-contract.mjs +26 -13
  19. package/cli/right-release.mjs +37 -7
  20. package/command-spec.mjs +4 -5
  21. package/compile-inputs.mjs +66 -0
  22. package/consumer-contracts.json +1044 -0
  23. package/consumer-contracts.mjs +627 -0
  24. package/consumer-input-contracts.mjs +422 -0
  25. package/consumer-policy.mjs +588 -0
  26. package/consumer-source-contracts.mjs +469 -0
  27. package/deny-default.toml +42 -0
  28. package/deps.mjs +5 -4
  29. package/deterministic-scans.mjs +513 -54
  30. package/development.mjs +5 -5
  31. package/disk-command.mjs +270 -30
  32. package/disk-policy.json +50 -10
  33. package/disk-policy.mjs +482 -35
  34. package/disk-report.mjs +129 -0
  35. package/door-policy.mjs +228 -0
  36. package/gate-executor.mjs +30 -0
  37. package/gate-stages.mjs +110 -0
  38. package/github-release.mjs +5 -3
  39. package/github-updater-release.mjs +48 -8
  40. package/github-upload-gate.mjs +6 -6
  41. package/init-command.mjs +175 -8
  42. package/ios-upload.mjs +4 -4
  43. package/legion-semantic-contracts.mjs +428 -0
  44. package/mirror-root-artifact.mjs +3 -4
  45. package/npm-license-notices.mjs +2 -1
  46. package/outdated-command.mjs +91 -0
  47. package/ownership-class-rules.mjs +277 -0
  48. package/ownership-key-authority.mjs +3 -1
  49. package/ownership-mandatory-set.mjs +3 -2
  50. package/package.json +46 -3
  51. package/pipeline-admission.mjs +12 -0
  52. package/publish-cargo.mjs +219 -4
  53. package/publish-update.mjs +19 -14
  54. package/release-config.mjs +87 -11
  55. package/release-dependencies-command.mjs +4 -3
  56. package/release-qa-audit.mjs +12 -10
  57. package/release-qa-command.mjs +42 -7
  58. package/release-qa-hardening.mjs +10 -3
  59. package/release-qa-policy.mjs +214 -47
  60. package/release.mjs +4 -4
  61. package/rightkit-registry-resolver.mjs +5 -2
  62. package/rightkit-versions.json +178 -170
  63. package/shell-contract-bridge.json +251 -0
  64. package/shell-contract.mjs +1069 -0
  65. package/sign-updater.mjs +24 -4
  66. package/source-closure-rules.mjs +204 -0
  67. package/stage-sidecar-command.mjs +7 -6
  68. package/stage-sidecar.mjs +437 -114
  69. package/suite-api.generated.mjs +96 -6
  70. package/suite-trust-roots.mjs +198 -0
  71. package/test-command.mjs +3 -2
  72. package/upload-release.mjs +3 -2
  73. package/web-build.mjs +2 -1
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  // right-release qa: run RightKit's own QA runner (rightkit-qa) and write the sealed, MAC'd QA receipt.
3
3
  //
4
- // right-release qa --stage source --platform mac|win [--config right-release.config.mjs]
5
- // right-release qa --stage installed --platform mac|win --release <sealed-id> [--config right-release.config.mjs]
4
+ // right-release qa --stage source --platform mac|win [--config right-release.config.json]
5
+ // right-release qa --stage installed --platform mac|win --release <sealed-id> [--config right-release.config.json]
6
6
  // right-release qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>] (release-qa-audit.mjs; exit 1 when an uploaded build lacks passing QA)
7
+ // right-release qa policy show | set --app <app> --mode warn|refuse --set-by <name> [--stage source|installed]
8
+ // | approve-runner --runner <file> --version <v> --platform mac|win --set-by <name> [--note <text>] (release-qa-policy.mjs)
7
9
  //
8
10
  // The release config declares the app's QA plan:
9
11
  // qa: { version: "1", config: "rightkit-qa.toml", runner?: "<path to rightkit-qa>",
@@ -20,14 +22,16 @@ import { createHash } from "node:crypto";
20
22
  import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
21
23
  import os from "node:os";
22
24
  import path from "node:path";
23
- import { pathToFileURL, fileURLToPath } from "node:url";
25
+ import { fileURLToPath } from "node:url";
24
26
  import { spawnSync } from "./background-process.mjs";
27
+ import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
25
28
  import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
26
29
  import { assertCleanSource } from "./source-gate.mjs";
27
30
  import { verifySealedRelease } from "./release-state.mjs";
28
31
  import { auditMain } from "./release-qa-audit.mjs";
29
32
  import { journeyRequiredItem, QA_RUN_ENV, relativePosix, validateJourneysReceipt } from "./release-qa-hardening.mjs";
30
33
  import { prepareInstalledArtifacts } from "./release-qa-installed.mjs";
34
+ import { main as policyMain } from "./release-qa-policy.mjs";
31
35
  import { darwinKeyFile, PROTECTED_SECRETS } from "./protected-credentials.mjs";
32
36
  import {
33
37
  buildQaReceipt, currentPolicyBundleDigest, dependencyClosure, ensureQaReceiptKey, evaluateRequiredChecks, loadQaFloor,
@@ -55,6 +59,32 @@ function resolveRunner({ qa, appRoot, platform = process.platform, runner = spaw
55
59
  return realpathSync(file);
56
60
  }
57
61
 
62
+ // Windows installs land in `%LOCALAPPDATA%\<Product>`; the runner learns the product folder names from
63
+ // RIGHTKIT_QA_INSTALLED_DIRS. Compared as strings with both separators so the answer never depends on the host OS.
64
+ const WIN_ABSOLUTE = /^(?:[A-Za-z]:[\\/]|\\\\)/;
65
+ const pathParts = (value) => String(value).split(/[\\/]+/).filter(Boolean);
66
+ const productDirOk = (name) => name.length <= 255 && ![".", ".."].includes(name) && !/[\u0000-\u001f\u007f;:]/.test(name);
67
+
68
+ /**
69
+ * Product folder names under %LOCALAPPDATA% that hold the app's installed copy, from the release config's
70
+ * `development.targets.win.install.expectedInstalledPath` (the one place a config names the installed path; the
71
+ * desktop-app-template sets it to `<LOCALAPPDATA>\<Product>\<exe>`). Only an absolute path inside LOCALAPPDATA
72
+ * counts (case-insensitive, either separator); the name is the first component below it and a file must sit under it.
73
+ * `Programs` is already a runner root, so it is dropped. Returns an array of distinct names (today at most one: the
74
+ * config declares one installed path).
75
+ */
76
+ export function installedDirNames(config, env = process.env) {
77
+ const localAppData = Object.entries(env ?? {}).find(([key]) => key.toLowerCase() === "localappdata")?.[1];
78
+ const installed = config?.development?.targets?.win?.install?.expectedInstalledPath;
79
+ if (typeof localAppData !== "string" || !WIN_ABSOLUTE.test(localAppData) || typeof installed !== "string" || !WIN_ABSOLUTE.test(installed)) return [];
80
+ const root = pathParts(localAppData).map((part) => part.toLowerCase());
81
+ const parts = pathParts(installed);
82
+ if (parts.some((part) => part === "." || part === "..")) return [];
83
+ if (parts.length < root.length + 2 || !root.every((part, index) => parts[index].toLowerCase() === part)) return [];
84
+ const name = parts[root.length];
85
+ return name.toLowerCase() === "programs" || !productDirOk(name) ? [] : [name];
86
+ }
87
+
58
88
  /** `rightkit-qa journeys run` prints `run <id> (<run dir>)` first; the receipt is `<run dir>/receipt.json`. */
59
89
  export function journeysReceiptFromOutput(stdout) {
60
90
  const dir = String(stdout ?? "").match(/^run \S+ \((.+)\)\s*$/m)?.[1];
@@ -77,7 +107,7 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
77
107
  if (!deps.skipPrimaryCheckout) assertPrimaryReleaseCheckout(repoRoot);
78
108
  assertCleanSource({ status: String(gitRunner("git", ["-C", repoRoot, "status", "--porcelain=v1", "-z", "--untracked-files=all"], { encoding: "utf8", windowsHide: true }).stdout ?? ""), commandId: "right-release qa" });
79
109
  const source = sourceIdentity(repoRoot, gitRunner);
80
- const config = (await import(`${pathToFileURL(configPath).href}?qa=${source.commit}`)).default;
110
+ const config = await importReleaseConfig(configPath, { query: `qa=${source.commit}` });
81
111
  if (!config?.app || !config?.version) throw new Error("release config must expose app and version");
82
112
  const { floor, digest: floorDigest } = loadQaFloor(deps.floorDir);
83
113
  const problems = validateAppQaConfig(config.qa, floor, stage);
@@ -117,7 +147,11 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
117
147
  try {
118
148
  const expand = (value) => String(value).replaceAll("{sealedDir}", sealed?.sealedDir ?? "").replaceAll("{releaseId}", releaseId)
119
149
  .replaceAll("{installedApp}", installedQa?.installedApp ?? "").replaceAll("{installedDir}", installedQa?.installedDir ?? "").replaceAll("{qaRoot}", installedQa?.qaRoot ?? "");
120
- const runEnv = { ...process.env, RIGHT_RELEASE_QA_STAGE: stage, RIGHT_RELEASE_QA_COMMIT: source.commit, RIGHT_RELEASE_QA_RELEASE_ID: releaseId, ...(sealed ? { RIGHT_RELEASE_QA_SEALED_DIR: sealed.sealedDir } : {}), ...(installedQa?.env ?? {}) };
150
+ const installedDirs = installedDirNames(config, deps.env ?? process.env);
151
+ const runEnv = {
152
+ ...process.env, RIGHT_RELEASE_QA_STAGE: stage, RIGHT_RELEASE_QA_COMMIT: source.commit, RIGHT_RELEASE_QA_RELEASE_ID: releaseId,
153
+ ...(sealed ? { RIGHT_RELEASE_QA_SEALED_DIR: sealed.sealedDir } : {}), ...(installedDirs.length ? { RIGHTKIT_QA_INSTALLED_DIRS: installedDirs.join(";") } : {}), ...(installedQa?.env ?? {}),
154
+ };
121
155
  const args = ["run", "--config", qaConfig, ...((config.qa.args?.[stage] ?? []).map(expand))];
122
156
  run = (deps.spawnRunner ?? spawnSync)(runnerFile, args, {
123
157
  cwd: appRoot, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024,
@@ -181,13 +215,14 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
181
215
  }
182
216
 
183
217
  function usage(code) {
184
- console.log("usage: right-release qa --stage source|installed --platform mac|win [--release <sealed-id>] [--config right-release.config.mjs] [--carry-to <dir>]\n (seals the QA receipt; admission is warn or refuse per app in qa-admission-policy.json; refused inside a QA run)\n right-release qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>]");
218
+ console.log("usage: right-release qa --stage source|installed --platform mac|win [--release <sealed-id>] [--config right-release.config.json] [--carry-to <dir>]\n (seals the QA receipt; admission is warn or refuse per app in qa-admission-policy.json; refused inside a QA run)\n right-release qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>]\n right-release qa policy show | set --app <app> --mode warn|refuse --set-by <name> [--stage source|installed]\n | approve-runner --runner <file> --version <v> --platform mac|win --set-by <name> [--note <text>]");
185
219
  process.exit(code);
186
220
  }
187
221
 
188
222
  export async function main(args) {
189
223
  if (args[0] === "audit") process.exit(auditMain(args.slice(1)));
190
- let stage = "", platform = process.platform === "win32" ? "win" : "mac", releaseId = "", configName = "right-release.config.mjs", carryTo = null;
224
+ if (args[0] === "policy") process.exit(policyMain(args.slice(1), { program: "right-release qa policy" }));
225
+ let stage = "", platform = process.platform === "win32" ? "win" : "mac", releaseId = "", configName = defaultReleaseConfigFile(), carryTo = null;
191
226
  for (let index = 0; index < args.length; index += 1) {
192
227
  const arg = args[index];
193
228
  if (arg === "--stage") stage = args[++index];
@@ -7,7 +7,8 @@
7
7
  // passed", not "one matching check passed".
8
8
  // admit side `hardenQaReceipt` re-checks the sealed receipt at admission, independently of what the run side
9
9
  // claimed: platform and release id, the approved runner digest, the critical journey set recomputed
10
- // from the manifest committed at the receipt's commit, and the signing/test-execution separation.
10
+ // from the manifest committed at the receipt's commit, and the signing/test-execution separation. The
11
+ // runner digest must be approved for the platform being sealed or published (qa-admission-policy.json).
11
12
  //
12
13
  // Signing outside test execution, what is checked here: the receipt records the pids of the test processes it
13
14
  // spawned and the window in which they ran; admission refuses a receipt whose window does not end before the
@@ -119,10 +120,16 @@ export function hardenQaReceipt({
119
120
  if (receipt.platform !== platform) refuse(`receipt is for platform ${receipt.platform}, this is ${platform}`);
120
121
  if (receipt.releaseId !== releaseId) refuse(`receipt is for release ${receipt.releaseId}, this is ${releaseId}`);
121
122
 
122
- // 2. The qualifying runner is pinned by an approved digest.
123
+ // 2. The qualifying runner is pinned by an approved digest, approved for this platform (an entry with no platform
124
+ // approves every platform).
123
125
  const approved = approvedRunnerDigests(policy);
124
126
  if (!approved.length) refuse("no rightkit-qa runner digest is approved in qa-admission-policy.json");
125
- if (!approved.includes(receipt.runner?.sha256)) refuse(`rightkit-qa runner sha256 ${String(receipt.runner?.sha256).slice(0, 16)} is not an approved runner`);
127
+ const digest = receipt.runner?.sha256;
128
+ if (!approved.includes(digest)) refuse(`rightkit-qa runner sha256 ${String(digest).slice(0, 16)} is not an approved runner`);
129
+ const entries = policy.approvedRunners.filter((runner) => runner.sha256 === digest);
130
+ if (!entries.some((runner) => !runner.platform || runner.platform === platform)) {
131
+ refuse(`rightkit-qa runner sha256 ${digest.slice(0, 16)} is approved only for ${[...new Set(entries.map((runner) => runner.platform))].sort().join(", ")}, this is ${platform}`);
132
+ }
126
133
 
127
134
  // 3. The journey floor id means every release-critical journey required on this platform passed.
128
135
  const mapping = floor?.journeys;
@@ -1,21 +1,38 @@
1
- // QA admission policy (ADR-0001 item 10): the per-app warn -> refuse switch and the approved rightkit-qa runner
2
- // digests, recorded in `qa-admission-policy.json` next to this module.
1
+ // QA admission policy (ADR-0001 item 10): the per-app, per-stage warn -> refuse switch and the approved rightkit-qa
2
+ // runners, recorded in `qa-admission-policy.json` next to this module.
3
3
  //
4
4
  // { "schema": 1,
5
- // "apps": { "<app>": { "mode": "warn" | "refuse", "since": "YYYY-MM-DD", "set_by": "<name>" } },
6
- // "approvedRunners": [ { "sha256": "<64 hex>", "version": "<rightkit-qa version>", "note": "..." } ] }
5
+ // "apps": { "<app>": { "mode": "warn" | "refuse", "since": "YYYY-MM-DD", "set_by": "<name>",
6
+ // "stages": { "source": { "mode": .., "since": .., "set_by": .. },
7
+ // "installed": { "mode": .., "since": .., "set_by": .. } } } },
8
+ // "approvedRunners": [ { "sha256": "<64 hex>", "version": "<rightkit-qa version>", "platform": "mac" | "win",
9
+ // "set_by": "<name>", "since": "YYYY-MM-DD", "note": "..." } ] }
7
10
  //
8
11
  // An app with no entry is in `warn` mode (default). In `refuse` mode `admit()` throws instead of warning.
9
12
  //
10
- // Only the owner (`adrian`) may move an app back from `refuse` to `warn` (or delete its entry). What is checkable
11
- // here, and enforced:
12
- // - every write through `setAdmissionMode` goes through `checkPolicyTransition`, which refuses a downgrade whose
13
- // new entry is not `set_by: "adrian"`;
13
+ // Stages: the build (`source`) and publication (`installed`) admissions each have their own mode. An app entry is the
14
+ // app-level { mode, since, set_by } (governs both stages), a `stages` object, or both; a stage entry overrides the
15
+ // app-level mode for that stage. An app entry needs a mode or a non-empty `stages`. Only `source` and `installed` are
16
+ // stage keys.
17
+ //
18
+ // Approved runners: `sha256` is required (lowercase hex). `platform` ("mac" | "win") binds the approval to the
19
+ // platform the receipt is sealed or published for; an entry without it approves the digest on every platform (legacy
20
+ // entries). `version`, `set_by`, `since` and `note` record who approved which rightkit-qa build and when. Add an entry
21
+ // with `approve-runner`, which checks the file's `--version` output against the published rightkit-qa version.
22
+ //
23
+ // Only the owner (`adrian`) may loosen admission. What is checkable here, and enforced:
24
+ // - every write goes through `checkPolicyTransition`, which refuses (a) a downgrade of any app and stage from refuse
25
+ // to anything else (a warn entry, a removed stage entry or a removed app) whose deciding entry is not
26
+ // `set_by: "adrian"`, and (b) a new approved runner (a sha256 + platform pair not in the previous policy) that is
27
+ // not `set_by: "adrian"`; removing a runner tightens admission and is always allowed;
14
28
  // - every load compares the working file with the copy committed at `baselineRef` (default HEAD) and refuses a
15
- // hand-edited downgrade the same way, so an uncommitted edit cannot quietly switch an app off refuse;
29
+ // hand-edited loosening the same way, so an uncommitted edit cannot quietly switch an app off refuse or approve a
30
+ // runner. A baseline ref that was asked for explicitly (the argument or RIGHT_RELEASE_QA_POLICY_BASELINE_REF) and
31
+ // cannot be read is a hard error; only the default (HEAD, nothing requested) falls back to no baseline, for a
32
+ // tree that is not a git checkout (an app's node_modules);
16
33
  // - an unreadable or malformed policy file is a hard error, never a silent fall back to warn.
17
34
  // What is NOT checkable from this file: `set_by` is a recorded claim, not an authenticated identity, and a
18
- // downgrade that was already committed is the new baseline. The file must therefore be owner-protected in the
35
+ // loosening that was already committed is the new baseline. The file must therefore be owner-protected in the
19
36
  // repository (CODEOWNERS on this path plus branch protection, and the file belongs in the protected policy bundle,
20
37
  // see ownership-policy-bundle.mjs). CI should set RIGHT_RELEASE_QA_POLICY_BASELINE_REF=origin/main so the baseline
21
38
  // is the protected branch rather than the local HEAD.
@@ -33,7 +50,11 @@ const DATE = /^\d{4}-\d{2}-\d{2}$/;
33
50
  export const QA_ADMISSION_POLICY_FILE = "qa-admission-policy.json";
34
51
  export const QA_POLICY_BASELINE_REF_ENV = "RIGHT_RELEASE_QA_POLICY_BASELINE_REF";
35
52
  export const ADMISSION_MODES = Object.freeze(["warn", "refuse"]);
36
- /** The only `set_by` that may move an app from refuse back to warn. */
53
+ /** The admissions an app's mode can be set for: the build (source) and the publication (installed). */
54
+ export const ADMISSION_STAGES = Object.freeze(["source", "installed"]);
55
+ /** The platforms an approved runner can be bound to. */
56
+ export const RUNNER_PLATFORMS = Object.freeze(["mac", "win"]);
57
+ /** The only `set_by` that may move an app back from refuse, or approve a runner. */
37
58
  export const POLICY_OWNER = "adrian";
38
59
 
39
60
  export class QaPolicyError extends Error {
@@ -44,42 +65,98 @@ export class QaPolicyError extends Error {
44
65
  }
45
66
  }
46
67
 
68
+ const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
69
+ const nonEmpty = (value) => typeof value === "string" && value.trim() !== "";
70
+
71
+ function validateModeEntry(entry, label, problems) {
72
+ if (!ADMISSION_MODES.includes(entry.mode)) problems.push(`${label} mode must be warn or refuse`);
73
+ if (typeof entry.since !== "string" || !DATE.test(entry.since)) problems.push(`${label} since must be YYYY-MM-DD`);
74
+ if (!nonEmpty(entry.set_by)) problems.push(`${label} set_by is required`);
75
+ }
76
+
77
+ function validateRunnerEntry(runner, problems) {
78
+ const label = `approvedRunners entry ${runner.sha256.slice(0, 16)}`;
79
+ if (runner.version !== undefined && !nonEmpty(runner.version)) problems.push(`${label} version must be a non-empty string`);
80
+ if (runner.platform !== undefined && !RUNNER_PLATFORMS.includes(runner.platform)) problems.push(`${label} platform must be ${RUNNER_PLATFORMS.join(" or ")}`);
81
+ if (runner.set_by !== undefined && !nonEmpty(runner.set_by)) problems.push(`${label} set_by must be a non-empty string`);
82
+ if (runner.since !== undefined && (typeof runner.since !== "string" || !DATE.test(runner.since))) problems.push(`${label} since must be YYYY-MM-DD`);
83
+ if (runner.note !== undefined && typeof runner.note !== "string") problems.push(`${label} note must be a string`);
84
+ }
85
+
47
86
  export function validateAdmissionPolicy(policy) {
48
87
  const problems = [];
49
- if (!policy || typeof policy !== "object" || Array.isArray(policy)) return ["policy must be an object"];
88
+ if (!isObject(policy)) return ["policy must be an object"];
50
89
  if (policy.schema !== 1) problems.push("policy schema must be 1");
51
- if (!policy.apps || typeof policy.apps !== "object" || Array.isArray(policy.apps)) problems.push("policy apps must be an object");
90
+ if (!isObject(policy.apps)) problems.push("policy apps must be an object");
52
91
  else {
53
92
  for (const [app, entry] of Object.entries(policy.apps)) {
93
+ const label = `policy app ${app}`;
54
94
  if (!app.trim()) problems.push("policy app name is empty");
55
- if (!entry || typeof entry !== "object") { problems.push(`policy app ${app} must be an object`); continue; }
56
- if (!ADMISSION_MODES.includes(entry.mode)) problems.push(`policy app ${app} mode must be warn or refuse`);
57
- if (typeof entry.since !== "string" || !DATE.test(entry.since)) problems.push(`policy app ${app} since must be YYYY-MM-DD`);
58
- if (typeof entry.set_by !== "string" || !entry.set_by.trim()) problems.push(`policy app ${app} set_by is required`);
95
+ if (!entry || typeof entry !== "object") { problems.push(`${label} must be an object`); continue; }
96
+ if (entry.stages !== undefined) {
97
+ if (!isObject(entry.stages)) problems.push(`${label} stages must be an object`);
98
+ else {
99
+ if (!Object.keys(entry.stages).length && entry.mode === undefined) problems.push(`${label} needs a mode or a non-empty stages object`);
100
+ for (const [stage, stageEntry] of Object.entries(entry.stages)) {
101
+ if (!ADMISSION_STAGES.includes(stage)) { problems.push(`${label} has unknown stage ${JSON.stringify(stage)} (expected ${ADMISSION_STAGES.join(" or ")})`); continue; }
102
+ if (!isObject(stageEntry)) { problems.push(`${label} stage ${stage} must be an object`); continue; }
103
+ validateModeEntry(stageEntry, `${label} stage ${stage}`, problems);
104
+ }
105
+ }
106
+ }
107
+ // The app-level fields are required unless the app is governed by its stages alone.
108
+ if (entry.mode !== undefined || entry.stages === undefined) validateModeEntry(entry, label, problems);
59
109
  }
60
110
  }
61
111
  if (!Array.isArray(policy.approvedRunners)) problems.push("policy approvedRunners must be an array");
62
112
  else {
63
113
  for (const runner of policy.approvedRunners) {
64
114
  if (!runner || typeof runner.sha256 !== "string" || !HEX64.test(runner.sha256)) problems.push("approvedRunners entries need a lowercase 64-hex sha256");
115
+ else validateRunnerEntry(runner, problems);
65
116
  }
66
117
  }
67
118
  return problems;
68
119
  }
69
120
 
70
- /** Problems when `next` weakens `previous`: a refuse entry that is gone or now warn without `set_by: "adrian"`. */
121
+ /** The entry that decides `stage` for `app`: its stage entry, else its app-level entry when that has a mode, else none. */
122
+ function decidingEntry(policy, app, stage) {
123
+ const entry = policy?.apps?.[app];
124
+ return entry?.stages?.[stage] ?? (entry?.mode ? entry : undefined);
125
+ }
126
+
127
+ const runnerKey = (runner) => `${runner?.sha256}|${runner?.platform ?? ""}`;
128
+
129
+ /**
130
+ * Problems when `next` loosens `previous`: for every app and stage, a refuse that is gone or now warn without the
131
+ * deciding entry being `set_by: "adrian"`; and every approved runner that is new without `set_by: "adrian"`.
132
+ */
71
133
  export function checkPolicyTransition(previous, next) {
72
- const problems = [];
73
- for (const [app, before] of Object.entries(previous?.apps ?? {})) {
74
- if (before?.mode !== "refuse") continue;
75
- const after = next?.apps?.[app];
76
- if (!after) problems.push(`${app}: the refuse entry was removed; only ${POLICY_OWNER} may move an app back to warn (set mode warn with set_by "${POLICY_OWNER}")`);
77
- else if (after.mode !== "refuse" && after.set_by !== POLICY_OWNER) problems.push(`${app}: refuse -> warn requires set_by "${POLICY_OWNER}", got ${JSON.stringify(after.set_by)}`);
134
+ const grouped = new Map();
135
+ const note = (app, stage, body) => {
136
+ const key = `${app}\u0000${body}`;
137
+ if (!grouped.has(key)) grouped.set(key, { app, body, stages: [] });
138
+ grouped.get(key).stages.push(stage);
139
+ };
140
+ for (const app of Object.keys(previous?.apps ?? {})) {
141
+ for (const stage of ADMISSION_STAGES) {
142
+ if (decidingEntry(previous, app, stage)?.mode !== "refuse") continue;
143
+ const after = decidingEntry(next, app, stage);
144
+ if (after?.mode === "refuse") continue;
145
+ if (!after) note(app, stage, `the refuse entry was removed; only ${POLICY_OWNER} may move an app back to warn (set mode warn with set_by "${POLICY_OWNER}")`);
146
+ else if (after.set_by !== POLICY_OWNER) note(app, stage, `refuse -> warn requires set_by "${POLICY_OWNER}", got ${JSON.stringify(after.set_by)}`);
147
+ }
148
+ }
149
+ const problems = [...grouped.values()].map(({ app, body, stages }) => `${app} (${stages.join(", ")}): ${body}`);
150
+ const known = new Set((previous?.approvedRunners ?? []).map(runnerKey));
151
+ for (const runner of next?.approvedRunners ?? []) {
152
+ if (known.has(runnerKey(runner)) || runner?.set_by === POLICY_OWNER) continue;
153
+ problems.push(`approvedRunners: runner ${String(runner?.sha256).slice(0, 16)}${runner?.platform ? ` (${runner.platform})` : ""} is new with set_by ${JSON.stringify(runner?.set_by ?? null)}; only ${POLICY_OWNER} may approve a runner (set_by "${POLICY_OWNER}")`);
78
154
  }
79
155
  return problems;
80
156
  }
81
157
 
82
158
  const digestOf = (policy) => createHash("sha256").update(canonicalJson(policy)).digest("hex");
159
+ const sha256Of = (bytes) => createHash("sha256").update(bytes).digest("hex");
83
160
 
84
161
  function parsePolicyText(text, label) {
85
162
  let policy;
@@ -89,15 +166,20 @@ function parsePolicyText(text, label) {
89
166
  return policy;
90
167
  }
91
168
 
169
+ /** { text } when git shows the policy file at `ref`; { text: null, detail } when it cannot. */
92
170
  function gitBaselineText({ dir, ref, runner = spawnSync }) {
93
171
  try {
94
172
  const result = runner("git", ["-C", dir, "show", `${ref}:./${QA_ADMISSION_POLICY_FILE}`], { encoding: "utf8", windowsHide: true });
95
- return result?.status === 0 ? String(result.stdout) : null;
96
- } catch { return null; }
173
+ if (result?.status === 0) return { text: String(result.stdout) };
174
+ return { text: null, detail: String(result?.stderr ?? "").trim().split(/\r?\n/)[0] || `git exited ${result?.status ?? "without a status"}` };
175
+ } catch (error) { return { text: null, detail: error.message }; }
97
176
  }
98
177
 
99
178
  /**
100
179
  * Load the policy. `baseline` (policy text) overrides the git lookup; `baseline: false` disables the comparison.
180
+ * A baseline ref requested explicitly (`baselineRef` or the env var) that cannot be read is an error: the comparison
181
+ * the caller asked for must not silently become no comparison. With no ref requested the baseline is HEAD, and a tree
182
+ * that is not a git checkout has none.
101
183
  * Returns { policy, digest, baseline: "supplied" | "git" | "none" }.
102
184
  */
103
185
  export function loadAdmissionPolicy({ dir = here, baseline, baselineRef, env = process.env, gitRunner } = {}) {
@@ -108,7 +190,11 @@ export function loadAdmissionPolicy({ dir = here, baseline, baselineRef, env = p
108
190
  let baselineText = null;
109
191
  if (typeof baseline === "string") { baselineText = baseline; source = "supplied"; }
110
192
  else if (baseline !== false) {
111
- baselineText = gitBaselineText({ dir, ref: baselineRef ?? env?.[QA_POLICY_BASELINE_REF_ENV] ?? "HEAD", runner: gitRunner });
193
+ const requested = [baselineRef, env?.[QA_POLICY_BASELINE_REF_ENV]].find(nonEmpty);
194
+ const ref = requested ?? "HEAD";
195
+ const found = gitBaselineText({ dir, ref, runner: gitRunner });
196
+ if (found.text === null && requested) throw new QaPolicyError(`QA admission policy baseline ${ref} cannot be read (${found.detail}); refusing to continue without the comparison that was requested`);
197
+ baselineText = found.text;
112
198
  if (baselineText !== null) source = "git";
113
199
  }
114
200
  if (baselineText !== null) {
@@ -118,46 +204,127 @@ export function loadAdmissionPolicy({ dir = here, baseline, baselineRef, env = p
118
204
  return { policy, digest: digestOf(policy), baseline: source };
119
205
  }
120
206
 
121
- /** The mode for an app; an app with no entry is `warn`. */
122
- export function admissionModeFor(policy, app) {
123
- const entry = policy?.apps?.[app];
207
+ /**
208
+ * The mode for an app at `stage` ("source" | "installed"): the stage entry, else the app-level entry, else `warn`.
209
+ * Without a stage it is the app-level result.
210
+ */
211
+ export function admissionModeFor(policy, app, stage) {
212
+ const entry = stage ? decidingEntry(policy, app, stage) : policy?.apps?.[app];
124
213
  return entry?.mode === "refuse" ? { mode: "refuse", since: entry.since, set_by: entry.set_by } : { mode: "warn", since: entry?.since ?? null, set_by: entry?.set_by ?? null };
125
214
  }
126
215
 
127
216
  export const approvedRunnerDigests = (policy) => (policy?.approvedRunners ?? []).map((runner) => runner.sha256);
128
217
 
218
+ /** Write the policy atomically, apps and runners in a deterministic order. */
219
+ function writePolicy(file, policy) {
220
+ const sorted = {
221
+ ...policy,
222
+ apps: Object.fromEntries(Object.entries(policy.apps).sort(([a], [b]) => a.localeCompare(b))),
223
+ approvedRunners: [...policy.approvedRunners].sort((a, b) => a.sha256.localeCompare(b.sha256) || (a.platform ?? "").localeCompare(b.platform ?? "")),
224
+ };
225
+ mkdirSync(path.dirname(file), { recursive: true });
226
+ const temp = `${file}.${process.pid}.tmp`;
227
+ writeFileSync(temp, `${JSON.stringify(sorted, null, 2)}\n`);
228
+ renameSync(temp, file);
229
+ }
230
+
231
+ /** Refuse a result that loosens `current` or is not a valid policy; the file is left untouched. */
232
+ function assertAllowed(current, next) {
233
+ const problems = [...checkPolicyTransition(current, next), ...validateAdmissionPolicy(next)];
234
+ if (problems.length) throw new QaPolicyError(`QA admission policy change refused: ${problems.join("; ")}`);
235
+ }
236
+
129
237
  /**
130
- * Record an app's mode. A downgrade (refuse -> warn) is refused unless `setBy` is the owner. Writes atomically.
238
+ * Record an app's mode. With a `stage` only that stage's entry is written (the app-level mode and the other stage are
239
+ * kept); without one the app-level entry is written and any `stages` are removed, so one mode governs both stages.
240
+ * A refuse -> warn move at any stage is refused unless `setBy` is the owner. Writes atomically.
131
241
  * The file is read without the git baseline here (the transition rule is applied directly to what is on disk).
242
+ * Returns the entry written.
132
243
  */
133
- export function setAdmissionMode({ app, mode, setBy, dir = here, now = () => new Date() }) {
244
+ export function setAdmissionMode({ app, mode, setBy, stage, dir = here, now = () => new Date() }) {
134
245
  if (!app || !String(app).trim()) throw new QaPolicyError("app is required");
135
246
  if (!ADMISSION_MODES.includes(mode)) throw new QaPolicyError("mode must be warn or refuse");
136
247
  if (!setBy || !String(setBy).trim()) throw new QaPolicyError("set_by is required");
248
+ if (stage !== undefined && !ADMISSION_STAGES.includes(stage)) throw new QaPolicyError(`stage must be ${ADMISSION_STAGES.join(" or ")}`);
137
249
  const file = path.join(dir, QA_ADMISSION_POLICY_FILE);
138
250
  const current = parsePolicyText(readFileSync(file, "utf8"), QA_ADMISSION_POLICY_FILE);
139
- const next = { ...current, apps: { ...current.apps, [app]: { mode, since: now().toISOString().slice(0, 10), set_by: String(setBy) } } };
140
- const problems = checkPolicyTransition(current, next);
141
- if (problems.length) throw new QaPolicyError(`QA admission policy change refused: ${problems.join("; ")}`);
142
- const sorted = { ...next, apps: Object.fromEntries(Object.entries(next.apps).sort(([a], [b]) => a.localeCompare(b))) };
143
- mkdirSync(path.dirname(file), { recursive: true });
144
- const temp = `${file}.${process.pid}.tmp`;
145
- writeFileSync(temp, `${JSON.stringify(sorted, null, 2)}\n`);
146
- renameSync(temp, file);
147
- return sorted.apps[app];
251
+ const entry = { mode, since: now().toISOString().slice(0, 10), set_by: String(setBy) };
252
+ let written;
253
+ if (stage) {
254
+ const stages = { ...current.apps[app]?.stages, [stage]: entry };
255
+ written = { ...current.apps[app], stages: Object.fromEntries(ADMISSION_STAGES.filter((name) => stages[name]).map((name) => [name, stages[name]])) };
256
+ } else written = entry;
257
+ const next = { ...current, apps: { ...current.apps, [app]: written } };
258
+ assertAllowed(current, next);
259
+ writePolicy(file, next);
260
+ return entry;
148
261
  }
149
262
 
150
- // `node release-qa-policy.mjs show | set --app <app> --mode warn|refuse --set-by <name>`
151
- export function main(args, { stdout = (text) => console.log(text), stderr = (text) => console.error(text) } = {}) {
263
+ function readRunnerBytes(file) {
264
+ try { return readFileSync(file); } catch (error) { throw new QaPolicyError(`runner file is unreadable (${file}): ${error.message}`); }
265
+ }
266
+
267
+ /**
268
+ * Approve a rightkit-qa runner build for a platform: only the owner may (it loosens admission), the file must report
269
+ * `rightkit-qa <version>` from `--version`, and that version must be the published rightkit-qa version in
270
+ * rightkit-versions.json (`registry.cargo`). Records { sha256, version, platform, set_by, since, note? } and returns it;
271
+ * an entry with the same sha256 and platform that is already present is returned unchanged. Writes atomically.
272
+ */
273
+ export function approveRunner({
274
+ runner, version, platform, setBy, note, dir = here, now = () => new Date(), spawn = spawnSync, versionsFile = path.join(here, "rightkit-versions.json"),
275
+ }) {
276
+ if (setBy !== POLICY_OWNER) throw new QaPolicyError(`approving a runner loosens QA admission; only ${POLICY_OWNER} may approve one (set_by ${JSON.stringify(setBy ?? null)})`);
277
+ if (!RUNNER_PLATFORMS.includes(platform)) throw new QaPolicyError(`platform must be ${RUNNER_PLATFORMS.join(" or ")}`);
278
+ if (!runner || !String(runner).trim()) throw new QaPolicyError("runner is required");
279
+ if (!nonEmpty(version)) throw new QaPolicyError("version is required");
280
+ if (note !== undefined && typeof note !== "string") throw new QaPolicyError("note must be a string");
281
+ let published;
282
+ try { published = JSON.parse(readFileSync(versionsFile, "utf8")).registry?.cargo?.["rightkit-qa"]; }
283
+ catch (error) { throw new QaPolicyError(`the published rightkit-qa version cannot be read (${versionsFile}): ${error.message}`); }
284
+ if (published !== version) throw new QaPolicyError(`rightkit-qa ${version} is not the published version (rightkit-versions.json registry.cargo has ${published ?? "no rightkit-qa"}); only the published runner can be approved`);
285
+ // The file that is digested is the file that is run: an absolute path, never a PATH lookup, and re-digested after.
286
+ const file = path.resolve(String(runner));
287
+ const sha256 = sha256Of(readRunnerBytes(file));
288
+ const run = spawn(file, ["--version"], { encoding: "utf8", windowsHide: true });
289
+ const output = String(run?.stdout ?? "").trim();
290
+ if (run?.status !== 0 || output !== `rightkit-qa ${version}`) {
291
+ throw new QaPolicyError(`${file} --version reported ${JSON.stringify(output.slice(0, 80))} (exit ${run?.status ?? run?.error?.message ?? "none"}), expected "rightkit-qa ${version}"`);
292
+ }
293
+ if (sha256Of(readRunnerBytes(file)) !== sha256) throw new QaPolicyError(`${file} changed while its version was being checked; nothing was approved`);
294
+ const policyFile = path.join(dir, QA_ADMISSION_POLICY_FILE);
295
+ const current = parsePolicyText(readFileSync(policyFile, "utf8"), QA_ADMISSION_POLICY_FILE);
296
+ const present = current.approvedRunners.find((item) => item.sha256 === sha256 && (item.platform ?? null) === platform);
297
+ if (present) return present;
298
+ const entry = { sha256, version, platform, set_by: setBy, since: now().toISOString().slice(0, 10), ...(note ? { note } : {}) };
299
+ const next = { ...current, approvedRunners: [...current.approvedRunners, entry] };
300
+ assertAllowed(current, next);
301
+ writePolicy(policyFile, next);
302
+ return entry;
303
+ }
304
+
305
+ /**
306
+ * `node release-qa-policy.mjs show | set ... | approve-runner ...` (also `right-release qa policy ...`). The options
307
+ * are test seams: `dir` (the policy directory), `env`, `now`, `spawn`, `versionsFile`, and `program` (the usage name).
308
+ */
309
+ export function main(args, {
310
+ stdout = (text) => console.log(text), stderr = (text) => console.error(text), dir, env, now, spawn, versionsFile, program = "release-qa-policy.mjs",
311
+ } = {}) {
152
312
  const value = (flag) => { const index = args.indexOf(flag); return index >= 0 ? args[index + 1] : undefined; };
153
313
  try {
154
- if (args[0] === "show") { stdout(JSON.stringify(loadAdmissionPolicy().policy, null, 2)); return 0; }
314
+ if (args[0] === "show") { stdout(JSON.stringify(loadAdmissionPolicy({ dir, env }).policy, null, 2)); return 0; }
155
315
  if (args[0] === "set") {
156
- const entry = setAdmissionMode({ app: value("--app"), mode: value("--mode"), setBy: value("--set-by") });
157
- stdout(`qa admission: ${value("--app")} -> ${entry.mode} (since ${entry.since}, set_by ${entry.set_by})`);
316
+ // A `--stage` with no value must not fall through to the app-level write, which removes the stages.
317
+ const stage = args.includes("--stage") ? value("--stage") ?? "" : undefined;
318
+ const entry = setAdmissionMode({ app: value("--app"), mode: value("--mode"), setBy: value("--set-by"), stage, dir, now });
319
+ stdout(`qa admission: ${value("--app")}${stage ? ` (${stage} stage)` : ""} -> ${entry.mode} (since ${entry.since}, set_by ${entry.set_by})`);
320
+ return 0;
321
+ }
322
+ if (args[0] === "approve-runner") {
323
+ const entry = approveRunner({ runner: value("--runner"), version: value("--version"), platform: value("--platform"), setBy: value("--set-by"), note: value("--note"), dir, now, spawn, versionsFile });
324
+ stdout(`qa runner approved: ${entry.sha256} rightkit-qa ${entry.version} (${entry.platform ?? "any platform"}, since ${entry.since}, set_by ${entry.set_by})`);
158
325
  return 0;
159
326
  }
160
- stderr("usage: release-qa-policy.mjs show | set --app <app> --mode warn|refuse --set-by <name>");
327
+ stderr(`usage: ${program} show\n ${program} set --app <app> --mode warn|refuse --set-by <name> [--stage source|installed]\n ${program} approve-runner --runner <file> --version <v> --platform mac|win --set-by <name> [--note <text>]`);
161
328
  return 2;
162
329
  } catch (error) {
163
330
  stderr(`release-qa-policy: ${error.message}`);
package/release.mjs CHANGED
@@ -58,7 +58,7 @@ 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
62
  import { describeRegistryError, formatNotRun, runAdmission } from "./admission.mjs";
63
63
  import { sccacheRefusal } from "./admission-host.mjs";
64
64
 
@@ -126,7 +126,7 @@ const SWIFT_PRIVATE_DISPATCH_WORKFLOWS = new Set(["release-candidate.yml", "rege
126
126
 
127
127
  const args = process.argv.slice(2);
128
128
  const opts = {
129
- config: "right-release.config.mjs",
129
+ config: defaultReleaseConfigFile(),
130
130
  platform: process.platform === "win32" ? "win" : process.platform === "darwin" ? "mac" : process.platform,
131
131
  upload: false,
132
132
  install: false,
@@ -245,7 +245,7 @@ if (opts.doctor) {
245
245
  doctorInvocation = { repoRoot, commit };
246
246
  }
247
247
  let config;
248
- try { config = (await import(pathToFileURL(configPath))).default; } catch (error) { fail(`release config could not be loaded (${configPath}): ${error instanceof Error ? error.message : String(error)}`); }
248
+ try { config = await importReleaseConfig(configPath); } catch (error) { fail(`release config could not be loaded (${configPath}): ${error instanceof Error ? error.message : String(error)}`); }
249
249
  if (!config) usage(2, `config did not export default: ${configPath}`);
250
250
  if (config.schema !== 1) fail(`unsupported config schema: ${config.schema ?? "<missing>"} (expected 1)`);
251
251
  // Field-level config refusals (updater.keyFile: keys come from the OS credential provider) are listed with every other finding.
@@ -841,7 +841,7 @@ releaseLock.release();
841
841
 
842
842
  function usage(code, message) {
843
843
  if (message) console.error(`right-release: ${message}\n`);
844
- 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)");
845
845
  process.exit(code);
846
846
  }
847
847
 
@@ -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) {