@rightkit/release 0.2.165 → 0.2.172
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 +121 -4
- package/addon-command.mjs +6 -13
- package/android-aab-scan.mjs +265 -0
- package/android-dev.mjs +111 -0
- package/android-ndk.mjs +77 -0
- package/android-play-upload.mjs +262 -0
- package/android-release.mjs +344 -0
- package/android-test-fixtures.mjs +79 -0
- package/apple-profile-create.mjs +363 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/build-release.mjs +73 -13
- package/cargo-guard.mjs +2 -0
- package/cargo-profile-contract.mjs +183 -0
- package/cargo-target.mjs +8 -0
- package/cli/right-release.mjs +14 -4
- package/committed-source.mjs +1 -1
- package/dev-artifact-refresh.mjs +176 -0
- package/dev-artifact-support.mjs +61 -0
- package/development-signing.mjs +71 -4
- package/development.mjs +78 -46
- package/direct-bootstrap.mjs +26 -41
- package/disk-command.mjs +298 -5
- package/disk-policy.json +7 -5
- package/disk-policy.mjs +105 -16
- package/disk-stable-root.mjs +19 -16
- package/engine-locator.mjs +116 -0
- package/external-bin.mjs +133 -0
- package/github-release.mjs +2 -2
- package/github-updater-release.mjs +154 -0
- package/hardeningscan.mjs +1 -1
- package/in-place-install.mjs +56 -0
- package/install-app-windows.mjs +63 -0
- package/install-app.mjs +53 -23
- package/local-publish.mjs +11 -1
- package/mac-signing-matrix.mjs +23 -11
- package/managed-cargo.mjs +6 -0
- package/media-sidecar-catalog.mjs +45 -0
- package/media-sidecar.mjs +306 -0
- package/ownership-approval-command.mjs +10 -44
- package/ownership-approval.mjs +3 -42
- package/ownership-catalog-config.json +2 -1
- package/ownership-catalog.mjs +4 -2
- package/ownership-command.mjs +7 -9
- package/ownership-dependency-allowlist.json +25 -1
- package/ownership-evidence.mjs +8 -5
- package/ownership-git-inventory.mjs +41 -7
- package/ownership-lockfiles.mjs +91 -8
- package/ownership-mandatory-set.json +36 -16
- package/ownership-report.mjs +5 -6
- package/ownership-scan-worker.mjs +2 -2
- package/ownership-verdict.mjs +10 -7
- package/ownership.mjs +47 -29
- package/package.json +7 -4
- package/portable-development.mjs +32 -115
- package/preflight.mjs +4 -6
- package/publication-artifacts.mjs +34 -0
- package/publish-update.mjs +72 -11
- package/qa-adoption-exemptions.json +5 -0
- package/qa-build-features.mjs +7 -0
- package/qa-contract.mjs +182 -0
- package/qa-control-scan.mjs +3 -3
- package/release-qa-command.mjs +38 -12
- package/release-qa-installed.mjs +136 -0
- package/release-qa-receipt.mjs +23 -2
- package/release.mjs +281 -13
- package/rightkit-ownership.json +537 -5
- package/rightkit-sync.mjs +26 -0
- package/rightkit-versions.json +109 -62
- package/runtime-budget-classes.json +28 -0
- package/runtime-budgets.mjs +4 -1
- package/sign-candidate.mjs +161 -0
- package/sign-dev-artifact.mjs +198 -0
- package/stage-binary.mjs +60 -0
- package/swap-helper-bundle.mjs +105 -0
- package/templates/cargo-profiles.toml +28 -0
- package/upload-release.mjs +11 -1
package/qa-build-features.mjs
CHANGED
|
@@ -16,6 +16,9 @@ export const FORBIDDEN_FEATURES = Object.freeze(["devtools", "content-devtools",
|
|
|
16
16
|
"qa-shot", "qa-tools", "dev-control", "bench-native",
|
|
17
17
|
// D4 (M21/M23): rightkit-daemon `test-mode` compiles the override selectors; never in a release build.
|
|
18
18
|
"test-mode"]);
|
|
19
|
+
// rightkit-qa 0.6.0 moved the embedded control server (the former rightkit-qa-driver `qa-control`) behind its `control`
|
|
20
|
+
// feature. It emits the same QA_CONTROL_MARKER, so the binary layer refuses it unchanged; this is the source layer.
|
|
21
|
+
export const QUALIFIED_FORBIDDEN_FEATURES = Object.freeze(["rightkit-qa/control", "rightkit-qa?/control"]);
|
|
19
22
|
// The only forbidden features an allow-listed app may enable: rightkit-tauri-shell's `content-devtools` (and the Tauri
|
|
20
23
|
// `devtools` it switches on). See devtools-policy.mjs; every other app is refused them.
|
|
21
24
|
const DEVTOOLS_FEATURES = Object.freeze(["devtools", "content-devtools"]);
|
|
@@ -31,6 +34,8 @@ export function isForbiddenFeature(item, { allowDevtools = false } = {}) {
|
|
|
31
34
|
const text = String(item).trim();
|
|
32
35
|
const name = featureName(text);
|
|
33
36
|
if (allowDevtools && DEVTOOLS_FEATURES.includes(name)) return false;
|
|
37
|
+
// `control` is too generic to forbid by bare name; rightkit-qa's dev-only server feature is refused when qualified.
|
|
38
|
+
if (QUALIFIED_FORBIDDEN_FEATURES.includes(text.replace(/\s+/g, ""))) return true;
|
|
34
39
|
return text === `dep:${FORBIDDEN_DEPENDENCY}` || FORBIDDEN_FEATURES.includes(name);
|
|
35
40
|
}
|
|
36
41
|
|
|
@@ -163,6 +168,8 @@ export function cargoManifestIssues(manifestText, label = "Cargo.toml", { allowD
|
|
|
163
168
|
const control = dependencies.get(name);
|
|
164
169
|
if (control && !control.optional && control.features.includes("qa-control")) issues.push(`${label}: dependency \`${name}\` lists \`qa-control\` unconditionally`);
|
|
165
170
|
}
|
|
171
|
+
const qa = dependencies.get("rightkit-qa");
|
|
172
|
+
if (qa && !qa.optional && qa.features.includes("control")) issues.push(`${label}: dependency \`rightkit-qa\` lists \`control\` unconditionally`);
|
|
166
173
|
// The crate was renamed from rightkit-shell; apps still on the old published name must stay covered.
|
|
167
174
|
for (const name of ["rightkit-tauri-shell", "rightkit-shell"]) {
|
|
168
175
|
const shell = dependencies.get(name);
|
package/qa-contract.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "./background-process.mjs";
|
|
4
|
+
import { breakingBump, loadRightkitVersions } from "./rightkit-drift.mjs";
|
|
3
5
|
|
|
4
6
|
export function validateQaBackdoorContract(root, packageScripts = {}) {
|
|
5
7
|
if (!existsSync(path.join(root, "right-qa.config.mjs"))) return [];
|
|
@@ -59,3 +61,183 @@ function readRustSources(root) {
|
|
|
59
61
|
visit(root);
|
|
60
62
|
return files.sort().map((file) => readFileSync(file, "utf8")).join("\n");
|
|
61
63
|
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------- QA adoption (mandatory for every app)
|
|
66
|
+
//
|
|
67
|
+
// RightKit QA is a mandatory component: every Orthic Labs app must carry a recognised QA lane. Recognised lanes:
|
|
68
|
+
// - the native wdio lane (`right-qa.config.mjs`, validated by validateQaBackdoorContract above),
|
|
69
|
+
// - `@rightkit/qa` in any non-node_modules package.json (dependencies or devDependencies), and/or
|
|
70
|
+
// - `rightkit-qa` in any Cargo.toml (dependencies, dev-dependencies or build-dependencies).
|
|
71
|
+
// `@rightkit/qa-chrome` is the web-UI companion; its presence is not enforced (a UI cannot be detected reliably)
|
|
72
|
+
// but its pin is lag-checked like the others.
|
|
73
|
+
// A pin behind the latest published version (rightkit-versions.json) is a warning; a breaking-bump lag (the
|
|
74
|
+
// `breakingBump` rule rightkit-drift uses) refuses. The only exemption is for repositories that are not apps
|
|
75
|
+
// (qa-adoption-exemptions.json, RightKit-owned: changing it takes a RightKit release). No per-app opt-out.
|
|
76
|
+
|
|
77
|
+
export const QA_EXEMPTIONS_FILE = "qa-adoption-exemptions.json";
|
|
78
|
+
const NPM_QA = "@rightkit/qa";
|
|
79
|
+
const NPM_QA_CHROME = "@rightkit/qa-chrome";
|
|
80
|
+
const CARGO_QA = "rightkit-qa";
|
|
81
|
+
const NPM_SECTIONS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
|
|
82
|
+
const SKIP_DIRS = /^(node_modules(\..*)?|target|vendor|dist|build|\.git|\.cache|\.right-release)$/;
|
|
83
|
+
const MAX_DEPTH = 6;
|
|
84
|
+
const EXEMPTION_KINDS = ["library", "tooling"];
|
|
85
|
+
|
|
86
|
+
const numeric = (value) => String(value).split("-")[0].split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
87
|
+
const newer = (candidate, current) => { const a = numeric(candidate); const b = numeric(current); for (let i = 0; i < 3; i += 1) if (a[i] !== b[i]) return a[i] > b[i]; return false; };
|
|
88
|
+
const latestFor = (versions, ecosystem, name) => versions.registry
|
|
89
|
+
? versions.registry[ecosystem]?.[name]
|
|
90
|
+
: ecosystem === "npm" ? versions.stagedNpm?.[name] ?? versions.npm?.[name] : versions.stagedCargo?.[name] ?? versions.cargo?.[name];
|
|
91
|
+
|
|
92
|
+
/** package.json and Cargo.toml files in the app tree, skipping node_modules (any suffix), target, vendor, dist and the RightKit tree. */
|
|
93
|
+
export function qaManifests(appRoot, { readdir = readdirSync } = {}) {
|
|
94
|
+
const found = [];
|
|
95
|
+
const visit = (dir, depth) => {
|
|
96
|
+
let entries;
|
|
97
|
+
try { entries = readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
const relative = path.relative(appRoot, path.join(dir, entry.name)).split(path.sep).join("/");
|
|
100
|
+
if (entry.isDirectory()) {
|
|
101
|
+
if (depth < MAX_DEPTH && !SKIP_DIRS.test(entry.name) && relative !== "rightkit" && relative !== "tools/rightkit") visit(path.join(dir, entry.name), depth + 1);
|
|
102
|
+
} else if (entry.name === "package.json" || entry.name === "Cargo.toml") found.push(relative);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
visit(appRoot, 0);
|
|
106
|
+
return found.sort();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `[dependencies]`-style section header that holds dependencies (including target-specific ones). */
|
|
110
|
+
const DEPENDENCY_HEADER = /^\[\s*(?:target\.[^\]]+\.)?(?:dev-|build-)?dependencies\s*\]/;
|
|
111
|
+
const DEPENDENCY_TABLE_HEADER = /^\[\s*(?:target\.[^\]]+\.)?(?:dev-|build-)?dependencies\.([A-Za-z0-9_-]+)\s*\]/;
|
|
112
|
+
const WORKSPACE_DEPENDENCY = /^\[\s*workspace\.dependencies\s*\]/;
|
|
113
|
+
|
|
114
|
+
/** `rightkit-qa` declarations in a Cargo.toml: [{ version | null }]. Path, git and workspace deps carry no version. */
|
|
115
|
+
export function cargoQaPins(text, crate = CARGO_QA) {
|
|
116
|
+
const pins = [];
|
|
117
|
+
let section = null;
|
|
118
|
+
let table = null;
|
|
119
|
+
const push = (body) => {
|
|
120
|
+
const version = (body.match(/\bversion\s*=\s*"[=^~<>\s]*(\d+\.\d+\.\d+)/) ?? body.match(/^"[=^~<>\s]*(\d+\.\d+\.\d+)/))?.[1] ?? null;
|
|
121
|
+
pins.push({ version: /\bpath\s*=|\bgit\s*=/.test(body) ? null : version });
|
|
122
|
+
};
|
|
123
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
124
|
+
const line = raw.replace(/\s+#.*$/, "").trim();
|
|
125
|
+
if (!line || line.startsWith("#")) continue;
|
|
126
|
+
if (line.startsWith("[")) {
|
|
127
|
+
table = line.match(DEPENDENCY_TABLE_HEADER);
|
|
128
|
+
if (table && table[1] === crate) { section = "table"; pins.push({ version: null, open: true }); continue; }
|
|
129
|
+
section = DEPENDENCY_HEADER.test(line) || WORKSPACE_DEPENDENCY.test(line) ? "deps" : null;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (section === "table") {
|
|
133
|
+
const open = pins.at(-1);
|
|
134
|
+
const version = line.match(/^version\s*=\s*"[=^~<>\s]*(\d+\.\d+\.\d+)/)?.[1];
|
|
135
|
+
if (version) open.version = version;
|
|
136
|
+
if (/^(path|git)\s*=/.test(line)) open.local = true;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (section === "deps") {
|
|
140
|
+
const match = line.match(new RegExp(`^${crate}(?:\\.[A-Za-z-]+)?\\s*=\\s*(.*)$`));
|
|
141
|
+
if (match) push(match[1]);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return pins.map(({ version, local }) => ({ version: local ? null : version }));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `@rightkit/qa` style declarations in one parsed package.json. */
|
|
148
|
+
export function npmQaPins(manifest, name) {
|
|
149
|
+
const pins = [];
|
|
150
|
+
for (const section of NPM_SECTIONS) {
|
|
151
|
+
const spec = manifest?.[section]?.[name];
|
|
152
|
+
if (typeof spec !== "string") continue;
|
|
153
|
+
pins.push({ version: spec.match(/^[\^~=]?(\d+\.\d+\.\d+)$/)?.[1] ?? null });
|
|
154
|
+
}
|
|
155
|
+
return pins;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Normalise a git remote to `host/owner/repo` lowercase, so ssh and https spellings of one repository agree. */
|
|
159
|
+
export function normalizeRepositoryIdentity(url) {
|
|
160
|
+
const text = String(url ?? "").trim().replace(/\.git$/i, "").replace(/\/+$/, "");
|
|
161
|
+
const ssh = text.match(/^(?:ssh:\/\/)?git@([^:/]+)[:/](.+)$/);
|
|
162
|
+
if (ssh) return `${ssh[1]}/${ssh[2]}`.toLowerCase();
|
|
163
|
+
return text.replace(/^[a-z+]+:\/\//i, "").replace(/^[^@/]+@/, "").toLowerCase();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function originRemote(root) {
|
|
167
|
+
const result = spawnSync("git", ["-C", root, "remote", "get-url", "origin"], { encoding: "utf8", windowsHide: true });
|
|
168
|
+
return result.status === 0 ? String(result.stdout).trim() : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function loadExemptions() {
|
|
172
|
+
try { return JSON.parse(readFileSync(new URL(`./${QA_EXEMPTIONS_FILE}`, import.meta.url), "utf8")); } catch { return { nonAppRepositories: [] }; }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The exemption for a repository that is not an app, or null. Matched by git `origin` identity against the
|
|
177
|
+
* RightKit-owned list; the RightKit source tree itself is recognised by its release package, with no list entry.
|
|
178
|
+
*/
|
|
179
|
+
export function qaExemption({ appRoot, repoRoot }, { exemptions = loadExemptions(), remote = originRemote, exists = existsSync, readFile = readFileSync } = {}) {
|
|
180
|
+
for (const root of [appRoot, repoRoot].filter(Boolean)) {
|
|
181
|
+
try {
|
|
182
|
+
const manifest = path.join(root, "packages", "release", "package.json");
|
|
183
|
+
if (exists(manifest) && JSON.parse(readFile(manifest, "utf8")).name === "@rightkit/release") return { kind: "tooling", reason: "the RightKit source tree is not an app" };
|
|
184
|
+
} catch { /* not the RightKit tree */ }
|
|
185
|
+
}
|
|
186
|
+
for (const entry of exemptions.nonAppRepositories ?? []) {
|
|
187
|
+
if (!EXEMPTION_KINDS.includes(entry?.kind) || !entry.reason || !entry.repositoryIdentity) continue;
|
|
188
|
+
for (const root of [appRoot, repoRoot].filter(Boolean)) {
|
|
189
|
+
const origin = remote(root);
|
|
190
|
+
if (origin && normalizeRepositoryIdentity(origin) === normalizeRepositoryIdentity(entry.repositoryIdentity)) return { kind: entry.kind, reason: entry.reason };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
/** Pure evaluation: { exempt, lane, refusals[], warnings[] }. */
|
|
198
|
+
export function evaluateQaAdoption({ appRoot, repoRoot, app }, deps = {}) {
|
|
199
|
+
const { readFile = readFileSync, exists = existsSync, versions = loadRightkitVersions() } = deps;
|
|
200
|
+
const exempt = qaExemption({ appRoot, repoRoot }, deps);
|
|
201
|
+
if (exempt) return { exempt, lane: null, refusals: [], warnings: [] };
|
|
202
|
+
const files = deps.manifests ?? qaManifests(appRoot, deps);
|
|
203
|
+
const pins = [];
|
|
204
|
+
const unreadable = [];
|
|
205
|
+
for (const file of files) {
|
|
206
|
+
let text;
|
|
207
|
+
try { text = readFile(path.join(appRoot, file), "utf8"); } catch (error) { unreadable.push(`${file}: ${error.message}`); continue; }
|
|
208
|
+
if (file.endsWith("package.json")) {
|
|
209
|
+
let manifest;
|
|
210
|
+
try { manifest = JSON.parse(text); } catch { unreadable.push(`${file}: not valid JSON`); continue; }
|
|
211
|
+
for (const name of [NPM_QA, NPM_QA_CHROME]) for (const pin of npmQaPins(manifest, name)) pins.push({ ecosystem: "npm", name, file, ...pin });
|
|
212
|
+
} else {
|
|
213
|
+
for (const pin of cargoQaPins(text)) pins.push({ ecosystem: "cargo", name: CARGO_QA, file, ...pin });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const label = app ?? "this app";
|
|
217
|
+
const adopted = [...new Set(pins.filter((pin) => pin.name === NPM_QA || pin.name === CARGO_QA).map((pin) => pin.name))];
|
|
218
|
+
const lane = exists(path.join(appRoot, "right-qa.config.mjs")) ? ["native wdio lane (right-qa.config.mjs)", ...adopted] : adopted;
|
|
219
|
+
const refusals = [];
|
|
220
|
+
const warnings = [];
|
|
221
|
+
if (lane.length === 0) {
|
|
222
|
+
refusals.push(`RightKit QA is mandatory for every Orthic Labs app, and ${label} has no QA lane: no right-qa.config.mjs, no ${NPM_QA} in a package.json, no ${CARGO_QA} in a Cargo.toml${unreadable.length ? ` (unreadable: ${unreadable.join("; ")})` : ""}. Adopt it: add ${NPM_QA} to devDependencies (plus ${NPM_QA_CHROME} for a web UI) and/or ${CARGO_QA} to Cargo.toml [dev-dependencies], add the rightkit-qa floor run (smoke.launch, journey), see docs/ADOPTION.md, then run: node rightkit-drift.mjs --apply. Only a repository that is not an app is exempt (${QA_EXEMPTIONS_FILE}); an app cannot opt out`);
|
|
223
|
+
}
|
|
224
|
+
const lagging = pins
|
|
225
|
+
.map((pin) => ({ ...pin, latest: pin.version && latestFor(versions, pin.ecosystem, pin.name) }))
|
|
226
|
+
.filter((pin) => pin.latest && newer(pin.latest, pin.version))
|
|
227
|
+
.map((pin) => ({ ...pin, breaking: breakingBump(pin.version, pin.latest) }));
|
|
228
|
+
const describe = (list) => list.map((pin) => `${pin.name} ${pin.version} -> ${pin.latest}${pin.breaking ? " (breaking)" : ""} (${pin.file})`).join("; ");
|
|
229
|
+
const breaking = lagging.filter((pin) => pin.breaking);
|
|
230
|
+
const soft = lagging.filter((pin) => !pin.breaking);
|
|
231
|
+
if (breaking.length) refusals.push(`${label} runs RightKit QA behind a breaking version: ${describe(breaking)}. Move the QA pins forward and adapt the QA setup: node rightkit-drift.mjs --apply --all`);
|
|
232
|
+
if (soft.length) warnings.push(`${label} runs RightKit QA behind the latest published version: ${describe(soft)}. Update with: node rightkit-drift.mjs --apply`);
|
|
233
|
+
return { exempt: null, lane, refusals, warnings };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Release gate: throws when the app has no QA lane or lags a breaking version; warnings go to `warn`. */
|
|
237
|
+
export function assertQaAdoption(opts, deps = {}) {
|
|
238
|
+
const { warn = (line) => console.error(line) } = deps;
|
|
239
|
+
const result = evaluateQaAdoption(opts, deps);
|
|
240
|
+
for (const line of result.warnings) warn(`[right-release] QA warning: ${line}`);
|
|
241
|
+
if (result.refusals.length) throw new Error(`QA adoption contract failed:\n- ${result.refusals.join("\n- ")}`);
|
|
242
|
+
return result;
|
|
243
|
+
}
|
package/qa-control-scan.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// (signing set, staged payloads, hardening targets, sealed files). The development lane (`development.mjs`)
|
|
7
7
|
// never calls it: dev builds are the QA builds. The source-level layer is `qa-build-features.mjs`.
|
|
8
8
|
//
|
|
9
|
-
// qa-control-server `rightkit-qa-driver` feature `qa-control
|
|
9
|
+
// qa-control-server `rightkit-qa` feature `control` (and the `rightkit-qa-driver` shim feature `qa-control`): every binary with the embedded server carries
|
|
10
10
|
// QA_CONTROL_MARKER (a `#[used]` static in `embedded/mod.rs`, also read by `Control::build`).
|
|
11
11
|
// wdio-webdriver `tauri-plugin-wdio-webdriver` (HeardRight/MailRight/ViewRight `qa-native`): needs 2 of 3
|
|
12
12
|
// independent signatures. WEAKER than the marker: the plugin source was not on disk when
|
|
@@ -20,7 +20,7 @@ import { closeSync, lstatSync, openSync, readFileSync, readSync, readdirSync } f
|
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import { inflateRawSync } from "node:zlib";
|
|
22
22
|
|
|
23
|
-
// Keep identical to `QA_CONTROL_MARKER` in crates/rightkit-qa
|
|
23
|
+
// Keep identical to `QA_CONTROL_MARKER` in crates/rightkit-qa/src/embedded/mod.rs.
|
|
24
24
|
export const QA_CONTROL_MARKER = "RIGHTKIT-QA-CONTROL-SERVER-PRESENT";
|
|
25
25
|
// Keep identical to `DEV_INSPECT_MARKER` in crates/rightkit-tauri-shell/src/devtools.rs (a `#[used]` static compiled only
|
|
26
26
|
// with rightkit-tauri-shell's `dev-inspect` feature and read by the process guard). No allow-list lifts this rule.
|
|
@@ -31,7 +31,7 @@ const CHUNK = 4 * 1024 * 1024;
|
|
|
31
31
|
// Each signature is matched as Latin-1/UTF-8 bytes and as UTF-16LE (Windows wide strings). `min` signatures of a
|
|
32
32
|
// rule must be present for it to fire; a signature counts once however often or in whichever encoding it occurs.
|
|
33
33
|
export const QA_RULES = Object.freeze([
|
|
34
|
-
{ id: "qa-control-server", min: 1, label: "RightKit QA control server (rightkit-qa-driver qa-control)",
|
|
34
|
+
{ id: "qa-control-server", min: 1, label: "RightKit QA control server (rightkit-qa control, or rightkit-qa-driver qa-control)",
|
|
35
35
|
signatures: [{ id: "marker", text: QA_CONTROL_MARKER }] },
|
|
36
36
|
{ id: "shell-dev-inspect", min: 1, marker: DEV_INSPECT_MARKER, label: "rightkit-tauri-shell developer inspection (rightkit-tauri-shell dev-inspect)",
|
|
37
37
|
signatures: [{ id: "marker", text: DEV_INSPECT_MARKER }] },
|
package/release-qa-command.mjs
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
//
|
|
8
8
|
// The release config declares the app's QA plan:
|
|
9
9
|
// qa: { version: "1", config: "rightkit-qa.toml", runner?: "<path to rightkit-qa>",
|
|
10
|
-
// args?: { source?: [..], installed?: [..] }, // {sealedDir} {releaseId} expand
|
|
10
|
+
// args?: { source?: [..], installed?: [..] }, // {sealedDir} {releaseId} expand; installed also {installedApp} {installedDir} {qaRoot}
|
|
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
|
|
11
12
|
// checks: { "smoke.launch": ["app.launch"], journey: ["journey.*"] } } // floor id -> evidence check names
|
|
12
13
|
// Every id in release-qa-floor.json must be mapped; skipped/failed/missing required checks fail the receipt.
|
|
13
14
|
import { createHash } from "node:crypto";
|
|
@@ -20,6 +21,7 @@ import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
|
|
|
20
21
|
import { assertCleanSource } from "./source-gate.mjs";
|
|
21
22
|
import { verifySealedRelease } from "./release-state.mjs";
|
|
22
23
|
import { auditMain } from "./release-qa-audit.mjs";
|
|
24
|
+
import { prepareInstalledArtifacts } from "./release-qa-installed.mjs";
|
|
23
25
|
import { darwinKeyFile, PROTECTED_SECRETS } from "./protected-credentials.mjs";
|
|
24
26
|
import {
|
|
25
27
|
buildQaReceipt, currentPolicyBundleDigest, dependencyClosure, ensureQaReceiptKey, evaluateRequiredChecks, loadQaFloor,
|
|
@@ -53,7 +55,7 @@ export function evidencePathFromOutput(stdout) {
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
/** Run the QA plan through the runner, then seal a receipt. `deps` are test seams; production uses the defaults. */
|
|
56
|
-
export async function runQa({ stage, platform, releaseIdArg, configPath, deps = {} }) {
|
|
58
|
+
export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo = null, deps = {} }) {
|
|
57
59
|
const gitRunner = deps.gitRunner ?? spawnSync;
|
|
58
60
|
const appRoot = path.dirname(configPath);
|
|
59
61
|
const repoRoot = String(gitRunner("git", ["-C", appRoot, "rev-parse", "--show-toplevel"], { encoding: "utf8", windowsHide: true }).stdout ?? "").trim();
|
|
@@ -86,12 +88,28 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, deps =
|
|
|
86
88
|
const runnerSha = sha256File(runnerFile);
|
|
87
89
|
const qaConfig = path.resolve(appRoot, config.qa.config);
|
|
88
90
|
if (!existsSync(qaConfig)) throw new Error(`qa.config does not exist: ${qaConfig}`);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
// #184: opt in with `qa.installed: { installDir? }`. Mounts the sealed DMG (mac) or runs the sealed NSIS installer into a
|
|
92
|
+
// throwaway directory (win) and hands the runner the opened app; always cleaned up, even when the runner throws.
|
|
93
|
+
let installedQa = null;
|
|
94
|
+
if (stage === "installed" && config.qa.installed && config.qa.installed !== false) {
|
|
95
|
+
installedQa = (deps.prepareInstalled ?? prepareInstalledArtifacts)({
|
|
96
|
+
sealedDir: sealed.sealedDir, manifest: sealed.manifest, platform,
|
|
97
|
+
installDir: typeof config.qa.installed === "object" ? config.qa.installed.installDir ?? null : null,
|
|
98
|
+
deps: { log: (event) => { if (!deps.quiet) console.error(`[qa] ${JSON.stringify(event)}`); } },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
let run;
|
|
102
|
+
try {
|
|
103
|
+
const expand = (value) => String(value).replaceAll("{sealedDir}", sealed?.sealedDir ?? "").replaceAll("{releaseId}", releaseId)
|
|
104
|
+
.replaceAll("{installedApp}", installedQa?.installedApp ?? "").replaceAll("{installedDir}", installedQa?.installedDir ?? "").replaceAll("{qaRoot}", installedQa?.qaRoot ?? "");
|
|
105
|
+
const args = ["run", "--config", qaConfig, ...((config.qa.args?.[stage] ?? []).map(expand))];
|
|
106
|
+
run = (deps.spawnRunner ?? spawnSync)(runnerFile, args, {
|
|
107
|
+
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 ?? {}) },
|
|
109
|
+
});
|
|
110
|
+
} finally {
|
|
111
|
+
installedQa?.cleanup();
|
|
112
|
+
}
|
|
95
113
|
if (!deps.quiet) { process.stdout.write(String(run.stdout ?? "")); process.stderr.write(String(run.stderr ?? "")); }
|
|
96
114
|
const evidenceFile = evidencePathFromOutput(run.stdout);
|
|
97
115
|
let evidence = null, evidenceSha256 = null;
|
|
@@ -112,31 +130,39 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, deps =
|
|
|
112
130
|
}), key);
|
|
113
131
|
const file = qaReceiptPath({ repoRoot, releaseId, platform, stage });
|
|
114
132
|
writeQaReceipt(file, receipt);
|
|
115
|
-
|
|
133
|
+
// #183: copy the sealed receipt next to the candidate handoff so the signing job (a different runner) can read it.
|
|
134
|
+
let carriedFile = null;
|
|
135
|
+
if (carryTo) {
|
|
136
|
+
carriedFile = path.join(path.resolve(carryTo), `${stage}.receipt.json`);
|
|
137
|
+
writeQaReceipt(carriedFile, receipt);
|
|
138
|
+
}
|
|
139
|
+
return { receipt, file, carriedFile };
|
|
116
140
|
}
|
|
117
141
|
|
|
118
142
|
function usage(code) {
|
|
119
|
-
console.log("usage: right-release qa --stage source|installed --platform mac|win [--release <sealed-id>] [--config right-release.config.mjs]\n right-release qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>]");
|
|
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>]");
|
|
120
144
|
process.exit(code);
|
|
121
145
|
}
|
|
122
146
|
|
|
123
147
|
export async function main(args) {
|
|
124
148
|
if (args[0] === "audit") process.exit(auditMain(args.slice(1)));
|
|
125
|
-
let stage = "", platform = process.platform === "win32" ? "win" : "mac", releaseId = "", configName = "right-release.config.mjs";
|
|
149
|
+
let stage = "", platform = process.platform === "win32" ? "win" : "mac", releaseId = "", configName = "right-release.config.mjs", carryTo = null;
|
|
126
150
|
for (let index = 0; index < args.length; index += 1) {
|
|
127
151
|
const arg = args[index];
|
|
128
152
|
if (arg === "--stage") stage = args[++index];
|
|
129
153
|
else if (arg === "--platform") platform = args[++index];
|
|
130
154
|
else if (arg === "--release") releaseId = args[++index];
|
|
131
155
|
else if (arg === "--config") configName = args[++index];
|
|
156
|
+
else if (arg === "--carry-to") carryTo = args[++index];
|
|
132
157
|
else if (arg === "-h" || arg === "--help") usage(0);
|
|
133
158
|
else { console.error(`right-release qa: unknown argument: ${arg}`); process.exit(2); }
|
|
134
159
|
}
|
|
135
160
|
if (!QA_STAGES.includes(stage)) { console.error("right-release qa: --stage must be source or installed"); process.exit(2); }
|
|
136
161
|
if (platform !== "win" && platform !== "mac") { console.error("right-release qa: --platform must be win or mac"); process.exit(2); }
|
|
137
162
|
try {
|
|
138
|
-
const { receipt, file } = await runQa({ stage, platform, releaseIdArg: releaseId, configPath: path.resolve(configName) });
|
|
163
|
+
const { receipt, file, carriedFile } = await runQa({ stage, platform, releaseIdArg: releaseId, configPath: path.resolve(configName), carryTo });
|
|
139
164
|
console.log(`right-release qa: ${stage} receipt ${receipt.status} ${receipt.digest.slice(0, 16)} -> ${file}`);
|
|
165
|
+
if (carriedFile) console.log(`right-release qa: carried copy -> ${carriedFile}`);
|
|
140
166
|
for (const item of receipt.required) console.log(` ${item.id}: ${item.status}`);
|
|
141
167
|
process.exit(receipt.status === "passed" ? 0 : 1);
|
|
142
168
|
} catch (error) {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Installed QA stage artifact preparation (Orthic-Labs/rightkit#184).
|
|
2
|
+
//
|
|
3
|
+
// `right-release qa --stage installed` used to hand the sealed directory to the QA runner and nothing more:
|
|
4
|
+
// install-app is Mac-only and nothing mounted or ran the sealed artifacts. This module opens the exact sealed
|
|
5
|
+
// bytes the receipt will name, without touching real user data:
|
|
6
|
+
//
|
|
7
|
+
// macOS mounts the sealed DMG read-only and not browsable (`hdiutil attach -readonly -nobrowse -noautoopen`)
|
|
8
|
+
// at a throwaway mount root under the OS temp directory, and exposes the .app inside the mount.
|
|
9
|
+
// Nothing is copied to /Applications and no user data directory is read or written.
|
|
10
|
+
// Windows runs the sealed NSIS installer silently (`/S /D=<throwaway dir>`; /D is the NSIS install-dir
|
|
11
|
+
// override and must be last) into a directory under the OS temp directory. Running an installer registers
|
|
12
|
+
// an uninstall entry for the current user, so it needs an explicit opt-in on a VM or throwaway host
|
|
13
|
+
// (`RIGHT_RELEASE_QA_ALLOW_INSTALL=1`) or GitHub-hosted CI. It is refused when an install directory
|
|
14
|
+
// outside the throwaway root is requested without that opt-in. Cleanup runs the installed uninstaller
|
|
15
|
+
// silently and removes the directory.
|
|
16
|
+
//
|
|
17
|
+
// Both paths also give the runner a throwaway QA data root (`RIGHT_RELEASE_QA_ROOT`) so the app under QA never
|
|
18
|
+
// has to use the real per-user data location. The caller owns `cleanup()`; it never throws.
|
|
19
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { spawnSync as defaultSpawnSync } from "./background-process.mjs";
|
|
23
|
+
import { detachWithRetrySync } from "./hdiutil-detach.mjs";
|
|
24
|
+
import { mountPointsFromAttach } from "./install-app.mjs";
|
|
25
|
+
|
|
26
|
+
export const QA_ALLOW_INSTALL_ENV = "RIGHT_RELEASE_QA_ALLOW_INSTALL";
|
|
27
|
+
|
|
28
|
+
export class InstalledQaError extends Error {
|
|
29
|
+
constructor(code, message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "InstalledQaError";
|
|
32
|
+
this.code = code;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const lower = (name) => String(name).toLowerCase();
|
|
37
|
+
|
|
38
|
+
/** The one sealed DMG (macOS) or NSIS setup executable (Windows). Anything else is ambiguous and refused. */
|
|
39
|
+
export function selectSealedInstaller({ files, platform }) {
|
|
40
|
+
const names = (files ?? []).map((file) => file.name);
|
|
41
|
+
const pick = platform === "win"
|
|
42
|
+
? names.filter((name) => lower(name).endsWith(".exe") && lower(name).includes("setup"))
|
|
43
|
+
: names.filter((name) => lower(name).endsWith(".dmg"));
|
|
44
|
+
if (pick.length !== 1) {
|
|
45
|
+
throw new InstalledQaError("INSTALLED_QA_ARTIFACT", `installed QA needs exactly one sealed ${platform === "win" ? "NSIS setup .exe" : ".dmg"}; found ${pick.length}${pick.length ? ` (${pick.join(", ")})` : ""}`);
|
|
46
|
+
}
|
|
47
|
+
return pick[0];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Windows installers write an uninstall entry: only on CI, or when the operator opted in on a VM / throwaway host. */
|
|
51
|
+
export function assertWindowsInstallAllowed(env = process.env) {
|
|
52
|
+
const ci = env.GITHUB_ACTIONS === "true" && env.RUNNER_ENVIRONMENT === "github-hosted";
|
|
53
|
+
if (ci || env[QA_ALLOW_INSTALL_ENV] === "1") return;
|
|
54
|
+
throw new InstalledQaError("INSTALLED_QA_INSTALL_NOT_ALLOWED", `running the sealed NSIS installer registers the app for the current user; run it on a VM or throwaway host and set ${QA_ALLOW_INSTALL_ENV}=1 (never on a machine holding real app data)`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const describe = (result) => `${result?.stderr ?? ""}${result?.stdout ?? ""}`.trim() || result?.error?.message || `exit ${result?.status}`;
|
|
58
|
+
|
|
59
|
+
function prepareMac({ sealedDir, name, tmpdir, spawn, log }) {
|
|
60
|
+
const dmg = path.join(sealedDir, name);
|
|
61
|
+
const mountRoot = mkdtempSync(path.join(tmpdir, "right-qa-dmg-"));
|
|
62
|
+
let mountPoint = null;
|
|
63
|
+
const cleanup = () => {
|
|
64
|
+
if (mountPoint) {
|
|
65
|
+
const out = detachWithRetrySync({ mount: mountPoint, log, detach: (args) => spawn("/usr/bin/hdiutil", args, { encoding: "utf8", windowsHide: true }) });
|
|
66
|
+
if (!out.ok) log({ event: "installed-qa-detach-failed", mount: mountPoint });
|
|
67
|
+
mountPoint = null;
|
|
68
|
+
}
|
|
69
|
+
try { rmSync(mountRoot, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
70
|
+
};
|
|
71
|
+
const attached = spawn("/usr/bin/hdiutil", ["attach", "-nobrowse", "-readonly", "-noautoopen", "-mountrandom", mountRoot, "-plist", dmg], { encoding: "utf8", windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
|
|
72
|
+
if (attached.status !== 0) { cleanup(); throw new InstalledQaError("INSTALLED_QA_MOUNT", `hdiutil attach failed for ${name}: ${describe(attached)}`); }
|
|
73
|
+
let mounts;
|
|
74
|
+
try { mounts = mountPointsFromAttach(attached.stdout); } catch (error) { cleanup(); throw new InstalledQaError("INSTALLED_QA_MOUNT", `cannot read hdiutil attach output for ${name}: ${error.message}`); }
|
|
75
|
+
if (!mounts.length) { cleanup(); throw new InstalledQaError("INSTALLED_QA_MOUNT", `${name} attached but exposed no mount point`); }
|
|
76
|
+
mountPoint = mounts[0];
|
|
77
|
+
const apps = [];
|
|
78
|
+
for (const mount of mounts) for (const entry of readdirSync(mount)) if (entry.endsWith(".app")) apps.push(path.join(mount, entry));
|
|
79
|
+
if (apps.length !== 1) { cleanup(); throw new InstalledQaError("INSTALLED_QA_APP", `expected exactly one .app in ${name}; found ${apps.length}`); }
|
|
80
|
+
log({ event: "installed-qa-mounted", dmg: name, mount: mountPoint, app: apps[0] });
|
|
81
|
+
return { kind: "dmg", installedApp: apps[0], installedDir: mountPoint, cleanup };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function prepareWindows({ sealedDir, name, tmpdir, spawn, log, env, installDir }) {
|
|
85
|
+
assertWindowsInstallAllowed(env);
|
|
86
|
+
const installer = path.join(sealedDir, name);
|
|
87
|
+
const root = mkdtempSync(path.join(tmpdir, "right-qa-nsis-"));
|
|
88
|
+
const target = installDir ?? path.join(root, "app");
|
|
89
|
+
if (!path.isAbsolute(target)) { rmSync(root, { recursive: true, force: true }); throw new InstalledQaError("INSTALLED_QA_INSTALL_DIR", `install directory must be absolute: ${target}`); }
|
|
90
|
+
if (installDir && !path.resolve(installDir).toLowerCase().startsWith(path.resolve(tmpdir).toLowerCase() + path.sep) && env[QA_ALLOW_INSTALL_ENV] !== "1") {
|
|
91
|
+
rmSync(root, { recursive: true, force: true });
|
|
92
|
+
throw new InstalledQaError("INSTALLED_QA_INSTALL_DIR", `install directory ${installDir} is outside the throwaway root and ${QA_ALLOW_INSTALL_ENV}=1 is not set`);
|
|
93
|
+
}
|
|
94
|
+
const cleanup = () => {
|
|
95
|
+
try {
|
|
96
|
+
const uninstaller = path.join(target, "uninstall.exe");
|
|
97
|
+
// `_?=<dir>` keeps the uninstaller in place and synchronous so the directory can be removed afterwards.
|
|
98
|
+
if (existsSync(uninstaller)) spawn(uninstaller, ["/S", `_?=${target}`], { encoding: "utf8", windowsHide: true, shell: false });
|
|
99
|
+
} catch { /* best effort */ }
|
|
100
|
+
try { rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
101
|
+
if (installDir) { try { rmSync(target, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
102
|
+
};
|
|
103
|
+
mkdirSync(target, { recursive: true });
|
|
104
|
+
// NSIS: /S silent, /D=<dir> install directory; /D must be the last argument, unquoted.
|
|
105
|
+
const result = spawn(installer, ["/S", `/D=${target}`], { encoding: "utf8", windowsHide: true, shell: false });
|
|
106
|
+
if (result.status !== 0) { cleanup(); throw new InstalledQaError("INSTALLED_QA_INSTALL", `sealed installer ${name} failed: ${describe(result)}`); }
|
|
107
|
+
const exes = existsSync(target) ? readdirSync(target).filter((entry) => lower(entry).endsWith(".exe") && lower(entry) !== "uninstall.exe") : [];
|
|
108
|
+
log({ event: "installed-qa-installed", installer: name, dir: target, executables: exes });
|
|
109
|
+
return { kind: "nsis", installedApp: exes.length === 1 ? path.join(target, exes[0]) : null, installedDir: target, cleanup };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Open the sealed installer for the runner. Returns `{ kind, installedApp, installedDir, qaRoot, env, cleanup }`.
|
|
114
|
+
* `env` is merged into the runner environment; `expand` placeholders are `{installedApp}`, `{installedDir}`, `{qaRoot}`.
|
|
115
|
+
*/
|
|
116
|
+
export function prepareInstalledArtifacts({ sealedDir, manifest, platform, installDir = null, deps = {} }) {
|
|
117
|
+
const { spawn = defaultSpawnSync, tmpdir = os.tmpdir(), env = process.env, log = () => {}, hostPlatform = process.platform } = deps;
|
|
118
|
+
if (platform === "mac" && hostPlatform !== "darwin") throw new InstalledQaError("INSTALLED_QA_HOST", "mounting the sealed DMG needs macOS");
|
|
119
|
+
if (platform === "win" && hostPlatform !== "win32") throw new InstalledQaError("INSTALLED_QA_HOST", "running the sealed NSIS installer needs Windows");
|
|
120
|
+
const name = selectSealedInstaller({ files: manifest.files, platform });
|
|
121
|
+
const opened = platform === "win"
|
|
122
|
+
? prepareWindows({ sealedDir, name, tmpdir, spawn, log, env, installDir })
|
|
123
|
+
: prepareMac({ sealedDir, name, tmpdir, spawn, log });
|
|
124
|
+
const qaRoot = mkdtempSync(path.join(tmpdir, "right-qa-root-"));
|
|
125
|
+
const cleanup = () => { opened.cleanup(); try { rmSync(qaRoot, { recursive: true, force: true }); } catch { /* best effort */ } };
|
|
126
|
+
return {
|
|
127
|
+
kind: opened.kind, installedApp: opened.installedApp, installedDir: opened.installedDir, qaRoot, cleanup,
|
|
128
|
+
env: {
|
|
129
|
+
RIGHT_RELEASE_QA_INSTALLED_KIND: opened.kind,
|
|
130
|
+
RIGHT_RELEASE_QA_INSTALLED_ARTIFACT: name,
|
|
131
|
+
RIGHT_RELEASE_QA_INSTALLED_DIR: opened.installedDir,
|
|
132
|
+
...(opened.installedApp ? { RIGHT_RELEASE_QA_INSTALLED_APP: opened.installedApp } : {}),
|
|
133
|
+
RIGHT_RELEASE_QA_ROOT: qaRoot,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
package/release-qa-receipt.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// key held in the protected credential store (Keychain / protected file / CI secret) — not app-writable JSON.
|
|
11
11
|
// The floor, this verifier, and the receipt schema are pinned in the protected policy bundle.
|
|
12
12
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
13
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { spawnSync } from "./background-process.mjs";
|
|
@@ -50,6 +50,22 @@ export function qaReceiptPath({ repoRoot, releaseId, platform, stage }) {
|
|
|
50
50
|
return path.join(repoRoot, ".right-release", "qa", releaseId, platformDirOf(platform), `${stage}.receipt.json`);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
export const QA_CARRIED_RECEIPT_ENV = "RIGHT_RELEASE_QA_RECEIPT";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Receipt carried from the job that ran source QA to the signing job (Orthic-Labs/rightkit#183).
|
|
57
|
+
* `RIGHT_RELEASE_QA_RECEIPT` names the receipt file, or a directory holding `source.receipt.json`.
|
|
58
|
+
* Returns the file path when it exists, else null. The carried file is verified exactly like a local one
|
|
59
|
+
* (MAC, commit, tree, lockfile closure, config, floor, bundle), so carrying adds no trust.
|
|
60
|
+
*/
|
|
61
|
+
export function carriedSourceReceiptPath(env = process.env) {
|
|
62
|
+
const value = String(env?.[QA_CARRIED_RECEIPT_ENV] ?? "").trim();
|
|
63
|
+
if (!value) return null;
|
|
64
|
+
const resolved = path.resolve(value);
|
|
65
|
+
const file = existsSync(resolved) && statSync(resolved).isDirectory() ? path.join(resolved, "source.receipt.json") : resolved;
|
|
66
|
+
return existsSync(file) ? file : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
53
69
|
export function loadQaFloor(dir = here) {
|
|
54
70
|
const file = path.join(dir, QA_FLOOR_FILE);
|
|
55
71
|
let floor;
|
|
@@ -247,10 +263,15 @@ function loadKeyOrRefuse(opts, command) {
|
|
|
247
263
|
*/
|
|
248
264
|
export function assertSourceQaAdmission({ repoRoot, releaseId, platform, configName, releaseConfigSha256, ...opts }) {
|
|
249
265
|
const command = qaCommandFor({ stage: "source", platform, releaseId, configName });
|
|
250
|
-
|
|
266
|
+
// A receipt carried from the QA job (RIGHT_RELEASE_QA_RECEIPT) wins over the local path; the signing job has no local one.
|
|
267
|
+
const carried = carriedSourceReceiptPath(opts.env ?? process.env);
|
|
268
|
+
const receipt = readQaReceipt(carried ?? qaReceiptPath({ repoRoot, releaseId, platform, stage: "source" }));
|
|
251
269
|
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
270
|
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
271
|
if (!verdict.ok) throw new QaAdmissionError(`source QA receipt rejected: ${verdict.reason}`, { command });
|
|
272
|
+
if (carried && (receipt.releaseId !== releaseId || receipt.platform !== platform)) {
|
|
273
|
+
throw new QaAdmissionError(`source QA receipt rejected: carried receipt is for ${receipt.releaseId} (${receipt.platform}), not ${releaseId} (${platform})`, { command });
|
|
274
|
+
}
|
|
254
275
|
return { digest: receipt.digest, floorDigest: receipt.floor.digest };
|
|
255
276
|
}
|
|
256
277
|
|