@rightkit/release 0.6.5 → 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 (42) hide show
  1. package/README.md +79 -7
  2. package/android-play-upload.mjs +4 -4
  3. package/app-check.mjs +474 -17
  4. package/apple-profile-create.mjs +4 -3
  5. package/apple-profile-renew.mjs +5 -4
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/rightkit-gate.mjs +431 -44
  8. package/binary-license-notices.mjs +2 -1
  9. package/build-release.mjs +7 -6
  10. package/cache-command.mjs +4 -3
  11. package/cargo-license-notices.mjs +2 -1
  12. package/cargo-manifest-text.mjs +25 -0
  13. package/cargo-profile-contract.mjs +26 -13
  14. package/cli/right-release.mjs +8 -1
  15. package/command-spec.mjs +4 -5
  16. package/deps.mjs +5 -4
  17. package/deterministic-scans.mjs +280 -32
  18. package/development.mjs +5 -5
  19. package/github-release.mjs +5 -3
  20. package/github-upload-gate.mjs +6 -6
  21. package/init-command.mjs +134 -8
  22. package/ios-upload.mjs +4 -4
  23. package/mirror-root-artifact.mjs +3 -4
  24. package/npm-license-notices.mjs +2 -1
  25. package/outdated-command.mjs +91 -0
  26. package/package.json +37 -1
  27. package/pipeline-admission.mjs +12 -0
  28. package/publish-cargo.mjs +215 -3
  29. package/publish-update.mjs +5 -3
  30. package/release-config.mjs +36 -5
  31. package/release-dependencies-command.mjs +4 -3
  32. package/release-qa-audit.mjs +12 -10
  33. package/release-qa-command.mjs +42 -7
  34. package/release-qa-hardening.mjs +10 -3
  35. package/release-qa-policy.mjs +214 -47
  36. package/release.mjs +4 -4
  37. package/rightkit-registry-resolver.mjs +5 -2
  38. package/rightkit-versions.json +11 -8
  39. package/stage-sidecar-command.mjs +5 -5
  40. package/test-command.mjs +3 -2
  41. package/upload-release.mjs +3 -2
  42. 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}`);
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) {
@@ -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.5",
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",
@@ -231,7 +231,8 @@
231
231
  "0.6.1",
232
232
  "0.6.2",
233
233
  "0.6.3",
234
- "0.6.4"
234
+ "0.6.4",
235
+ "0.6.5"
235
236
  ],
236
237
  "@rightkit/qa": [
237
238
  "0.1.0",
@@ -373,7 +374,8 @@
373
374
  "0.1.5",
374
375
  "0.1.6",
375
376
  "0.1.8",
376
- "0.1.10"
377
+ "0.1.10",
378
+ "0.1.11"
377
379
  ],
378
380
  "@rightkit/updates": [
379
381
  "0.2.4"
@@ -486,7 +488,7 @@
486
488
  "rightkit-pdf": "0.3.3",
487
489
  "rightkit-platform-rules": "0.1.0",
488
490
  "rightkit-playback": "0.3.0",
489
- "rightkit-qa": "0.9.0",
491
+ "rightkit-qa": "0.9.1",
490
492
  "rightkit-qa-driver": "0.7.0",
491
493
  "rightkit-record": "0.3.3",
492
494
  "rightkit-review": "0.4.0",
@@ -610,7 +612,7 @@
610
612
  "@rightkit/app-shell": "0.6.2",
611
613
  "@rightkit/app-update": "0.4.2",
612
614
  "@rightkit/ax": "0.3.2",
613
- "@rightkit/ci": "0.3.0",
615
+ "@rightkit/ci": "0.3.1",
614
616
  "@rightkit/doc-render": "0.2.1",
615
617
  "@rightkit/git": "0.2.42",
616
618
  "@rightkit/hooks": "0.1.2",
@@ -628,7 +630,7 @@
628
630
  "@rightkit/qa": "0.6.1",
629
631
  "@rightkit/qa-chrome": "0.1.11",
630
632
  "@rightkit/react-shell": "0.7.2",
631
- "@rightkit/release": "0.6.4",
633
+ "@rightkit/release": "0.6.5",
632
634
  "@rightkit/settings-ui": "0.7.0",
633
635
  "@rightkit/shell": "0.1.1",
634
636
  "@rightkit/tauri": "0.3.0",
@@ -762,7 +764,8 @@
762
764
  "0.6.4",
763
765
  "0.7.0",
764
766
  "0.7.1",
765
- "0.8.0"
767
+ "0.8.0",
768
+ "0.9.0"
766
769
  ],
767
770
  "rightkit-sandbox": [
768
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; }
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { prepareGitHubRelease, publishGitHubRelease } from "./github-release.mjs";
6
+ import { defaultReleaseConfigFile } from "./release-config.mjs";
6
7
  import { authorizeGitHubUpload } from "./github-upload-gate.mjs";
7
8
  import { recordPublishedVersion } from "./artifact-storage.mjs";
8
9
  import { admitInstalledQa } from "./release-qa-audit.mjs";
@@ -12,7 +13,7 @@ let platform = process.platform === "win32" ? "win" : process.platform === "darw
12
13
  let releaseId = "";
13
14
  let tier = "";
14
15
  let repo = "";
15
- let configName = "right-release.config.mjs";
16
+ let configName = defaultReleaseConfigFile();
16
17
  let dryRun = false;
17
18
  let apply = false;
18
19
 
@@ -87,7 +88,7 @@ function writeJson(file, value) {
87
88
  }
88
89
 
89
90
  function usage(code) {
90
- console.log("usage: right-release upload --release <sealed-id> --platform win|mac --tier patch|update [--config right-release.config.mjs] [--repo owner/repo] [--dry-run]\n right-release upload --platform android --release <sealed-id> [--config right-release.config.mjs] [--apply] (Play internal track; dry run unless --apply)\n right-release upload --platform ios --release <sealed-id> [--config right-release.config.mjs] [--apply] (TestFlight; dry run unless --apply)");
91
+ console.log("usage: right-release upload --release <sealed-id> --platform win|mac --tier patch|update [--config right-release.config.json] [--repo owner/repo] [--dry-run]\n right-release upload --platform android --release <sealed-id> [--config right-release.config.json] [--apply] (Play internal track; dry run unless --apply)\n right-release upload --platform ios --release <sealed-id> [--config right-release.config.json] [--apply] (TestFlight; dry run unless --apply)");
91
92
  process.exit(code);
92
93
  }
93
94
 
package/web-build.mjs CHANGED
@@ -15,9 +15,10 @@
15
15
  import { pathToFileURL } from "node:url";
16
16
  import { spawnSync } from "./background-process.mjs";
17
17
  import { CommandSpecError, loadAppConfig, resolveInvocation, validateStep } from "./command-spec.mjs";
18
+ import { defaultReleaseConfigFile } from "./release-config.mjs";
18
19
 
19
20
  export function parseWebBuildArgs(argv) {
20
- const opts = { config: "right-release.config.mjs", dryRun: false };
21
+ const opts = { config: defaultReleaseConfigFile(), dryRun: false };
21
22
  for (let i = 0; i < argv.length; i += 1) {
22
23
  const arg = argv[i];
23
24
  if (arg === "--config") {