@rightkit/release 0.6.24 → 0.6.26
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.
- package/README.md +11 -35
- package/android-dev.mjs +2 -1
- package/app-check.mjs +1 -2
- package/app-closure.mjs +3 -2
- package/assembled-app.mjs +11 -3
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/bin/rightkit-gate.mjs +83 -26
- package/build-helper-trace.mjs +264 -0
- package/build-helpers-command.mjs +184 -0
- package/build-launch.mjs +11 -75
- package/build-release.mjs +10 -11
- package/cargo-profile-contract.mjs +19 -29
- package/cargo-target-shim.mjs +31 -0
- package/cli/right-release.mjs +5 -7
- package/create-mac-updater.mjs +4 -0
- package/cross-targets.mjs +94 -0
- package/deterministic-scans.mjs +310 -20
- package/development.mjs +8 -8
- package/freshness-census.mjs +1 -1
- package/gate-stages.mjs +1 -1
- package/install-assembled-app.mjs +1 -1
- package/install-tauri-authenticode.mjs +3 -3
- package/local-publish.mjs +27 -5
- package/mac-package-recipe.mjs +55 -2
- package/managed-cargo.mjs +27 -1
- package/managed-roots.mjs +12 -4
- package/package.json +1 -5
- package/pipeline-admission.mjs +1 -1
- package/pipeline-receipts.mjs +302 -302
- package/provenance-verify.mjs +3 -1
- package/qa-build-features.mjs +11 -2
- package/release-qa-audit.mjs +9 -27
- package/release-qa-command.mjs +23 -64
- package/release-qa-fixtures.mjs +9 -5
- package/release-qa-floor.json +0 -1
- package/release-qa-policy.mjs +9 -10
- package/release-qa-receipt.mjs +6 -44
- package/release-source-identity.mjs +1 -12
- package/release-switch-registry.json +4 -1
- package/rightkit-registry-resolver.mjs +35 -3
- package/rightkit-versions.json +2 -2
- package/sign-windows.mjs +1 -1
- package/snapshot-broker.mjs +33 -8
- package/snapshot-metadata.mjs +32 -0
- package/snapshot-request.mjs +6 -3
- package/stage-sidecar.mjs +28 -11
- package/suite-signing-policy.mjs +8 -58
- package/templates/cargo-profiles.toml +6 -25
- package/test-command.mjs +40 -140
- package/updater-shipped-key.mjs +26 -0
- package/web-build.mjs +29 -4
- package/qa-journeys-prebuilt-run.mjs +0 -18
- package/qa-journeys-prebuilt.mjs +0 -148
- package/qa-layout.mjs +0 -229
- package/qa-snapshot-run.mjs +0 -53
- package/qa-snapshot.mjs +0 -164
- package/sign-qa-bundle.mjs +0 -210
- package/suite-signing.mjs +0 -259
package/provenance-verify.mjs
CHANGED
|
@@ -217,10 +217,12 @@ export const CAUSE_EVIDENCE_SCHEMA = "rustright.cause-evidence.v1";
|
|
|
217
217
|
/** The refusal codes of a verdict that is not `explained` (the other cause-evidence refusals are `evidence`, like every malformed signed field). */
|
|
218
218
|
export const CAUSE_UNKNOWN = "cause-unknown";
|
|
219
219
|
export const CAUSE_UNEXPLAINED = "cause-unexplained";
|
|
220
|
-
/** cargo_evidence.rs EXPLAINED_CAUSES: the only causes that explain a rebuilt unit; any other (including `unexplained`, `fingerprint-missing` and one this list does not know) does not. */
|
|
220
|
+
/** cargo_evidence.rs EXPLAINED_CAUSES (the same 10, in the broker's order): the only causes that explain a rebuilt unit; any other (including `unexplained`, `fingerprint-missing` and one this list does not know) does not. */
|
|
221
221
|
export const EXPLAINED_CAUSES = Object.freeze([
|
|
222
222
|
"first-build", "toolchain-changed", "source-or-dependency-changed", "env-rustflags-profile-features-or-target-changed",
|
|
223
223
|
"generation-change", "authorized-eviction", "new-unit", "new-namespace",
|
|
224
|
+
// A unit missing its recorded fingerprint row while its package has a fingerprint dir under another build (rustright judge step 0, cargo_evidence.rs same_package_other_build).
|
|
225
|
+
"after-failed-build", "fingerprint-changed",
|
|
224
226
|
]);
|
|
225
227
|
const CAUSE_STATES = Object.freeze(["complete", "incomplete", "unavailable"]);
|
|
226
228
|
const CAUSE_VERDICTS = Object.freeze(["explained", "unexplained", "unknown"]);
|
package/qa-build-features.mjs
CHANGED
|
@@ -179,8 +179,17 @@ export function cargoManifestIssues(manifestText, label = "Cargo.toml", { allowD
|
|
|
179
179
|
const shell = dependencies.get(name);
|
|
180
180
|
if (shell && !shell.optional && shell.features.includes("dev-inspect")) issues.push(`${label}: dependency \`${name}\` lists \`dev-inspect\` unconditionally (developer inspection ships in release builds)`);
|
|
181
181
|
}
|
|
182
|
-
|
|
183
|
-
|
|
182
|
+
// GC-A09: no `test-mode` feature exists anywhere (own `[features]` table or any dependency, optional or not,
|
|
183
|
+
// `<crate>/test-mode` forwards included): tests are isolated by injection (a temp data root, a MemoryBackend).
|
|
184
|
+
const isTestModeName = (name) => name === "test-mode" || name.endsWith("-test-mode");
|
|
185
|
+
for (const name of features.keys()) {
|
|
186
|
+
if (isTestModeName(name)) issues.push(`${label}: declares feature \`${name}\` (no test-mode feature may exist: inject a temp data root and a MemoryBackend instead)`);
|
|
187
|
+
}
|
|
188
|
+
for (const [alias, dep] of dependencies) {
|
|
189
|
+
for (const feature of dep.features) {
|
|
190
|
+
if (isTestModeName(feature)) issues.push(`${label}: dependency \`${alias}\` enables feature \`${feature}\` (no test-mode feature may exist: inject a temp data root and a MemoryBackend instead)`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
184
193
|
const wdio = dependencies.get(FORBIDDEN_DEPENDENCY);
|
|
185
194
|
if (wdio && !wdio.optional) issues.push(`${label}: dependency \`${FORBIDDEN_DEPENDENCY}\` is not optional`);
|
|
186
195
|
const reached = new Set(); const stack = [...(features.get("default") ?? [])];
|
package/release-qa-audit.mjs
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
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
|
|
5
|
-
// (installed) admission each read their own mode. The gates in release-qa-receipt.mjs verify receipts, and the
|
|
4
|
+
// per app once the app has earned it. Only the publication (installed) admission exists (GATE-CONTRACT GC-A07: no source QA stage). The gates in release-qa-receipt.mjs verify receipts, and the
|
|
6
5
|
// hardening in release-qa-hardening.mjs re-checks platform, release id, approved runner digest (for that platform),
|
|
7
6
|
// every release-critical journey and the signing/test separation. In warn mode a refusal becomes a loud stderr
|
|
8
|
-
// warning and a durable record (
|
|
9
|
-
// `state/<releaseId>/<platformDir>/qa-installed.json` for the installed stage). In refuse mode the same refusal
|
|
7
|
+
// warning and a durable record (`state/<releaseId>/<platformDir>/qa-installed.json`). In refuse mode the same refusal
|
|
10
8
|
// throws a QaAdmissionError and nothing is sealed or published. `qa audit` reads the records so a weekly run can
|
|
11
9
|
// flag released builds that lack passing QA.
|
|
12
10
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -14,13 +12,13 @@ import path from "node:path";
|
|
|
14
12
|
import { spawnSync } from "./background-process.mjs";
|
|
15
13
|
import { hardenQaReceipt } from "./release-qa-hardening.mjs";
|
|
16
14
|
import { admissionModeFor, loadAdmissionPolicy } from "./release-qa-policy.mjs";
|
|
17
|
-
import { assertInstalledQaAdmission,
|
|
15
|
+
import { assertInstalledQaAdmission, loadQaFloor, QaAdmissionError, qaReceiptPath, readQaReceipt } from "./release-qa-receipt.mjs";
|
|
18
16
|
import { platformDirOf, platformOfDir } from "./platform-dir.mjs";
|
|
19
17
|
|
|
20
18
|
|
|
21
19
|
/** "missing" when no receipt exists; "rejected" for anything else the gate refused (bad receipt, no key, wrong commit). */
|
|
22
20
|
function refusalStatus(error) {
|
|
23
|
-
return /^no
|
|
21
|
+
return /^no installed QA receipt/.test(error.message) ? "missing" : "rejected";
|
|
24
22
|
}
|
|
25
23
|
const refusalReason = (error) => String(error.message).split("\n run:")[0];
|
|
26
24
|
|
|
@@ -58,14 +56,6 @@ function hardening(stage, opts, loaded, hooks) {
|
|
|
58
56
|
});
|
|
59
57
|
}
|
|
60
58
|
|
|
61
|
-
/** Build stage. Returns the `qa` block sealed into the release manifest. */
|
|
62
|
-
export function admitSourceQa(opts, hooks = {}) {
|
|
63
|
-
const { loaded, app, mode } = policyContext(opts, hooks, "source");
|
|
64
|
-
const outcome = admit(() => assertSourceQaAdmission({ ...opts, afterVerify: hardening("source", opts, loaded, hooks) }), { ...hooks, mode, app });
|
|
65
|
-
if (outcome.status === "passed") return { status: "passed", sourceReceiptDigest: outcome.result.digest, floorDigest: outcome.result.floorDigest };
|
|
66
|
-
return { status: outcome.status, reason: outcome.reason };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
59
|
export const installedQaRecordPath = ({ repoRoot, releaseId, platform }) =>
|
|
70
60
|
path.join(repoRoot, ".right-release", "state", releaseId, platformDirOf(platform), "qa-installed.json");
|
|
71
61
|
|
|
@@ -92,13 +82,6 @@ export function admitInstalledQa(opts, { provider, now = () => new Date(), ...ho
|
|
|
92
82
|
const readJson = (file) => { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; } };
|
|
93
83
|
const subdirs = (dir) => (existsSync(dir) ? readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : []);
|
|
94
84
|
|
|
95
|
-
function sourceStatus(manifest) {
|
|
96
|
-
const qa = manifest.qa;
|
|
97
|
-
if (qa?.status === "passed" || (!qa?.status && qa?.sourceReceiptDigest)) return { status: "passed" };
|
|
98
|
-
if (qa?.status) return { status: qa.status, ...(qa.reason ? { reason: qa.reason } : {}) };
|
|
99
|
-
return { status: "missing", reason: "sealed manifest records no source QA" };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
85
|
function installedStatus({ repoRoot, releaseId, platformDir }) {
|
|
103
86
|
const stateDir = path.join(repoRoot, ".right-release", "state", releaseId, platformDir);
|
|
104
87
|
const record = readJson(path.join(stateDir, "qa-installed.json"));
|
|
@@ -121,12 +104,11 @@ export function auditQa({ repoRoot, since } = {}) {
|
|
|
121
104
|
if (!manifest) continue;
|
|
122
105
|
if (since && String(manifest.sealedAt ?? "") < since) continue;
|
|
123
106
|
const installed = installedStatus({ repoRoot, releaseId, platformDir });
|
|
124
|
-
const source = sourceStatus(manifest);
|
|
125
107
|
const { uploaded, ...installedRest } = installed;
|
|
126
108
|
rows.push({
|
|
127
109
|
releaseId, platform: platformDir, version: manifest.version ?? null, sealedAt: manifest.sealedAt ?? null, uploaded,
|
|
128
|
-
|
|
129
|
-
ok: !uploaded ||
|
|
110
|
+
installed: installedRest,
|
|
111
|
+
ok: !uploaded || installedRest.status === "passed",
|
|
130
112
|
});
|
|
131
113
|
}
|
|
132
114
|
}
|
|
@@ -135,8 +117,8 @@ export function auditQa({ repoRoot, since } = {}) {
|
|
|
135
117
|
|
|
136
118
|
export function formatAuditTable(report) {
|
|
137
119
|
if (!report.rows.length) return "right-release qa audit: no sealed releases found";
|
|
138
|
-
const header = ["release", "platform", "
|
|
139
|
-
const body = report.rows.map((row) => [row.releaseId, row.platform, row.
|
|
120
|
+
const header = ["release", "platform", "installed", "result"];
|
|
121
|
+
const body = report.rows.map((row) => [row.releaseId, row.platform, row.installed.status, row.ok ? (row.uploaded ? "ok" : "ok (not uploaded)") : "FLAG"]);
|
|
140
122
|
const widths = header.map((_, column) => Math.max(header[column].length, ...body.map((line) => line[column].length)));
|
|
141
123
|
const line = (cells) => cells.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd();
|
|
142
124
|
const flagged = report.rows.filter((row) => !row.ok);
|
|
@@ -144,7 +126,7 @@ export function formatAuditTable(report) {
|
|
|
144
126
|
line(header), ...body.map(line),
|
|
145
127
|
"",
|
|
146
128
|
flagged.length
|
|
147
|
-
? `${flagged.length} uploaded build(s) lack passing QA:\n${flagged.map((row) => ` ${row.releaseId} ${row.platform}: ${[row.
|
|
129
|
+
? `${flagged.length} uploaded build(s) lack passing QA:\n${flagged.map((row) => ` ${row.releaseId} ${row.platform}: ${[row.installed.status !== "passed" && `installed ${row.installed.status}${row.installed.reason ? ` (${row.installed.reason})` : ""}`].filter(Boolean).join("; ")}`).join("\n")}`
|
|
148
130
|
: "all uploaded builds have passing QA",
|
|
149
131
|
].join("\n");
|
|
150
132
|
}
|
package/release-qa-command.mjs
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// right-release qa: run RightKit's own QA runner (rightkit-qa) and write the sealed, MAC'd QA receipt.
|
|
3
3
|
//
|
|
4
|
-
// right-release qa --stage source --platform mac|win [--config right-release.config.json]
|
|
5
4
|
// right-release qa --stage installed --platform mac|win --release <sealed-id> [--config right-release.config.json]
|
|
6
5
|
// right-release qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>] (release-qa-audit.mjs; exit 1 when an uploaded build lacks passing QA)
|
|
7
|
-
// right-release qa policy show | set --app <app> --mode warn|refuse --set-by <name> [--stage
|
|
6
|
+
// right-release qa policy show | set --app <app> --mode warn|refuse --set-by <name> [--stage installed]
|
|
8
7
|
// | approve-runner --runner <file> --version <v> --platform mac|win --set-by <name> [--note <text>] (release-qa-policy.mjs)
|
|
9
8
|
// right-release qa runner enroll --runner <built rightkit-qa> --version <v> [--platform mac|win] [--rotate] | show (qa-runner.mjs)
|
|
10
9
|
//
|
|
11
10
|
// The release config declares the app's QA plan:
|
|
12
11
|
// qa: { version: "1", config: "rightkit-qa.toml", runner?: "<path to rightkit-qa>",
|
|
13
|
-
// args?: {
|
|
12
|
+
// args?: { installed?: [..] }, // {sealedDir} {releaseId} expand; installed also {installedApp} {installedDir} {qaRoot}
|
|
14
13
|
// 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
|
|
15
|
-
// journeys?: { manifest?: "journeys.toml", receipt?: "<path to a rightkit.journeys.receipt/1>", args?: {
|
|
16
|
-
// layout?: { binary: "<bin target of the QA shell>", beside?: [..], build?: [<tests.suites names>] }, // source stage: run against the gate-built QA shell in the layout an install has, see qa-layout.mjs
|
|
14
|
+
// journeys?: { manifest?: "journeys.toml", receipt?: "<path to a rightkit.journeys.receipt/1>", args?: { installed?: [..] } },
|
|
17
15
|
// checks: { "smoke.launch": ["app.launch"], journey: ["journey.*"] } } // floor id -> evidence check names
|
|
18
16
|
// `qa.version` that is a rightkit-qa version (0.10.0) names the runner: the managed, digest-recorded one enrolled by `right-release qa runner enroll`
|
|
19
17
|
// (qa-runner.mjs), never a PATH binary. A legacy plan version ("1") with no `qa.runner` still takes the first `rightkit-qa` on PATH.
|
|
@@ -21,6 +19,7 @@
|
|
|
21
19
|
// `qa.journeys` maps the floor's `journey` id to "every release-critical journey required on this platform passed":
|
|
22
20
|
// `rightkit-qa journeys run` is run (or `receipt` is read), the receipt is validated through `rightkit-qa journeys
|
|
23
21
|
// validate-receipt --manifest .. --tree ..`, and the validated summary is sealed into the receipt (release-qa-hardening.mjs).
|
|
22
|
+
// There is no source stage (GATE-CONTRACT GC-A07): QA journeys run after the build against the installed dev copy.
|
|
24
23
|
// This command refuses to seal from inside a QA run (RIGHT_RELEASE_QA_STAGE set): sealing is never test execution.
|
|
25
24
|
import { createHash } from "node:crypto";
|
|
26
25
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
@@ -42,10 +41,7 @@ import {
|
|
|
42
41
|
} from "./release-qa-receipt.mjs";
|
|
43
42
|
import { platformDirOf } from "./platform-dir.mjs";
|
|
44
43
|
import { isRunnerVersion, main as runnerMain, resolveManagedQaRunner } from "./qa-runner.mjs";
|
|
45
|
-
import {
|
|
46
|
-
import { gateRunsRoot, resolveManagedRoots } from "./managed-roots.mjs";
|
|
47
|
-
import { planTests, runSuites } from "./test-command.mjs";
|
|
48
|
-
import { QA_SIGNED_FILES_ENV } from "./suite-signing-policy.mjs";
|
|
44
|
+
import { resolveManagedRoots } from "./managed-roots.mjs";
|
|
49
45
|
|
|
50
46
|
const sha256File = (file) => createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
51
47
|
|
|
@@ -106,7 +102,11 @@ export function evidencePathFromOutput(stdout) {
|
|
|
106
102
|
}
|
|
107
103
|
|
|
108
104
|
/** Run the QA plan through the runner, then seal a receipt. `deps` are test seams; production uses the defaults. */
|
|
109
|
-
export
|
|
105
|
+
export const SOURCE_QA_REMOVED = "source QA is removed (GATE-CONTRACT GC-A07): QA journeys run after the build against the installed dev copy";
|
|
106
|
+
|
|
107
|
+
export async function runQa({ stage, platform, releaseIdArg, configPath, deps = {} }) {
|
|
108
|
+
if (stage === "source") throw new Error(SOURCE_QA_REMOVED);
|
|
109
|
+
if (stage !== "installed") throw new Error(`--stage must be installed (${SOURCE_QA_REMOVED})`);
|
|
110
110
|
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`);
|
|
111
111
|
const executionStartedAt = new Date();
|
|
112
112
|
const gitRunner = deps.gitRunner ?? spawnSync;
|
|
@@ -128,22 +128,16 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
|
|
|
128
128
|
const { floor, digest: floorDigest } = loadQaFloor(deps.floorDir);
|
|
129
129
|
const problems = validateAppQaConfig(config.qa, floor, stage);
|
|
130
130
|
if (problems.length) throw new Error(`release config QA plan does not meet the protected QA floor:\n- ${problems.join("\n- ")}`);
|
|
131
|
-
const layoutProblems = config.qa.layout === undefined ? [] : validateQaLayout(config.qa.layout);
|
|
132
|
-
if (layoutProblems.length) throw new Error(`release config qa.layout is invalid:\n- ${layoutProblems.join("\n- ")}`);
|
|
133
131
|
|
|
134
132
|
let releaseId = releaseIdArg;
|
|
135
133
|
let sealed = null;
|
|
136
134
|
let artifacts = [];
|
|
137
|
-
|
|
138
|
-
if (stage === "source") releaseId = `${config.app}-${config.version}-${source.commit.slice(0, 8)}`;
|
|
139
|
-
else {
|
|
135
|
+
{
|
|
140
136
|
if (!releaseId) throw new Error("--release <sealed-id> is required for the installed stage");
|
|
141
|
-
sealed = verifySealedRelease(path.join(repoRoot, ".right-release", "sealed", releaseId, platformDirOf(platform)));
|
|
137
|
+
sealed = (deps.verifySealedRelease ?? verifySealedRelease)(path.join(repoRoot, ".right-release", "sealed", releaseId, platformDirOf(platform)));
|
|
142
138
|
// R4: a split binds the sealed release to the selected commit (and its tree), never to the checkout's HEAD.
|
|
143
139
|
if (split) assertBindsSource(release, sealed.manifest, "the sealed release");
|
|
144
140
|
else if (sealed.manifest.commit !== source.commit) throw new Error("sealed release was built from a different commit than the current checkout");
|
|
145
|
-
sourceReceiptDigest = sealed.manifest.qa?.sourceReceiptDigest ?? null;
|
|
146
|
-
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`");
|
|
147
141
|
artifacts = sealed.manifest.files.map((file) => ({ name: file.name, sha256: file.sha256, sizeBytes: file.sizeBytes }));
|
|
148
142
|
}
|
|
149
143
|
|
|
@@ -181,37 +175,16 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
|
|
|
181
175
|
});
|
|
182
176
|
}
|
|
183
177
|
let run, journeysRun = null;
|
|
184
|
-
let layoutStage = null;
|
|
185
178
|
try {
|
|
186
|
-
// Source stage with a declared layout: the QA shell and its layout are staged in a run-scoped directory from the gate's compile outputs (qa-layout.mjs); the snapshot is never written.
|
|
187
|
-
if (stage === "source" && config.qa.layout?.build) {
|
|
188
|
-
// The QA shell is built here, by the app's own build (and sign) suites, never by the release compile (qa-layout.mjs `build`).
|
|
189
|
-
const env = deps.env ?? process.env;
|
|
190
|
-
const plan = planTests(config, { suites: config.qa.layout.build, appRoot });
|
|
191
|
-
const stageFrom = ({ outputs, env: suiteEnv }) => {
|
|
192
|
-
const dirs = [...new Set(plan.filter((item) => !item.sign).flatMap((item) => outputs.get(item.name) ?? []))];
|
|
193
|
-
if (!dirs.length) throw new Error(`qa.layout.build: ${plan.map((item) => item.name).join(", ")} passed but named no output directory (no carried receipt, and no RIGHTKIT_GATE_TARGET_DIRS from the gate)`);
|
|
194
|
-
const signed = plan.some((item) => item.sign) ? JSON.parse(suiteEnv[QA_SIGNED_FILES_ENV] ?? "{}") : null;
|
|
195
|
-
return (deps.stageLayout ?? stageQaLayout)({ layout: config.qa.layout, snapshotAppRoot: appRoot, qaConfigFile: qaConfig, runsRoot: deps.runsRoot ?? gateRunsRoot({ env }), dirs, platform, signed });
|
|
196
|
-
};
|
|
197
|
-
const built = await (deps.runSuites ?? runSuites)(plan, { appRoot, prefix: "qa_build", env, config, log: (line) => console.error(line), errLog: (line) => console.error(line), within: stageFrom });
|
|
198
|
-
if (built.exitCode !== 0) throw new Error(`qa.layout.build: the QA shell build failed (${built.results.filter((r) => r.exitCode !== 0).map((r) => r.suite).join(", ")}); no QA ran`);
|
|
199
|
-
layoutStage = built.within;
|
|
200
|
-
} else if (stage === "source" && config.qa.layout) {
|
|
201
|
-
const dirs = deps.compiledDirs ?? compiledDirsFromEnv(deps.env ?? process.env);
|
|
202
|
-
if (!dirs) throw new Error("qa.layout needs the gate's compile outputs (RIGHT_RELEASE_QA_COMPILED_DIRS names none): run source QA through `rightkit-gate --to package`, after its compile");
|
|
203
|
-
layoutStage = (deps.stageLayout ?? stageQaLayout)({ layout: config.qa.layout, snapshotAppRoot: appRoot, qaConfigFile: qaConfig, runsRoot: deps.runsRoot ?? gateRunsRoot({ env: deps.env ?? process.env }), dirs, platform });
|
|
204
|
-
}
|
|
205
179
|
const expand = (value) => String(value).replaceAll("{sealedDir}", sealed?.sealedDir ?? "").replaceAll("{releaseId}", releaseId)
|
|
206
180
|
.replaceAll("{installedApp}", installedQa?.installedApp ?? "").replaceAll("{installedDir}", installedQa?.installedDir ?? "").replaceAll("{qaRoot}", installedQa?.qaRoot ?? "");
|
|
207
181
|
const installedDirs = installedDirNames(config, deps.env ?? process.env);
|
|
208
182
|
const runEnv = {
|
|
209
183
|
...process.env, RIGHT_RELEASE_QA_STAGE: stage, RIGHT_RELEASE_QA_COMMIT: source.commit, RIGHT_RELEASE_QA_RELEASE_ID: releaseId,
|
|
210
184
|
...(sealed ? { RIGHT_RELEASE_QA_SEALED_DIR: sealed.sealedDir } : {}), ...(installedDirs.length ? { RIGHTKIT_QA_INSTALLED_DIRS: installedDirs.join(";") } : {}), ...(installedQa?.env ?? {}),
|
|
211
|
-
...(layoutStage ? { [QA_APP_BINARY_ENV]: layoutStage.binary } : {}),
|
|
212
185
|
};
|
|
213
|
-
const runCwd =
|
|
214
|
-
const args = ["run", "--config",
|
|
186
|
+
const runCwd = appRoot;
|
|
187
|
+
const args = ["run", "--config", qaConfig, ...((config.qa.args?.[stage] ?? []).map(expand))];
|
|
215
188
|
run = (deps.spawnRunner ?? spawnSync)(runnerFile, args, {
|
|
216
189
|
cwd: runCwd, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024,
|
|
217
190
|
env: runEnv,
|
|
@@ -222,9 +195,6 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
|
|
|
222
195
|
cwd: runCwd, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024, env: runEnv,
|
|
223
196
|
});
|
|
224
197
|
}
|
|
225
|
-
} catch (error) {
|
|
226
|
-
layoutStage?.cleanup();
|
|
227
|
-
throw error;
|
|
228
198
|
} finally {
|
|
229
199
|
installedQa?.cleanup();
|
|
230
200
|
}
|
|
@@ -252,36 +222,26 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
|
|
|
252
222
|
required = required.map((item) => (item.id === floor.journeys.floorId ? journeyRequiredItem({ floorId: item.id, validation }) : item));
|
|
253
223
|
}
|
|
254
224
|
const executionFinishedAt = new Date();
|
|
255
|
-
// The runner's outputs (evidence, journeys receipt) have been read; the run-scoped layout is not evidence and goes.
|
|
256
|
-
const layoutEvidence = layoutStage?.evidence ?? null;
|
|
257
|
-
layoutStage?.cleanup();
|
|
258
225
|
// A passing receipt needs the runner to exit cleanly AND an evidence bundle that itself passed.
|
|
259
226
|
if (run.status !== 0 && evidence) evidence = { ...evidence, status: "failed" };
|
|
260
|
-
|
|
261
|
-
const receipt = sealQaReceipt({ ...buildQaReceipt({
|
|
227
|
+
const receipt = sealQaReceipt(buildQaReceipt({
|
|
262
228
|
stage, app: config.app, platform, releaseId, source, closure: dependencyClosure(repoRoot, source.commit, gitRunner),
|
|
263
229
|
releaseConfigSha256: sha256File(configPath), qa: config.qa, floor, floorDigest,
|
|
264
230
|
policyBundleDigest: deps.policyBundleDigest ?? currentPolicyBundleDigest(deps.floorDir),
|
|
265
231
|
runner: { basename: path.basename(runnerFile).replace(/\.exe$/i, ""), path: runnerFile, sha256: runnerSha, ...(managedRunner ? { version: managedRunner.version, source: "managed" } : {}) },
|
|
266
|
-
evidence, evidenceSha256, required, artifacts,
|
|
232
|
+
evidence, evidenceSha256, required, artifacts, journeys,
|
|
267
233
|
execution: {
|
|
268
234
|
startedAt: executionStartedAt.toISOString(), finishedAt: executionFinishedAt.toISOString(),
|
|
269
235
|
runnerPids: [run.pid, journeysRun?.pid].filter((pid) => Number.isInteger(pid) && pid > 0), sealerPid: process.pid,
|
|
270
236
|
},
|
|
271
|
-
}),
|
|
237
|
+
}), key);
|
|
272
238
|
const file = qaReceiptPath({ repoRoot, releaseId, platform, stage });
|
|
273
239
|
writeQaReceipt(file, receipt);
|
|
274
|
-
|
|
275
|
-
let carriedFile = null;
|
|
276
|
-
if (carryTo) {
|
|
277
|
-
carriedFile = path.join(path.resolve(carryTo), `${stage}.receipt.json`);
|
|
278
|
-
writeQaReceipt(carriedFile, receipt);
|
|
279
|
-
}
|
|
280
|
-
return { receipt, file, carriedFile };
|
|
240
|
+
return { receipt, file };
|
|
281
241
|
}
|
|
282
242
|
|
|
283
243
|
function usage(code) {
|
|
284
|
-
console.log("usage: right-release qa --stage
|
|
244
|
+
console.log("usage: right-release qa --stage installed --platform mac|win [--release <sealed-id>] [--config right-release.config.json]\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>]\n right-release qa policy show | set --app <app> --mode warn|refuse --set-by <name> [--stage installed]\n | approve-runner --runner <file> --version <v> --platform mac|win --set-by <name> [--note <text>]");
|
|
285
245
|
process.exit(code);
|
|
286
246
|
}
|
|
287
247
|
|
|
@@ -289,23 +249,22 @@ export async function main(args) {
|
|
|
289
249
|
if (args[0] === "audit") process.exit(auditMain(args.slice(1)));
|
|
290
250
|
if (args[0] === "policy") process.exit(policyMain(args.slice(1), { program: "right-release qa policy" }));
|
|
291
251
|
if (args[0] === "runner") process.exit(runnerMain(args.slice(1), { program: "right-release qa runner" }));
|
|
292
|
-
let stage = "", platform = process.platform === "win32" ? "win" : "mac", releaseId = "", configName = defaultReleaseConfigFile()
|
|
252
|
+
let stage = "", platform = process.platform === "win32" ? "win" : "mac", releaseId = "", configName = defaultReleaseConfigFile();
|
|
293
253
|
for (let index = 0; index < args.length; index += 1) {
|
|
294
254
|
const arg = args[index];
|
|
295
255
|
if (arg === "--stage") stage = args[++index];
|
|
296
256
|
else if (arg === "--platform") platform = args[++index];
|
|
297
257
|
else if (arg === "--release") releaseId = args[++index];
|
|
298
258
|
else if (arg === "--config") configName = args[++index];
|
|
299
|
-
else if (arg === "--carry-to") carryTo = args[++index];
|
|
300
259
|
else if (arg === "-h" || arg === "--help") usage(0);
|
|
301
260
|
else { console.error(`right-release qa: unknown argument: ${arg}`); process.exit(2); }
|
|
302
261
|
}
|
|
303
|
-
if (
|
|
262
|
+
if (stage === "source") { console.error(`right-release qa: ${SOURCE_QA_REMOVED}`); process.exit(2); }
|
|
263
|
+
if (!QA_STAGES.includes(stage)) { console.error("right-release qa: --stage must be installed"); process.exit(2); }
|
|
304
264
|
if (platform !== "win" && platform !== "mac") { console.error("right-release qa: --platform must be win or mac"); process.exit(2); }
|
|
305
265
|
try {
|
|
306
|
-
const { receipt, file
|
|
266
|
+
const { receipt, file } = await runQa({ stage, platform, releaseIdArg: releaseId, configPath: path.resolve(configName) });
|
|
307
267
|
console.log(`right-release qa: ${stage} receipt ${receipt.status} ${receipt.digest.slice(0, 16)} -> ${file}`);
|
|
308
|
-
if (carriedFile) console.log(`right-release qa: carried copy -> ${carriedFile}`);
|
|
309
268
|
for (const item of receipt.required) console.log(` ${item.id}: ${item.status}`);
|
|
310
269
|
process.exit(receipt.status === "passed" ? 0 : 1);
|
|
311
270
|
} catch (error) {
|
package/release-qa-fixtures.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Shared builders for the QA admission hardening tests (release-qa-hardening.test.mjs
|
|
1
|
+
// Shared builders for the QA admission hardening tests (release-qa-hardening.test.mjs).
|
|
2
2
|
// Test support only: nothing in the release path imports this file.
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
@@ -82,6 +82,10 @@ export function journeysReceiptJson({ root, platform = "mac", manifestText = MAN
|
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/** The one sealed artifact the installed-stage fixtures publish, and the manifest that names it (GATE-CONTRACT GC-A07: installed is the only QA stage). */
|
|
86
|
+
export const ARTIFACTS = [{ name: "demo.dmg", sha256: SHA("a"), sizeBytes: 3 }];
|
|
87
|
+
export const manifestOf = (root, releaseId) => ({ releaseId, app: "demo", commit: sourceIdentity(root).commit, files: ARTIFACTS });
|
|
88
|
+
|
|
85
89
|
export const accepting = () => ({ status: 0, stdout: "receipt claims passed, recomputed passed: ACCEPTED\n", stderr: "" });
|
|
86
90
|
export const refusing = () => ({ status: 1, stdout: "- journey_failed [J-001]: journey failed\nreceipt claims passed, recomputed failed: REFUSED\n", stderr: "" });
|
|
87
91
|
|
|
@@ -89,12 +93,12 @@ const qa = { version: "1", config: "rightkit-qa.toml", journeys: {}, checks: { "
|
|
|
89
93
|
const evidence = () => ({ schemaVersion: 4, runId: "r1", status: "passed", finishedAt: "2026-10-09T00:00:00Z", checks: [{ name: "app.launch", status: "passed" }] });
|
|
90
94
|
|
|
91
95
|
/**
|
|
92
|
-
* A sealed
|
|
96
|
+
* A sealed installed QA receipt written where the gate reads it, as `right-release qa` would write it
|
|
93
97
|
* with qa.journeys configured. Timestamps are relative to now so the execution window precedes admission.
|
|
94
98
|
*/
|
|
95
99
|
export function writeHardenedReceipt(root, {
|
|
96
|
-
stage = "
|
|
97
|
-
spawn = accepting, execution, tweakBlock, nowMs = Date.now(), artifacts =
|
|
100
|
+
stage = "installed", platform = "mac", releaseId, receiptPlatform = platform, receiptReleaseId = releaseId, statuses = {}, runnerSha = RUNNER_SHA,
|
|
101
|
+
spawn = accepting, execution, tweakBlock, nowMs = Date.now(), artifacts = ARTIFACTS,
|
|
98
102
|
} = {}) {
|
|
99
103
|
const at = (offsetSec) => new Date(nowMs + offsetSec * 1000).toISOString();
|
|
100
104
|
const dir = mkdtempSync(path.join(tmpdir(), "rk-qa-jr-"));
|
|
@@ -111,7 +115,7 @@ export function writeHardenedReceipt(root, {
|
|
|
111
115
|
const receipt = sealQaReceipt(buildQaReceipt({
|
|
112
116
|
stage, app: "demo", platform: receiptPlatform, releaseId: receiptReleaseId, source, closure: dependencyClosure(root, source.commit), releaseConfigSha256: SHA("d"),
|
|
113
117
|
qa, floor, floorDigest: digest, policyBundleDigest: BUNDLE, runner: { basename: "rightkit-qa", path: "/x/rightkit-qa", sha256: runnerSha },
|
|
114
|
-
evidence: evidence(), evidenceSha256: SHA("f"), required, artifacts,
|
|
118
|
+
evidence: evidence(), evidenceSha256: SHA("f"), required, artifacts, journeys: block, now: new Date(nowMs - 5000),
|
|
115
119
|
execution: execution === false ? null : (execution ?? { startedAt: at(-120), finishedAt: at(-10), runnerPids: [4242], sealerPid: 4343 }),
|
|
116
120
|
}), KEY);
|
|
117
121
|
writeQaReceipt(qaReceiptPath({ repoRoot: root, releaseId, platform, stage }), receipt);
|
package/release-qa-floor.json
CHANGED
package/release-qa-policy.mjs
CHANGED
|
@@ -3,17 +3,16 @@
|
|
|
3
3
|
//
|
|
4
4
|
// { "schema": 1,
|
|
5
5
|
// "apps": { "<app>": { "mode": "warn" | "refuse", "since": "YYYY-MM-DD", "set_by": "<name>",
|
|
6
|
-
// "stages": { "
|
|
7
|
-
// "installed": { "mode": .., "since": .., "set_by": .. } } } },
|
|
6
|
+
// "stages": { "installed": { "mode": .., "since": .., "set_by": .. } } } },
|
|
8
7
|
// "approvedRunners": [ { "sha256": "<64 hex>", "version": "<rightkit-qa version>", "platform": "mac" | "win",
|
|
9
8
|
// "set_by": "<name>", "since": "YYYY-MM-DD", "note": "..." } ] }
|
|
10
9
|
//
|
|
11
10
|
// An app with no entry is in `warn` mode (default). In `refuse` mode `admit()` throws instead of warning.
|
|
12
11
|
//
|
|
13
|
-
// Stages: the
|
|
14
|
-
// app-level { mode, since, set_by }
|
|
15
|
-
// app-level mode for that stage. An app entry needs a mode or a non-empty `stages`. Only `
|
|
16
|
-
// stage
|
|
12
|
+
// Stages: the publication (`installed`) admission is the only one (GATE-CONTRACT GC-A07 removed source QA). An app entry is the
|
|
13
|
+
// app-level { mode, since, set_by }, a `stages` object, or both; a stage entry overrides the
|
|
14
|
+
// app-level mode for that stage. An app entry needs a mode or a non-empty `stages`. Only `installed` is a
|
|
15
|
+
// stage key.
|
|
17
16
|
//
|
|
18
17
|
// Approved runners: `sha256` is required (lowercase hex). `platform` ("mac" | "win") binds the approval to the
|
|
19
18
|
// platform the receipt is sealed or published for; an entry without it approves the digest on every platform (legacy
|
|
@@ -50,8 +49,8 @@ const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
50
49
|
export const QA_ADMISSION_POLICY_FILE = "qa-admission-policy.json";
|
|
51
50
|
export const QA_POLICY_BASELINE_REF_ENV = "RIGHT_RELEASE_QA_POLICY_BASELINE_REF";
|
|
52
51
|
export const ADMISSION_MODES = Object.freeze(["warn", "refuse"]);
|
|
53
|
-
/** The admissions an app's mode can be set for: the
|
|
54
|
-
export const ADMISSION_STAGES = Object.freeze(["
|
|
52
|
+
/** The admissions an app's mode can be set for: the publication (installed); there is no source stage (GATE-CONTRACT GC-A07). */
|
|
53
|
+
export const ADMISSION_STAGES = Object.freeze(["installed"]);
|
|
55
54
|
/** The platforms an approved runner can be bound to. */
|
|
56
55
|
export const RUNNER_PLATFORMS = Object.freeze(["mac", "win"]);
|
|
57
56
|
/** The only `set_by` that may move an app back from refuse, or approve a runner. */
|
|
@@ -205,7 +204,7 @@ export function loadAdmissionPolicy({ dir = here, baseline, baselineRef, env = p
|
|
|
205
204
|
}
|
|
206
205
|
|
|
207
206
|
/**
|
|
208
|
-
* The mode for an app at `stage` ("
|
|
207
|
+
* The mode for an app at `stage` ("installed"): the stage entry, else the app-level entry, else `warn`.
|
|
209
208
|
* Without a stage it is the app-level result.
|
|
210
209
|
*/
|
|
211
210
|
export function admissionModeFor(policy, app, stage) {
|
|
@@ -326,7 +325,7 @@ export function main(args, {
|
|
|
326
325
|
stdout(`qa runner approved: ${entry.sha256} rightkit-qa ${entry.version} (${entry.platform ?? "any platform"}, since ${entry.since}, set_by ${entry.set_by})`);
|
|
327
326
|
return 0;
|
|
328
327
|
}
|
|
329
|
-
stderr(`usage: ${program} show\n ${program} set --app <app> --mode warn|refuse --set-by <name> [--stage
|
|
328
|
+
stderr(`usage: ${program} show\n ${program} set --app <app> --mode warn|refuse --set-by <name> [--stage installed]\n ${program} approve-runner --runner <file> --version <v> --platform mac|win --set-by <name> [--note <text>]`);
|
|
330
329
|
return 2;
|
|
331
330
|
} catch (error) {
|
|
332
331
|
stderr(`release-qa-policy: ${error.message}`);
|
package/release-qa-receipt.mjs
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// Release QA receipts (hardening step 4): no signed release on any channel without a passing, artifact-bound
|
|
2
2
|
// QA receipt for the exact commit.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// to the source receipt digest recorded in the sealed manifest.
|
|
4
|
+
// installed stage admits upload/publication: QA ran on the built, sealed artifacts (exact sha256).
|
|
5
|
+
// There is no source stage (GATE-CONTRACT GC-A07): QA journeys run after the build against the installed dev copy.
|
|
7
6
|
//
|
|
8
7
|
// A receipt is written only by `right-release qa`, which runs RightKit's own runner (`rightkit-qa`), digests the
|
|
9
8
|
// runner binary and the evidence bundle it produced, evaluates the protected QA floor, and MACs the result with a
|
|
@@ -25,7 +24,7 @@ import { SCANNER_VERSION } from "./ownership.mjs";
|
|
|
25
24
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
26
25
|
const HEX64 = /^[a-f0-9]{64}$/;
|
|
27
26
|
export const QA_RECEIPT_SCHEMA = 1;
|
|
28
|
-
export const QA_STAGES = Object.freeze(["
|
|
27
|
+
export const QA_STAGES = Object.freeze(["installed"]);
|
|
29
28
|
export const QA_KEY_ENV = "RIGHTKIT_QA_RECEIPT_KEY";
|
|
30
29
|
const MAC_DOMAIN = "rightkit-qa-receipt-v1\0";
|
|
31
30
|
|
|
@@ -51,22 +50,6 @@ export function qaReceiptPath({ repoRoot, releaseId, platform, stage }) {
|
|
|
51
50
|
return path.join(repoRoot, ".right-release", "qa", releaseId, platformDirOf(platform), `${stage}.receipt.json`);
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
export const QA_CARRIED_RECEIPT_ENV = "RIGHT_RELEASE_QA_RECEIPT";
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Receipt carried from the job that ran source QA to the signing job (Orthic-Labs/rightkit#183).
|
|
58
|
-
* `RIGHT_RELEASE_QA_RECEIPT` names the receipt file, or a directory holding `source.receipt.json`.
|
|
59
|
-
* Returns the file path when it exists, else null. The carried file is verified exactly like a local one
|
|
60
|
-
* (MAC, commit, tree, lockfile closure, config, floor, bundle), so carrying adds no trust.
|
|
61
|
-
*/
|
|
62
|
-
export function carriedSourceReceiptPath(env = process.env) {
|
|
63
|
-
const value = String(env?.[QA_CARRIED_RECEIPT_ENV] ?? "").trim();
|
|
64
|
-
if (!value) return null;
|
|
65
|
-
const resolved = path.resolve(value);
|
|
66
|
-
const file = existsSync(resolved) && statSync(resolved).isDirectory() ? path.join(resolved, "source.receipt.json") : resolved;
|
|
67
|
-
return existsSync(file) ? file : null;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
53
|
export function loadQaFloor(dir = here) {
|
|
71
54
|
const file = path.join(dir, QA_FLOOR_FILE);
|
|
72
55
|
let floor;
|
|
@@ -196,7 +179,7 @@ export function receiptDigest(receipt) {
|
|
|
196
179
|
|
|
197
180
|
export function buildQaReceipt({
|
|
198
181
|
stage, app, platform, releaseId, source, closure, releaseConfigSha256, qa, floor, floorDigest, policyBundleDigest,
|
|
199
|
-
runner, evidence, evidenceSha256, required, artifacts = [],
|
|
182
|
+
runner, evidence, evidenceSha256, required, artifacts = [], now = new Date(), journeys = null, execution = null,
|
|
200
183
|
}) {
|
|
201
184
|
const evidenceOk = evidence?.status === "passed" && Boolean(evidence?.finishedAt);
|
|
202
185
|
const status = evidenceOk && required.every((item) => item.status === "passed") ? "passed" : "failed";
|
|
@@ -211,7 +194,7 @@ export function buildQaReceipt({
|
|
|
211
194
|
runner,
|
|
212
195
|
evidence: { sha256: evidenceSha256, runId: evidence?.runId ?? null, status: evidence?.status ?? null, schemaVersion: evidence?.schemaVersion ?? null },
|
|
213
196
|
required, checks: (evidence?.checks ?? []).map((check) => ({ name: String(check.name), status: String(check.status) })),
|
|
214
|
-
artifacts,
|
|
197
|
+
artifacts, status, createdAt: now.toISOString(),
|
|
215
198
|
...(journeys ? { journeys } : {}),
|
|
216
199
|
...(execution ? { execution } : {}),
|
|
217
200
|
};
|
|
@@ -221,7 +204,7 @@ export function buildQaReceipt({
|
|
|
221
204
|
* Verify a receipt against the exact current state. Returns { ok, reason, receipt }.
|
|
222
205
|
* `expected.artifacts` ([{name, sha256}]) must equal the receipt's artifacts exactly (installed stage).
|
|
223
206
|
*/
|
|
224
|
-
export function verifyQaReceipt(receipt, { key, stage, repoRoot, releaseConfigSha256, floorDir = here, artifacts,
|
|
207
|
+
export function verifyQaReceipt(receipt, { key, stage, repoRoot, releaseConfigSha256, floorDir = here, artifacts, gitRunner, policyBundleDigest, source, closure }) {
|
|
225
208
|
const refuse = (reason) => ({ ok: false, reason });
|
|
226
209
|
if (!receipt || receipt.schema !== QA_RECEIPT_SCHEMA) return refuse("receipt is missing or has an unsupported schema");
|
|
227
210
|
if (receipt.stage !== stage) return refuse(`receipt is for stage ${receipt.stage}, not ${stage}`);
|
|
@@ -257,7 +240,6 @@ export function verifyQaReceipt(receipt, { key, stage, repoRoot, releaseConfigSh
|
|
|
257
240
|
const wanted = [...(artifacts ?? [])].map((item) => `${item.name}:${item.sha256}`).sort();
|
|
258
241
|
const have = (receipt.artifacts ?? []).map((item) => `${item.name}:${item.sha256}`).sort();
|
|
259
242
|
if (!wanted.length || JSON.stringify(wanted) !== JSON.stringify(have)) return refuse("receipt artifact hashes do not match the sealed artifacts");
|
|
260
|
-
if (!sourceReceiptDigest || receipt.sourceReceiptDigest !== sourceReceiptDigest) return refuse("installed QA is not chained to the source QA receipt sealed into this release");
|
|
261
243
|
}
|
|
262
244
|
return { ok: true, receipt };
|
|
263
245
|
}
|
|
@@ -276,25 +258,6 @@ function loadKeyOrRefuse(opts, command) {
|
|
|
276
258
|
catch (error) { throw new QaAdmissionError(`QA receipt key is unavailable (${error.message})`, { command }); }
|
|
277
259
|
}
|
|
278
260
|
|
|
279
|
-
/**
|
|
280
|
-
* Build gate: source QA must have passed for this exact commit before a candidate is built/signed.
|
|
281
|
-
* Returns the receipt digest to record in the sealed manifest.
|
|
282
|
-
*/
|
|
283
|
-
export function assertSourceQaAdmission({ repoRoot, releaseId, platform, configName, releaseConfigSha256, ...opts }) {
|
|
284
|
-
const command = qaCommandFor({ stage: "source", platform, releaseId, configName });
|
|
285
|
-
// A receipt carried from the QA job (RIGHT_RELEASE_QA_RECEIPT) wins over the local path; the signing job has no local one.
|
|
286
|
-
const carried = carriedSourceReceiptPath(opts.env ?? process.env);
|
|
287
|
-
const receipt = readQaReceipt(carried ?? qaReceiptPath({ repoRoot, releaseId, platform, stage: "source" }));
|
|
288
|
-
if (!receipt) throw new QaAdmissionError(`no source QA receipt for ${releaseId} (${platform}); a candidate cannot be built or signed without passing source QA`, { command });
|
|
289
|
-
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 });
|
|
290
|
-
if (!verdict.ok) throw new QaAdmissionError(`source QA receipt rejected: ${verdict.reason}`, { command });
|
|
291
|
-
if (carried && (receipt.releaseId !== releaseId || receipt.platform !== platform)) {
|
|
292
|
-
throw new QaAdmissionError(`source QA receipt rejected: carried receipt is for ${receipt.releaseId} (${receipt.platform}), not ${releaseId} (${platform})`, { command });
|
|
293
|
-
}
|
|
294
|
-
opts.afterVerify?.(receipt); // hardening hook (release-qa-hardening.mjs); throws QaAdmissionError
|
|
295
|
-
return { digest: receipt.digest, floorDigest: receipt.floor.digest };
|
|
296
|
-
}
|
|
297
|
-
|
|
298
261
|
/**
|
|
299
262
|
* Upload/publication gate for every provider (R2 + RightApps, GitHub Releases, updater channels): an installed-QA
|
|
300
263
|
* receipt for the exact sealed artifact hashes and commit must exist and pass. Dev builds never reach this gate —
|
|
@@ -308,7 +271,6 @@ export function assertInstalledQaAdmission({ repoRoot, manifest, platform, confi
|
|
|
308
271
|
const verdict = verifyQaReceipt(receipt, {
|
|
309
272
|
key: loadKeyOrRefuse(opts, command), stage: "installed", repoRoot, releaseConfigSha256, floorDir: opts.floorDir,
|
|
310
273
|
artifacts: (manifest.files ?? []).map((file) => ({ name: file.name, sha256: file.sha256 })),
|
|
311
|
-
sourceReceiptDigest: manifest.qa?.sourceReceiptDigest,
|
|
312
274
|
gitRunner: opts.gitRunner, policyBundleDigest: opts.policyBundleDigest, source: opts.source, closure: opts.closure,
|
|
313
275
|
});
|
|
314
276
|
if (!verdict.ok) throw new QaAdmissionError(`installed QA receipt rejected: ${verdict.reason}`, { command });
|
|
@@ -23,7 +23,6 @@ import { spawnSync } from "./background-process.mjs";
|
|
|
23
23
|
import { resolveSelectedCommit } from "./committed-source.mjs";
|
|
24
24
|
import { canonicalRepositoryIdentity } from "./ownership-approval.mjs";
|
|
25
25
|
import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
|
|
26
|
-
import { dependencyClosure } from "./release-qa-receipt.mjs";
|
|
27
26
|
import { brandReleaseSource, isReleaseSource } from "./release-source-brand.mjs";
|
|
28
27
|
import { assertCleanSource } from "./source-gate.mjs";
|
|
29
28
|
import { SOURCE_SPLIT_CODE, SourceSplitError, inSnapshot, readSourceSplit } from "./source-split.mjs";
|
|
@@ -150,7 +149,7 @@ export function assertBindsSource(source, record, label = "the record") {
|
|
|
150
149
|
}
|
|
151
150
|
|
|
152
151
|
/**
|
|
153
|
-
* The `{ commit, tree, repositoryIdentity }` a
|
|
152
|
+
* The `{ commit, tree, repositoryIdentity }` a QA receipt binds, for a snapshot source: the selected commit and tree, and the
|
|
154
153
|
* repository identity from the checkout's origin. Reads no HEAD. (The checkout route keeps release-qa-receipt.mjs `sourceIdentity`.)
|
|
155
154
|
*/
|
|
156
155
|
export function snapshotSourceIdentity(source, git = spawnSync) {
|
|
@@ -159,14 +158,4 @@ export function snapshotSourceIdentity(source, git = spawnSync) {
|
|
|
159
158
|
return { commit: source.commit, tree: source.tree, repositoryIdentity: canonicalRepositoryIdentity(origin) };
|
|
160
159
|
}
|
|
161
160
|
|
|
162
|
-
/**
|
|
163
|
-
* The `{ source, closure }` options `admitSourceQa` / `assertSourceQaAdmission` / `assertInstalledQaAdmission` take so that the receipt they
|
|
164
|
-
* verify is compared with the SELECTED commit (its tree, its repository identity, its lockfile closure read from its objects) instead of the
|
|
165
|
-
* checkout's HEAD. `{}` for the checkout route: those functions then read HEAD exactly as before. Spread it into their options.
|
|
166
|
-
*/
|
|
167
|
-
export function qaAdmissionSourceOptions(source, git = spawnSync) {
|
|
168
|
-
if (!isSnapshotSource(source)) return {};
|
|
169
|
-
return { source: snapshotSourceIdentity(source, git), closure: dependencyClosure(source.checkout, source.commit, git) };
|
|
170
|
-
}
|
|
171
|
-
|
|
172
161
|
export { SOURCE_SPLIT_CODE };
|
|
@@ -13,12 +13,15 @@
|
|
|
13
13
|
"demo",
|
|
14
14
|
"dev-overrides",
|
|
15
15
|
"qa-probes",
|
|
16
|
+
"qa-env",
|
|
16
17
|
"dev-worker-override",
|
|
17
18
|
"qa-shot",
|
|
18
19
|
"qa-tools",
|
|
19
20
|
"dev-control",
|
|
20
21
|
"bench-native",
|
|
21
|
-
"rightkit-qa/control"
|
|
22
|
+
"rightkit-qa/control",
|
|
23
|
+
"test-overrides",
|
|
24
|
+
"qa-views"
|
|
22
25
|
],
|
|
23
26
|
"testModeMarker": "RIGHTKIT-TEST-MODE-COMPILED",
|
|
24
27
|
"denyNames": [
|