@rightkit/release 0.6.2 → 0.6.4

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 (44) hide show
  1. package/README.md +37 -5
  2. package/bin/darwin/rightkit-swap +0 -0
  3. package/bin/rightkit-gate.mjs +4 -4
  4. package/build-release.mjs +2 -2
  5. package/cli/right-release.mjs +3 -2
  6. package/deterministic-scans.mjs +21 -10
  7. package/development.mjs +3 -2
  8. package/hosted-workflow-policy.mjs +111 -0
  9. package/ios-dev.mjs +2 -2
  10. package/ios-host.mjs +7 -7
  11. package/ios-package-recipe.mjs +2 -2
  12. package/local-publish.mjs +1 -1
  13. package/local-qualify.mjs +3 -3
  14. package/local-runtime.mjs +1 -1
  15. package/ownership-scope.mjs +9 -0
  16. package/package.json +1 -1
  17. package/pipeline-admission.mjs +5 -4
  18. package/pipeline-receipts.mjs +302 -302
  19. package/pipeline-scan.mjs +3 -3
  20. package/pipeline-tools.mjs +175 -171
  21. package/prebuild-gates.mjs +187 -0
  22. package/preflight.mjs +5 -1
  23. package/progress-control.mjs +1 -1
  24. package/publish-update.mjs +4 -3
  25. package/qa-admission-policy.json +5 -0
  26. package/release-config.mjs +353 -330
  27. package/release-qa-audit.mjs +37 -11
  28. package/release-qa-command.mjs +46 -5
  29. package/release-qa-fixtures.mjs +127 -0
  30. package/release-qa-floor.json +6 -1
  31. package/release-qa-hardening.mjs +173 -0
  32. package/release-qa-policy.mjs +168 -0
  33. package/release-qa-receipt.mjs +21 -1
  34. package/release-switch-scan.mjs +137 -6
  35. package/release.mjs +256 -198
  36. package/rightapps-register.mjs +3 -1
  37. package/rightkit-drift.mjs +1 -1
  38. package/rightkit-ownership.json +15 -5
  39. package/rightkit-versions.json +52 -38
  40. package/stage-sidecar.mjs +17 -15
  41. package/suite-api-literal.mjs +109 -0
  42. package/suite-api.generated.mjs +78 -0
  43. package/swap-helper-bundle.mjs +2 -2
  44. package/win-package-recipe.mjs +7 -4
@@ -1,14 +1,18 @@
1
- // Warn-and-record QA admission plus the ad hoc `right-release qa audit` scan.
1
+ // QA admission plus the ad hoc `right-release qa audit` scan.
2
2
  //
3
- // A missing or invalid QA receipt never blocks a release. The gates in release-qa-receipt.mjs still verify
4
- // receipts exactly as before; this module turns a refusal into a loud stderr warning and a durable record
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
5
7
  // (the sealed manifest `qa` field for the source stage, `state/<releaseId>/<platformDir>/qa-installed.json`
6
- // for the installed stage). `qa audit` reads those records so a weekly run can flag released builds
7
- // that lack passing QA.
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.
8
10
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
9
11
  import path from "node:path";
10
12
  import { spawnSync } from "./background-process.mjs";
11
- import { assertInstalledQaAdmission, assertSourceQaAdmission, qaReceiptPath, readQaReceipt } from "./release-qa-receipt.mjs";
13
+ import { hardenQaReceipt } from "./release-qa-hardening.mjs";
14
+ import { admissionModeFor, loadAdmissionPolicy } from "./release-qa-policy.mjs";
15
+ import { assertInstalledQaAdmission, assertSourceQaAdmission, loadQaFloor, QaAdmissionError, qaReceiptPath, readQaReceipt } from "./release-qa-receipt.mjs";
12
16
 
13
17
  const platformDirOf = (platform) => (platform === "win" ? "windows" : platform);
14
18
 
@@ -22,19 +26,40 @@ export function formatQaWarning({ status, reason, command }) {
22
26
  return `[right-release] QA warning: ${reason}; release continues and is recorded as QA ${status} for audit.${command ? ` Run: ${command}` : ""}`;
23
27
  }
24
28
 
25
- function admit(check, { warn = (line) => console.error(line) } = {}) {
29
+ /** App name of a source release id `<app>-<version>-<commit8>` (callers should pass `app` explicitly). */
30
+ export const appOfReleaseId = (releaseId) => String(releaseId).match(/^(.+?)-\d+\.\d+\.\d+(?:[-+].*)?-[0-9a-f]{8}$/)?.[1] ?? String(releaseId);
31
+
32
+ function admit(check, { warn = (line) => console.error(line), mode = { mode: "warn" }, app } = {}) {
26
33
  try { return { status: "passed", result: check() }; }
27
34
  catch (error) {
28
35
  if (error?.code !== "QA_ADMISSION_REFUSED") throw error;
29
36
  const outcome = { status: refusalStatus(error), reason: refusalReason(error), command: error.command ?? null };
37
+ if (mode.mode === "refuse") {
38
+ throw new QaAdmissionError(`QA admission refused for ${app} (policy refuse since ${mode.since} by ${mode.set_by}): ${outcome.reason}`, { command: outcome.command ?? undefined });
39
+ }
30
40
  warn(formatQaWarning(outcome));
31
41
  return outcome;
32
42
  }
33
43
  }
34
44
 
45
+ function policyContext(opts, hooks = {}) {
46
+ const loaded = hooks.policy ?? loadAdmissionPolicy({ dir: hooks.policyDir, env: opts.env });
47
+ const app = opts.app ?? opts.manifest?.app ?? appOfReleaseId(opts.releaseId ?? opts.manifest?.releaseId);
48
+ return { loaded, app, mode: admissionModeFor(loaded.policy, app) };
49
+ }
50
+
51
+ function hardening(stage, opts, loaded, hooks) {
52
+ const releaseId = opts.releaseId ?? opts.manifest?.releaseId;
53
+ return (receipt) => hardenQaReceipt({
54
+ receipt, stage, platform: opts.platform, releaseId, policy: loaded.policy, floor: loadQaFloor(opts.floorDir).floor,
55
+ repoRoot: opts.repoRoot, configName: opts.configName, gitRunner: opts.gitRunner, env: opts.env ?? process.env, now: hooks.now, pid: hooks.pid, signingStartedAt: hooks.signingStartedAt,
56
+ });
57
+ }
58
+
35
59
  /** Build stage. Returns the `qa` block sealed into the release manifest. */
36
- export function admitSourceQa(opts, hooks) {
37
- const outcome = admit(() => assertSourceQaAdmission(opts), hooks);
60
+ export function admitSourceQa(opts, hooks = {}) {
61
+ const { loaded, app, mode } = policyContext(opts, hooks);
62
+ const outcome = admit(() => assertSourceQaAdmission({ ...opts, afterVerify: hardening("source", opts, loaded, hooks) }), { ...hooks, mode, app });
38
63
  if (outcome.status === "passed") return { status: "passed", sourceReceiptDigest: outcome.result.digest, floorDigest: outcome.result.floorDigest };
39
64
  return { status: outcome.status, reason: outcome.reason };
40
65
  }
@@ -42,9 +67,10 @@ export function admitSourceQa(opts, hooks) {
42
67
  export const installedQaRecordPath = ({ repoRoot, releaseId, platform }) =>
43
68
  path.join(repoRoot, ".right-release", "state", releaseId, platformDirOf(platform), "qa-installed.json");
44
69
 
45
- /** Publication stage (upload, publish-update, github). Records the outcome durably and never blocks. */
70
+ /** Publication stage (upload, publish-update, github). Records the outcome durably; throws in `refuse` mode. */
46
71
  export function admitInstalledQa(opts, { provider, now = () => new Date(), ...hooks } = {}) {
47
- const outcome = admit(() => assertInstalledQaAdmission(opts), hooks);
72
+ const { loaded, app, mode } = policyContext(opts, hooks);
73
+ const outcome = admit(() => assertInstalledQaAdmission({ ...opts, afterVerify: hardening("installed", opts, loaded, { ...hooks, now }) }), { ...hooks, mode, app });
48
74
  const record = {
49
75
  status: outcome.status,
50
76
  ...(outcome.status === "passed" ? { receiptDigest: outcome.result.digest } : { reason: outcome.reason }),
@@ -9,8 +9,13 @@
9
9
  // qa: { version: "1", config: "rightkit-qa.toml", runner?: "<path to rightkit-qa>",
10
10
  // args?: { source?: [..], installed?: [..] }, // {sealedDir} {releaseId} expand; installed also {installedApp} {installedDir} {qaRoot}
11
11
  // installed?: { installDir?: "<abs dir>" }, // opt in: mount the sealed DMG (mac) / run the sealed NSIS installer (win, needs RIGHT_RELEASE_QA_ALLOW_INSTALL=1 off CI), see release-qa-installed.mjs
12
+ // journeys?: { manifest?: "journeys.toml", receipt?: "<path to a rightkit.journeys.receipt/1>", args?: { source?: [..], installed?: [..] } },
12
13
  // checks: { "smoke.launch": ["app.launch"], journey: ["journey.*"] } } // floor id -> evidence check names
13
14
  // Every id in release-qa-floor.json must be mapped; skipped/failed/missing required checks fail the receipt.
15
+ // `qa.journeys` maps the floor's `journey` id to "every release-critical journey required on this platform passed":
16
+ // `rightkit-qa journeys run` is run (or `receipt` is read), the receipt is validated through `rightkit-qa journeys
17
+ // validate-receipt --manifest .. --tree ..`, and the validated summary is sealed into the receipt (release-qa-hardening.mjs).
18
+ // This command refuses to seal from inside a QA run (RIGHT_RELEASE_QA_STAGE set): sealing is never test execution.
14
19
  import { createHash } from "node:crypto";
15
20
  import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
16
21
  import os from "node:os";
@@ -21,6 +26,7 @@ import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
21
26
  import { assertCleanSource } from "./source-gate.mjs";
22
27
  import { verifySealedRelease } from "./release-state.mjs";
23
28
  import { auditMain } from "./release-qa-audit.mjs";
29
+ import { journeyRequiredItem, QA_RUN_ENV, relativePosix, validateJourneysReceipt } from "./release-qa-hardening.mjs";
24
30
  import { prepareInstalledArtifacts } from "./release-qa-installed.mjs";
25
31
  import { darwinKeyFile, PROTECTED_SECRETS } from "./protected-credentials.mjs";
26
32
  import {
@@ -49,6 +55,12 @@ function resolveRunner({ qa, appRoot, platform = process.platform, runner = spaw
49
55
  return realpathSync(file);
50
56
  }
51
57
 
58
+ /** `rightkit-qa journeys run` prints `run <id> (<run dir>)` first; the receipt is `<run dir>/receipt.json`. */
59
+ export function journeysReceiptFromOutput(stdout) {
60
+ const dir = String(stdout ?? "").match(/^run \S+ \((.+)\)\s*$/m)?.[1];
61
+ return dir ? path.join(dir, "receipt.json") : null;
62
+ }
63
+
52
64
  export function evidencePathFromOutput(stdout) {
53
65
  const line = String(stdout).split(/\r?\n/).reverse().find((item) => item.startsWith("[qa]") && /evidence\s+\S/.test(item));
54
66
  return line?.match(/evidence\s+(.+?)\s*$/)?.[1] ?? null;
@@ -56,6 +68,8 @@ export function evidencePathFromOutput(stdout) {
56
68
 
57
69
  /** Run the QA plan through the runner, then seal a receipt. `deps` are test seams; production uses the defaults. */
58
70
  export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo = null, deps = {} }) {
71
+ if ((deps.env ?? process.env)[QA_RUN_ENV]) throw new Error(`refusing to seal a QA receipt inside a QA run (${QA_RUN_ENV} is set): sealing and signing happen outside test execution`);
72
+ const executionStartedAt = new Date();
59
73
  const gitRunner = deps.gitRunner ?? spawnSync;
60
74
  const appRoot = path.dirname(configPath);
61
75
  const repoRoot = String(gitRunner("git", ["-C", appRoot, "rev-parse", "--show-toplevel"], { encoding: "utf8", windowsHide: true }).stdout ?? "").trim();
@@ -86,6 +100,7 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
86
100
  const key = ensureQaReceiptKey({ env: deps.env ?? process.env, credentialStore: deps.credentialStore, provision: deps.provision ?? provisionKeyOnHost });
87
101
  const runnerFile = deps.runnerFile ?? resolveRunner({ qa: config.qa, appRoot });
88
102
  const runnerSha = sha256File(runnerFile);
103
+ const journeysConfig = config.qa.journeys ?? null;
89
104
  const qaConfig = path.resolve(appRoot, config.qa.config);
90
105
  if (!existsSync(qaConfig)) throw new Error(`qa.config does not exist: ${qaConfig}`);
91
106
  // #184: opt in with `qa.installed: { installDir? }`. Mounts the sealed DMG (mac) or runs the sealed NSIS installer into a
@@ -98,15 +113,22 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
98
113
  deps: { log: (event) => { if (!deps.quiet) console.error(`[qa] ${JSON.stringify(event)}`); } },
99
114
  });
100
115
  }
101
- let run;
116
+ let run, journeysRun = null;
102
117
  try {
103
118
  const expand = (value) => String(value).replaceAll("{sealedDir}", sealed?.sealedDir ?? "").replaceAll("{releaseId}", releaseId)
104
119
  .replaceAll("{installedApp}", installedQa?.installedApp ?? "").replaceAll("{installedDir}", installedQa?.installedDir ?? "").replaceAll("{qaRoot}", installedQa?.qaRoot ?? "");
120
+ const runEnv = { ...process.env, RIGHT_RELEASE_QA_STAGE: stage, RIGHT_RELEASE_QA_COMMIT: source.commit, RIGHT_RELEASE_QA_RELEASE_ID: releaseId, ...(sealed ? { RIGHT_RELEASE_QA_SEALED_DIR: sealed.sealedDir } : {}), ...(installedQa?.env ?? {}) };
105
121
  const args = ["run", "--config", qaConfig, ...((config.qa.args?.[stage] ?? []).map(expand))];
106
122
  run = (deps.spawnRunner ?? spawnSync)(runnerFile, args, {
107
123
  cwd: appRoot, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024,
108
- env: { ...process.env, RIGHT_RELEASE_QA_STAGE: stage, RIGHT_RELEASE_QA_COMMIT: source.commit, RIGHT_RELEASE_QA_RELEASE_ID: releaseId, ...(sealed ? { RIGHT_RELEASE_QA_SEALED_DIR: sealed.sealedDir } : {}), ...(installedQa?.env ?? {}) },
124
+ env: runEnv,
109
125
  });
126
+ // Journeys lane: the one release proof (ADR-0001 item 10). A receipt named in the config is read as is.
127
+ if (journeysConfig && !journeysConfig.receipt) {
128
+ journeysRun = (deps.spawnJourneys ?? deps.spawnRunner ?? spawnSync)(runnerFile, ["journeys", "run", "--root", appRoot, ...((journeysConfig.args?.[stage] ?? []).map(expand))], {
129
+ cwd: appRoot, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024, env: runEnv,
130
+ });
131
+ }
110
132
  } finally {
111
133
  installedQa?.cleanup();
112
134
  }
@@ -118,7 +140,22 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
118
140
  evidenceSha256 = createHash("sha256").update(bytes).digest("hex");
119
141
  evidence = JSON.parse(bytes.toString("utf8"));
120
142
  }
121
- const required = evaluateRequiredChecks({ floor, stage, qa: config.qa, evidence });
143
+ // The runner binary that produced the evidence is the one that was digested; a swap during the run is refused.
144
+ if (sha256File(runnerFile) !== runnerSha) throw new Error("the rightkit-qa runner changed while QA was running; refusing to seal");
145
+ let journeys = null;
146
+ let required = evaluateRequiredChecks({ floor, stage, qa: config.qa, evidence });
147
+ if (journeysConfig && floor.journeys) {
148
+ if (journeysRun && !deps.quiet) { process.stdout.write(String(journeysRun.stdout ?? "")); process.stderr.write(String(journeysRun.stderr ?? "")); }
149
+ const manifestFile = path.resolve(appRoot, journeysConfig.manifest ?? "journeys.toml");
150
+ const receiptFile = journeysConfig.receipt ? path.resolve(appRoot, String(journeysConfig.receipt)) : journeysReceiptFromOutput(journeysRun?.stdout);
151
+ const validation = receiptFile
152
+ ? validateJourneysReceipt({ runnerFile, runnerSha256: runnerSha, receiptFile, manifestFile, manifestRelPath: relativePosix(repoRoot, manifestFile), platform, spawn: deps.spawnValidator ?? deps.spawnRunner ?? spawnSync })
153
+ : { ok: false, problems: ["rightkit-qa journeys run reported no run directory, so there is no journeys receipt to validate"], block: null };
154
+ if (!deps.quiet) for (const problem of validation.problems) console.error(`[qa] journeys: ${problem}`);
155
+ journeys = validation.block;
156
+ required = required.map((item) => (item.id === floor.journeys.floorId ? journeyRequiredItem({ floorId: item.id, validation }) : item));
157
+ }
158
+ const executionFinishedAt = new Date();
122
159
  // A passing receipt needs the runner to exit cleanly AND an evidence bundle that itself passed.
123
160
  if (run.status !== 0 && evidence) evidence = { ...evidence, status: "failed" };
124
161
  const receipt = sealQaReceipt(buildQaReceipt({
@@ -126,7 +163,11 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
126
163
  releaseConfigSha256: sha256File(configPath), qa: config.qa, floor, floorDigest,
127
164
  policyBundleDigest: deps.policyBundleDigest ?? currentPolicyBundleDigest(deps.floorDir),
128
165
  runner: { basename: path.basename(runnerFile).replace(/\.exe$/i, ""), path: runnerFile, sha256: runnerSha },
129
- evidence, evidenceSha256, required, artifacts, sourceReceiptDigest,
166
+ evidence, evidenceSha256, required, artifacts, sourceReceiptDigest, journeys,
167
+ execution: {
168
+ startedAt: executionStartedAt.toISOString(), finishedAt: executionFinishedAt.toISOString(),
169
+ runnerPids: [run.pid, journeysRun?.pid].filter((pid) => Number.isInteger(pid) && pid > 0), sealerPid: process.pid,
170
+ },
130
171
  }), key);
131
172
  const file = qaReceiptPath({ repoRoot, releaseId, platform, stage });
132
173
  writeQaReceipt(file, receipt);
@@ -140,7 +181,7 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
140
181
  }
141
182
 
142
183
  function usage(code) {
143
- console.log("usage: right-release qa --stage source|installed --platform mac|win [--release <sealed-id>] [--config right-release.config.mjs] [--carry-to <dir>]\n right-release qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>]");
184
+ console.log("usage: right-release qa --stage source|installed --platform mac|win [--release <sealed-id>] [--config right-release.config.mjs] [--carry-to <dir>]\n (seals the QA receipt; admission is warn or refuse per app in qa-admission-policy.json; refused inside a QA run)\n right-release qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>]");
144
185
  process.exit(code);
145
186
  }
146
187
 
@@ -0,0 +1,127 @@
1
+ // Shared builders for the QA admission hardening tests (release-qa-hardening.test.mjs, release-qa-carried.test.mjs).
2
+ // Test support only: nothing in the release path imports this file.
3
+ import { createHash } from "node:crypto";
4
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { spawnSync } from "./background-process.mjs";
9
+ import { journeyRequiredItem, validateJourneysReceipt } from "./release-qa-hardening.mjs";
10
+ import {
11
+ buildQaReceipt, dependencyClosure, evaluateRequiredChecks, loadQaFloor, qaReceiptPath, sealQaReceipt, sourceIdentity, writeQaReceipt,
12
+ } from "./release-qa-receipt.mjs";
13
+
14
+ export const here = path.dirname(fileURLToPath(import.meta.url));
15
+ export const KEY = "unit-test-key";
16
+ export const store = { read: () => KEY };
17
+ export const BUNDLE = "b".repeat(64);
18
+ export const SHA = (c) => c.repeat(64);
19
+ export const RUNNER_SHA = SHA("e");
20
+ export const OTHER_RUNNER_SHA = SHA("9");
21
+ export const policyWith = ({ apps = {}, approvedRunners = [{ sha256: RUNNER_SHA, version: "0.8.0" }] } = {}) => ({ policy: { schema: 1, apps, approvedRunners }, digest: SHA("a"), baseline: "none" });
22
+
23
+ // J-001 and J-002 are release-critical on macos and windows, J-003 is not critical, J-004 is critical on windows only.
24
+ export const MANIFEST_TOML = `version = 1
25
+ app = "demo"
26
+
27
+ [[journey]]
28
+ id = "J-001"
29
+ platforms = ["macos", "windows"]
30
+ release_critical = true
31
+
32
+ [[journey]]
33
+ id = "J-002"
34
+ platforms = ["macos", "windows"]
35
+ release_critical = true
36
+
37
+ [[journey]]
38
+ id = "J-003"
39
+ platforms = ["macos", "windows"]
40
+ release_critical = false
41
+
42
+ [[journey]]
43
+ id = "J-004"
44
+ platforms = ["windows"]
45
+ release_critical = true
46
+ `;
47
+
48
+ export const sha256 = (value) => createHash("sha256").update(value).digest("hex");
49
+
50
+ function sh(cwd, args) {
51
+ const result = spawnSync("git", args, { cwd, encoding: "utf8" });
52
+ if (result.status !== 0) throw new Error(`git ${args.join(" ")}: ${result.stderr}`);
53
+ return result.stdout.trim();
54
+ }
55
+
56
+ /** A git repo with a lockfile and a committed journeys.toml. */
57
+ export function gitFixture(manifestText = MANIFEST_TOML) {
58
+ const root = mkdtempSync(path.join(tmpdir(), "rk-qa-hard-"));
59
+ sh(root, ["init", "-q", "-b", "main"]);
60
+ sh(root, ["config", "user.email", "t@example.com"]); sh(root, ["config", "user.name", "t"]);
61
+ sh(root, ["remote", "add", "origin", "https://github.com/bogusyogi/example-app.git"]);
62
+ writeFileSync(path.join(root, "Cargo.lock"), "lock-1\n");
63
+ writeFileSync(path.join(root, ".gitignore"), ".right-release/\n");
64
+ writeFileSync(path.join(root, "journeys.toml"), manifestText);
65
+ sh(root, ["add", "-A"]); sh(root, ["commit", "-q", "-m", "init"]);
66
+ return root;
67
+ }
68
+
69
+ export const OS = { mac: "macos", win: "windows" };
70
+
71
+ /** A journeys receipt as rightkit-qa writes it (only the fields the release side reads). */
72
+ export function journeysReceiptJson({ root, platform = "mac", manifestText = MANIFEST_TOML, statuses = {}, hostOs, startedAt, endedAt }) {
73
+ const os = hostOs ?? OS[platform];
74
+ const critical = (id) => ["J-001", "J-002", "J-004"].includes(id);
75
+ const ids = ["J-001", "J-002", "J-003", ...(platform === "win" ? ["J-004"] : [])];
76
+ return {
77
+ schema: "rightkit.journeys.receipt/1", run_id: "run-1", runner: "rightkit-qa", runner_version: "0.8.0", verdict: "passed",
78
+ started_at: startedAt, ended_at: endedAt, host: { os, arch: "x86_64", session: "interactive" },
79
+ source: { head: sourceIdentity(root).commit, tree: sourceIdentity(root).tree, dirty: false, diff_sha256: null },
80
+ manifest: { path: "journeys.toml", sha256: sha256(manifestText) }, selection: { ids, platform: os, tier: "fast" },
81
+ journeys: ids.map((id) => ({ id, status: statuses[id] ?? "passed", release_critical: critical(id) })),
82
+ };
83
+ }
84
+
85
+ export const accepting = () => ({ status: 0, stdout: "receipt claims passed, recomputed passed: ACCEPTED\n", stderr: "" });
86
+ export const refusing = () => ({ status: 1, stdout: "- journey_failed [J-001]: journey failed\nreceipt claims passed, recomputed failed: REFUSED\n", stderr: "" });
87
+
88
+ const qa = { version: "1", config: "rightkit-qa.toml", journeys: {}, checks: { "smoke.launch": ["app.launch"] } };
89
+ const evidence = () => ({ schemaVersion: 4, runId: "r1", status: "passed", finishedAt: "2026-10-09T00:00:00Z", checks: [{ name: "app.launch", status: "passed" }] });
90
+
91
+ /**
92
+ * A sealed source (or installed) QA receipt written where the gate reads it, as `right-release qa` would write it
93
+ * with qa.journeys configured. Timestamps are relative to now so the execution window precedes admission.
94
+ */
95
+ export function writeHardenedReceipt(root, {
96
+ stage = "source", platform = "mac", releaseId, receiptPlatform = platform, receiptReleaseId = releaseId, statuses = {}, runnerSha = RUNNER_SHA,
97
+ spawn = accepting, execution, tweakBlock, nowMs = Date.now(), artifacts = [], sourceReceiptDigest = null,
98
+ } = {}) {
99
+ const at = (offsetSec) => new Date(nowMs + offsetSec * 1000).toISOString();
100
+ const dir = mkdtempSync(path.join(tmpdir(), "rk-qa-jr-"));
101
+ const receiptFile = path.join(dir, "receipt.json");
102
+ writeFileSync(receiptFile, JSON.stringify(journeysReceiptJson({ root, platform: receiptPlatform, statuses, startedAt: at(-110), endedAt: at(-20) })));
103
+ const manifestFile = path.join(root, "journeys.toml");
104
+ const validation = validateJourneysReceipt({ runnerFile: "rightkit-qa", runnerSha256: runnerSha, receiptFile, manifestFile, manifestRelPath: "journeys.toml", platform: receiptPlatform, spawn });
105
+ const { floor, digest } = loadQaFloor(here);
106
+ const source = sourceIdentity(root);
107
+ // `tweakBlock` forges only the receipt's journeys block; the sealed required item stays the honest one, so the
108
+ // admission side's recompute is what must catch the forgery.
109
+ const block = tweakBlock ? tweakBlock(validation.block) : validation.block;
110
+ const required = evaluateRequiredChecks({ floor, stage, qa, evidence: evidence() }).map((item) => (item.id === floor.journeys.floorId ? journeyRequiredItem({ floorId: item.id, validation }) : item));
111
+ const receipt = sealQaReceipt(buildQaReceipt({
112
+ stage, app: "demo", platform: receiptPlatform, releaseId: receiptReleaseId, source, closure: dependencyClosure(root, source.commit), releaseConfigSha256: SHA("d"),
113
+ qa, floor, floorDigest: digest, policyBundleDigest: BUNDLE, runner: { basename: "rightkit-qa", path: "/x/rightkit-qa", sha256: runnerSha },
114
+ evidence: evidence(), evidenceSha256: SHA("f"), required, artifacts, sourceReceiptDigest, journeys: block, now: new Date(nowMs - 5000),
115
+ execution: execution === false ? null : (execution ?? { startedAt: at(-120), finishedAt: at(-10), runnerPids: [4242], sealerPid: 4343 }),
116
+ }), KEY);
117
+ writeQaReceipt(qaReceiptPath({ repoRoot: root, releaseId, platform, stage }), receipt);
118
+ return { receipt, validation };
119
+ }
120
+
121
+ export const gateOpts = (root, releaseId, platform = "mac", extra = {}) => ({
122
+ repoRoot: root, releaseId, platform, configName: "right-release.config.mjs", releaseConfigSha256: SHA("d"),
123
+ credentialStore: store, policyBundleDigest: BUNDLE, floorDir: here, ...extra,
124
+ });
125
+
126
+ export const releaseIdOf = (root) => `demo-1.0.0-${sourceIdentity(root).commit.slice(0, 8)}`;
127
+ export const makeDir = (root, sub) => { const dir = path.join(root, sub); mkdirSync(dir, { recursive: true }); return dir; };
@@ -1,9 +1,14 @@
1
1
  {
2
2
  "schema": 1,
3
- "version": 1,
3
+ "version": 2,
4
4
  "runner": "rightkit-qa",
5
5
  "stages": {
6
6
  "source": { "required": ["smoke.launch", "journey"] },
7
7
  "installed": { "required": ["smoke.launch", "journey"] }
8
+ },
9
+ "journeys": {
10
+ "floorId": "journey",
11
+ "receiptSchema": "rightkit.journeys.receipt/1",
12
+ "rule": "all-release-critical-passed"
8
13
  }
9
14
  }
@@ -0,0 +1,173 @@
1
+ // Release admission hardening (ADR-0001 item 10). Two halves:
2
+ //
3
+ // run side `validateJourneysReceipt` runs `rightkit-qa journeys validate-receipt` (the pinned runner, as a
4
+ // subprocess; exit code and the final ACCEPTED line), cross-checks the receipt against the committed
5
+ // journeys manifest, and returns the `journeys` block that `right-release qa` seals into the MAC'd
6
+ // QA receipt. The floor id `journey` means "every release-critical journey required on this platform
7
+ // passed", not "one matching check passed".
8
+ // admit side `hardenQaReceipt` re-checks the sealed receipt at admission, independently of what the run side
9
+ // claimed: platform and release id, the approved runner digest, the critical journey set recomputed
10
+ // from the manifest committed at the receipt's commit, and the signing/test-execution separation.
11
+ //
12
+ // Signing outside test execution, what is checked here: the receipt records the pids of the test processes it
13
+ // spawned and the window in which they ran; admission refuses a receipt whose window does not end before the
14
+ // receipt was sealed and before admission (so sealing or signing cannot overlap the run), refuses when the
15
+ // admitting process is one of those test processes, refuses when RIGHT_RELEASE_QA_STAGE is set (admission or
16
+ // signing invoked from inside a QA run), and `right-release qa` refuses to seal inside a QA run. Not checkable
17
+ // here: that the test processes cannot reach the signing key (that is the OS credential store and the signing
18
+ // agent's ACL) and that CI runs QA and signing as separate jobs (the carried receipt, rightkit#183, is the hook).
19
+ import { createHash } from "node:crypto";
20
+ import { readFileSync } from "node:fs";
21
+ import path from "node:path";
22
+ import { parse as parseToml } from "smol-toml";
23
+ import { spawnSync } from "./background-process.mjs";
24
+ import { approvedRunnerDigests } from "./release-qa-policy.mjs";
25
+ import { QaAdmissionError, qaCommandFor } from "./release-qa-receipt.mjs";
26
+
27
+ export const JOURNEYS_RECEIPT_SCHEMA = "rightkit.journeys.receipt/1";
28
+ export const PLATFORM_OS = Object.freeze({ mac: "macos", win: "windows" });
29
+ export const QA_RUN_ENV = "RIGHT_RELEASE_QA_STAGE";
30
+ const CLOCK_SKEW_MS = 60_000;
31
+ const HEX64 = /^[a-f0-9]{64}$/;
32
+
33
+ const sha256Of = (bytes) => createHash("sha256").update(bytes).digest("hex");
34
+
35
+ /** Release-critical journey ids the manifest requires on `platform` (mac|win), sorted. */
36
+ export function criticalJourneyIds(manifestText, platform) {
37
+ const os = PLATFORM_OS[platform];
38
+ if (!os) throw new Error(`unknown platform ${platform}`);
39
+ const journeys = parseToml(manifestText).journey;
40
+ if (!Array.isArray(journeys)) return [];
41
+ return journeys.filter((journey) => journey?.release_critical === true && Array.isArray(journey.platforms) && journey.platforms.includes(os))
42
+ .map((journey) => String(journey.id)).sort();
43
+ }
44
+
45
+ /**
46
+ * Validate a journeys receipt through the runner and against the manifest. Never throws for a refusal.
47
+ * Returns { ok, problems, block }; `block` is the `journeys` section for the QA receipt (null when nothing readable).
48
+ */
49
+ export function validateJourneysReceipt({ runnerFile, runnerSha256, receiptFile, manifestFile, manifestRelPath, platform, spawn = spawnSync }) {
50
+ const problems = [];
51
+ let bytes, receipt, manifestBytes;
52
+ try { bytes = readFileSync(receiptFile); receipt = JSON.parse(bytes.toString("utf8")); }
53
+ catch (error) { return { ok: false, problems: [`journeys receipt is unreadable (${receiptFile}): ${error.message}`], block: null }; }
54
+ try { manifestBytes = readFileSync(manifestFile); }
55
+ catch (error) { return { ok: false, problems: [`journeys manifest is unreadable (${manifestFile}): ${error.message}`], block: null }; }
56
+ const os = PLATFORM_OS[platform];
57
+ if (receipt.schema !== JOURNEYS_RECEIPT_SCHEMA) problems.push(`journeys receipt schema is ${JSON.stringify(receipt.schema)}, expected ${JOURNEYS_RECEIPT_SCHEMA}`);
58
+ if (receipt.runner !== "rightkit-qa") problems.push("journeys receipt was not produced by the rightkit-qa runner");
59
+ if (receipt.host?.os !== os) problems.push(`journeys receipt ran on ${receipt.host?.os}, this release is ${os}`);
60
+ if (receipt.selection?.platform !== os) problems.push(`journeys receipt selected platform ${receipt.selection?.platform}, this release is ${os}`);
61
+ if (receipt.manifest?.sha256 !== sha256Of(manifestBytes)) problems.push("journeys receipt was run against a different journeys manifest than the committed one");
62
+ if (!/^[a-f0-9]{40}$/.test(receipt.source?.tree ?? "")) problems.push("journeys receipt source.tree is not a 40-hex tree");
63
+ if (receipt.source?.dirty !== false) problems.push("journeys receipt records a dirty source tree");
64
+
65
+ // The runner re-checks every D5 rule; the exit code and the final line must both say accepted.
66
+ const run = spawn(runnerFile, ["journeys", "validate-receipt", receiptFile, "--manifest", manifestFile, "--tree", String(receipt.source?.tree ?? "")], { encoding: "utf8", windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
67
+ const lines = String(run?.stdout ?? "").split(/\r?\n/).filter(Boolean);
68
+ const accepted = run?.status === 0 && /: ACCEPTED$/.test(lines.at(-1) ?? "");
69
+ if (!accepted) problems.push(`rightkit-qa journeys validate-receipt refused the receipt (exit ${run?.status ?? "none"}): ${lines.slice(0, 5).join(" | ") || String(run?.stderr ?? "").trim() || "no output"}`);
70
+
71
+ // Every release-critical journey required on this platform, from the manifest, must be passed in the receipt.
72
+ let required = [];
73
+ try { required = criticalJourneyIds(manifestBytes.toString("utf8"), platform); }
74
+ catch (error) { problems.push(`journeys manifest cannot be parsed: ${error.message}`); }
75
+ if (!required.length) problems.push(`the journeys manifest declares no release-critical journey for ${os}; zero is never a pass`);
76
+ const byId = new Map((receipt.journeys ?? []).map((journey) => [journey.id, journey]));
77
+ for (const id of required) {
78
+ const journey = byId.get(id);
79
+ if (!journey) problems.push(`release-critical journey ${id} has no result in the receipt`);
80
+ else if (journey.status !== "passed") problems.push(`release-critical journey ${id} is ${journey.status}`);
81
+ else if (journey.release_critical !== true) problems.push(`release-critical journey ${id} is not marked release_critical in the receipt`);
82
+ }
83
+ const block = {
84
+ schema: JOURNEYS_RECEIPT_SCHEMA, receiptSha256: sha256Of(bytes), runId: String(receipt.run_id ?? ""), verdict: String(receipt.verdict ?? ""),
85
+ hostOs: String(receipt.host?.os ?? ""), selectionPlatform: String(receipt.selection?.platform ?? ""), tree: String(receipt.source?.tree ?? ""),
86
+ startedAt: String(receipt.started_at ?? ""), endedAt: String(receipt.ended_at ?? ""), runnerVersion: String(receipt.runner_version ?? ""),
87
+ validator: { runnerSha256, exitCode: run?.status ?? null, accepted },
88
+ manifest: { path: manifestRelPath, sha256: sha256Of(manifestBytes) },
89
+ required, results: (receipt.journeys ?? []).map((journey) => ({ id: String(journey.id), status: String(journey.status), releaseCritical: journey.release_critical === true })),
90
+ };
91
+ return { ok: problems.length === 0, problems, block };
92
+ }
93
+
94
+ /** The sealed `required` entry for the floor's journey id: passed only when every critical journey passed. */
95
+ export function journeyRequiredItem({ floorId, validation }) {
96
+ const results = validation.block?.results ?? [];
97
+ const status = validation.ok ? "passed" : validation.block ? "failed" : "missing";
98
+ return { id: floorId, status, evidenceChecks: results.map((result) => ({ name: result.id, status: result.status })), ...(validation.ok ? {} : { problems: validation.problems }) };
99
+ }
100
+
101
+ const readCommitted = ({ repoRoot, commit, file, gitRunner = spawnSync }) => {
102
+ const result = gitRunner("git", ["-C", repoRoot, "show", `${commit}:${file}`], { encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024 });
103
+ if (result?.status !== 0) throw new Error(`git show ${commit.slice(0, 12)}:${file} failed`);
104
+ return String(result.stdout);
105
+ };
106
+
107
+ /**
108
+ * Admission-time re-check of a receipt that already passed `verifyQaReceipt`. Throws QaAdmissionError.
109
+ * `releaseId` and `platform` are what is being sealed (source) or published (installed). `signingStartedAt` is
110
+ * optional: a caller that knows when signing began passes it; the default is `now`.
111
+ */
112
+ export function hardenQaReceipt({
113
+ receipt, stage, platform, releaseId, policy, floor, repoRoot, configName, gitRunner, env = process.env, now = () => new Date(), pid = process.pid, signingStartedAt,
114
+ }) {
115
+ const command = qaCommandFor({ stage, platform, releaseId, configName });
116
+ const refuse = (reason) => { throw new QaAdmissionError(`${stage} QA receipt rejected: ${reason}`, { command }); };
117
+
118
+ // 1. The receipt is for this platform and this release id.
119
+ if (receipt.platform !== platform) refuse(`receipt is for platform ${receipt.platform}, this is ${platform}`);
120
+ if (receipt.releaseId !== releaseId) refuse(`receipt is for release ${receipt.releaseId}, this is ${releaseId}`);
121
+
122
+ // 2. The qualifying runner is pinned by an approved digest.
123
+ const approved = approvedRunnerDigests(policy);
124
+ if (!approved.length) refuse("no rightkit-qa runner digest is approved in qa-admission-policy.json");
125
+ if (!approved.includes(receipt.runner?.sha256)) refuse(`rightkit-qa runner sha256 ${String(receipt.runner?.sha256).slice(0, 16)} is not an approved runner`);
126
+
127
+ // 3. The journey floor id means every release-critical journey required on this platform passed.
128
+ const mapping = floor?.journeys;
129
+ if (!mapping?.floorId) refuse("the QA floor does not map the journey floor id to a journeys receipt");
130
+ const journeys = receipt.journeys;
131
+ if (!journeys) refuse("receipt carries no journeys receipt block; run `rightkit-qa journeys run` through `right-release qa` (qa.journeys in the release config)");
132
+ const os = PLATFORM_OS[platform];
133
+ if (journeys.schema !== mapping.receiptSchema) refuse(`journeys receipt schema is ${journeys.schema}`);
134
+ if (journeys.verdict !== "passed" || journeys.validator?.accepted !== true || journeys.validator?.exitCode !== 0) refuse("the journeys receipt was not accepted by rightkit-qa journeys validate-receipt");
135
+ if (journeys.validator?.runnerSha256 !== receipt.runner?.sha256) refuse("the journeys receipt was validated by a different runner than the one pinned");
136
+ if (journeys.hostOs !== os || journeys.selectionPlatform !== os) refuse(`journeys receipt is for ${journeys.hostOs}/${journeys.selectionPlatform}, this is ${os}`);
137
+ if (journeys.tree !== receipt.tree) refuse("journeys receipt source tree differs from the QA receipt tree");
138
+ if (!HEX64.test(journeys.receiptSha256 ?? "") || !HEX64.test(journeys.manifest?.sha256 ?? "")) refuse("journeys block has malformed digests");
139
+ let committedManifest;
140
+ try { committedManifest = readCommitted({ repoRoot, commit: receipt.commit, file: journeys.manifest.path, gitRunner }); }
141
+ catch (error) { refuse(`the journeys manifest at the receipt's commit cannot be read: ${error.message}`); }
142
+ if (sha256Of(committedManifest) !== journeys.manifest.sha256) refuse("the journeys manifest committed at the receipt's commit differs from the one the journeys run used");
143
+ let required;
144
+ try { required = criticalJourneyIds(committedManifest, platform); } catch (error) { refuse(`journeys manifest cannot be parsed: ${error.message}`); }
145
+ if (!required.length) refuse(`the journeys manifest declares no release-critical journey for ${os}; zero is never a pass`);
146
+ const byId = new Map((journeys.results ?? []).map((result) => [result.id, result]));
147
+ const notPassed = required.filter((id) => byId.get(id)?.status !== "passed" || byId.get(id)?.releaseCritical !== true);
148
+ if (notPassed.length) refuse(`release-critical journeys not passed on ${os}: ${notPassed.map((id) => `${id} (${byId.get(id)?.status ?? "missing"})`).join(", ")}`);
149
+ const item = (receipt.required ?? []).find((entry) => entry.id === mapping.floorId);
150
+ if (item?.status !== "passed") refuse(`floor id ${mapping.floorId} is ${item?.status ?? "not recorded"}`);
151
+
152
+ // 4. Signing (and admission) happen outside test execution.
153
+ const exec = receipt.execution;
154
+ if (!exec) refuse("receipt records no execution window; it predates the signing/test separation rule");
155
+ const at = (value) => Date.parse(value);
156
+ const [started, finished, created, nowMs] = [at(exec.startedAt), at(exec.finishedAt), at(receipt.createdAt), now().getTime()];
157
+ if ([started, finished, created].some(Number.isNaN)) refuse("execution window has malformed timestamps");
158
+ if (!(started <= finished)) refuse("execution window ends before it starts");
159
+ if (finished > created) refuse("the receipt was sealed before the test run finished (sealing overlapped test execution)");
160
+ const signing = signingStartedAt ? at(signingStartedAt) : nowMs;
161
+ if (Number.isNaN(signing) || finished > signing) refuse("signing began before the test run finished (signing overlapped test execution)");
162
+ if (created > nowMs + CLOCK_SKEW_MS) refuse("receipt is dated in the future");
163
+ const jStart = at(journeys.startedAt), jEnd = at(journeys.endedAt);
164
+ if (Number.isNaN(jStart) || Number.isNaN(jEnd) || jStart < started - CLOCK_SKEW_MS || jEnd > finished + CLOCK_SKEW_MS) refuse("the journeys run is not inside the recorded execution window");
165
+ const testPids = exec.runnerPids;
166
+ if (!Array.isArray(testPids) || !testPids.length || testPids.some((value) => !Number.isInteger(value) || value < 1)) refuse("receipt records no test process ids");
167
+ if (testPids.includes(exec.sealerPid)) refuse("the sealing process is a test process");
168
+ if (testPids.includes(pid)) refuse("admission is running inside a test process");
169
+ if (env?.[QA_RUN_ENV]) refuse(`${QA_RUN_ENV} is set: admission or signing was invoked from inside a QA run`);
170
+ return { requiredJourneyIds: required, testPids };
171
+ }
172
+
173
+ export const relativePosix = (root, file) => path.relative(root, file).split(path.sep).join("/");