@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
package/publish-cargo.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { createHash } from "node:crypto";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { enforcePublicationGate, defaultEvidencePath, provenGateSteps, singleHostOfPolicy, batchClippyMissingFeatureSets } from "./publication-gate.mjs";
6
7
  import { runCargoSync } from "./managed-cargo.mjs";
@@ -12,9 +13,10 @@ import { recordPublished, workspaceFeedOptions } from "./rightkit-sync.mjs";
12
13
  import { readCargoPackage } from "./crate-graph.mjs";
13
14
  import { runtimeBudgetCheck, RUNTIME_BUDGET_CODE } from "./runtime-budgets.mjs";
14
15
  import { formatPreflight, PREFLIGHT_FAIL } from "./preflight.mjs";
16
+ import { cargoManifestState } from "./cargo-manifest-text.mjs";
15
17
 
16
18
  const FORBIDDEN_PACKAGE_PATH = /(^|\/)(?:\.env(?:\.[^/]*)?|target(?:\/.*)?|node_modules(?:\/.*)?|[^/]+\.(?:p8|p12|pem|key))$/i;
17
- const SECRET_PATTERNS = [
19
+ export const SECRET_PATTERNS = [
18
20
  /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
19
21
  /\bAKIA[0-9A-Z]{16}\b/,
20
22
  /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/,
@@ -139,7 +141,202 @@ export function packageFileViolations(files) {
139
141
  .filter((file) => FORBIDDEN_PACKAGE_PATH.test(file));
140
142
  }
141
143
 
142
- export function scanCrateForSecrets(crateDir) {
144
+ // ---- the secret scan ---------------------------------------------------------------------------------------------------
145
+ //
146
+ // Strict (the default): a crate package, its archive and the local-publish batch scan. `scanCrateForSecrets(dir)` returns the
147
+ // relative paths that are refused by name (FORBIDDEN_PACKAGE_PATH, which includes every .env* file, so env templates too) or whose
148
+ // content matches SECRET_PATTERNS. Nothing published needs a template or a test vector, and the records file below is never read.
149
+ //
150
+ // Repository gate (`{ envTemplates: true }`, deterministic-scans.mjs "secret-scan"): the units of a commit that is not being
151
+ // published. An app legitimately commits env templates (`.env.example`) and test vectors for its own redaction and screening code,
152
+ // and no content heuristic tells a placeholder from a pasted credential, so a file is admitted by a reviewed record, not by a guess.
153
+ // `.rightkit/secret-scan-records.json` in the repository:
154
+ // { "schema": 1, "records": [ { "path": "<repo-relative>", "sha256": "<hex of the bytes, CRLF read as LF>", "kind": "env-template" | "fixture", "note": "<what it holds and why it is safe>" } ] }
155
+ // - an env template (.env.example, .env.<name>.example, .env.sample, .env.template) is admitted only by a record of kind
156
+ // "env-template" whose sha256 is the file's: any edit needs a new record, which is the review, in the pull request diff;
157
+ // - any other file whose content matches SECRET_PATTERNS is admitted only by a record of kind "fixture" with the file's sha256;
158
+ // - never recordable: .env and every .env.* that is not a template, *.pem, *.p8, *.p12, *.key (refused by name, and a record naming
159
+ // one is itself a violation); a template that is not UTF-8 (NUL bytes, a UTF-16 mark), that holds a PEM private key header or one
160
+ // of the other high-precision credential shapes below, or that sits inside a publishable crate or package is refused even when recorded;
161
+ // - a malformed records file is a violation and admits nothing.
162
+ // Each violation is `<path>[:<line>]: <reason>`; a hit names the line and the shape, never the matched text.
163
+ export const SECRET_SCAN_RECORDS_FILE = ".rightkit/secret-scan-records.json";
164
+ const RECORD_KINDS = Object.freeze(["env-template", "fixture"]);
165
+ const RECORD_KEYS = Object.freeze(["path", "sha256", "kind", "note"]);
166
+ const RECORD_NOTE_PLACEHOLDER = "<what it holds and why it is safe>";
167
+ const ENV_TEMPLATE = /(^|\/)\.env(?:\.[^/]*)?\.(?:example|sample|template)$/i;
168
+ // Labels of SECRET_PATTERNS, index for index.
169
+ const SECRET_PATTERN_LABELS = Object.freeze(["a PEM private key header", "an AWS access key id (AKIA)", "a GitHub token (gh*_)", "an npm token (npm_)"]);
170
+ // Credential shapes that are never a placeholder, matched in env templates besides SECRET_PATTERNS. Every expression is linear:
171
+ // each quantified class is followed by a delimiter it cannot match or by the end of the match.
172
+ const TEMPLATE_SHAPES = Object.freeze([
173
+ [/-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----/, "a PEM private key header"],
174
+ [/github_pat_[A-Za-z0-9_]{22,}/, "a GitHub fine-grained token (github_pat_)"],
175
+ [/sk_live_[A-Za-z0-9]{16,}/, "a Stripe live key (sk_live_)"],
176
+ [/xox[abprs]-[A-Za-z0-9-]{10,}/, "a Slack token (xox*-)"],
177
+ [/AIza[0-9A-Za-z_-]{35}/, "a Google API key (AIza)"],
178
+ [/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/, "a Slack webhook URL"],
179
+ ]);
180
+ const FILE_SHAPES = Object.freeze(SECRET_PATTERNS.map((pattern, index) => [pattern, SECRET_PATTERN_LABELS[index]]));
181
+ const TEMPLATE_ALL_SHAPES = Object.freeze([...FILE_SHAPES, ...TEMPLATE_SHAPES]);
182
+ const UTF8 = new TextDecoder("utf-8", { fatal: true });
183
+
184
+ const neverRecordable = (relative) => FORBIDDEN_PACKAGE_PATH.test(relative) && !ENV_TEMPLATE.test(relative);
185
+ const sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex");
186
+ // A record's digest reads CRLF as LF, so one record holds on every host: `git archive` (the gate's export) writes CRLF on a
187
+ // Windows host with `core.autocrlf=true` or `core.eol=crlf` for `text=auto` files and LF elsewhere. The content checks still read
188
+ // the bytes as exported; a lone CR is kept, so no other byte is ever ignored.
189
+ const recordDigest = (bytes) => sha256Hex(Buffer.from(Buffer.from(bytes).toString("latin1").replace(/\r\n/g, "\n"), "latin1"));
190
+ const recordLine = (relative, sha256, kind) => JSON.stringify({ path: relative, sha256, kind, note: RECORD_NOTE_PLACEHOLDER });
191
+ const repoRelativePath = (p) => p !== "" && p !== "." && !p.includes("\\") && !p.includes("\0") && !p.startsWith("/") && !/^[A-Za-z]:/.test(p) && !p.endsWith("/")
192
+ && path.posix.normalize(p) === p && !p.split("/").includes("..");
193
+
194
+ /** `{ records: Map<path, { sha256, kind, note }>, problems: string[] }` from the text of the records file (undefined: no file). A record with a problem admits nothing. */
195
+ export function parseSecretScanRecords(text) {
196
+ const records = new Map();
197
+ const problems = [];
198
+ const bad = (message) => problems.push(`${SECRET_SCAN_RECORDS_FILE}: ${message}`);
199
+ if (text === undefined) return { records, problems };
200
+ let doc;
201
+ try { doc = JSON.parse(String(text).replace(/^/, "")); } catch (error) { bad(`is not valid JSON (${error.message})`); return { records, problems }; }
202
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc)) { bad('must be an object { "schema": 1, "records": [...] }'); return { records, problems }; }
203
+ for (const key of Object.keys(doc)) if (key !== "schema" && key !== "records") bad(`has an unknown key ${JSON.stringify(key)}`);
204
+ if (doc.schema !== 1) { bad(`schema must be 1 (found ${JSON.stringify(doc.schema)}); no record is read`); return { records, problems }; }
205
+ if (!Array.isArray(doc.records)) { bad('"records" must be an array'); return { records, problems }; }
206
+ const counts = new Map();
207
+ const valid = [];
208
+ doc.records.forEach((record, index) => {
209
+ if (record === null || typeof record !== "object" || Array.isArray(record)) { bad(`record ${index} must be an object`); return; }
210
+ const label = typeof record.path === "string" ? `record ${index} (${JSON.stringify(record.path)})` : `record ${index}`;
211
+ let ok = true;
212
+ const fail = (message) => { bad(`${label}: ${message}`); ok = false; };
213
+ for (const key of Object.keys(record)) if (!RECORD_KEYS.includes(key)) fail(`has an unknown key ${JSON.stringify(key)}`);
214
+ if (typeof record.path !== "string" || !repoRelativePath(record.path)) fail('"path" must be a normalised repository-relative path with forward slashes');
215
+ if (typeof record.sha256 !== "string" || !/^[0-9a-f]{64}$/i.test(record.sha256)) fail('"sha256" must be 64 hex characters, the sha256 of the file bytes with CRLF read as LF');
216
+ if (!RECORD_KINDS.includes(record.kind)) fail(`"kind" must be one of ${RECORD_KINDS.join(", ")}`);
217
+ if (typeof record.note !== "string" || record.note.trim() === "" || record.note.trim() === RECORD_NOTE_PLACEHOLDER) fail('"note" must say what the file holds and why it is safe');
218
+ if (typeof record.path === "string") counts.set(record.path, (counts.get(record.path) ?? 0) + 1);
219
+ if (!ok) return;
220
+ if (neverRecordable(record.path)) fail("names a path that can never be recorded (.env and every .env.* that is not a template, *.pem, *.p8, *.p12, *.key)");
221
+ else if (record.kind === "env-template" && !ENV_TEMPLATE.test(record.path)) fail('kind "env-template" names a path that is not an env template (.env.example, .env.<name>.example, .env.sample, .env.template)');
222
+ else if (record.kind === "fixture" && ENV_TEMPLATE.test(record.path)) fail('kind "fixture" cannot admit an env template; record it as "env-template"');
223
+ else valid.push(record);
224
+ });
225
+ for (const [recorded, count] of counts) if (count > 1) bad(`${JSON.stringify(recorded)} is recorded ${count} times; keep one record per path`);
226
+ for (const record of valid) if (counts.get(record.path) === 1) records.set(record.path, { sha256: record.sha256.toLowerCase(), kind: record.kind, note: record.note });
227
+ return { records, problems };
228
+ }
229
+
230
+ // The records file of the repository being scanned, read as data: a regular file whose real path stays inside the repository.
231
+ function readSecretScanRecords(repoRoot) {
232
+ const file = path.join(repoRoot, SECRET_SCAN_RECORDS_FILE);
233
+ try {
234
+ if (!fs.lstatSync(file).isFile()) return { problems: [`${SECRET_SCAN_RECORDS_FILE}: is not a regular file`] };
235
+ const relative = path.relative(fs.realpathSync(repoRoot), fs.realpathSync(file));
236
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return { problems: [`${SECRET_SCAN_RECORDS_FILE}: resolves outside the repository`] };
237
+ return parseSecretScanRecords(fs.readFileSync(file, "utf8"));
238
+ } catch (error) {
239
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return parseSecretScanRecords(undefined);
240
+ return { problems: [`${SECRET_SCAN_RECORDS_FILE}: cannot be read (${error?.code ?? error?.message})`] };
241
+ }
242
+ }
243
+
244
+ /** Why a file is not UTF-8 text (NUL bytes, a UTF-16 byte-order mark, an invalid sequence), or null. */
245
+ function notUtf8(bytes) {
246
+ if (bytes.length >= 2 && ((bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff))) return "starts with a UTF-16 byte-order mark";
247
+ if (bytes.includes(0)) return "contains NUL bytes";
248
+ try { UTF8.decode(bytes); } catch { return "is not valid UTF-8"; }
249
+ return null;
250
+ }
251
+
252
+ /**
253
+ * `[{ label, lines }]` for the shapes that match `text`: at most the first five matching lines of each, found in one pass.
254
+ * After a hit the search resumes on the next line, so the work is linear in the text however many matches a line holds.
255
+ */
256
+ function contentHits(text, shapes) {
257
+ const hits = new Map();
258
+ for (const [shape, label] of shapes) {
259
+ const pattern = new RegExp(shape.source, `${shape.flags.replace("g", "")}g`);
260
+ const lines = hits.get(label) ?? [];
261
+ let line = 1;
262
+ let newline = text.indexOf("\n");
263
+ for (let match = pattern.exec(text); match !== null && lines.length < 5; match = pattern.exec(text)) {
264
+ while (newline !== -1 && newline < match.index) { line += 1; newline = text.indexOf("\n", newline + 1); }
265
+ if (!lines.includes(line)) lines.push(line);
266
+ pattern.lastIndex = newline === -1 ? text.length : newline + 1;
267
+ }
268
+ if (lines.length) hits.set(label, lines);
269
+ }
270
+ const found = [...hits].map(([label, lines]) => ({ label, lines: [...lines].sort((a, b) => a - b).slice(0, 5) }));
271
+ return found.sort((a, b) => a.lines[0] - b.lines[0] || (a.label < b.label ? -1 : 1));
272
+ }
273
+
274
+ const hitsText = (hits) => hits.map((hit) => `${hit.label} (line${hit.lines.length > 1 ? "s" : ""} ${hit.lines.join(", ")})`).join("; ");
275
+ const firstLine = (hits) => Math.min(...hits.flatMap((hit) => hit.lines));
276
+
277
+ // The publishable crate or package that holds `file`, or null: the nearest Cargo.toml and the nearest package.json at or above
278
+ // it, up to the repository root. A crate is publishable when its [package] does not say publish = false / []; a package when its
279
+ // package.json is not "private": true. A manifest that cannot be read counts as publishable.
280
+ function publishableOwner(file, repoRoot) {
281
+ const root = path.resolve(repoRoot);
282
+ const shown = (dir) => path.relative(root, dir).replaceAll("\\", "/") || ".";
283
+ let cargoDecided = false;
284
+ let npmDecided = false;
285
+ for (let dir = path.dirname(path.resolve(file)); ; dir = path.dirname(dir)) {
286
+ const outside = path.relative(root, dir);
287
+ if (outside === ".." || outside.startsWith(`..${path.sep}`) || path.isAbsolute(outside)) return null;
288
+ if (!cargoDecided && fs.existsSync(path.join(dir, "Cargo.toml"))) {
289
+ cargoDecided = true;
290
+ let state = null;
291
+ try { state = cargoManifestState(fs.readFileSync(path.join(dir, "Cargo.toml"), "utf8")); } catch { /* unreadable: publishable */ }
292
+ if (state === null || (state.hasPackage && !state.unpublished)) return `the publishable crate at ${shown(dir)} (its Cargo.toml [package] does not say publish = false)`;
293
+ }
294
+ if (!npmDecided && fs.existsSync(path.join(dir, "package.json"))) {
295
+ npmDecided = true;
296
+ let manifest = null;
297
+ try { manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8").replace(/^/, "")); } catch { /* unreadable: publishable */ }
298
+ if (manifest?.private !== true) return `the publishable package at ${shown(dir)} (its package.json is not "private": true)`;
299
+ }
300
+ if ((cargoDecided && npmDecided) || dir === root || path.dirname(dir) === dir) return null;
301
+ }
302
+ }
303
+
304
+ function envTemplateViolations(relative, file, bytes, records, repoRoot) {
305
+ const owner = publishableOwner(file, repoRoot);
306
+ const encoding = notUtf8(bytes);
307
+ const hits = encoding === null ? contentHits(bytes.toString("utf8"), TEMPLATE_ALL_SHAPES) : [];
308
+ const refused = [];
309
+ if (owner) refused.push(`${relative}: env template inside ${owner}; a publishable unit never carries one (refused by name, as at publish time and in the archive scan). Move it out of the unit, or mark the unit unpublished`);
310
+ if (encoding) refused.push(`${relative}: env template ${encoding}, so it cannot be read as UTF-8 text; refused even when recorded. Save it as UTF-8`);
311
+ if (hits.length) refused.push(`${relative}:${firstLine(hits)}: env template matches ${hitsText(hits)}; refused even when recorded. Replace the value with a placeholder`);
312
+ if (refused.length) return refused;
313
+ const sha256 = recordDigest(bytes);
314
+ const record = records.get(relative);
315
+ if (record?.kind === "env-template" && record.sha256 === sha256) return [];
316
+ const line = recordLine(relative, sha256, "env-template");
317
+ if (record?.kind === "env-template") return [`${relative}: env template changed since it was recorded (recorded sha256 ${record.sha256}, now ${sha256}); after reviewing the change, replace its record in ${SECRET_SCAN_RECORDS_FILE} with: ${line}`];
318
+ return [`${relative}: env template (sha256 ${sha256}) is not recorded in ${SECRET_SCAN_RECORDS_FILE}; after reviewing that it holds only names and placeholders, add this record: ${line}`];
319
+ }
320
+
321
+ function fileViolations(relative, bytes, records) {
322
+ const hits = contentHits(bytes.toString("utf8"), FILE_SHAPES);
323
+ if (!hits.length) return [];
324
+ const sha256 = recordDigest(bytes);
325
+ const record = records.get(relative);
326
+ if (record?.kind === "fixture" && record.sha256 === sha256) return [];
327
+ const line = recordLine(relative, sha256, "fixture");
328
+ const where = `${relative}:${firstLine(hits)}: content matches ${hitsText(hits)}`;
329
+ if (record?.kind === "fixture") return [`${where}; its fixture record is out of date (recorded sha256 ${record.sha256}, now ${sha256}). After reviewing the change, replace its record in ${SECRET_SCAN_RECORDS_FILE} with: ${line}`];
330
+ return [`${where}; sha256 ${sha256}. If this is a redaction or screening test vector and not a credential, review it and add this record to ${SECRET_SCAN_RECORDS_FILE}: ${line}`];
331
+ }
332
+
333
+ /**
334
+ * Files under `crateDir` that must not be committed or packaged. Strict by default: bare relative paths. With `envTemplates: true`
335
+ * (the repository gate) the reviewed records of `<repoRoot>/.rightkit/secret-scan-records.json` apply and each violation is a
336
+ * `<repo-relative path>[:<line>]: <reason>` line; `repoRoot` (default `crateDir`) is the root those paths and the records are relative to.
337
+ */
338
+ export function scanCrateForSecrets(crateDir, { envTemplates = false, repoRoot = crateDir } = {}) {
339
+ if (envTemplates) return scanRepositoryForSecrets(crateDir, repoRoot);
143
340
  const violations = [];
144
341
  for (const file of walkFiles(crateDir)) {
145
342
  const relative = path.relative(crateDir, file).replaceAll("\\", "/");
@@ -147,13 +344,31 @@ export function scanCrateForSecrets(crateDir) {
147
344
  violations.push(relative);
148
345
  continue;
149
346
  }
150
- const bytes = fs.readFileSync(file);
151
- const text = bytes.toString("utf8");
347
+ const text = fs.readFileSync(file).toString("utf8");
152
348
  if (SECRET_PATTERNS.some((pattern) => pattern.test(text))) violations.push(relative);
153
349
  }
154
350
  return [...new Set(violations)].sort();
155
351
  }
156
352
 
353
+ function scanRepositoryForSecrets(dir, repoRoot) {
354
+ const { records = new Map(), problems = [] } = readSecretScanRecords(repoRoot);
355
+ const violations = [...problems];
356
+ for (const file of walkFiles(dir)) {
357
+ const inDir = path.relative(dir, file).replaceAll("\\", "/");
358
+ const relative = path.relative(repoRoot, file).replaceAll("\\", "/");
359
+ if (neverRecordable(inDir)) {
360
+ violations.push(`${relative}: refused by name (an .env file, a key or certificate file, or build output); it is never committed and cannot be recorded`);
361
+ continue;
362
+ }
363
+ // A file that vanished between the walk and the read was a tool's temporary file (pnpm writes `_tmp_<pid>_*` next to a
364
+ // lockfile it reads), never committed content.
365
+ let bytes;
366
+ try { bytes = fs.readFileSync(file); } catch (e) { if (e.code === "ENOENT") continue; throw e; }
367
+ violations.push(...(ENV_TEMPLATE.test(inDir) ? envTemplateViolations(relative, file, bytes, records, repoRoot) : fileViolations(relative, bytes, records)));
368
+ }
369
+ return [...new Set(violations)].sort();
370
+ }
371
+
157
372
  /**
158
373
  * Package, qualify and (unless `options.dryRun`) publish one crate. A real publish is refused (HOST_UPLOAD_REFUSED) before anything
159
374
  * runs unless `options.capability` is a publish capability (host-upload-refusal.mjs createPublishCapability) created from a
@@ -3,11 +3,12 @@ import { readFile } from "node:fs/promises";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { spawnSync } from "./background-process.mjs";
5
5
  import path from "node:path";
6
- import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { fileURLToPath } from "node:url";
7
7
  import { pruneReleaseObjects } from "./prune-r2.mjs";
8
8
  import { loadReleaseToken } from "./release-token.mjs";
9
9
  import { registerRightAppsRelease } from "./rightapps-register.mjs";
10
10
  import { recordPublishedVersion } from "./artifact-storage.mjs";
11
+ import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
11
12
  import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
12
13
  import { assertCleanSource } from "./source-gate.mjs";
13
14
  import { verifyPublicationRelease, PublicationError, assertArtifactUnchanged, digestBytes } from "./publication-artifacts.mjs";
@@ -15,7 +16,7 @@ import { uploadVerifiedR2Artifact } from "./r2-verified-upload.mjs";
15
16
  import { verifyPlatformTrust } from "./github-release.mjs";
16
17
  import { admitInstalledQa } from "./release-qa-audit.mjs";
17
18
  import { RIGHT_SUITE_API_ORIGIN, RIGHT_SUITE_GATED_ARTIFACT_HOST, RIGHT_SUITE_PUBLIC_INSTALLER_BASE } from "./suite-api.generated.mjs";
18
- import { assertAssetName, assertGitHubRepository, assertUpdaterKeyId, assertUpdaterPlatform, assertUpdaterSignature, buildLatestJson, githubAssetUrl, githubTag, publishGitHubUpdaterRelease } from "./github-updater-release.mjs";
19
+ import { assertAssetName, assertGitHubRepository, assertUpdaterKeyId, assertUpdaterPlatform, assertUpdaterSignature, buildLatestJson, githubAssetUrl, githubTag, publishGitHubUpdaterRelease, resolveUpdaterTrust } from "./github-updater-release.mjs";
19
20
 
20
21
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
21
22
  const API_BASE = process.env.RIGHTAPPS_API_URL || RIGHT_SUITE_API_ORIGIN;
@@ -24,7 +25,7 @@ const PUBLIC_BASE = process.env.RIGHTAPPS_PUBLIC_DOWNLOAD_BASE || RIGHT_SUITE_PU
24
25
  const PIPELINE_VERSION = JSON.parse(await readFile(path.join(TOOL_ROOT, "package.json"), "utf8")).version;
25
26
  let tier = process.env.RIGHT_RELEASE_TIER || "";
26
27
  const args = process.argv.slice(2);
27
- let configName = "right-release.config.mjs";
28
+ let configName = defaultReleaseConfigFile();
28
29
  let platform = process.platform === "win32" ? "win" : "mac";
29
30
  let dryRun = false;
30
31
  let releaseId = "";
@@ -45,7 +46,8 @@ if (tier !== "patch" && tier !== "update") fail("tier is required (--tier patch|
45
46
  if (!["win", "mac"].includes(platform)) throw new PublicationError("PUBLICATION_PLATFORM_INVALID", "--platform must be win or mac");
46
47
  const configPath = path.resolve(configName);
47
48
  const root = path.dirname(configPath);
48
- const config = (await import(pathToFileURL(configPath))).default;
49
+ let config;
50
+ try { config = await importReleaseConfig(configPath); } catch (error) { fail(`release config could not be loaded (${configPath}): ${error instanceof Error ? error.message : String(error)}`); }
49
51
  const provider = config?.distribution?.provider;
50
52
  if (provider !== "r2-rightapps" && provider !== "github-releases") {
51
53
  fail("release config distribution.provider must be r2-rightapps or github-releases");
@@ -117,18 +119,21 @@ function git(args) {
117
119
  if (!githubLane && tier === "patch" && !installers.length) fail(`${config?.app ?? "app"} ${platform} patch has no installer.artifacts`);
118
120
  if ((githubLane || tier === "update") && !artifacts.length) fail(`${config?.app ?? "app"} ${platform} update has no updater.artifacts`);
119
121
 
120
- // Cryptographic gate before any upload: every sealed minisign signature must verify over the
121
- // exact final artifact bytes against the declared public key. No declared key means the
122
- // signature cannot be verified, so a sealed (real) publish fails closed; key-id pinning is
123
- // supplementary only. Integrator owns declaring distribution.updaterPublicKey in app configs
124
- // (suite key already pinned per-app as tauri plugins.updater.pubkey).
122
+ // Cryptographic gate before any upload: every sealed minisign signature must verify over the exact final artifact
123
+ // bytes against the updater trust root. A Right Suite app takes that key, and its key id, from the Right Suite contract
124
+ // (a declared key must be the contract's) and is always pinned to the id; any other app must declare
125
+ // distribution.updaterPublicKey, and without one a sealed (real) publish fails closed.
125
126
  const updaterPublicKey = config.distribution?.updaterPublicKey ?? config.distribution?.updaterPubkey ?? target?.updater?.pubkey ?? target?.updater?.publicKey;
127
+ const trust = sealed && artifacts.length && !appInstallerKind
128
+ ? resolveUpdaterTrust({ app: config.app, configuredPublicKey: updaterPublicKey, configuredKeyId: config.distribution.updaterKeyId })
129
+ : null;
126
130
  if (sealed) for (const artifact of artifacts) {
127
131
  const signature = signatureFor(artifact);
128
- if (!appInstallerKind) {
129
- if (!updaterPublicKey) throw new PublicationError("PUBLICATION_SIGNATURE_UNVERIFIABLE", `no updater public key declared (distribution.updaterPublicKey); refusing to upload ${path.basename(artifact.file)} with an unverified signature`);
130
- assertUpdaterSignature(assertArtifactUnchanged(verifiedArtifact(artifact.file)), signature, updaterPublicKey, `signature of ${path.basename(artifact.file)}`);
131
- assertUpdaterKeyId(signature, config.distribution.updaterKeyId, `signature of ${path.basename(artifact.file)}`);
132
+ if (trust) {
133
+ const label = `signature of ${path.basename(artifact.file)}`;
134
+ // The id comes first: a signature by another key would otherwise fail verification without saying whose it is.
135
+ assertUpdaterKeyId(signature, trust.keyId, label, { app: config.app });
136
+ assertUpdaterSignature(assertArtifactUnchanged(verifiedArtifact(artifact.file)), signature, trust.publicKey, label);
132
137
  }
133
138
  }
134
139
  let releaseToken = null;
@@ -154,7 +159,7 @@ if (githubLane) {
154
159
  const name = assertAssetName(path.basename(file));
155
160
  // Same signature source and process as the r2 lane: the sealed minisign .sig written by sign-updater.
156
161
  const signature = dryRun ? `<signature:${artifact.signature ?? "msix-authenticode"}>` : signatureFor(artifact);
157
- if (!dryRun && !appInstallerKind) assertUpdaterKeyId(signature, config.distribution.updaterKeyId, `signature of ${name}`);
162
+ if (!dryRun && !appInstallerKind) assertUpdaterKeyId(signature, trust.keyId, `signature of ${name}`, { app: config.app });
158
163
  platforms[artifact.platform] = { signature, url: githubAssetUrl(githubRepo, tag, name) };
159
164
  assets.set(file, { file, evidence: dryRun ? undefined : verifiedArtifact(file) });
160
165
  if (!appInstallerKind) {
@@ -1,6 +1,11 @@
1
1
  // The release config contract (M24): `right-release.config.json`, a finite, versioned JSON schema read by dev, build, doctor, install
2
- // and upload alike. It is data. RightKit never imports, evaluates or converts an executable (`.mjs`, `.js`, `.cjs`, `.ts`) config: the
3
- // file name is judged BEFORE anything is opened or imported, including for `--dry-run`, so a legacy config cannot run a single line.
2
+ // and upload alike. A `.json` config is data: it is never imported or evaluated. `loadReleaseConfigSync` judges the file NAME before
3
+ // anything is opened (an executable name handed to it is refused with RELEASE_CONFIG_LEGACY_EXECUTABLE) and loads the file strictly.
4
+ //
5
+ // Legacy branch: an app that has not moved yet owns only a `right-release.config.mjs`. `defaultReleaseConfigFile` keeps naming it while
6
+ // no `right-release.config.json` sits beside it, and `importReleaseConfig` still imports that module (executable, outside M24; the
7
+ // pipeline scanner reports it as R0-legacy-config). The sunset of the legacy branch is tracked separately. As soon as the JSON file
8
+ // exists it wins by default and an explicit `--config x.json` always takes the strict loader; the module is never read for it.
4
9
  //
5
10
  // Load order (build, dev, upload): resolve the primary checkout and the config path first, then `loadReleaseConfigSync` parses the JSON
6
11
  // strictly (no comments, no trailing commas, no duplicate keys), checks `schema: 1` and the finite top-level key set, expands the
@@ -12,15 +17,19 @@
12
17
  // { "$fromJson": { "file": "src-tauri/tauri.conf.json", "pointer": "/version" } } a string read from an app-relative JSON file
13
18
  // { "$fromToml": { "file": "src-tauri/Cargo.toml", "path": "package.version" } } a string read from an app-relative TOML file
14
19
  // { "$json": <value> } JSON.stringify(<value>), for a JSON argument
15
- // "{{app}}" "{{version}}" "{{home}}" "{{localAppData}}" replaced inside any string value
20
+ // "{{app}}" "{{version}}" "{{home}}" "{{localAppData}}" replaced inside any string value, `$comment`
21
+ // included (write no token in a comment). localAppData is
22
+ // %LOCALAPPDATA% on Windows and <home>/AppData/Local elsewhere
16
23
  // Anything else with braces is left alone, and an unknown {{token}} is an error.
17
24
  import { createHash } from "node:crypto";
18
25
  import { existsSync, lstatSync, readFileSync } from "node:fs";
19
26
  import os from "node:os";
20
27
  import path from "node:path";
28
+ import { pathToFileURL } from "node:url";
21
29
  import { parse as parseToml } from "smol-toml";
22
30
  import { collectConfigCommands, formatFindings, validateConfigPipeline } from "./pipeline-config.mjs";
23
31
  import { loadGeneratorRegistry } from "./pipeline-generators.mjs";
32
+ import { RIGHT_SUITE_APP_KEYS, RIGHT_SUITE_UPDATER_PUBLIC_KEYS } from "./suite-api.generated.mjs";
24
33
 
25
34
  export const RELEASE_CONFIG_FILE = "right-release.config.json";
26
35
  export const LEGACY_RELEASE_CONFIG_FILE = "right-release.config.mjs";
@@ -198,7 +207,7 @@ function expand(node, context, label) {
198
207
  return node.replace(/\{\{([A-Za-z][A-Za-z0-9]*)\}\}/g, (_, token) => {
199
208
  if (!TEMPLATE_TOKENS.includes(token)) fail("RELEASE_CONFIG_TEMPLATE", `${label}: unknown template token {{${token}}} (allowed: ${TEMPLATE_TOKENS.map((t) => `{{${t}}}`).join(", ")})`);
200
209
  const resolved = context.values[token];
201
- if (typeof resolved !== "string" || resolved === "") fail("RELEASE_CONFIG_TEMPLATE", `${label}: {{${token}}} is not available${token === "localAppData" ? " (LOCALAPPDATA is not set)" : token === "version" ? " here (version cannot refer to itself)" : ""}`);
210
+ if (typeof resolved !== "string" || resolved === "") fail("RELEASE_CONFIG_TEMPLATE", `${label}: {{${token}}} is not available${token === "version" ? " here (version cannot refer to itself)" : ""}`);
202
211
  return resolved;
203
212
  });
204
213
  }
@@ -259,16 +268,31 @@ export function validateReleaseConfigShape(config, { file = RELEASE_CONFIG_FILE,
259
268
  return config;
260
269
  }
261
270
 
271
+ // The same rule as UPDATER_KEY_ID_RE in github-updater-release.mjs, kept here because that module's import graph loads the release state
272
+ // machine, which this loader must not depend on.
273
+ const UPDATER_KEY_ID = /^[0-9A-F]{16}$/;
274
+ // A typed template ({ "$fromJson": ... }) is a string only after expansion, so the raw-phase check leaves it to the final one.
275
+ const isTemplateNode = (value) => isObject(value) && Object.keys(value).some((key) => key.startsWith("$"));
276
+ const isBlank = (value) => value === undefined || value === null || (typeof value === "string" && !value.trim());
277
+
262
278
  /**
263
279
  * Field-level refusals that need no other part of the config, so a caller (the loader, the doctor) can list them next to every other
264
- * finding: `[{ name, code, pointer, detail }]`. Today: `targets.<t>.updater.keyFile`. Updater minisign keys come from the OS credential
265
- * provider (`protected-credentials.mjs`, secret "updater-signing-key", namespace `rightsuite-updater-key`; `sign-updater.mjs` refuses
266
- * `--key-file`), so a config that names a key file could only fail mid-signing. It is refused up front instead, and never forwarded.
280
+ * finding: `[{ name, code, pointer, detail }]`.
281
+ *
282
+ * `targets.<t>.updater.keyFile`: updater minisign keys come from the OS credential provider (`protected-credentials.mjs`, secret
283
+ * "updater-signing-key", namespace `rightsuite-updater-key`; `sign-updater.mjs` refuses `--key-file`), so a config that names a key
284
+ * file could only fail mid-signing. It is refused up front instead, and never forwarded.
285
+ *
286
+ * `distribution.updaterKeyId` must be 16 upper-case hex digits and, for a Right Suite app, a key the Right Suite contract accepts; a
287
+ * Right Suite app must not declare an updater public key of its own (`distribution.updaterPublicKey`, `updaterPubkey`,
288
+ * `targets.<t>.updater.pubkey` or `publicKey`) unless it is exactly a contract key's text. Publish and seal refuse the same things
289
+ * (`resolveUpdaterTrust`); here they are found before a build starts.
267
290
  */
268
291
  export function releaseConfigFieldProblems(config) {
269
292
  const problems = [];
270
- if (!isObject(config) || !isObject(config.targets)) return problems;
271
- for (const [name, target] of Object.entries(config.targets)) {
293
+ if (!isObject(config)) return problems;
294
+ const targets = isObject(config.targets) ? Object.entries(config.targets) : [];
295
+ for (const [name, target] of targets) {
272
296
  if (!isObject(target) || !isObject(target.updater) || !Object.prototype.hasOwnProperty.call(target.updater, "keyFile")) continue;
273
297
  problems.push({
274
298
  name: "updater-key-file",
@@ -277,6 +301,35 @@ export function releaseConfigFieldProblems(config) {
277
301
  detail: `targets.${name}.updater.keyFile is not supported: updater signing keys come from the OS credential provider (rightkit-credentials, namespace rightsuite-updater-key; see protected-credentials.mjs and sign-updater.mjs), never from a key file. Remove updater.keyFile from the release config.`,
278
302
  });
279
303
  }
304
+ const suiteApp = typeof config.app === "string" && RIGHT_SUITE_APP_KEYS.includes(config.app.toLowerCase());
305
+ const distribution = isObject(config.distribution) ? config.distribution : {};
306
+ const keyId = distribution.updaterKeyId;
307
+ if (keyId !== undefined && !isTemplateNode(keyId)) {
308
+ const accepted = RIGHT_SUITE_UPDATER_PUBLIC_KEYS.map((key) => key.kid);
309
+ const detail = typeof keyId !== "string" || !UPDATER_KEY_ID.test(keyId)
310
+ ? `distribution.updaterKeyId must be the 16 upper-case hex digits of a minisign key id, got ${JSON.stringify(keyId)}.`
311
+ : suiteApp && !accepted.includes(keyId)
312
+ ? `distribution.updaterKeyId ${keyId} is not an updater key in the Right Suite contract (accepted: ${accepted.join(", ")}). Remove it to sign with the contract's signing key.`
313
+ : null;
314
+ if (detail) problems.push({ name: "updater-key-id", code: "RELEASE_CONFIG_UPDATER_KEY_ID", pointer: "/distribution/updaterKeyId", detail });
315
+ }
316
+ if (suiteApp) {
317
+ const contractTexts = RIGHT_SUITE_UPDATER_PUBLIC_KEYS.map((key) => key.publicKey);
318
+ const declared = (pointer, label, value) => {
319
+ if (isBlank(value) || isTemplateNode(value) || contractTexts.includes(value)) return;
320
+ problems.push({
321
+ name: "updater-public-key",
322
+ code: "RELEASE_CONFIG_UPDATER_PUBLIC_KEY",
323
+ pointer,
324
+ detail: `${label} is not a Right Suite contract key: ${config.app} is a suite app and takes its updater keys from the contract (contracts/right-suite-api.v1.json), so remove ${label}.`,
325
+ });
326
+ };
327
+ for (const field of ["updaterPublicKey", "updaterPubkey"]) declared(`/distribution/${field}`, `distribution.${field}`, distribution[field]);
328
+ for (const [name, target] of targets) {
329
+ if (!isObject(target) || !isObject(target.updater)) continue;
330
+ for (const field of ["pubkey", "publicKey"]) declared(`/targets/${pointerKey(name)}/updater/${field}`, `targets.${name}.updater.${field}`, target.updater[field]);
331
+ }
332
+ }
280
333
  return problems;
281
334
  }
282
335
 
@@ -289,7 +342,7 @@ function packageScripts(appRoot, readText) {
289
342
 
290
343
  /**
291
344
  * Load, validate and expand a release config. Returns `{ config, configPath, appRoot, sha256, inputs, lineOf }`. Throws ReleaseConfigError
292
- * (RELEASE_CONFIG_LEGACY_EXECUTABLE | _NOT_JSON | _NOT_FOUND | _INVALID_JSON | _SCHEMA | _KEY | _UPDATER_KEY_FILE | _TEMPLATE | _M24). `validatePipeline: false`
345
+ * (RELEASE_CONFIG_LEGACY_EXECUTABLE | _NOT_JSON | _NOT_FOUND | _INVALID_JSON | _SCHEMA | _KEY | _UPDATER_KEY_FILE | _UPDATER_KEY_ID | _UPDATER_PUBLIC_KEY | _TEMPLATE | _M24). `validatePipeline: false`
293
346
  * skips only the M24 command admission (the pipeline scanner reports those findings itself); nothing else is optional.
294
347
  * File access, the generator registry, the environment and the home directory are injectable for tests.
295
348
  */
@@ -313,7 +366,9 @@ export function loadReleaseConfigSync(file, {
313
366
  const raw = parsed.value;
314
367
  validateReleaseConfigShape(raw, { file: configPath, phase: "raw" });
315
368
  const inputs = new Map();
316
- const base = { appRoot, inputs, readBytes, lstat, values: { home: homedir(), localAppData: env.LOCALAPPDATA ?? "", app: "", version: "" } };
369
+ // {{localAppData}} is %LOCALAPPDATA% on Windows. Elsewhere it is the Windows profile default under the home directory (a path
370
+ // no other host uses), so one config that names a Windows install path loads on every host instead of failing on the Mac.
371
+ const base = { appRoot, inputs, readBytes, lstat, values: { home: homedir(), localAppData: env.LOCALAPPDATA || path.join(homedir(), "AppData", "Local"), app: "", version: "" } };
317
372
  base.values.app = expand(raw.app, { ...base }, "/app");
318
373
  if (raw.version !== undefined) base.values.version = expand(raw.version, { ...base, values: { ...base.values, version: "" } }, "/version");
319
374
  const config = expand(raw, base, "");
@@ -344,6 +399,27 @@ export async function loadReleaseConfig(file, options) {
344
399
  return loadReleaseConfigSync(file, options).config;
345
400
  }
346
401
 
402
+ /** True for a `.json` config path: the only extension the strict loader accepts. */
403
+ export const isJsonReleaseConfig = (file) => typeof file === "string" && path.extname(file).toLowerCase() === ".json";
404
+
405
+ /** The config name a command defaults to: `right-release.config.json` when `dir` holds it, else the legacy module an unmoved app still owns. */
406
+ export function defaultReleaseConfigFile(dir = process.cwd(), { exists = existsSync } = {}) {
407
+ return exists(path.join(dir, RELEASE_CONFIG_FILE)) ? RELEASE_CONFIG_FILE : LEGACY_RELEASE_CONFIG_FILE;
408
+ }
409
+
410
+ /**
411
+ * Load the config at `file` for a command. A `.json` path goes through the strict, M24-validated loader (throws ReleaseConfigError); any
412
+ * other path is the legacy branch and is imported as before. Legacy-only options: `query` is the cache-busting query a site appends
413
+ * (`upload=<ts>`, `commit=<sha>`) so a module re-evaluates per run, and `lenient` keeps the `default ?? config ?? module` fallback the
414
+ * app-root commands accept; every other option goes to `loadReleaseConfigSync`.
415
+ */
416
+ export async function importReleaseConfig(file, { query, lenient = false, ...loadOptions } = {}) {
417
+ const absolute = path.resolve(file);
418
+ if (isJsonReleaseConfig(absolute)) return loadReleaseConfigSync(absolute, loadOptions).config;
419
+ const loaded = await import(`${pathToFileURL(absolute).href}${query ? `?${query}` : ""}`);
420
+ return lenient ? loaded.default ?? loaded.config ?? loaded : loaded.default;
421
+ }
422
+
347
423
  /** `{ config, appRoot, configPath }` for the commands that need the app root beside the config. */
348
424
  export async function loadAppConfigFile(file = RELEASE_CONFIG_FILE, options) {
349
425
  const loaded = loadReleaseConfigSync(file, options);
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import path from "node:path";
3
- import { fileURLToPath, pathToFileURL } from "node:url";
3
+ import { fileURLToPath } from "node:url";
4
+ import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
4
5
  import { assertReleaseDependencies } from "./release-dependencies.mjs";
5
6
  import { nodeHostTriple } from "./stage-sidecar-command.mjs";
6
7
  import { expandPackageRecipeTarget } from "./package-recipe-targets.mjs";
7
8
 
8
9
  /** Standalone policy check: host target by default, all declared release targets on request. */
9
10
  export async function main(argv, { cwd = process.cwd(), platform = process.platform, arch = process.arch, run } = {}) {
10
- let configPath = "right-release.config.mjs", selected, allTargets = false;
11
+ let configPath = defaultReleaseConfigFile(cwd), selected, allTargets = false;
11
12
  for (let i = 0; i < argv.length; i += 1) {
12
13
  const arg = argv[i];
13
14
  if (["--config", "--platform"].includes(arg)) {
@@ -20,7 +21,7 @@ export async function main(argv, { cwd = process.cwd(), platform = process.platf
20
21
  if (allTargets && selected) throw new Error("use --platform or --all-targets");
21
22
  const file = path.resolve(cwd, configPath);
22
23
  const root = path.dirname(file);
23
- const config = (await import(pathToFileURL(file).href)).default;
24
+ const config = await importReleaseConfig(file);
24
25
  if (config?.releaseDependencies === undefined) throw new Error("check-release-dependencies requires releaseDependencies in config");
25
26
  const host = { darwin: "mac", win32: "win", linux: "linux" }[platform];
26
27
  const platforms = allTargets ? Object.keys(config.targets ?? {}) : [selected ?? host];
@@ -1,12 +1,14 @@
1
1
  // QA admission plus the ad hoc `right-release qa audit` scan.
2
2
  //
3
3
  // Per-app policy (qa-admission-policy.json, release-qa-policy.mjs): `warn` is the default, `refuse` is switched on
4
- // per app once the app has earned it. The gates in release-qa-receipt.mjs verify receipts, and the hardening in
5
- // release-qa-hardening.mjs re-checks platform, release id, approved runner digest, every release-critical journey
6
- // and the signing/test separation. In warn mode a refusal becomes a loud stderr warning and a durable record
7
- // (the sealed manifest `qa` field for the source stage, `state/<releaseId>/<platformDir>/qa-installed.json`
8
- // for the installed stage). In refuse mode the same refusal throws a QaAdmissionError and nothing is sealed or
9
- // published. `qa audit` reads the records so a weekly run can flag released builds that lack passing QA.
4
+ // per app once the app has earned it, and separately per stage: the build (source) admission and the publication
5
+ // (installed) admission each read their own mode. The gates in release-qa-receipt.mjs verify receipts, and the
6
+ // hardening in release-qa-hardening.mjs re-checks platform, release id, approved runner digest (for that platform),
7
+ // every release-critical journey and the signing/test separation. In warn mode a refusal becomes a loud stderr
8
+ // warning and a durable record (the sealed manifest `qa` field for the source stage,
9
+ // `state/<releaseId>/<platformDir>/qa-installed.json` for the installed stage). In refuse mode the same refusal
10
+ // throws a QaAdmissionError and nothing is sealed or published. `qa audit` reads the records so a weekly run can
11
+ // flag released builds that lack passing QA.
10
12
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
11
13
  import path from "node:path";
12
14
  import { spawnSync } from "./background-process.mjs";
@@ -42,10 +44,10 @@ function admit(check, { warn = (line) => console.error(line), mode = { mode: "wa
42
44
  }
43
45
  }
44
46
 
45
- function policyContext(opts, hooks = {}) {
47
+ function policyContext(opts, hooks = {}, stage) {
46
48
  const loaded = hooks.policy ?? loadAdmissionPolicy({ dir: hooks.policyDir, env: opts.env });
47
49
  const app = opts.app ?? opts.manifest?.app ?? appOfReleaseId(opts.releaseId ?? opts.manifest?.releaseId);
48
- return { loaded, app, mode: admissionModeFor(loaded.policy, app) };
50
+ return { loaded, app, mode: admissionModeFor(loaded.policy, app, stage) };
49
51
  }
50
52
 
51
53
  function hardening(stage, opts, loaded, hooks) {
@@ -58,7 +60,7 @@ function hardening(stage, opts, loaded, hooks) {
58
60
 
59
61
  /** Build stage. Returns the `qa` block sealed into the release manifest. */
60
62
  export function admitSourceQa(opts, hooks = {}) {
61
- const { loaded, app, mode } = policyContext(opts, hooks);
63
+ const { loaded, app, mode } = policyContext(opts, hooks, "source");
62
64
  const outcome = admit(() => assertSourceQaAdmission({ ...opts, afterVerify: hardening("source", opts, loaded, hooks) }), { ...hooks, mode, app });
63
65
  if (outcome.status === "passed") return { status: "passed", sourceReceiptDigest: outcome.result.digest, floorDigest: outcome.result.floorDigest };
64
66
  return { status: outcome.status, reason: outcome.reason };
@@ -69,7 +71,7 @@ export const installedQaRecordPath = ({ repoRoot, releaseId, platform }) =>
69
71
 
70
72
  /** Publication stage (upload, publish-update, github). Records the outcome durably; throws in `refuse` mode. */
71
73
  export function admitInstalledQa(opts, { provider, now = () => new Date(), ...hooks } = {}) {
72
- const { loaded, app, mode } = policyContext(opts, hooks);
74
+ const { loaded, app, mode } = policyContext(opts, hooks, "installed");
73
75
  const outcome = admit(() => assertInstalledQaAdmission({ ...opts, afterVerify: hardening("installed", opts, loaded, { ...hooks, now }) }), { ...hooks, mode, app });
74
76
  const record = {
75
77
  status: outcome.status,