@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
@@ -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}`);