@rightkit/release 0.6.5 → 0.6.7
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 +196 -11
- package/admission-files.mjs +3 -2
- package/advisory-db.json +4 -0
- package/advisory-db.mjs +124 -0
- package/advisory-exceptions.mjs +13 -10
- package/android-play-upload.mjs +4 -4
- package/app-check.mjs +702 -26
- package/apple-profile-create.mjs +4 -3
- package/apple-profile-renew.mjs +5 -4
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-agent-hook.mjs +9 -0
- package/bin/rightkit-gate.mjs +671 -133
- package/binary-license-notices.mjs +2 -1
- package/build-release.mjs +20 -12
- 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 +37 -7
- package/command-spec.mjs +4 -5
- package/compile-inputs.mjs +66 -0
- package/consumer-contracts.json +1044 -0
- package/consumer-contracts.mjs +627 -0
- package/consumer-input-contracts.mjs +422 -0
- package/consumer-policy.mjs +588 -0
- package/consumer-source-contracts.mjs +469 -0
- package/deny-default.toml +42 -0
- package/deps.mjs +5 -4
- package/deterministic-scans.mjs +513 -54
- package/development.mjs +5 -5
- package/disk-command.mjs +270 -30
- package/disk-policy.json +50 -10
- package/disk-policy.mjs +482 -35
- package/disk-report.mjs +129 -0
- package/door-policy.mjs +228 -0
- package/gate-executor.mjs +30 -0
- package/gate-stages.mjs +110 -0
- package/github-release.mjs +5 -3
- package/github-updater-release.mjs +48 -8
- package/github-upload-gate.mjs +6 -6
- package/init-command.mjs +175 -8
- package/ios-upload.mjs +4 -4
- package/legion-semantic-contracts.mjs +428 -0
- package/mirror-root-artifact.mjs +3 -4
- package/npm-license-notices.mjs +2 -1
- package/outdated-command.mjs +91 -0
- package/ownership-class-rules.mjs +277 -0
- package/ownership-key-authority.mjs +3 -1
- package/ownership-mandatory-set.mjs +3 -2
- package/package.json +46 -3
- package/pipeline-admission.mjs +12 -0
- package/publish-cargo.mjs +219 -4
- package/publish-update.mjs +19 -14
- package/release-config.mjs +87 -11
- 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 +178 -170
- package/shell-contract-bridge.json +251 -0
- package/shell-contract.mjs +1069 -0
- package/sign-updater.mjs +24 -4
- package/source-closure-rules.mjs +204 -0
- package/stage-sidecar-command.mjs +7 -6
- package/stage-sidecar.mjs +437 -114
- package/suite-api.generated.mjs +96 -6
- package/suite-trust-roots.mjs +198 -0
- 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,9 +13,10 @@ 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
|
-
const SECRET_PATTERNS = [
|
|
19
|
+
export const SECRET_PATTERNS = [
|
|
18
20
|
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
|
|
19
21
|
/\bAKIA[0-9A-Z]{16}\b/,
|
|
20
22
|
/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/,
|
|
@@ -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,31 @@ 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
|
+
// A file that vanished between the walk and the read was a tool's temporary file (pnpm writes `_tmp_<pid>_*` next to a
|
|
364
|
+
// lockfile it reads), never committed content.
|
|
365
|
+
let bytes;
|
|
366
|
+
try { bytes = fs.readFileSync(file); } catch (e) { if (e.code === "ENOENT") continue; throw e; }
|
|
367
|
+
violations.push(...(ENV_TEMPLATE.test(inDir) ? envTemplateViolations(relative, file, bytes, records, repoRoot) : fileViolations(relative, bytes, records)));
|
|
368
|
+
}
|
|
369
|
+
return [...new Set(violations)].sort();
|
|
370
|
+
}
|
|
371
|
+
|
|
157
372
|
/**
|
|
158
373
|
* Package, qualify and (unless `options.dryRun`) publish one crate. A real publish is refused (HOST_UPLOAD_REFUSED) before anything
|
|
159
374
|
* 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";
|
|
@@ -15,7 +16,7 @@ import { uploadVerifiedR2Artifact } from "./r2-verified-upload.mjs";
|
|
|
15
16
|
import { verifyPlatformTrust } from "./github-release.mjs";
|
|
16
17
|
import { admitInstalledQa } from "./release-qa-audit.mjs";
|
|
17
18
|
import { RIGHT_SUITE_API_ORIGIN, RIGHT_SUITE_GATED_ARTIFACT_HOST, RIGHT_SUITE_PUBLIC_INSTALLER_BASE } from "./suite-api.generated.mjs";
|
|
18
|
-
import { assertAssetName, assertGitHubRepository, assertUpdaterKeyId, assertUpdaterPlatform, assertUpdaterSignature, buildLatestJson, githubAssetUrl, githubTag, publishGitHubUpdaterRelease } from "./github-updater-release.mjs";
|
|
19
|
+
import { assertAssetName, assertGitHubRepository, assertUpdaterKeyId, assertUpdaterPlatform, assertUpdaterSignature, buildLatestJson, githubAssetUrl, githubTag, publishGitHubUpdaterRelease, resolveUpdaterTrust } from "./github-updater-release.mjs";
|
|
19
20
|
|
|
20
21
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
21
22
|
const API_BASE = process.env.RIGHTAPPS_API_URL || RIGHT_SUITE_API_ORIGIN;
|
|
@@ -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");
|
|
@@ -117,18 +119,21 @@ function git(args) {
|
|
|
117
119
|
if (!githubLane && tier === "patch" && !installers.length) fail(`${config?.app ?? "app"} ${platform} patch has no installer.artifacts`);
|
|
118
120
|
if ((githubLane || tier === "update") && !artifacts.length) fail(`${config?.app ?? "app"} ${platform} update has no updater.artifacts`);
|
|
119
121
|
|
|
120
|
-
// Cryptographic gate before any upload: every sealed minisign signature must verify over the
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
// (suite key already pinned per-app as tauri plugins.updater.pubkey).
|
|
122
|
+
// Cryptographic gate before any upload: every sealed minisign signature must verify over the exact final artifact
|
|
123
|
+
// bytes against the updater trust root. A Right Suite app takes that key, and its key id, from the Right Suite contract
|
|
124
|
+
// (a declared key must be the contract's) and is always pinned to the id; any other app must declare
|
|
125
|
+
// distribution.updaterPublicKey, and without one a sealed (real) publish fails closed.
|
|
125
126
|
const updaterPublicKey = config.distribution?.updaterPublicKey ?? config.distribution?.updaterPubkey ?? target?.updater?.pubkey ?? target?.updater?.publicKey;
|
|
127
|
+
const trust = sealed && artifacts.length && !appInstallerKind
|
|
128
|
+
? resolveUpdaterTrust({ app: config.app, configuredPublicKey: updaterPublicKey, configuredKeyId: config.distribution.updaterKeyId })
|
|
129
|
+
: null;
|
|
126
130
|
if (sealed) for (const artifact of artifacts) {
|
|
127
131
|
const signature = signatureFor(artifact);
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
assertUpdaterKeyId(signature,
|
|
132
|
+
if (trust) {
|
|
133
|
+
const label = `signature of ${path.basename(artifact.file)}`;
|
|
134
|
+
// The id comes first: a signature by another key would otherwise fail verification without saying whose it is.
|
|
135
|
+
assertUpdaterKeyId(signature, trust.keyId, label, { app: config.app });
|
|
136
|
+
assertUpdaterSignature(assertArtifactUnchanged(verifiedArtifact(artifact.file)), signature, trust.publicKey, label);
|
|
132
137
|
}
|
|
133
138
|
}
|
|
134
139
|
let releaseToken = null;
|
|
@@ -154,7 +159,7 @@ if (githubLane) {
|
|
|
154
159
|
const name = assertAssetName(path.basename(file));
|
|
155
160
|
// Same signature source and process as the r2 lane: the sealed minisign .sig written by sign-updater.
|
|
156
161
|
const signature = dryRun ? `<signature:${artifact.signature ?? "msix-authenticode"}>` : signatureFor(artifact);
|
|
157
|
-
if (!dryRun && !appInstallerKind) assertUpdaterKeyId(signature,
|
|
162
|
+
if (!dryRun && !appInstallerKind) assertUpdaterKeyId(signature, trust.keyId, `signature of ${name}`, { app: config.app });
|
|
158
163
|
platforms[artifact.platform] = { signature, url: githubAssetUrl(githubRepo, tag, name) };
|
|
159
164
|
assets.set(file, { file, evidence: dryRun ? undefined : verifiedArtifact(file) });
|
|
160
165
|
if (!appInstallerKind) {
|
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,15 +17,19 @@
|
|
|
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";
|
|
32
|
+
import { RIGHT_SUITE_APP_KEYS, RIGHT_SUITE_UPDATER_PUBLIC_KEYS } from "./suite-api.generated.mjs";
|
|
24
33
|
|
|
25
34
|
export const RELEASE_CONFIG_FILE = "right-release.config.json";
|
|
26
35
|
export const LEGACY_RELEASE_CONFIG_FILE = "right-release.config.mjs";
|
|
@@ -198,7 +207,7 @@ function expand(node, context, label) {
|
|
|
198
207
|
return node.replace(/\{\{([A-Za-z][A-Za-z0-9]*)\}\}/g, (_, token) => {
|
|
199
208
|
if (!TEMPLATE_TOKENS.includes(token)) fail("RELEASE_CONFIG_TEMPLATE", `${label}: unknown template token {{${token}}} (allowed: ${TEMPLATE_TOKENS.map((t) => `{{${t}}}`).join(", ")})`);
|
|
200
209
|
const resolved = context.values[token];
|
|
201
|
-
if (typeof resolved !== "string" || resolved === "") fail("RELEASE_CONFIG_TEMPLATE", `${label}: {{${token}}} is not available${token === "
|
|
210
|
+
if (typeof resolved !== "string" || resolved === "") fail("RELEASE_CONFIG_TEMPLATE", `${label}: {{${token}}} is not available${token === "version" ? " here (version cannot refer to itself)" : ""}`);
|
|
202
211
|
return resolved;
|
|
203
212
|
});
|
|
204
213
|
}
|
|
@@ -259,16 +268,31 @@ export function validateReleaseConfigShape(config, { file = RELEASE_CONFIG_FILE,
|
|
|
259
268
|
return config;
|
|
260
269
|
}
|
|
261
270
|
|
|
271
|
+
// The same rule as UPDATER_KEY_ID_RE in github-updater-release.mjs, kept here because that module's import graph loads the release state
|
|
272
|
+
// machine, which this loader must not depend on.
|
|
273
|
+
const UPDATER_KEY_ID = /^[0-9A-F]{16}$/;
|
|
274
|
+
// A typed template ({ "$fromJson": ... }) is a string only after expansion, so the raw-phase check leaves it to the final one.
|
|
275
|
+
const isTemplateNode = (value) => isObject(value) && Object.keys(value).some((key) => key.startsWith("$"));
|
|
276
|
+
const isBlank = (value) => value === undefined || value === null || (typeof value === "string" && !value.trim());
|
|
277
|
+
|
|
262
278
|
/**
|
|
263
279
|
* Field-level refusals that need no other part of the config, so a caller (the loader, the doctor) can list them next to every other
|
|
264
|
-
* finding: `[{ name, code, pointer, detail }]`.
|
|
265
|
-
*
|
|
266
|
-
*
|
|
280
|
+
* finding: `[{ name, code, pointer, detail }]`.
|
|
281
|
+
*
|
|
282
|
+
* `targets.<t>.updater.keyFile`: updater minisign keys come from the OS credential provider (`protected-credentials.mjs`, secret
|
|
283
|
+
* "updater-signing-key", namespace `rightsuite-updater-key`; `sign-updater.mjs` refuses `--key-file`), so a config that names a key
|
|
284
|
+
* file could only fail mid-signing. It is refused up front instead, and never forwarded.
|
|
285
|
+
*
|
|
286
|
+
* `distribution.updaterKeyId` must be 16 upper-case hex digits and, for a Right Suite app, a key the Right Suite contract accepts; a
|
|
287
|
+
* Right Suite app must not declare an updater public key of its own (`distribution.updaterPublicKey`, `updaterPubkey`,
|
|
288
|
+
* `targets.<t>.updater.pubkey` or `publicKey`) unless it is exactly a contract key's text. Publish and seal refuse the same things
|
|
289
|
+
* (`resolveUpdaterTrust`); here they are found before a build starts.
|
|
267
290
|
*/
|
|
268
291
|
export function releaseConfigFieldProblems(config) {
|
|
269
292
|
const problems = [];
|
|
270
|
-
if (!isObject(config)
|
|
271
|
-
|
|
293
|
+
if (!isObject(config)) return problems;
|
|
294
|
+
const targets = isObject(config.targets) ? Object.entries(config.targets) : [];
|
|
295
|
+
for (const [name, target] of targets) {
|
|
272
296
|
if (!isObject(target) || !isObject(target.updater) || !Object.prototype.hasOwnProperty.call(target.updater, "keyFile")) continue;
|
|
273
297
|
problems.push({
|
|
274
298
|
name: "updater-key-file",
|
|
@@ -277,6 +301,35 @@ export function releaseConfigFieldProblems(config) {
|
|
|
277
301
|
detail: `targets.${name}.updater.keyFile is not supported: updater signing keys come from the OS credential provider (rightkit-credentials, namespace rightsuite-updater-key; see protected-credentials.mjs and sign-updater.mjs), never from a key file. Remove updater.keyFile from the release config.`,
|
|
278
302
|
});
|
|
279
303
|
}
|
|
304
|
+
const suiteApp = typeof config.app === "string" && RIGHT_SUITE_APP_KEYS.includes(config.app.toLowerCase());
|
|
305
|
+
const distribution = isObject(config.distribution) ? config.distribution : {};
|
|
306
|
+
const keyId = distribution.updaterKeyId;
|
|
307
|
+
if (keyId !== undefined && !isTemplateNode(keyId)) {
|
|
308
|
+
const accepted = RIGHT_SUITE_UPDATER_PUBLIC_KEYS.map((key) => key.kid);
|
|
309
|
+
const detail = typeof keyId !== "string" || !UPDATER_KEY_ID.test(keyId)
|
|
310
|
+
? `distribution.updaterKeyId must be the 16 upper-case hex digits of a minisign key id, got ${JSON.stringify(keyId)}.`
|
|
311
|
+
: suiteApp && !accepted.includes(keyId)
|
|
312
|
+
? `distribution.updaterKeyId ${keyId} is not an updater key in the Right Suite contract (accepted: ${accepted.join(", ")}). Remove it to sign with the contract's signing key.`
|
|
313
|
+
: null;
|
|
314
|
+
if (detail) problems.push({ name: "updater-key-id", code: "RELEASE_CONFIG_UPDATER_KEY_ID", pointer: "/distribution/updaterKeyId", detail });
|
|
315
|
+
}
|
|
316
|
+
if (suiteApp) {
|
|
317
|
+
const contractTexts = RIGHT_SUITE_UPDATER_PUBLIC_KEYS.map((key) => key.publicKey);
|
|
318
|
+
const declared = (pointer, label, value) => {
|
|
319
|
+
if (isBlank(value) || isTemplateNode(value) || contractTexts.includes(value)) return;
|
|
320
|
+
problems.push({
|
|
321
|
+
name: "updater-public-key",
|
|
322
|
+
code: "RELEASE_CONFIG_UPDATER_PUBLIC_KEY",
|
|
323
|
+
pointer,
|
|
324
|
+
detail: `${label} is not a Right Suite contract key: ${config.app} is a suite app and takes its updater keys from the contract (contracts/right-suite-api.v1.json), so remove ${label}.`,
|
|
325
|
+
});
|
|
326
|
+
};
|
|
327
|
+
for (const field of ["updaterPublicKey", "updaterPubkey"]) declared(`/distribution/${field}`, `distribution.${field}`, distribution[field]);
|
|
328
|
+
for (const [name, target] of targets) {
|
|
329
|
+
if (!isObject(target) || !isObject(target.updater)) continue;
|
|
330
|
+
for (const field of ["pubkey", "publicKey"]) declared(`/targets/${pointerKey(name)}/updater/${field}`, `targets.${name}.updater.${field}`, target.updater[field]);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
280
333
|
return problems;
|
|
281
334
|
}
|
|
282
335
|
|
|
@@ -289,7 +342,7 @@ function packageScripts(appRoot, readText) {
|
|
|
289
342
|
|
|
290
343
|
/**
|
|
291
344
|
* Load, validate and expand a release config. Returns `{ config, configPath, appRoot, sha256, inputs, lineOf }`. Throws ReleaseConfigError
|
|
292
|
-
* (RELEASE_CONFIG_LEGACY_EXECUTABLE | _NOT_JSON | _NOT_FOUND | _INVALID_JSON | _SCHEMA | _KEY | _UPDATER_KEY_FILE | _TEMPLATE | _M24). `validatePipeline: false`
|
|
345
|
+
* (RELEASE_CONFIG_LEGACY_EXECUTABLE | _NOT_JSON | _NOT_FOUND | _INVALID_JSON | _SCHEMA | _KEY | _UPDATER_KEY_FILE | _UPDATER_KEY_ID | _UPDATER_PUBLIC_KEY | _TEMPLATE | _M24). `validatePipeline: false`
|
|
293
346
|
* skips only the M24 command admission (the pipeline scanner reports those findings itself); nothing else is optional.
|
|
294
347
|
* File access, the generator registry, the environment and the home directory are injectable for tests.
|
|
295
348
|
*/
|
|
@@ -313,7 +366,9 @@ export function loadReleaseConfigSync(file, {
|
|
|
313
366
|
const raw = parsed.value;
|
|
314
367
|
validateReleaseConfigShape(raw, { file: configPath, phase: "raw" });
|
|
315
368
|
const inputs = new Map();
|
|
316
|
-
|
|
369
|
+
// {{localAppData}} is %LOCALAPPDATA% on Windows. Elsewhere it is the Windows profile default under the home directory (a path
|
|
370
|
+
// no other host uses), so one config that names a Windows install path loads on every host instead of failing on the Mac.
|
|
371
|
+
const base = { appRoot, inputs, readBytes, lstat, values: { home: homedir(), localAppData: env.LOCALAPPDATA || path.join(homedir(), "AppData", "Local"), app: "", version: "" } };
|
|
317
372
|
base.values.app = expand(raw.app, { ...base }, "/app");
|
|
318
373
|
if (raw.version !== undefined) base.values.version = expand(raw.version, { ...base, values: { ...base.values, version: "" } }, "/version");
|
|
319
374
|
const config = expand(raw, base, "");
|
|
@@ -344,6 +399,27 @@ export async function loadReleaseConfig(file, options) {
|
|
|
344
399
|
return loadReleaseConfigSync(file, options).config;
|
|
345
400
|
}
|
|
346
401
|
|
|
402
|
+
/** True for a `.json` config path: the only extension the strict loader accepts. */
|
|
403
|
+
export const isJsonReleaseConfig = (file) => typeof file === "string" && path.extname(file).toLowerCase() === ".json";
|
|
404
|
+
|
|
405
|
+
/** The config name a command defaults to: `right-release.config.json` when `dir` holds it, else the legacy module an unmoved app still owns. */
|
|
406
|
+
export function defaultReleaseConfigFile(dir = process.cwd(), { exists = existsSync } = {}) {
|
|
407
|
+
return exists(path.join(dir, RELEASE_CONFIG_FILE)) ? RELEASE_CONFIG_FILE : LEGACY_RELEASE_CONFIG_FILE;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Load the config at `file` for a command. A `.json` path goes through the strict, M24-validated loader (throws ReleaseConfigError); any
|
|
412
|
+
* other path is the legacy branch and is imported as before. Legacy-only options: `query` is the cache-busting query a site appends
|
|
413
|
+
* (`upload=<ts>`, `commit=<sha>`) so a module re-evaluates per run, and `lenient` keeps the `default ?? config ?? module` fallback the
|
|
414
|
+
* app-root commands accept; every other option goes to `loadReleaseConfigSync`.
|
|
415
|
+
*/
|
|
416
|
+
export async function importReleaseConfig(file, { query, lenient = false, ...loadOptions } = {}) {
|
|
417
|
+
const absolute = path.resolve(file);
|
|
418
|
+
if (isJsonReleaseConfig(absolute)) return loadReleaseConfigSync(absolute, loadOptions).config;
|
|
419
|
+
const loaded = await import(`${pathToFileURL(absolute).href}${query ? `?${query}` : ""}`);
|
|
420
|
+
return lenient ? loaded.default ?? loaded.config ?? loaded : loaded.default;
|
|
421
|
+
}
|
|
422
|
+
|
|
347
423
|
/** `{ config, appRoot, configPath }` for the commands that need the app root beside the config. */
|
|
348
424
|
export async function loadAppConfigFile(file = RELEASE_CONFIG_FILE, options) {
|
|
349
425
|
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,
|