@rightkit/release 0.6.5 → 0.6.6
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 +79 -7
- package/android-play-upload.mjs +4 -4
- package/app-check.mjs +474 -17
- package/apple-profile-create.mjs +4 -3
- package/apple-profile-renew.mjs +5 -4
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +431 -44
- package/binary-license-notices.mjs +2 -1
- package/build-release.mjs +7 -6
- package/cache-command.mjs +4 -3
- package/cargo-license-notices.mjs +2 -1
- package/cargo-manifest-text.mjs +25 -0
- package/cargo-profile-contract.mjs +26 -13
- package/cli/right-release.mjs +8 -1
- package/command-spec.mjs +4 -5
- package/deps.mjs +5 -4
- package/deterministic-scans.mjs +280 -32
- package/development.mjs +5 -5
- package/github-release.mjs +5 -3
- package/github-upload-gate.mjs +6 -6
- package/init-command.mjs +134 -8
- package/ios-upload.mjs +4 -4
- package/mirror-root-artifact.mjs +3 -4
- package/npm-license-notices.mjs +2 -1
- package/outdated-command.mjs +91 -0
- package/package.json +37 -1
- package/pipeline-admission.mjs +12 -0
- package/publish-cargo.mjs +215 -3
- package/publish-update.mjs +5 -3
- package/release-config.mjs +36 -5
- package/release-dependencies-command.mjs +4 -3
- package/release-qa-audit.mjs +12 -10
- package/release-qa-command.mjs +42 -7
- package/release-qa-hardening.mjs +10 -3
- package/release-qa-policy.mjs +214 -47
- package/release.mjs +4 -4
- package/rightkit-registry-resolver.mjs +5 -2
- package/rightkit-versions.json +11 -8
- package/stage-sidecar-command.mjs +5 -5
- package/test-command.mjs +3 -2
- package/upload-release.mjs +3 -2
- package/web-build.mjs +2 -1
package/publish-cargo.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { enforcePublicationGate, defaultEvidencePath, provenGateSteps, singleHostOfPolicy, batchClippyMissingFeatureSets } from "./publication-gate.mjs";
|
|
6
7
|
import { runCargoSync } from "./managed-cargo.mjs";
|
|
@@ -12,6 +13,7 @@ import { recordPublished, workspaceFeedOptions } from "./rightkit-sync.mjs";
|
|
|
12
13
|
import { readCargoPackage } from "./crate-graph.mjs";
|
|
13
14
|
import { runtimeBudgetCheck, RUNTIME_BUDGET_CODE } from "./runtime-budgets.mjs";
|
|
14
15
|
import { formatPreflight, PREFLIGHT_FAIL } from "./preflight.mjs";
|
|
16
|
+
import { cargoManifestState } from "./cargo-manifest-text.mjs";
|
|
15
17
|
|
|
16
18
|
const FORBIDDEN_PACKAGE_PATH = /(^|\/)(?:\.env(?:\.[^/]*)?|target(?:\/.*)?|node_modules(?:\/.*)?|[^/]+\.(?:p8|p12|pem|key))$/i;
|
|
17
19
|
const SECRET_PATTERNS = [
|
|
@@ -139,7 +141,202 @@ export function packageFileViolations(files) {
|
|
|
139
141
|
.filter((file) => FORBIDDEN_PACKAGE_PATH.test(file));
|
|
140
142
|
}
|
|
141
143
|
|
|
142
|
-
|
|
144
|
+
// ---- the secret scan ---------------------------------------------------------------------------------------------------
|
|
145
|
+
//
|
|
146
|
+
// Strict (the default): a crate package, its archive and the local-publish batch scan. `scanCrateForSecrets(dir)` returns the
|
|
147
|
+
// relative paths that are refused by name (FORBIDDEN_PACKAGE_PATH, which includes every .env* file, so env templates too) or whose
|
|
148
|
+
// content matches SECRET_PATTERNS. Nothing published needs a template or a test vector, and the records file below is never read.
|
|
149
|
+
//
|
|
150
|
+
// Repository gate (`{ envTemplates: true }`, deterministic-scans.mjs "secret-scan"): the units of a commit that is not being
|
|
151
|
+
// published. An app legitimately commits env templates (`.env.example`) and test vectors for its own redaction and screening code,
|
|
152
|
+
// and no content heuristic tells a placeholder from a pasted credential, so a file is admitted by a reviewed record, not by a guess.
|
|
153
|
+
// `.rightkit/secret-scan-records.json` in the repository:
|
|
154
|
+
// { "schema": 1, "records": [ { "path": "<repo-relative>", "sha256": "<hex of the bytes, CRLF read as LF>", "kind": "env-template" | "fixture", "note": "<what it holds and why it is safe>" } ] }
|
|
155
|
+
// - an env template (.env.example, .env.<name>.example, .env.sample, .env.template) is admitted only by a record of kind
|
|
156
|
+
// "env-template" whose sha256 is the file's: any edit needs a new record, which is the review, in the pull request diff;
|
|
157
|
+
// - any other file whose content matches SECRET_PATTERNS is admitted only by a record of kind "fixture" with the file's sha256;
|
|
158
|
+
// - never recordable: .env and every .env.* that is not a template, *.pem, *.p8, *.p12, *.key (refused by name, and a record naming
|
|
159
|
+
// one is itself a violation); a template that is not UTF-8 (NUL bytes, a UTF-16 mark), that holds a PEM private key header or one
|
|
160
|
+
// of the other high-precision credential shapes below, or that sits inside a publishable crate or package is refused even when recorded;
|
|
161
|
+
// - a malformed records file is a violation and admits nothing.
|
|
162
|
+
// Each violation is `<path>[:<line>]: <reason>`; a hit names the line and the shape, never the matched text.
|
|
163
|
+
export const SECRET_SCAN_RECORDS_FILE = ".rightkit/secret-scan-records.json";
|
|
164
|
+
const RECORD_KINDS = Object.freeze(["env-template", "fixture"]);
|
|
165
|
+
const RECORD_KEYS = Object.freeze(["path", "sha256", "kind", "note"]);
|
|
166
|
+
const RECORD_NOTE_PLACEHOLDER = "<what it holds and why it is safe>";
|
|
167
|
+
const ENV_TEMPLATE = /(^|\/)\.env(?:\.[^/]*)?\.(?:example|sample|template)$/i;
|
|
168
|
+
// Labels of SECRET_PATTERNS, index for index.
|
|
169
|
+
const SECRET_PATTERN_LABELS = Object.freeze(["a PEM private key header", "an AWS access key id (AKIA)", "a GitHub token (gh*_)", "an npm token (npm_)"]);
|
|
170
|
+
// Credential shapes that are never a placeholder, matched in env templates besides SECRET_PATTERNS. Every expression is linear:
|
|
171
|
+
// each quantified class is followed by a delimiter it cannot match or by the end of the match.
|
|
172
|
+
const TEMPLATE_SHAPES = Object.freeze([
|
|
173
|
+
[/-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----/, "a PEM private key header"],
|
|
174
|
+
[/github_pat_[A-Za-z0-9_]{22,}/, "a GitHub fine-grained token (github_pat_)"],
|
|
175
|
+
[/sk_live_[A-Za-z0-9]{16,}/, "a Stripe live key (sk_live_)"],
|
|
176
|
+
[/xox[abprs]-[A-Za-z0-9-]{10,}/, "a Slack token (xox*-)"],
|
|
177
|
+
[/AIza[0-9A-Za-z_-]{35}/, "a Google API key (AIza)"],
|
|
178
|
+
[/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/, "a Slack webhook URL"],
|
|
179
|
+
]);
|
|
180
|
+
const FILE_SHAPES = Object.freeze(SECRET_PATTERNS.map((pattern, index) => [pattern, SECRET_PATTERN_LABELS[index]]));
|
|
181
|
+
const TEMPLATE_ALL_SHAPES = Object.freeze([...FILE_SHAPES, ...TEMPLATE_SHAPES]);
|
|
182
|
+
const UTF8 = new TextDecoder("utf-8", { fatal: true });
|
|
183
|
+
|
|
184
|
+
const neverRecordable = (relative) => FORBIDDEN_PACKAGE_PATH.test(relative) && !ENV_TEMPLATE.test(relative);
|
|
185
|
+
const sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
186
|
+
// A record's digest reads CRLF as LF, so one record holds on every host: `git archive` (the gate's export) writes CRLF on a
|
|
187
|
+
// Windows host with `core.autocrlf=true` or `core.eol=crlf` for `text=auto` files and LF elsewhere. The content checks still read
|
|
188
|
+
// the bytes as exported; a lone CR is kept, so no other byte is ever ignored.
|
|
189
|
+
const recordDigest = (bytes) => sha256Hex(Buffer.from(Buffer.from(bytes).toString("latin1").replace(/\r\n/g, "\n"), "latin1"));
|
|
190
|
+
const recordLine = (relative, sha256, kind) => JSON.stringify({ path: relative, sha256, kind, note: RECORD_NOTE_PLACEHOLDER });
|
|
191
|
+
const repoRelativePath = (p) => p !== "" && p !== "." && !p.includes("\\") && !p.includes("\0") && !p.startsWith("/") && !/^[A-Za-z]:/.test(p) && !p.endsWith("/")
|
|
192
|
+
&& path.posix.normalize(p) === p && !p.split("/").includes("..");
|
|
193
|
+
|
|
194
|
+
/** `{ records: Map<path, { sha256, kind, note }>, problems: string[] }` from the text of the records file (undefined: no file). A record with a problem admits nothing. */
|
|
195
|
+
export function parseSecretScanRecords(text) {
|
|
196
|
+
const records = new Map();
|
|
197
|
+
const problems = [];
|
|
198
|
+
const bad = (message) => problems.push(`${SECRET_SCAN_RECORDS_FILE}: ${message}`);
|
|
199
|
+
if (text === undefined) return { records, problems };
|
|
200
|
+
let doc;
|
|
201
|
+
try { doc = JSON.parse(String(text).replace(/^/, "")); } catch (error) { bad(`is not valid JSON (${error.message})`); return { records, problems }; }
|
|
202
|
+
if (doc === null || typeof doc !== "object" || Array.isArray(doc)) { bad('must be an object { "schema": 1, "records": [...] }'); return { records, problems }; }
|
|
203
|
+
for (const key of Object.keys(doc)) if (key !== "schema" && key !== "records") bad(`has an unknown key ${JSON.stringify(key)}`);
|
|
204
|
+
if (doc.schema !== 1) { bad(`schema must be 1 (found ${JSON.stringify(doc.schema)}); no record is read`); return { records, problems }; }
|
|
205
|
+
if (!Array.isArray(doc.records)) { bad('"records" must be an array'); return { records, problems }; }
|
|
206
|
+
const counts = new Map();
|
|
207
|
+
const valid = [];
|
|
208
|
+
doc.records.forEach((record, index) => {
|
|
209
|
+
if (record === null || typeof record !== "object" || Array.isArray(record)) { bad(`record ${index} must be an object`); return; }
|
|
210
|
+
const label = typeof record.path === "string" ? `record ${index} (${JSON.stringify(record.path)})` : `record ${index}`;
|
|
211
|
+
let ok = true;
|
|
212
|
+
const fail = (message) => { bad(`${label}: ${message}`); ok = false; };
|
|
213
|
+
for (const key of Object.keys(record)) if (!RECORD_KEYS.includes(key)) fail(`has an unknown key ${JSON.stringify(key)}`);
|
|
214
|
+
if (typeof record.path !== "string" || !repoRelativePath(record.path)) fail('"path" must be a normalised repository-relative path with forward slashes');
|
|
215
|
+
if (typeof record.sha256 !== "string" || !/^[0-9a-f]{64}$/i.test(record.sha256)) fail('"sha256" must be 64 hex characters, the sha256 of the file bytes with CRLF read as LF');
|
|
216
|
+
if (!RECORD_KINDS.includes(record.kind)) fail(`"kind" must be one of ${RECORD_KINDS.join(", ")}`);
|
|
217
|
+
if (typeof record.note !== "string" || record.note.trim() === "" || record.note.trim() === RECORD_NOTE_PLACEHOLDER) fail('"note" must say what the file holds and why it is safe');
|
|
218
|
+
if (typeof record.path === "string") counts.set(record.path, (counts.get(record.path) ?? 0) + 1);
|
|
219
|
+
if (!ok) return;
|
|
220
|
+
if (neverRecordable(record.path)) fail("names a path that can never be recorded (.env and every .env.* that is not a template, *.pem, *.p8, *.p12, *.key)");
|
|
221
|
+
else if (record.kind === "env-template" && !ENV_TEMPLATE.test(record.path)) fail('kind "env-template" names a path that is not an env template (.env.example, .env.<name>.example, .env.sample, .env.template)');
|
|
222
|
+
else if (record.kind === "fixture" && ENV_TEMPLATE.test(record.path)) fail('kind "fixture" cannot admit an env template; record it as "env-template"');
|
|
223
|
+
else valid.push(record);
|
|
224
|
+
});
|
|
225
|
+
for (const [recorded, count] of counts) if (count > 1) bad(`${JSON.stringify(recorded)} is recorded ${count} times; keep one record per path`);
|
|
226
|
+
for (const record of valid) if (counts.get(record.path) === 1) records.set(record.path, { sha256: record.sha256.toLowerCase(), kind: record.kind, note: record.note });
|
|
227
|
+
return { records, problems };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// The records file of the repository being scanned, read as data: a regular file whose real path stays inside the repository.
|
|
231
|
+
function readSecretScanRecords(repoRoot) {
|
|
232
|
+
const file = path.join(repoRoot, SECRET_SCAN_RECORDS_FILE);
|
|
233
|
+
try {
|
|
234
|
+
if (!fs.lstatSync(file).isFile()) return { problems: [`${SECRET_SCAN_RECORDS_FILE}: is not a regular file`] };
|
|
235
|
+
const relative = path.relative(fs.realpathSync(repoRoot), fs.realpathSync(file));
|
|
236
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return { problems: [`${SECRET_SCAN_RECORDS_FILE}: resolves outside the repository`] };
|
|
237
|
+
return parseSecretScanRecords(fs.readFileSync(file, "utf8"));
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return parseSecretScanRecords(undefined);
|
|
240
|
+
return { problems: [`${SECRET_SCAN_RECORDS_FILE}: cannot be read (${error?.code ?? error?.message})`] };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Why a file is not UTF-8 text (NUL bytes, a UTF-16 byte-order mark, an invalid sequence), or null. */
|
|
245
|
+
function notUtf8(bytes) {
|
|
246
|
+
if (bytes.length >= 2 && ((bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff))) return "starts with a UTF-16 byte-order mark";
|
|
247
|
+
if (bytes.includes(0)) return "contains NUL bytes";
|
|
248
|
+
try { UTF8.decode(bytes); } catch { return "is not valid UTF-8"; }
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* `[{ label, lines }]` for the shapes that match `text`: at most the first five matching lines of each, found in one pass.
|
|
254
|
+
* After a hit the search resumes on the next line, so the work is linear in the text however many matches a line holds.
|
|
255
|
+
*/
|
|
256
|
+
function contentHits(text, shapes) {
|
|
257
|
+
const hits = new Map();
|
|
258
|
+
for (const [shape, label] of shapes) {
|
|
259
|
+
const pattern = new RegExp(shape.source, `${shape.flags.replace("g", "")}g`);
|
|
260
|
+
const lines = hits.get(label) ?? [];
|
|
261
|
+
let line = 1;
|
|
262
|
+
let newline = text.indexOf("\n");
|
|
263
|
+
for (let match = pattern.exec(text); match !== null && lines.length < 5; match = pattern.exec(text)) {
|
|
264
|
+
while (newline !== -1 && newline < match.index) { line += 1; newline = text.indexOf("\n", newline + 1); }
|
|
265
|
+
if (!lines.includes(line)) lines.push(line);
|
|
266
|
+
pattern.lastIndex = newline === -1 ? text.length : newline + 1;
|
|
267
|
+
}
|
|
268
|
+
if (lines.length) hits.set(label, lines);
|
|
269
|
+
}
|
|
270
|
+
const found = [...hits].map(([label, lines]) => ({ label, lines: [...lines].sort((a, b) => a - b).slice(0, 5) }));
|
|
271
|
+
return found.sort((a, b) => a.lines[0] - b.lines[0] || (a.label < b.label ? -1 : 1));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const hitsText = (hits) => hits.map((hit) => `${hit.label} (line${hit.lines.length > 1 ? "s" : ""} ${hit.lines.join(", ")})`).join("; ");
|
|
275
|
+
const firstLine = (hits) => Math.min(...hits.flatMap((hit) => hit.lines));
|
|
276
|
+
|
|
277
|
+
// The publishable crate or package that holds `file`, or null: the nearest Cargo.toml and the nearest package.json at or above
|
|
278
|
+
// it, up to the repository root. A crate is publishable when its [package] does not say publish = false / []; a package when its
|
|
279
|
+
// package.json is not "private": true. A manifest that cannot be read counts as publishable.
|
|
280
|
+
function publishableOwner(file, repoRoot) {
|
|
281
|
+
const root = path.resolve(repoRoot);
|
|
282
|
+
const shown = (dir) => path.relative(root, dir).replaceAll("\\", "/") || ".";
|
|
283
|
+
let cargoDecided = false;
|
|
284
|
+
let npmDecided = false;
|
|
285
|
+
for (let dir = path.dirname(path.resolve(file)); ; dir = path.dirname(dir)) {
|
|
286
|
+
const outside = path.relative(root, dir);
|
|
287
|
+
if (outside === ".." || outside.startsWith(`..${path.sep}`) || path.isAbsolute(outside)) return null;
|
|
288
|
+
if (!cargoDecided && fs.existsSync(path.join(dir, "Cargo.toml"))) {
|
|
289
|
+
cargoDecided = true;
|
|
290
|
+
let state = null;
|
|
291
|
+
try { state = cargoManifestState(fs.readFileSync(path.join(dir, "Cargo.toml"), "utf8")); } catch { /* unreadable: publishable */ }
|
|
292
|
+
if (state === null || (state.hasPackage && !state.unpublished)) return `the publishable crate at ${shown(dir)} (its Cargo.toml [package] does not say publish = false)`;
|
|
293
|
+
}
|
|
294
|
+
if (!npmDecided && fs.existsSync(path.join(dir, "package.json"))) {
|
|
295
|
+
npmDecided = true;
|
|
296
|
+
let manifest = null;
|
|
297
|
+
try { manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8").replace(/^/, "")); } catch { /* unreadable: publishable */ }
|
|
298
|
+
if (manifest?.private !== true) return `the publishable package at ${shown(dir)} (its package.json is not "private": true)`;
|
|
299
|
+
}
|
|
300
|
+
if ((cargoDecided && npmDecided) || dir === root || path.dirname(dir) === dir) return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function envTemplateViolations(relative, file, bytes, records, repoRoot) {
|
|
305
|
+
const owner = publishableOwner(file, repoRoot);
|
|
306
|
+
const encoding = notUtf8(bytes);
|
|
307
|
+
const hits = encoding === null ? contentHits(bytes.toString("utf8"), TEMPLATE_ALL_SHAPES) : [];
|
|
308
|
+
const refused = [];
|
|
309
|
+
if (owner) refused.push(`${relative}: env template inside ${owner}; a publishable unit never carries one (refused by name, as at publish time and in the archive scan). Move it out of the unit, or mark the unit unpublished`);
|
|
310
|
+
if (encoding) refused.push(`${relative}: env template ${encoding}, so it cannot be read as UTF-8 text; refused even when recorded. Save it as UTF-8`);
|
|
311
|
+
if (hits.length) refused.push(`${relative}:${firstLine(hits)}: env template matches ${hitsText(hits)}; refused even when recorded. Replace the value with a placeholder`);
|
|
312
|
+
if (refused.length) return refused;
|
|
313
|
+
const sha256 = recordDigest(bytes);
|
|
314
|
+
const record = records.get(relative);
|
|
315
|
+
if (record?.kind === "env-template" && record.sha256 === sha256) return [];
|
|
316
|
+
const line = recordLine(relative, sha256, "env-template");
|
|
317
|
+
if (record?.kind === "env-template") return [`${relative}: env template changed since it was recorded (recorded sha256 ${record.sha256}, now ${sha256}); after reviewing the change, replace its record in ${SECRET_SCAN_RECORDS_FILE} with: ${line}`];
|
|
318
|
+
return [`${relative}: env template (sha256 ${sha256}) is not recorded in ${SECRET_SCAN_RECORDS_FILE}; after reviewing that it holds only names and placeholders, add this record: ${line}`];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function fileViolations(relative, bytes, records) {
|
|
322
|
+
const hits = contentHits(bytes.toString("utf8"), FILE_SHAPES);
|
|
323
|
+
if (!hits.length) return [];
|
|
324
|
+
const sha256 = recordDigest(bytes);
|
|
325
|
+
const record = records.get(relative);
|
|
326
|
+
if (record?.kind === "fixture" && record.sha256 === sha256) return [];
|
|
327
|
+
const line = recordLine(relative, sha256, "fixture");
|
|
328
|
+
const where = `${relative}:${firstLine(hits)}: content matches ${hitsText(hits)}`;
|
|
329
|
+
if (record?.kind === "fixture") return [`${where}; its fixture record is out of date (recorded sha256 ${record.sha256}, now ${sha256}). After reviewing the change, replace its record in ${SECRET_SCAN_RECORDS_FILE} with: ${line}`];
|
|
330
|
+
return [`${where}; sha256 ${sha256}. If this is a redaction or screening test vector and not a credential, review it and add this record to ${SECRET_SCAN_RECORDS_FILE}: ${line}`];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Files under `crateDir` that must not be committed or packaged. Strict by default: bare relative paths. With `envTemplates: true`
|
|
335
|
+
* (the repository gate) the reviewed records of `<repoRoot>/.rightkit/secret-scan-records.json` apply and each violation is a
|
|
336
|
+
* `<repo-relative path>[:<line>]: <reason>` line; `repoRoot` (default `crateDir`) is the root those paths and the records are relative to.
|
|
337
|
+
*/
|
|
338
|
+
export function scanCrateForSecrets(crateDir, { envTemplates = false, repoRoot = crateDir } = {}) {
|
|
339
|
+
if (envTemplates) return scanRepositoryForSecrets(crateDir, repoRoot);
|
|
143
340
|
const violations = [];
|
|
144
341
|
for (const file of walkFiles(crateDir)) {
|
|
145
342
|
const relative = path.relative(crateDir, file).replaceAll("\\", "/");
|
|
@@ -147,13 +344,28 @@ export function scanCrateForSecrets(crateDir) {
|
|
|
147
344
|
violations.push(relative);
|
|
148
345
|
continue;
|
|
149
346
|
}
|
|
150
|
-
const
|
|
151
|
-
const text = bytes.toString("utf8");
|
|
347
|
+
const text = fs.readFileSync(file).toString("utf8");
|
|
152
348
|
if (SECRET_PATTERNS.some((pattern) => pattern.test(text))) violations.push(relative);
|
|
153
349
|
}
|
|
154
350
|
return [...new Set(violations)].sort();
|
|
155
351
|
}
|
|
156
352
|
|
|
353
|
+
function scanRepositoryForSecrets(dir, repoRoot) {
|
|
354
|
+
const { records = new Map(), problems = [] } = readSecretScanRecords(repoRoot);
|
|
355
|
+
const violations = [...problems];
|
|
356
|
+
for (const file of walkFiles(dir)) {
|
|
357
|
+
const inDir = path.relative(dir, file).replaceAll("\\", "/");
|
|
358
|
+
const relative = path.relative(repoRoot, file).replaceAll("\\", "/");
|
|
359
|
+
if (neverRecordable(inDir)) {
|
|
360
|
+
violations.push(`${relative}: refused by name (an .env file, a key or certificate file, or build output); it is never committed and cannot be recorded`);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const bytes = fs.readFileSync(file);
|
|
364
|
+
violations.push(...(ENV_TEMPLATE.test(inDir) ? envTemplateViolations(relative, file, bytes, records, repoRoot) : fileViolations(relative, bytes, records)));
|
|
365
|
+
}
|
|
366
|
+
return [...new Set(violations)].sort();
|
|
367
|
+
}
|
|
368
|
+
|
|
157
369
|
/**
|
|
158
370
|
* Package, qualify and (unless `options.dryRun`) publish one crate. A real publish is refused (HOST_UPLOAD_REFUSED) before anything
|
|
159
371
|
* runs unless `options.capability` is a publish capability (host-upload-refusal.mjs createPublishCapability) created from a
|
package/publish-update.mjs
CHANGED
|
@@ -3,11 +3,12 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { spawnSync } from "./background-process.mjs";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import { fileURLToPath
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { pruneReleaseObjects } from "./prune-r2.mjs";
|
|
8
8
|
import { loadReleaseToken } from "./release-token.mjs";
|
|
9
9
|
import { registerRightAppsRelease } from "./rightapps-register.mjs";
|
|
10
10
|
import { recordPublishedVersion } from "./artifact-storage.mjs";
|
|
11
|
+
import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
|
|
11
12
|
import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
|
|
12
13
|
import { assertCleanSource } from "./source-gate.mjs";
|
|
13
14
|
import { verifyPublicationRelease, PublicationError, assertArtifactUnchanged, digestBytes } from "./publication-artifacts.mjs";
|
|
@@ -24,7 +25,7 @@ const PUBLIC_BASE = process.env.RIGHTAPPS_PUBLIC_DOWNLOAD_BASE || RIGHT_SUITE_PU
|
|
|
24
25
|
const PIPELINE_VERSION = JSON.parse(await readFile(path.join(TOOL_ROOT, "package.json"), "utf8")).version;
|
|
25
26
|
let tier = process.env.RIGHT_RELEASE_TIER || "";
|
|
26
27
|
const args = process.argv.slice(2);
|
|
27
|
-
let configName =
|
|
28
|
+
let configName = defaultReleaseConfigFile();
|
|
28
29
|
let platform = process.platform === "win32" ? "win" : "mac";
|
|
29
30
|
let dryRun = false;
|
|
30
31
|
let releaseId = "";
|
|
@@ -45,7 +46,8 @@ if (tier !== "patch" && tier !== "update") fail("tier is required (--tier patch|
|
|
|
45
46
|
if (!["win", "mac"].includes(platform)) throw new PublicationError("PUBLICATION_PLATFORM_INVALID", "--platform must be win or mac");
|
|
46
47
|
const configPath = path.resolve(configName);
|
|
47
48
|
const root = path.dirname(configPath);
|
|
48
|
-
|
|
49
|
+
let config;
|
|
50
|
+
try { config = await importReleaseConfig(configPath); } catch (error) { fail(`release config could not be loaded (${configPath}): ${error instanceof Error ? error.message : String(error)}`); }
|
|
49
51
|
const provider = config?.distribution?.provider;
|
|
50
52
|
if (provider !== "r2-rightapps" && provider !== "github-releases") {
|
|
51
53
|
fail("release config distribution.provider must be r2-rightapps or github-releases");
|
package/release-config.mjs
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// The release config contract (M24): `right-release.config.json`, a finite, versioned JSON schema read by dev, build, doctor, install
|
|
2
|
-
// and upload alike.
|
|
3
|
-
//
|
|
2
|
+
// and upload alike. A `.json` config is data: it is never imported or evaluated. `loadReleaseConfigSync` judges the file NAME before
|
|
3
|
+
// anything is opened (an executable name handed to it is refused with RELEASE_CONFIG_LEGACY_EXECUTABLE) and loads the file strictly.
|
|
4
|
+
//
|
|
5
|
+
// Legacy branch: an app that has not moved yet owns only a `right-release.config.mjs`. `defaultReleaseConfigFile` keeps naming it while
|
|
6
|
+
// no `right-release.config.json` sits beside it, and `importReleaseConfig` still imports that module (executable, outside M24; the
|
|
7
|
+
// pipeline scanner reports it as R0-legacy-config). The sunset of the legacy branch is tracked separately. As soon as the JSON file
|
|
8
|
+
// exists it wins by default and an explicit `--config x.json` always takes the strict loader; the module is never read for it.
|
|
4
9
|
//
|
|
5
10
|
// Load order (build, dev, upload): resolve the primary checkout and the config path first, then `loadReleaseConfigSync` parses the JSON
|
|
6
11
|
// strictly (no comments, no trailing commas, no duplicate keys), checks `schema: 1` and the finite top-level key set, expands the
|
|
@@ -12,12 +17,15 @@
|
|
|
12
17
|
// { "$fromJson": { "file": "src-tauri/tauri.conf.json", "pointer": "/version" } } a string read from an app-relative JSON file
|
|
13
18
|
// { "$fromToml": { "file": "src-tauri/Cargo.toml", "path": "package.version" } } a string read from an app-relative TOML file
|
|
14
19
|
// { "$json": <value> } JSON.stringify(<value>), for a JSON argument
|
|
15
|
-
// "{{app}}" "{{version}}" "{{home}}" "{{localAppData}}" replaced inside any string value
|
|
20
|
+
// "{{app}}" "{{version}}" "{{home}}" "{{localAppData}}" replaced inside any string value, `$comment`
|
|
21
|
+
// included (write no token in a comment). localAppData is
|
|
22
|
+
// %LOCALAPPDATA% on Windows and <home>/AppData/Local elsewhere
|
|
16
23
|
// Anything else with braces is left alone, and an unknown {{token}} is an error.
|
|
17
24
|
import { createHash } from "node:crypto";
|
|
18
25
|
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
19
26
|
import os from "node:os";
|
|
20
27
|
import path from "node:path";
|
|
28
|
+
import { pathToFileURL } from "node:url";
|
|
21
29
|
import { parse as parseToml } from "smol-toml";
|
|
22
30
|
import { collectConfigCommands, formatFindings, validateConfigPipeline } from "./pipeline-config.mjs";
|
|
23
31
|
import { loadGeneratorRegistry } from "./pipeline-generators.mjs";
|
|
@@ -198,7 +206,7 @@ function expand(node, context, label) {
|
|
|
198
206
|
return node.replace(/\{\{([A-Za-z][A-Za-z0-9]*)\}\}/g, (_, token) => {
|
|
199
207
|
if (!TEMPLATE_TOKENS.includes(token)) fail("RELEASE_CONFIG_TEMPLATE", `${label}: unknown template token {{${token}}} (allowed: ${TEMPLATE_TOKENS.map((t) => `{{${t}}}`).join(", ")})`);
|
|
200
208
|
const resolved = context.values[token];
|
|
201
|
-
if (typeof resolved !== "string" || resolved === "") fail("RELEASE_CONFIG_TEMPLATE", `${label}: {{${token}}} is not available${token === "
|
|
209
|
+
if (typeof resolved !== "string" || resolved === "") fail("RELEASE_CONFIG_TEMPLATE", `${label}: {{${token}}} is not available${token === "version" ? " here (version cannot refer to itself)" : ""}`);
|
|
202
210
|
return resolved;
|
|
203
211
|
});
|
|
204
212
|
}
|
|
@@ -313,7 +321,9 @@ export function loadReleaseConfigSync(file, {
|
|
|
313
321
|
const raw = parsed.value;
|
|
314
322
|
validateReleaseConfigShape(raw, { file: configPath, phase: "raw" });
|
|
315
323
|
const inputs = new Map();
|
|
316
|
-
|
|
324
|
+
// {{localAppData}} is %LOCALAPPDATA% on Windows. Elsewhere it is the Windows profile default under the home directory (a path
|
|
325
|
+
// no other host uses), so one config that names a Windows install path loads on every host instead of failing on the Mac.
|
|
326
|
+
const base = { appRoot, inputs, readBytes, lstat, values: { home: homedir(), localAppData: env.LOCALAPPDATA || path.join(homedir(), "AppData", "Local"), app: "", version: "" } };
|
|
317
327
|
base.values.app = expand(raw.app, { ...base }, "/app");
|
|
318
328
|
if (raw.version !== undefined) base.values.version = expand(raw.version, { ...base, values: { ...base.values, version: "" } }, "/version");
|
|
319
329
|
const config = expand(raw, base, "");
|
|
@@ -344,6 +354,27 @@ export async function loadReleaseConfig(file, options) {
|
|
|
344
354
|
return loadReleaseConfigSync(file, options).config;
|
|
345
355
|
}
|
|
346
356
|
|
|
357
|
+
/** True for a `.json` config path: the only extension the strict loader accepts. */
|
|
358
|
+
export const isJsonReleaseConfig = (file) => typeof file === "string" && path.extname(file).toLowerCase() === ".json";
|
|
359
|
+
|
|
360
|
+
/** The config name a command defaults to: `right-release.config.json` when `dir` holds it, else the legacy module an unmoved app still owns. */
|
|
361
|
+
export function defaultReleaseConfigFile(dir = process.cwd(), { exists = existsSync } = {}) {
|
|
362
|
+
return exists(path.join(dir, RELEASE_CONFIG_FILE)) ? RELEASE_CONFIG_FILE : LEGACY_RELEASE_CONFIG_FILE;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Load the config at `file` for a command. A `.json` path goes through the strict, M24-validated loader (throws ReleaseConfigError); any
|
|
367
|
+
* other path is the legacy branch and is imported as before. Legacy-only options: `query` is the cache-busting query a site appends
|
|
368
|
+
* (`upload=<ts>`, `commit=<sha>`) so a module re-evaluates per run, and `lenient` keeps the `default ?? config ?? module` fallback the
|
|
369
|
+
* app-root commands accept; every other option goes to `loadReleaseConfigSync`.
|
|
370
|
+
*/
|
|
371
|
+
export async function importReleaseConfig(file, { query, lenient = false, ...loadOptions } = {}) {
|
|
372
|
+
const absolute = path.resolve(file);
|
|
373
|
+
if (isJsonReleaseConfig(absolute)) return loadReleaseConfigSync(absolute, loadOptions).config;
|
|
374
|
+
const loaded = await import(`${pathToFileURL(absolute).href}${query ? `?${query}` : ""}`);
|
|
375
|
+
return lenient ? loaded.default ?? loaded.config ?? loaded : loaded.default;
|
|
376
|
+
}
|
|
377
|
+
|
|
347
378
|
/** `{ config, appRoot, configPath }` for the commands that need the app root beside the config. */
|
|
348
379
|
export async function loadAppConfigFile(file = RELEASE_CONFIG_FILE, options) {
|
|
349
380
|
const loaded = loadReleaseConfigSync(file, options);
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { fileURLToPath
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
|
|
4
5
|
import { assertReleaseDependencies } from "./release-dependencies.mjs";
|
|
5
6
|
import { nodeHostTriple } from "./stage-sidecar-command.mjs";
|
|
6
7
|
import { expandPackageRecipeTarget } from "./package-recipe-targets.mjs";
|
|
7
8
|
|
|
8
9
|
/** Standalone policy check: host target by default, all declared release targets on request. */
|
|
9
10
|
export async function main(argv, { cwd = process.cwd(), platform = process.platform, arch = process.arch, run } = {}) {
|
|
10
|
-
let configPath =
|
|
11
|
+
let configPath = defaultReleaseConfigFile(cwd), selected, allTargets = false;
|
|
11
12
|
for (let i = 0; i < argv.length; i += 1) {
|
|
12
13
|
const arg = argv[i];
|
|
13
14
|
if (["--config", "--platform"].includes(arg)) {
|
|
@@ -20,7 +21,7 @@ export async function main(argv, { cwd = process.cwd(), platform = process.platf
|
|
|
20
21
|
if (allTargets && selected) throw new Error("use --platform or --all-targets");
|
|
21
22
|
const file = path.resolve(cwd, configPath);
|
|
22
23
|
const root = path.dirname(file);
|
|
23
|
-
const config =
|
|
24
|
+
const config = await importReleaseConfig(file);
|
|
24
25
|
if (config?.releaseDependencies === undefined) throw new Error("check-release-dependencies requires releaseDependencies in config");
|
|
25
26
|
const host = { darwin: "mac", win32: "win", linux: "linux" }[platform];
|
|
26
27
|
const platforms = allTargets ? Object.keys(config.targets ?? {}) : [selected ?? host];
|
package/release-qa-audit.mjs
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// QA admission plus the ad hoc `right-release qa audit` scan.
|
|
2
2
|
//
|
|
3
3
|
// Per-app policy (qa-admission-policy.json, release-qa-policy.mjs): `warn` is the default, `refuse` is switched on
|
|
4
|
-
// per app once the app has earned it
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
4
|
+
// per app once the app has earned it, and separately per stage: the build (source) admission and the publication
|
|
5
|
+
// (installed) admission each read their own mode. The gates in release-qa-receipt.mjs verify receipts, and the
|
|
6
|
+
// hardening in release-qa-hardening.mjs re-checks platform, release id, approved runner digest (for that platform),
|
|
7
|
+
// every release-critical journey and the signing/test separation. In warn mode a refusal becomes a loud stderr
|
|
8
|
+
// warning and a durable record (the sealed manifest `qa` field for the source stage,
|
|
9
|
+
// `state/<releaseId>/<platformDir>/qa-installed.json` for the installed stage). In refuse mode the same refusal
|
|
10
|
+
// throws a QaAdmissionError and nothing is sealed or published. `qa audit` reads the records so a weekly run can
|
|
11
|
+
// flag released builds that lack passing QA.
|
|
10
12
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
13
|
import path from "node:path";
|
|
12
14
|
import { spawnSync } from "./background-process.mjs";
|
|
@@ -42,10 +44,10 @@ function admit(check, { warn = (line) => console.error(line), mode = { mode: "wa
|
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
function policyContext(opts, hooks = {}) {
|
|
47
|
+
function policyContext(opts, hooks = {}, stage) {
|
|
46
48
|
const loaded = hooks.policy ?? loadAdmissionPolicy({ dir: hooks.policyDir, env: opts.env });
|
|
47
49
|
const app = opts.app ?? opts.manifest?.app ?? appOfReleaseId(opts.releaseId ?? opts.manifest?.releaseId);
|
|
48
|
-
return { loaded, app, mode: admissionModeFor(loaded.policy, app) };
|
|
50
|
+
return { loaded, app, mode: admissionModeFor(loaded.policy, app, stage) };
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
function hardening(stage, opts, loaded, hooks) {
|
|
@@ -58,7 +60,7 @@ function hardening(stage, opts, loaded, hooks) {
|
|
|
58
60
|
|
|
59
61
|
/** Build stage. Returns the `qa` block sealed into the release manifest. */
|
|
60
62
|
export function admitSourceQa(opts, hooks = {}) {
|
|
61
|
-
const { loaded, app, mode } = policyContext(opts, hooks);
|
|
63
|
+
const { loaded, app, mode } = policyContext(opts, hooks, "source");
|
|
62
64
|
const outcome = admit(() => assertSourceQaAdmission({ ...opts, afterVerify: hardening("source", opts, loaded, hooks) }), { ...hooks, mode, app });
|
|
63
65
|
if (outcome.status === "passed") return { status: "passed", sourceReceiptDigest: outcome.result.digest, floorDigest: outcome.result.floorDigest };
|
|
64
66
|
return { status: outcome.status, reason: outcome.reason };
|
|
@@ -69,7 +71,7 @@ export const installedQaRecordPath = ({ repoRoot, releaseId, platform }) =>
|
|
|
69
71
|
|
|
70
72
|
/** Publication stage (upload, publish-update, github). Records the outcome durably; throws in `refuse` mode. */
|
|
71
73
|
export function admitInstalledQa(opts, { provider, now = () => new Date(), ...hooks } = {}) {
|
|
72
|
-
const { loaded, app, mode } = policyContext(opts, hooks);
|
|
74
|
+
const { loaded, app, mode } = policyContext(opts, hooks, "installed");
|
|
73
75
|
const outcome = admit(() => assertInstalledQaAdmission({ ...opts, afterVerify: hardening("installed", opts, loaded, { ...hooks, now }) }), { ...hooks, mode, app });
|
|
74
76
|
const record = {
|
|
75
77
|
status: outcome.status,
|
package/release-qa-command.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
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.
|
|
5
|
-
// right-release qa --stage installed --platform mac|win --release <sealed-id> [--config right-release.config.
|
|
4
|
+
// right-release qa --stage source --platform mac|win [--config right-release.config.json]
|
|
5
|
+
// right-release qa --stage installed --platform mac|win --release <sealed-id> [--config right-release.config.json]
|
|
6
6
|
// 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 source|installed]
|
|
8
|
+
// | approve-runner --runner <file> --version <v> --platform mac|win --set-by <name> [--note <text>] (release-qa-policy.mjs)
|
|
7
9
|
//
|
|
8
10
|
// The release config declares the app's QA plan:
|
|
9
11
|
// qa: { version: "1", config: "rightkit-qa.toml", runner?: "<path to rightkit-qa>",
|
|
@@ -20,14 +22,16 @@ import { createHash } from "node:crypto";
|
|
|
20
22
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
21
23
|
import os from "node:os";
|
|
22
24
|
import path from "node:path";
|
|
23
|
-
import {
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
24
26
|
import { spawnSync } from "./background-process.mjs";
|
|
27
|
+
import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
|
|
25
28
|
import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
|
|
26
29
|
import { assertCleanSource } from "./source-gate.mjs";
|
|
27
30
|
import { verifySealedRelease } from "./release-state.mjs";
|
|
28
31
|
import { auditMain } from "./release-qa-audit.mjs";
|
|
29
32
|
import { journeyRequiredItem, QA_RUN_ENV, relativePosix, validateJourneysReceipt } from "./release-qa-hardening.mjs";
|
|
30
33
|
import { prepareInstalledArtifacts } from "./release-qa-installed.mjs";
|
|
34
|
+
import { main as policyMain } from "./release-qa-policy.mjs";
|
|
31
35
|
import { darwinKeyFile, PROTECTED_SECRETS } from "./protected-credentials.mjs";
|
|
32
36
|
import {
|
|
33
37
|
buildQaReceipt, currentPolicyBundleDigest, dependencyClosure, ensureQaReceiptKey, evaluateRequiredChecks, loadQaFloor,
|
|
@@ -55,6 +59,32 @@ function resolveRunner({ qa, appRoot, platform = process.platform, runner = spaw
|
|
|
55
59
|
return realpathSync(file);
|
|
56
60
|
}
|
|
57
61
|
|
|
62
|
+
// Windows installs land in `%LOCALAPPDATA%\<Product>`; the runner learns the product folder names from
|
|
63
|
+
// RIGHTKIT_QA_INSTALLED_DIRS. Compared as strings with both separators so the answer never depends on the host OS.
|
|
64
|
+
const WIN_ABSOLUTE = /^(?:[A-Za-z]:[\\/]|\\\\)/;
|
|
65
|
+
const pathParts = (value) => String(value).split(/[\\/]+/).filter(Boolean);
|
|
66
|
+
const productDirOk = (name) => name.length <= 255 && ![".", ".."].includes(name) && !/[\u0000-\u001f\u007f;:]/.test(name);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Product folder names under %LOCALAPPDATA% that hold the app's installed copy, from the release config's
|
|
70
|
+
* `development.targets.win.install.expectedInstalledPath` (the one place a config names the installed path; the
|
|
71
|
+
* desktop-app-template sets it to `<LOCALAPPDATA>\<Product>\<exe>`). Only an absolute path inside LOCALAPPDATA
|
|
72
|
+
* counts (case-insensitive, either separator); the name is the first component below it and a file must sit under it.
|
|
73
|
+
* `Programs` is already a runner root, so it is dropped. Returns an array of distinct names (today at most one: the
|
|
74
|
+
* config declares one installed path).
|
|
75
|
+
*/
|
|
76
|
+
export function installedDirNames(config, env = process.env) {
|
|
77
|
+
const localAppData = Object.entries(env ?? {}).find(([key]) => key.toLowerCase() === "localappdata")?.[1];
|
|
78
|
+
const installed = config?.development?.targets?.win?.install?.expectedInstalledPath;
|
|
79
|
+
if (typeof localAppData !== "string" || !WIN_ABSOLUTE.test(localAppData) || typeof installed !== "string" || !WIN_ABSOLUTE.test(installed)) return [];
|
|
80
|
+
const root = pathParts(localAppData).map((part) => part.toLowerCase());
|
|
81
|
+
const parts = pathParts(installed);
|
|
82
|
+
if (parts.some((part) => part === "." || part === "..")) return [];
|
|
83
|
+
if (parts.length < root.length + 2 || !root.every((part, index) => parts[index].toLowerCase() === part)) return [];
|
|
84
|
+
const name = parts[root.length];
|
|
85
|
+
return name.toLowerCase() === "programs" || !productDirOk(name) ? [] : [name];
|
|
86
|
+
}
|
|
87
|
+
|
|
58
88
|
/** `rightkit-qa journeys run` prints `run <id> (<run dir>)` first; the receipt is `<run dir>/receipt.json`. */
|
|
59
89
|
export function journeysReceiptFromOutput(stdout) {
|
|
60
90
|
const dir = String(stdout ?? "").match(/^run \S+ \((.+)\)\s*$/m)?.[1];
|
|
@@ -77,7 +107,7 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
|
|
|
77
107
|
if (!deps.skipPrimaryCheckout) assertPrimaryReleaseCheckout(repoRoot);
|
|
78
108
|
assertCleanSource({ status: String(gitRunner("git", ["-C", repoRoot, "status", "--porcelain=v1", "-z", "--untracked-files=all"], { encoding: "utf8", windowsHide: true }).stdout ?? ""), commandId: "right-release qa" });
|
|
79
109
|
const source = sourceIdentity(repoRoot, gitRunner);
|
|
80
|
-
const config =
|
|
110
|
+
const config = await importReleaseConfig(configPath, { query: `qa=${source.commit}` });
|
|
81
111
|
if (!config?.app || !config?.version) throw new Error("release config must expose app and version");
|
|
82
112
|
const { floor, digest: floorDigest } = loadQaFloor(deps.floorDir);
|
|
83
113
|
const problems = validateAppQaConfig(config.qa, floor, stage);
|
|
@@ -117,7 +147,11 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
|
|
|
117
147
|
try {
|
|
118
148
|
const expand = (value) => String(value).replaceAll("{sealedDir}", sealed?.sealedDir ?? "").replaceAll("{releaseId}", releaseId)
|
|
119
149
|
.replaceAll("{installedApp}", installedQa?.installedApp ?? "").replaceAll("{installedDir}", installedQa?.installedDir ?? "").replaceAll("{qaRoot}", installedQa?.qaRoot ?? "");
|
|
120
|
-
const
|
|
150
|
+
const installedDirs = installedDirNames(config, deps.env ?? process.env);
|
|
151
|
+
const runEnv = {
|
|
152
|
+
...process.env, RIGHT_RELEASE_QA_STAGE: stage, RIGHT_RELEASE_QA_COMMIT: source.commit, RIGHT_RELEASE_QA_RELEASE_ID: releaseId,
|
|
153
|
+
...(sealed ? { RIGHT_RELEASE_QA_SEALED_DIR: sealed.sealedDir } : {}), ...(installedDirs.length ? { RIGHTKIT_QA_INSTALLED_DIRS: installedDirs.join(";") } : {}), ...(installedQa?.env ?? {}),
|
|
154
|
+
};
|
|
121
155
|
const args = ["run", "--config", qaConfig, ...((config.qa.args?.[stage] ?? []).map(expand))];
|
|
122
156
|
run = (deps.spawnRunner ?? spawnSync)(runnerFile, args, {
|
|
123
157
|
cwd: appRoot, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024,
|
|
@@ -181,13 +215,14 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo
|
|
|
181
215
|
}
|
|
182
216
|
|
|
183
217
|
function usage(code) {
|
|
184
|
-
console.log("usage: right-release qa --stage source|installed --platform mac|win [--release <sealed-id>] [--config right-release.config.
|
|
218
|
+
console.log("usage: right-release qa --stage source|installed --platform mac|win [--release <sealed-id>] [--config right-release.config.json] [--carry-to <dir>]\n (seals the QA receipt; admission is warn or refuse per app in qa-admission-policy.json; refused inside a QA run)\n right-release qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>]\n right-release qa policy show | set --app <app> --mode warn|refuse --set-by <name> [--stage source|installed]\n | approve-runner --runner <file> --version <v> --platform mac|win --set-by <name> [--note <text>]");
|
|
185
219
|
process.exit(code);
|
|
186
220
|
}
|
|
187
221
|
|
|
188
222
|
export async function main(args) {
|
|
189
223
|
if (args[0] === "audit") process.exit(auditMain(args.slice(1)));
|
|
190
|
-
|
|
224
|
+
if (args[0] === "policy") process.exit(policyMain(args.slice(1), { program: "right-release qa policy" }));
|
|
225
|
+
let stage = "", platform = process.platform === "win32" ? "win" : "mac", releaseId = "", configName = defaultReleaseConfigFile(), carryTo = null;
|
|
191
226
|
for (let index = 0; index < args.length; index += 1) {
|
|
192
227
|
const arg = args[index];
|
|
193
228
|
if (arg === "--stage") stage = args[++index];
|
package/release-qa-hardening.mjs
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// passed", not "one matching check passed".
|
|
8
8
|
// admit side `hardenQaReceipt` re-checks the sealed receipt at admission, independently of what the run side
|
|
9
9
|
// claimed: platform and release id, the approved runner digest, the critical journey set recomputed
|
|
10
|
-
// from the manifest committed at the receipt's commit, and the signing/test-execution separation.
|
|
10
|
+
// from the manifest committed at the receipt's commit, and the signing/test-execution separation. The
|
|
11
|
+
// runner digest must be approved for the platform being sealed or published (qa-admission-policy.json).
|
|
11
12
|
//
|
|
12
13
|
// Signing outside test execution, what is checked here: the receipt records the pids of the test processes it
|
|
13
14
|
// spawned and the window in which they ran; admission refuses a receipt whose window does not end before the
|
|
@@ -119,10 +120,16 @@ export function hardenQaReceipt({
|
|
|
119
120
|
if (receipt.platform !== platform) refuse(`receipt is for platform ${receipt.platform}, this is ${platform}`);
|
|
120
121
|
if (receipt.releaseId !== releaseId) refuse(`receipt is for release ${receipt.releaseId}, this is ${releaseId}`);
|
|
121
122
|
|
|
122
|
-
// 2. The qualifying runner is pinned by an approved digest
|
|
123
|
+
// 2. The qualifying runner is pinned by an approved digest, approved for this platform (an entry with no platform
|
|
124
|
+
// approves every platform).
|
|
123
125
|
const approved = approvedRunnerDigests(policy);
|
|
124
126
|
if (!approved.length) refuse("no rightkit-qa runner digest is approved in qa-admission-policy.json");
|
|
125
|
-
|
|
127
|
+
const digest = receipt.runner?.sha256;
|
|
128
|
+
if (!approved.includes(digest)) refuse(`rightkit-qa runner sha256 ${String(digest).slice(0, 16)} is not an approved runner`);
|
|
129
|
+
const entries = policy.approvedRunners.filter((runner) => runner.sha256 === digest);
|
|
130
|
+
if (!entries.some((runner) => !runner.platform || runner.platform === platform)) {
|
|
131
|
+
refuse(`rightkit-qa runner sha256 ${digest.slice(0, 16)} is approved only for ${[...new Set(entries.map((runner) => runner.platform))].sort().join(", ")}, this is ${platform}`);
|
|
132
|
+
}
|
|
126
133
|
|
|
127
134
|
// 3. The journey floor id means every release-critical journey required on this platform passed.
|
|
128
135
|
const mapping = floor?.journeys;
|