@rightkit/release 0.2.142 → 0.2.144

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.
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from "node:fs/promises";
3
+ import { readFileSync } from "node:fs";
3
4
  import { spawnSync } from "./background-process.mjs";
4
5
  import path from "node:path";
5
6
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -9,9 +10,10 @@ import { registerRightAppsRelease } from "./rightapps-register.mjs";
9
10
  import { recordPublishedVersion } from "./artifact-storage.mjs";
10
11
  import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
11
12
  import { assertCleanSource } from "./source-gate.mjs";
12
- import { verifyPublicationRelease, PublicationError, assertArtifactUnchanged } from "./publication-artifacts.mjs";
13
+ import { verifyPublicationRelease, PublicationError, assertArtifactUnchanged, digestBytes } from "./publication-artifacts.mjs";
13
14
  import { uploadVerifiedR2Artifact } from "./r2-verified-upload.mjs";
14
15
  import { verifyPlatformTrust } from "./github-release.mjs";
16
+ import { assertInstalledQaAdmission } from "./release-qa-receipt.mjs";
15
17
 
16
18
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
17
19
  const API_BASE = process.env.RIGHTAPPS_API_URL || "https://api.spoares.com";
@@ -67,6 +69,11 @@ if (!dryRun || releaseId) {
67
69
  if (!installer) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "sealed installer required for platform trust verification");
68
70
  verifyPlatformTrust({ sealed, installer: path.join(sealed.sealedDir, installer.name) });
69
71
  for (const artifact of sealed.artifacts.values()) assertArtifactUnchanged(artifact);
72
+ // Hardening step 4: installed QA on these exact artifact hashes and commit must have passed (R2 patch + update tiers).
73
+ if (!dryRun) {
74
+ try { assertInstalledQaAdmission({ repoRoot, manifest: sealed.manifest, platform, configName: path.relative(process.cwd(), configPath) || configName, releaseConfigSha256: digestBytes(readFileSync(configPath)) }); }
75
+ catch (error) { if (error?.code !== "QA_ADMISSION_REFUSED") throw error; fail(error.message); }
76
+ }
70
77
  }
71
78
  const artifactFile = (declared) => {
72
79
  if (!sealed) return path.resolve(root, declared);
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ // right-release qa: run RightKit's own QA runner (rightkit-qa) and write the sealed, MAC'd QA receipt.
3
+ //
4
+ // right-release qa --stage source --platform mac|win [--config right-release.config.mjs]
5
+ // right-release qa --stage installed --platform mac|win --release <sealed-id> [--config right-release.config.mjs]
6
+ //
7
+ // The release config declares the app's QA plan:
8
+ // qa: { version: "1", config: "rightkit-qa.toml", runner?: "<path to rightkit-qa>",
9
+ // args?: { source?: [..], installed?: [..] }, // {sealedDir} {releaseId} expand
10
+ // checks: { "smoke.launch": ["app.launch"], journey: ["journey.*"] } } // floor id -> evidence check names
11
+ // Every id in release-qa-floor.json must be mapped; skipped/failed/missing required checks fail the receipt.
12
+ import { createHash } from "node:crypto";
13
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { pathToFileURL, fileURLToPath } from "node:url";
17
+ import { spawnSync } from "./background-process.mjs";
18
+ import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
19
+ import { assertCleanSource } from "./source-gate.mjs";
20
+ import { verifySealedRelease } from "./release-state.mjs";
21
+ import { darwinKeyFile, PROTECTED_SECRETS } from "./protected-credentials.mjs";
22
+ import {
23
+ buildQaReceipt, currentPolicyBundleDigest, dependencyClosure, ensureQaReceiptKey, evaluateRequiredChecks, loadQaFloor,
24
+ qaReceiptPath, QA_STAGES, sealQaReceipt, sourceIdentity, validateAppQaConfig, writeQaReceipt,
25
+ } from "./release-qa-receipt.mjs";
26
+
27
+ const sha256File = (file) => createHash("sha256").update(readFileSync(file)).digest("hex");
28
+
29
+ // First `right-release qa` on a host creates the MAC key as an owner-only file in the per-user RightKit directory.
30
+ function provisionKeyOnHost(value, platform = process.platform) {
31
+ const spec = PROTECTED_SECRETS["qa-receipt-key"];
32
+ let file;
33
+ if (platform === "darwin") file = darwinKeyFile(process.env, spec);
34
+ else if (platform === "win32") file = path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "RightKit", spec.file);
35
+ else throw new Error("no protected credential store on this platform; set RIGHTKIT_QA_RECEIPT_KEY in hosted CI");
36
+ mkdirSync(path.dirname(file), { recursive: true });
37
+ writeFileSync(file, value, { mode: 0o600, flag: "wx" });
38
+ }
39
+
40
+ function resolveRunner({ qa, appRoot, platform = process.platform, runner = spawnSync }) {
41
+ if (qa.runner) { const file = path.resolve(appRoot, qa.runner); if (!existsSync(file)) throw new Error(`qa.runner does not exist: ${file}`); return realpathSync(file); }
42
+ const found = runner(platform === "win32" ? "where" : "which", ["rightkit-qa"], { encoding: "utf8", windowsHide: true });
43
+ const file = String(found.stdout ?? "").split(/\r?\n/)[0].trim();
44
+ if (found.status !== 0 || !file) throw new Error("rightkit-qa runner not found on PATH; install it (RightKit crates/rightkit-qa) or set qa.runner in the release config");
45
+ return realpathSync(file);
46
+ }
47
+
48
+ export function evidencePathFromOutput(stdout) {
49
+ const line = String(stdout).split(/\r?\n/).reverse().find((item) => item.startsWith("[qa]") && /evidence\s+\S/.test(item));
50
+ return line?.match(/evidence\s+(.+?)\s*$/)?.[1] ?? null;
51
+ }
52
+
53
+ /** Run the QA plan through the runner, then seal a receipt. `deps` are test seams; production uses the defaults. */
54
+ export async function runQa({ stage, platform, releaseIdArg, configPath, deps = {} }) {
55
+ const gitRunner = deps.gitRunner ?? spawnSync;
56
+ const appRoot = path.dirname(configPath);
57
+ const repoRoot = String(gitRunner("git", ["-C", appRoot, "rev-parse", "--show-toplevel"], { encoding: "utf8", windowsHide: true }).stdout ?? "").trim();
58
+ if (!repoRoot) throw new Error("release config must live inside a Git repository");
59
+ if (!deps.skipPrimaryCheckout) assertPrimaryReleaseCheckout(repoRoot);
60
+ assertCleanSource({ status: String(gitRunner("git", ["-C", repoRoot, "status", "--porcelain=v1", "-z", "--untracked-files=all"], { encoding: "utf8", windowsHide: true }).stdout ?? ""), commandId: "right-release qa" });
61
+ const source = sourceIdentity(repoRoot, gitRunner);
62
+ const config = (await import(`${pathToFileURL(configPath).href}?qa=${source.commit}`)).default;
63
+ if (!config?.app || !config?.version) throw new Error("release config must expose app and version");
64
+ const { floor, digest: floorDigest } = loadQaFloor(deps.floorDir);
65
+ const problems = validateAppQaConfig(config.qa, floor, stage);
66
+ if (problems.length) throw new Error(`release config QA plan does not meet the protected QA floor:\n- ${problems.join("\n- ")}`);
67
+
68
+ let releaseId = releaseIdArg;
69
+ let sealed = null;
70
+ let artifacts = [];
71
+ let sourceReceiptDigest = null;
72
+ if (stage === "source") releaseId = `${config.app}-${config.version}-${source.commit.slice(0, 8)}`;
73
+ else {
74
+ if (!releaseId) throw new Error("--release <sealed-id> is required for the installed stage");
75
+ sealed = verifySealedRelease(path.join(repoRoot, ".right-release", "sealed", releaseId, platform === "win" ? "windows" : "mac"));
76
+ if (sealed.manifest.commit !== source.commit) throw new Error("sealed release was built from a different commit than the current checkout");
77
+ sourceReceiptDigest = sealed.manifest.qa?.sourceReceiptDigest ?? null;
78
+ if (!sourceReceiptDigest) throw new Error("sealed release records no source QA receipt; it was not built through the QA gate; rebuild after `right-release qa --stage source`");
79
+ artifacts = sealed.manifest.files.map((file) => ({ name: file.name, sha256: file.sha256, sizeBytes: file.sizeBytes }));
80
+ }
81
+
82
+ const key = ensureQaReceiptKey({ env: deps.env ?? process.env, credentialStore: deps.credentialStore, provision: deps.provision ?? provisionKeyOnHost });
83
+ const runnerFile = deps.runnerFile ?? resolveRunner({ qa: config.qa, appRoot });
84
+ const runnerSha = sha256File(runnerFile);
85
+ const qaConfig = path.resolve(appRoot, config.qa.config);
86
+ if (!existsSync(qaConfig)) throw new Error(`qa.config does not exist: ${qaConfig}`);
87
+ const expand = (value) => String(value).replaceAll("{sealedDir}", sealed?.sealedDir ?? "").replaceAll("{releaseId}", releaseId);
88
+ const args = ["run", "--config", qaConfig, ...((config.qa.args?.[stage] ?? []).map(expand))];
89
+ const run = (deps.spawnRunner ?? spawnSync)(runnerFile, args, {
90
+ cwd: appRoot, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024,
91
+ 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 } : {}) },
92
+ });
93
+ if (!deps.quiet) { process.stdout.write(String(run.stdout ?? "")); process.stderr.write(String(run.stderr ?? "")); }
94
+ const evidenceFile = evidencePathFromOutput(run.stdout);
95
+ let evidence = null, evidenceSha256 = null;
96
+ if (evidenceFile && existsSync(evidenceFile)) {
97
+ const bytes = readFileSync(evidenceFile);
98
+ evidenceSha256 = createHash("sha256").update(bytes).digest("hex");
99
+ evidence = JSON.parse(bytes.toString("utf8"));
100
+ }
101
+ const required = evaluateRequiredChecks({ floor, stage, qa: config.qa, evidence });
102
+ // A passing receipt needs the runner to exit cleanly AND an evidence bundle that itself passed.
103
+ if (run.status !== 0 && evidence) evidence = { ...evidence, status: "failed" };
104
+ const receipt = sealQaReceipt(buildQaReceipt({
105
+ stage, app: config.app, platform, releaseId, source, closure: dependencyClosure(repoRoot, source.commit, gitRunner),
106
+ releaseConfigSha256: sha256File(configPath), qa: config.qa, floor, floorDigest,
107
+ policyBundleDigest: deps.policyBundleDigest ?? currentPolicyBundleDigest(deps.floorDir),
108
+ runner: { basename: path.basename(runnerFile).replace(/\.exe$/i, ""), path: runnerFile, sha256: runnerSha },
109
+ evidence, evidenceSha256, required, artifacts, sourceReceiptDigest,
110
+ }), key);
111
+ const file = qaReceiptPath({ repoRoot, releaseId, platform, stage });
112
+ writeQaReceipt(file, receipt);
113
+ return { receipt, file };
114
+ }
115
+
116
+ function usage(code) {
117
+ console.log("usage: right-release qa --stage source|installed --platform mac|win [--release <sealed-id>] [--config right-release.config.mjs]");
118
+ process.exit(code);
119
+ }
120
+
121
+ export async function main(args) {
122
+ let stage = "", platform = process.platform === "win32" ? "win" : "mac", releaseId = "", configName = "right-release.config.mjs";
123
+ for (let index = 0; index < args.length; index += 1) {
124
+ const arg = args[index];
125
+ if (arg === "--stage") stage = args[++index];
126
+ else if (arg === "--platform") platform = args[++index];
127
+ else if (arg === "--release") releaseId = args[++index];
128
+ else if (arg === "--config") configName = args[++index];
129
+ else if (arg === "-h" || arg === "--help") usage(0);
130
+ else { console.error(`right-release qa: unknown argument: ${arg}`); process.exit(2); }
131
+ }
132
+ if (!QA_STAGES.includes(stage)) { console.error("right-release qa: --stage must be source or installed"); process.exit(2); }
133
+ if (platform !== "win" && platform !== "mac") { console.error("right-release qa: --platform must be win or mac"); process.exit(2); }
134
+ try {
135
+ const { receipt, file } = await runQa({ stage, platform, releaseIdArg: releaseId, configPath: path.resolve(configName) });
136
+ console.log(`right-release qa: ${stage} receipt ${receipt.status} ${receipt.digest.slice(0, 16)} -> ${file}`);
137
+ for (const item of receipt.required) console.log(` ${item.id}: ${item.status}`);
138
+ process.exit(receipt.status === "passed" ? 0 : 1);
139
+ } catch (error) {
140
+ console.error(`right-release qa: ${error.message}`);
141
+ process.exit(1);
142
+ }
143
+ }
144
+
145
+ if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) await main(process.argv.slice(2));
@@ -0,0 +1,9 @@
1
+ {
2
+ "schema": 1,
3
+ "version": 1,
4
+ "runner": "rightkit-qa",
5
+ "stages": {
6
+ "source": { "required": ["smoke.launch", "journey"] },
7
+ "installed": { "required": ["smoke.launch", "journey"] }
8
+ }
9
+ }
@@ -0,0 +1,276 @@
1
+ // Release QA receipts (hardening step 4): no signed release on any channel without a passing, artifact-bound
2
+ // QA receipt for the exact commit.
3
+ //
4
+ // source stage admits the candidate build/sign: QA ran against the committed source.
5
+ // installed stage admits upload/publication: QA ran on the built, sealed artifacts (exact sha256) and chains
6
+ // to the source receipt digest recorded in the sealed manifest.
7
+ //
8
+ // A receipt is written only by `right-release qa`, which runs RightKit's own runner (`rightkit-qa`), digests the
9
+ // runner binary and the evidence bundle it produced, evaluates the protected QA floor, and MACs the result with a
10
+ // key held in the protected credential store (Keychain / protected file / CI secret) — not app-writable JSON.
11
+ // The floor, this verifier, and the receipt schema are pinned in the protected policy bundle.
12
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
13
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { spawnSync } from "./background-process.mjs";
17
+ import { canonicalJson, canonicalRepositoryIdentity } from "./ownership-approval.mjs";
18
+ import { OWNERSHIP_LOCKFILES } from "./ownership-lockfiles.mjs";
19
+ import { QA_FLOOR_FILE, runningPolicyBundle } from "./ownership-policy-bundle.mjs";
20
+ import { createCredentialStore, ProtectedSecretMissingError } from "./protected-credentials.mjs";
21
+ import { isManagedHost } from "./managed-host-secrets.mjs";
22
+ import { SCANNER_VERSION } from "./ownership.mjs";
23
+
24
+ const here = path.dirname(fileURLToPath(import.meta.url));
25
+ const HEX64 = /^[a-f0-9]{64}$/;
26
+ export const QA_RECEIPT_SCHEMA = 1;
27
+ export const QA_STAGES = Object.freeze(["source", "installed"]);
28
+ export const QA_KEY_ENV = "RIGHTKIT_QA_RECEIPT_KEY";
29
+ const MAC_DOMAIN = "rightkit-qa-receipt-v1\0";
30
+
31
+ export class QaAdmissionError extends Error {
32
+ constructor(message, { command } = {}) {
33
+ super(command ? `${message}\n run: ${command}` : message);
34
+ this.name = "QaAdmissionError";
35
+ this.code = "QA_ADMISSION_REFUSED";
36
+ this.command = command ?? null;
37
+ }
38
+ }
39
+
40
+ const sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex");
41
+ const digestOf = (value) => sha256Hex(canonicalJson(value));
42
+ const platformDirOf = (platform) => (platform === "win" ? "windows" : platform);
43
+
44
+ /** Exact command an agent must run to obtain the missing/invalid receipt. */
45
+ export function qaCommandFor({ stage, platform, releaseId, configName = "right-release.config.mjs" }) {
46
+ return `right-release qa --stage ${stage} --platform ${platform}${stage === "installed" ? ` --release ${releaseId}` : ""} --config ${configName}`;
47
+ }
48
+
49
+ export function qaReceiptPath({ repoRoot, releaseId, platform, stage }) {
50
+ return path.join(repoRoot, ".right-release", "qa", releaseId, platformDirOf(platform), `${stage}.receipt.json`);
51
+ }
52
+
53
+ export function loadQaFloor(dir = here) {
54
+ const file = path.join(dir, QA_FLOOR_FILE);
55
+ let floor;
56
+ try { floor = JSON.parse(readFileSync(file, "utf8")); } catch (error) { throw new Error(`QA floor is unreadable (${QA_FLOOR_FILE}): ${error.message}`); }
57
+ const problems = validateQaFloor(floor);
58
+ if (problems.length) throw new Error(`QA floor is invalid: ${problems.join("; ")}`);
59
+ return { floor, digest: digestOf(floor) };
60
+ }
61
+
62
+ export function validateQaFloor(floor) {
63
+ const problems = [];
64
+ if (!floor || typeof floor !== "object" || Array.isArray(floor)) return ["floor must be an object"];
65
+ if (floor.schema !== 1) problems.push("floor schema must be 1");
66
+ if (!Number.isInteger(floor.version) || floor.version < 1) problems.push("floor version must be a positive integer");
67
+ if (typeof floor.runner !== "string" || !floor.runner) problems.push("floor runner is required");
68
+ for (const stage of QA_STAGES) {
69
+ const required = floor.stages?.[stage]?.required;
70
+ if (!Array.isArray(required) || required.length === 0 || required.some((id) => typeof id !== "string" || !id)) problems.push(`floor stage ${stage} must list required check ids`);
71
+ }
72
+ return problems;
73
+ }
74
+
75
+ /** The app's release config declares how floor ids map to evidence check names (exact, or `prefix*`). */
76
+ export function validateAppQaConfig(qa, floor, stage) {
77
+ const problems = [];
78
+ if (!qa || typeof qa !== "object") return [`release config must declare qa { version, config, checks } (floor requires ${floor.stages[stage].required.join(", ")})`];
79
+ if (typeof qa.version !== "string" && typeof qa.version !== "number") problems.push("qa.version is required");
80
+ if (typeof qa.config !== "string" || !qa.config) problems.push("qa.config (rightkit-qa.toml path) is required");
81
+ for (const id of floor.stages[stage].required) {
82
+ const patterns = qa.checks?.[id];
83
+ if (!Array.isArray(patterns) || patterns.length === 0 || patterns.some((p) => typeof p !== "string" || !p)) problems.push(`qa.checks must map required floor check "${id}" to evidence check names`);
84
+ }
85
+ return problems;
86
+ }
87
+
88
+ const matches = (pattern, name) => (pattern.endsWith("*") ? name.startsWith(pattern.slice(0, -1)) : name === pattern);
89
+
90
+ /** Per required id: passed only when at least one mapped evidence check exists and every match passed. */
91
+ export function evaluateRequiredChecks({ floor, stage, qa, evidence }) {
92
+ const evidenceChecks = Array.isArray(evidence?.checks) ? evidence.checks : [];
93
+ return floor.stages[stage].required.map((id) => {
94
+ const patterns = qa.checks?.[id] ?? [];
95
+ const found = evidenceChecks.filter((check) => patterns.some((pattern) => matches(pattern, String(check.name))));
96
+ const bad = found.filter((check) => check.status !== "passed");
97
+ const status = found.length === 0 ? "missing" : bad.length ? "failed" : "passed";
98
+ return { id, status, evidenceChecks: found.map((check) => ({ name: String(check.name), status: String(check.status) })) };
99
+ });
100
+ }
101
+
102
+ function git(repoRoot, args, { runner = spawnSync, binary = false } = {}) {
103
+ const result = runner("git", ["-C", repoRoot, ...args], { encoding: binary ? "buffer" : "utf8", windowsHide: true, maxBuffer: 256 * 1024 * 1024 });
104
+ if (result?.status !== 0) throw new Error(`QA receipt: git ${args.join(" ")} failed`);
105
+ return binary ? result.stdout : String(result.stdout).trim();
106
+ }
107
+
108
+ /** Dependency closure: sha256 of every committed lockfile at the exact commit. */
109
+ export function dependencyClosure(repoRoot, commit, runner) {
110
+ const names = git(repoRoot, ["ls-tree", "-r", "--name-only", commit], { runner }).split("\n").filter(Boolean);
111
+ const lockfiles = names.filter((name) => OWNERSHIP_LOCKFILES.includes(path.posix.basename(name))).sort()
112
+ .map((name) => ({ path: name, sha256: sha256Hex(git(repoRoot, ["show", `${commit}:${name}`], { runner, binary: true })) }));
113
+ return { lockfiles, digest: digestOf(lockfiles) };
114
+ }
115
+
116
+ export function sourceIdentity(repoRoot, runner) {
117
+ const commit = git(repoRoot, ["rev-parse", "HEAD"], { runner });
118
+ return {
119
+ commit,
120
+ tree: git(repoRoot, ["rev-parse", `${commit}^{tree}`], { runner }),
121
+ repositoryIdentity: canonicalRepositoryIdentity(git(repoRoot, ["remote", "get-url", "origin"], { runner })),
122
+ };
123
+ }
124
+
125
+ export function currentPolicyBundleDigest(dir = here) {
126
+ return runningPolicyBundle({ scannerVersion: SCANNER_VERSION, dir }).digest;
127
+ }
128
+
129
+ /** MAC key from the protected store (Keychain / protected file). Hosted CI may supply it as a per-run secret. */
130
+ export function resolveQaReceiptKey({ env = process.env, credentialStore } = {}) {
131
+ const store = credentialStore ?? createCredentialStore({ env });
132
+ try { return store.read("qa-receipt-key"); }
133
+ catch (error) {
134
+ if (!(error instanceof ProtectedSecretMissingError)) throw error;
135
+ const fallback = !isManagedHost(env) && env.GITHUB_ACTIONS === "true" ? String(env[QA_KEY_ENV] ?? "").trim() : "";
136
+ if (fallback) return fallback;
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ /** First `right-release qa` on a host creates the key. macOS Keychain / Windows protected file. */
142
+ export function ensureQaReceiptKey({ env = process.env, credentialStore, platform = process.platform, provision } = {}) {
143
+ try { return resolveQaReceiptKey({ env, credentialStore }); }
144
+ catch (error) {
145
+ if (!(error instanceof ProtectedSecretMissingError) || !provision) throw error;
146
+ provision(randomBytes(32).toString("hex"), platform);
147
+ return resolveQaReceiptKey({ env, credentialStore });
148
+ }
149
+ }
150
+
151
+ const mac = (key, digest) => createHmac("sha256", key).update(`${MAC_DOMAIN}${digest}`).digest("hex");
152
+
153
+ /** Seal a receipt body: digest over the canonical body, MAC over the digest. */
154
+ export function sealQaReceipt(body, key) {
155
+ const digest = digestOf(body);
156
+ return { ...body, digest, mac: mac(key, digest) };
157
+ }
158
+
159
+ export function receiptDigest(receipt) {
160
+ const { digest, mac: _mac, ...body } = receipt;
161
+ return digestOf(body);
162
+ }
163
+
164
+ export function buildQaReceipt({
165
+ stage, app, platform, releaseId, source, closure, releaseConfigSha256, qa, floor, floorDigest, policyBundleDigest,
166
+ runner, evidence, evidenceSha256, required, artifacts = [], sourceReceiptDigest = null, now = new Date(),
167
+ }) {
168
+ const evidenceOk = evidence?.status === "passed" && Boolean(evidence?.finishedAt);
169
+ const status = evidenceOk && required.every((item) => item.status === "passed") ? "passed" : "failed";
170
+ return {
171
+ schema: QA_RECEIPT_SCHEMA, stage, app, platform, releaseId,
172
+ repositoryIdentity: source.repositoryIdentity, commit: source.commit, tree: source.tree,
173
+ dependencyClosure: closure,
174
+ buildConfig: { releaseConfigSha256, platform },
175
+ qaPlan: { version: String(qa.version), configPath: qa.config, planDigest: digestOf({ version: String(qa.version), config: qa.config, checks: qa.checks ?? {}, args: qa.args ?? {} }) },
176
+ floor: { version: floor.version, digest: floorDigest },
177
+ policyBundleDigest,
178
+ runner,
179
+ evidence: { sha256: evidenceSha256, runId: evidence?.runId ?? null, status: evidence?.status ?? null, schemaVersion: evidence?.schemaVersion ?? null },
180
+ required, checks: (evidence?.checks ?? []).map((check) => ({ name: String(check.name), status: String(check.status) })),
181
+ artifacts, sourceReceiptDigest, status, createdAt: now.toISOString(),
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Verify a receipt against the exact current state. Returns { ok, reason, receipt }.
187
+ * `expected.artifacts` ([{name, sha256}]) must equal the receipt's artifacts exactly (installed stage).
188
+ */
189
+ export function verifyQaReceipt(receipt, { key, stage, repoRoot, releaseConfigSha256, floorDir = here, artifacts, sourceReceiptDigest, gitRunner, policyBundleDigest, source, closure }) {
190
+ const refuse = (reason) => ({ ok: false, reason });
191
+ if (!receipt || receipt.schema !== QA_RECEIPT_SCHEMA) return refuse("receipt is missing or has an unsupported schema");
192
+ if (receipt.stage !== stage) return refuse(`receipt is for stage ${receipt.stage}, not ${stage}`);
193
+ if (!HEX64.test(receipt.digest ?? "") || !HEX64.test(receipt.mac ?? "")) return refuse("receipt is unsealed");
194
+ if (receiptDigest(receipt) !== receipt.digest) return refuse("receipt contents do not match its digest");
195
+ const expectedMac = Buffer.from(mac(key, receipt.digest), "hex");
196
+ if (!timingSafeEqual(expectedMac, Buffer.from(receipt.mac, "hex"))) return refuse("receipt MAC is invalid (not written by this host's `right-release qa`)");
197
+ if (receipt.status !== "passed") return refuse("receipt records a failed QA run");
198
+ let floor;
199
+ try { floor = loadQaFloor(floorDir); } catch (error) { return refuse(error.message); }
200
+ if (receipt.floor?.digest !== floor.digest) return refuse("QA floor changed since the receipt was written");
201
+ let bundle;
202
+ try { bundle = policyBundleDigest ?? currentPolicyBundleDigest(floorDir); } catch (error) { return refuse(`policy bundle could not be computed: ${error.message}`); }
203
+ if (receipt.policyBundleDigest !== bundle) return refuse("protected policy bundle changed since the receipt was written");
204
+ if (receipt.evidence?.status !== "passed") return refuse("evidence bundle did not pass");
205
+ // Every floor-required check must be recorded and passed — skipped, failed, or missing refuses.
206
+ for (const id of floor.floor.stages[stage].required) {
207
+ const item = (receipt.required ?? []).find((entry) => entry.id === id);
208
+ if (!item) return refuse(`required check ${id} is not recorded in the receipt`);
209
+ if (item.status !== "passed") return refuse(`required check ${id} is ${item.status}`);
210
+ if (!item.evidenceChecks?.length || item.evidenceChecks.some((check) => check.status !== "passed")) return refuse(`required check ${id} has incomplete or non-passing evidence`);
211
+ }
212
+ let current, currentClosure;
213
+ try { current = source ?? sourceIdentity(repoRoot, gitRunner); currentClosure = closure ?? dependencyClosure(repoRoot, current.commit, gitRunner); }
214
+ catch (error) { return refuse(error.message); }
215
+ if (receipt.commit !== current.commit) return refuse(`receipt is for commit ${String(receipt.commit).slice(0, 12)}, current commit is ${current.commit.slice(0, 12)}`);
216
+ if (receipt.tree !== current.tree) return refuse("receipt source tree differs from the current commit tree");
217
+ if (receipt.repositoryIdentity !== current.repositoryIdentity) return refuse("receipt repository identity differs");
218
+ if (receipt.dependencyClosure?.digest !== currentClosure.digest) return refuse("dependency closure (lockfile digests) changed since the receipt was written");
219
+ if (releaseConfigSha256 !== undefined && receipt.buildConfig?.releaseConfigSha256 !== releaseConfigSha256) return refuse("release config changed since the receipt was written");
220
+ if (receipt.runner?.basename !== floor.floor.runner || !HEX64.test(receipt.runner?.sha256 ?? "")) return refuse(`receipt was not produced by the ${floor.floor.runner} runner`);
221
+ if (stage === "installed") {
222
+ const wanted = [...(artifacts ?? [])].map((item) => `${item.name}:${item.sha256}`).sort();
223
+ const have = (receipt.artifacts ?? []).map((item) => `${item.name}:${item.sha256}`).sort();
224
+ if (!wanted.length || JSON.stringify(wanted) !== JSON.stringify(have)) return refuse("receipt artifact hashes do not match the sealed artifacts");
225
+ if (!sourceReceiptDigest || receipt.sourceReceiptDigest !== sourceReceiptDigest) return refuse("installed QA is not chained to the source QA receipt sealed into this release");
226
+ }
227
+ return { ok: true, receipt };
228
+ }
229
+
230
+ export function readQaReceipt(file) {
231
+ try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; }
232
+ }
233
+
234
+ export function writeQaReceipt(file, receipt) {
235
+ mkdirSync(path.dirname(file), { recursive: true });
236
+ writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
237
+ }
238
+
239
+ function loadKeyOrRefuse(opts, command) {
240
+ try { return (opts.resolveKey ?? resolveQaReceiptKey)({ env: opts.env, credentialStore: opts.credentialStore }); }
241
+ catch (error) { throw new QaAdmissionError(`QA receipt key is unavailable (${error.message})`, { command }); }
242
+ }
243
+
244
+ /**
245
+ * Build gate: source QA must have passed for this exact commit before a candidate is built/signed.
246
+ * Returns the receipt digest to record in the sealed manifest.
247
+ */
248
+ export function assertSourceQaAdmission({ repoRoot, releaseId, platform, configName, releaseConfigSha256, ...opts }) {
249
+ const command = qaCommandFor({ stage: "source", platform, releaseId, configName });
250
+ const receipt = readQaReceipt(qaReceiptPath({ repoRoot, releaseId, platform, stage: "source" }));
251
+ if (!receipt) throw new QaAdmissionError(`no source QA receipt for ${releaseId} (${platform}); a candidate cannot be built or signed without passing source QA`, { command });
252
+ const verdict = verifyQaReceipt(receipt, { key: loadKeyOrRefuse(opts, command), stage: "source", repoRoot, releaseConfigSha256, floorDir: opts.floorDir, gitRunner: opts.gitRunner, policyBundleDigest: opts.policyBundleDigest, source: opts.source, closure: opts.closure });
253
+ if (!verdict.ok) throw new QaAdmissionError(`source QA receipt rejected: ${verdict.reason}`, { command });
254
+ return { digest: receipt.digest, floorDigest: receipt.floor.digest };
255
+ }
256
+
257
+ /**
258
+ * Upload/publication gate for every provider (R2 + RightApps, GitHub Releases, updater channels): an installed-QA
259
+ * receipt for the exact sealed artifact hashes and commit must exist and pass. Dev builds never reach this gate —
260
+ * install-app --dev and the devArtifact lane never upload — and there is deliberately no manifest flag that skips it.
261
+ */
262
+ export function assertInstalledQaAdmission({ repoRoot, manifest, platform, configName, releaseConfigSha256, ...opts }) {
263
+ const releaseId = manifest.releaseId;
264
+ const command = qaCommandFor({ stage: "installed", platform, releaseId, configName });
265
+ const receipt = readQaReceipt(qaReceiptPath({ repoRoot, releaseId, platform, stage: "installed" }));
266
+ if (!receipt) throw new QaAdmissionError(`no installed QA receipt for ${releaseId} (${platform}); nothing is published or uploaded without passing installed QA on these exact artifacts`, { command });
267
+ const verdict = verifyQaReceipt(receipt, {
268
+ key: loadKeyOrRefuse(opts, command), stage: "installed", repoRoot, releaseConfigSha256, floorDir: opts.floorDir,
269
+ artifacts: (manifest.files ?? []).map((file) => ({ name: file.name, sha256: file.sha256 })),
270
+ sourceReceiptDigest: manifest.qa?.sourceReceiptDigest,
271
+ gitRunner: opts.gitRunner, policyBundleDigest: opts.policyBundleDigest, source: opts.source, closure: opts.closure,
272
+ });
273
+ if (!verdict.ok) throw new QaAdmissionError(`installed QA receipt rejected: ${verdict.reason}`, { command });
274
+ if (receipt.commit !== manifest.commit) throw new QaAdmissionError("installed QA receipt commit differs from the sealed release commit", { command });
275
+ return { digest: receipt.digest };
276
+ }
package/release.mjs CHANGED
@@ -711,7 +711,7 @@ async function validateRightKitPackageContract(root, appName, hostedWorkflows) {
711
711
  if (pkg.packageManager !== RIGHTKIT_VERSIONS.packageManager) {
712
712
  fail(`${appName ?? pkg.name ?? "app"} packageManager must be ${RIGHTKIT_VERSIONS.packageManager}, got ${pkg.packageManager ?? "<missing>"}`);
713
713
  }
714
- if (JSON.stringify(scripts).includes("../tools/right-release") || JSON.stringify(scripts).includes("../tools/rightkit/packages/release")) {
714
+ if (JSON.stringify(scripts).includes("../tools/right-release") || JSON.stringify(scripts).includes("../rightkit/packages/release")) {
715
715
  fail(`${appName ?? pkg.name ?? "app"} package.json must call the right-release bin, not parent-workspace release source`);
716
716
  }
717
717
  const rightKitDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
@@ -33,7 +33,7 @@ const latestFor = (versions, ecosystem, name) => versions.registry
33
33
  export function dependencyManifests(appRoot) {
34
34
  const listed = spawnSync("git", ["-C", appRoot, "ls-files", "-z", "--", "package.json", "*/package.json", "Cargo.toml", "*/Cargo.toml"], { encoding: "utf8", windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
35
35
  const files = listed.status === 0 ? listed.stdout.split("\0").filter(Boolean) : ["package.json", "Cargo.toml"].filter((file) => existsSync(path.join(appRoot, file)));
36
- return files.filter((file) => !/(^|\/)(node_modules|vendor|target|dist)\//.test(file) && !file.startsWith("tools/rightkit/"));
36
+ return files.filter((file) => !/(^|\/)(node_modules|vendor|target|dist)\//.test(file) && !file.startsWith("rightkit/"));
37
37
  }
38
38
 
39
39
  /** Pins behind the latest published RightKit version. Workspace, path, git and range-less specs are skipped. */