@rightkit/release 0.6.4 → 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 +109 -7
- package/admission-files.mjs +279 -0
- package/admission-host.mjs +403 -0
- package/admission.mjs +146 -0
- package/android-play-upload.mjs +4 -4
- package/app-check.mjs +1326 -0
- 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 +495 -69
- package/binary-license-notices.mjs +2 -1
- package/build-release.mjs +87 -26
- 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 +45 -5
- package/command-spec.mjs +4 -5
- package/deps.mjs +5 -4
- package/deterministic-scans.mjs +301 -37
- 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/inventory-check.mjs +12 -1
- 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/ownership-dependency-allowlist.json +2 -0
- package/package.json +41 -3
- package/pipeline-admission.mjs +12 -0
- package/prebuild-gates.mjs +7 -3
- 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 +117 -62
- package/rightkit-registry-resolver.mjs +5 -2
- package/rightkit-versions.json +18 -13
- 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/deterministic-scans.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// every scan that applies to a change runs here, before any artifact build, local or CI.
|
|
3
3
|
//
|
|
4
4
|
// Class A: compile-free, executes no tests and no repository-supplied plugin or generator.
|
|
5
|
-
// Class B: needs the compiler or runs tests or repository code (clippy, check, doc, tsc, eslint,
|
|
5
|
+
// Class B: needs the compiler or runs tests or repository code (clippy, check, doc, tsc, eslint, biome,
|
|
6
6
|
// package tests, the repository's own docs generator).
|
|
7
7
|
//
|
|
8
8
|
// A required tool that is missing FAILS (status "fail", code TOOL_MISSING); a scan is never skipped.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// pass (result.ok and result.complete are false while any scan is deferred) and cannot become a SCAN_B receipt.
|
|
12
12
|
// Content scans run on the exported HEAD (contentRoot). Only a scan that inherently needs the live
|
|
13
13
|
// checkout uses liveRoot: `rustright cargo` scans (the broker binds to the registered checkout), the
|
|
14
|
-
// tsc, eslint and package tests (need installed node_modules).
|
|
14
|
+
// tsc, eslint, biome and package tests (need installed node_modules).
|
|
15
15
|
// Class B runs only when every selected class A scan passed (SCAN_B is distinct from SCAN_A and
|
|
16
16
|
// follows it). Rust commands go through `rustright cargo`, JavaScript tools through `pnpm exec`.
|
|
17
17
|
// The runner takes injectable spawn/exists/readFile/readdir so tests execute nothing real. The
|
|
@@ -26,6 +26,7 @@ import { spawn as spawnChild, spawnSync } from "./background-process.mjs";
|
|
|
26
26
|
import { checkAdvisoryExceptions } from "./advisory-exceptions.mjs";
|
|
27
27
|
import { canonicalJson, digestScanSet, requiredScanIds, rustCratePlan, rustScanId } from "./scan-digest.mjs";
|
|
28
28
|
import { reach } from "./lock-graph.mjs";
|
|
29
|
+
import { cargoManifestState } from "./cargo-manifest-text.mjs";
|
|
29
30
|
|
|
30
31
|
export const SCAN_RESULT_SCHEMA = "rightkit.deterministic-scans/1";
|
|
31
32
|
export const SCAN_CLASS = Object.freeze({ A: "A", B: "B" });
|
|
@@ -37,7 +38,7 @@ export const CLASS_A_SCAN_PREFIXES = Object.freeze([
|
|
|
37
38
|
"changelog-version", "license", "secret-scan", "path-policy", "advisory-exceptions", "suite-api",
|
|
38
39
|
]);
|
|
39
40
|
export const CLASS_B_SCAN_PREFIXES = Object.freeze([
|
|
40
|
-
"clippy", "check", "check-all-features", "check-features", "check-dependent", "doc", "tsc", "eslint", "swift-build", "test", "generated-docs",
|
|
41
|
+
"clippy", "check", "check-all-features", "check-features", "check-dependent", "doc", "tsc", "eslint", "biome", "swift-build", "test", "generated-docs",
|
|
41
42
|
]);
|
|
42
43
|
|
|
43
44
|
/**
|
|
@@ -202,13 +203,22 @@ export const ancestorConfigChanged = (ws, changed, pattern = ANCESTOR_RUST_CONFI
|
|
|
202
203
|
return rel !== null && pattern.test(rel);
|
|
203
204
|
}));
|
|
204
205
|
const WORKSPACE_JS_FILE = /^(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|\.npmrc)$/;
|
|
205
|
-
|
|
206
|
-
|
|
206
|
+
// The linter a package has is the one it configures. ESLint reads a flat `eslint.config.*` or a legacy `.eslintrc*` from the package directory or
|
|
207
|
+
// any ancestor of it; Biome reads `biome.json` / `biome.jsonc` the same way. A Biome-only app (ViewRight, ScrapeRight: `"lint": "biome lint ."`)
|
|
208
|
+
// has no ESLint config and no ESLint dependency, so `eslint .` there fails with "couldn't find an eslint.config" (a gate false positive):
|
|
209
|
+
// planScans plans eslint only where an ESLint config exists and biome only where a Biome config exists, both where both do.
|
|
210
|
+
export const ESLINT_CONFIG_FILES = Object.freeze([
|
|
211
|
+
"eslint.config.js", "eslint.config.mjs", ".eslintrc.json",
|
|
212
|
+
"eslint.config.cjs", "eslint.config.ts", "eslint.config.mts", "eslint.config.cts",
|
|
213
|
+
".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.yaml", ".eslintrc.yml",
|
|
214
|
+
]);
|
|
215
|
+
export const BIOME_CONFIG_FILES = Object.freeze(["biome.json", "biome.jsonc"]);
|
|
207
216
|
// Everything scripts/generate-index.mjs reads or writes (its header and loadInventory): crate and package manifests and
|
|
208
|
-
// READMEs, the root Cargo.toml (workspace version), the versions and ownership data, the ownership-availability helper it imports, itself,
|
|
217
|
+
// READMEs, the root Cargo.toml (workspace version), the versions and ownership data, the ownership-availability helper it imports, itself,
|
|
218
|
+
// the port matrix generator it imports, and its outputs (the port matrix's MD and HTML are checked for consistency with its JSON).
|
|
209
219
|
// The Right Suite API contract and every projection scripts/gen-suite-api.mjs writes from it (the test-vector copy included).
|
|
210
220
|
const SUITE_API_INPUT = /^(contracts\/right-suite-api\.v1\.json|scripts\/gen-suite-api\.mjs|crates\/rightkit-license\/src\/suite_api_generated\.rs|packages\/app-update\/src\/suite-api\.generated\.ts|packages\/release\/suite-api\.generated\.mjs|crates\/rightkit-license\/test-vectors\/right-suite-api\.v1\.json)$/;
|
|
211
|
-
const GENERATOR_INPUT = /^(crates\/[^/]+\/(Cargo\.toml|README\.md)|packages\/[^/]+\/(package\.json|README\.md)|Cargo\.toml|packages\/release\/(rightkit-versions\.json|rightkit-ownership\.json|ownership-availability\.mjs)|scripts\/generate-index\.mjs|INDEX\.md|docs\/(ADOPTION|CAPABILITY-MAP)\.md)$/;
|
|
221
|
+
const GENERATOR_INPUT = /^(crates\/[^/]+\/(Cargo\.toml|README\.md)|packages\/[^/]+\/(package\.json|README\.md)|Cargo\.toml|packages\/release\/(rightkit-versions\.json|rightkit-ownership\.json|ownership-availability\.mjs)|scripts\/generate-(index|port-matrix)\.mjs|INDEX\.md|docs\/(ADOPTION|CAPABILITY-MAP|PORT-MATRIX)\.md|docs\/port-matrix\.(html|json))$/;
|
|
212
222
|
const PROBES = Object.freeze({
|
|
213
223
|
rustfmt: ["--version"], node: ["--version"], rustright: ["cargo", "--version"], pnpm: ["--version"],
|
|
214
224
|
actionlint: ["-version"], "right-git": ["--version"], swift: ["--version"],
|
|
@@ -220,6 +230,121 @@ function commandText(d) {
|
|
|
220
230
|
return `${env}${d.tool} ${d.args.join(" ")}`.trim();
|
|
221
231
|
}
|
|
222
232
|
|
|
233
|
+
// ---- package scripts that call the broker -----------------------------------------------------------------------------------
|
|
234
|
+
// A package's `test` script may run Rust through the broker (`rustright storage run --app x -- right-release test`, `rustright cargo test`) or
|
|
235
|
+
// through plain cargo, which on a host with no broker means the unmanaged toolchain. On such a host the `test:npm:<unit>` scan cannot pass, and
|
|
236
|
+
// its failure says nothing about the code: rightkit-gate defers it with the Rust gate. scriptBrokerCall only reads the scripts; it runs nothing.
|
|
237
|
+
const SCRIPT_TEXT_LIMIT = 16 * 1024;
|
|
238
|
+
const BROKER_TOOLS = new Set(["rustright", "cargo"]);
|
|
239
|
+
const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
240
|
+
// Words that run the command after them.
|
|
241
|
+
const COMMAND_WRAPPERS = new Set(["cross-env", "env", "time", "exec", "command", "sudo", "nice", "nohup"]);
|
|
242
|
+
const PACKAGE_MANAGERS = new Set(["pnpm", "npm", "yarn"]);
|
|
243
|
+
const PACKAGE_RUNNERS = new Set(["npx", "bunx"]);
|
|
244
|
+
// Package manager options that point the run at another package or at every package: the script that runs is not in this package.json.
|
|
245
|
+
const RETARGET_FLAGS = new Set(["-r", "--recursive", "-w", "--workspace-root", "--workspaces", "-ws", "--include-workspace-root"]);
|
|
246
|
+
const RETARGET_VALUE_OPTIONS = new Set(["-F", "--filter", "--filter-prod", "-C", "--dir", "--prefix", "--workspace"]);
|
|
247
|
+
const SAME_DIR_VALUES = new Set([".", "./"]);
|
|
248
|
+
// Options that take the next word as their value (the rest are flags).
|
|
249
|
+
const VALUE_OPTIONS = new Set(["--reporter", "--loglevel", "--color", "--workspace-concurrency", "--stream", "--shell-emulator", "--test-pattern"]);
|
|
250
|
+
|
|
251
|
+
/** The simple commands of a shell line, each as its words: split on `&&`, `||`, `;`, `|`, `&`, `(`, `)` and newlines outside quotes. A backslash outside quotes is a path separator (Windows), not an escape. */
|
|
252
|
+
function shellCommands(text) {
|
|
253
|
+
const commands = []; let words = []; let word = ""; let inWord = false; let quote = null;
|
|
254
|
+
const endWord = () => { if (inWord) words.push(word); word = ""; inWord = false; };
|
|
255
|
+
const endCommand = () => { endWord(); if (words.length) commands.push(words); words = []; };
|
|
256
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
257
|
+
const c = text[i];
|
|
258
|
+
if (quote) {
|
|
259
|
+
if (c === quote) quote = null;
|
|
260
|
+
else if (c === "\\" && quote === '"' && i + 1 < text.length) word += text[++i];
|
|
261
|
+
else word += c;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (c === '"' || c === "'") { quote = c; inWord = true; continue; }
|
|
265
|
+
if (c === "&" || c === "|" || c === ";" || c === "\n" || c === "(" || c === ")") { endCommand(); continue; }
|
|
266
|
+
if (/\s/.test(c)) { endWord(); continue; }
|
|
267
|
+
word += c; inWord = true;
|
|
268
|
+
}
|
|
269
|
+
endCommand();
|
|
270
|
+
return commands;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** What `pnpm|npm|yarn <args>` does in this package: { run: <script name> } or {} (another package, a built-in, an unknown word). */
|
|
274
|
+
function inspectPackageManager(args, scripts) {
|
|
275
|
+
let i = 0;
|
|
276
|
+
while (i < args.length && args[i].startsWith("-") && args[i] !== "--") {
|
|
277
|
+
const [name, ...inline] = args[i].split("=");
|
|
278
|
+
const value = inline.length ? inline.join("=") : undefined;
|
|
279
|
+
if (RETARGET_FLAGS.has(name)) return {};
|
|
280
|
+
if (RETARGET_VALUE_OPTIONS.has(name)) {
|
|
281
|
+
if (!SAME_DIR_VALUES.has(value ?? args[i + 1])) return {};
|
|
282
|
+
i += value === undefined ? 2 : 1;
|
|
283
|
+
} else i += VALUE_OPTIONS.has(name) && value === undefined ? 2 : 1;
|
|
284
|
+
}
|
|
285
|
+
const sub = args[i];
|
|
286
|
+
if (sub === undefined || sub === "--") return {};
|
|
287
|
+
const rest = args.slice(i + 1);
|
|
288
|
+
if (sub === "exec" || sub === "dlx") return inspectCommand(rest, scripts);
|
|
289
|
+
if (sub === "run" || sub === "run-script" || sub === "rum" || sub === "urun") {
|
|
290
|
+
let j = 0;
|
|
291
|
+
while (j < rest.length && rest[j].startsWith("-") && rest[j] !== "--") j += VALUE_OPTIONS.has(rest[j]) ? 2 : 1;
|
|
292
|
+
return rest[j] !== undefined && rest[j] !== "--" ? { run: rest[j] } : {};
|
|
293
|
+
}
|
|
294
|
+
if (sub === "test" || sub === "t" || sub === "tst") return { run: "test" };
|
|
295
|
+
return Object.hasOwn(scripts, sub) ? { run: sub } : {};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** What one simple command does: { call: "rustright" | "cargo" } when it runs the tool (through env words and wrappers), { run: <script> } for a package script, else {}. */
|
|
299
|
+
function inspectCommand(words, scripts) {
|
|
300
|
+
let i = 0;
|
|
301
|
+
for (let guard = 0; guard < 16; guard += 1) {
|
|
302
|
+
while (i < words.length && ENV_ASSIGNMENT.test(words[i])) i += 1;
|
|
303
|
+
if (i >= words.length) return {};
|
|
304
|
+
const word = path.posix.basename(words[i].replaceAll("\\", "/")).replace(/\.(exe|cmd|bat)$/i, "");
|
|
305
|
+
if (BROKER_TOOLS.has(word)) return { call: word };
|
|
306
|
+
if (PACKAGE_MANAGERS.has(word)) return inspectPackageManager(words.slice(i + 1), scripts);
|
|
307
|
+
if (COMMAND_WRAPPERS.has(word) || PACKAGE_RUNNERS.has(word)) {
|
|
308
|
+
i += 1;
|
|
309
|
+
while (i < words.length && words[i].startsWith("-")) i += 1;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
return {};
|
|
313
|
+
}
|
|
314
|
+
return {};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Whether the package script `entry` runs `rustright` or `cargo`, directly or through the scripts it starts in the same package.json
|
|
319
|
+
* (`pnpm <script>`, `pnpm run <script>`, `npm run <script>`, `yarn [run] <script>`, `npm test`), their `pre<script>` and `post<script>` hooks, and
|
|
320
|
+
* `exec`/`dlx`/`npx` wrappers. A run pointed at another package (`--filter`, `-r`, `--dir` elsewhere) is not followed. Bounded to `maxDepth` nested
|
|
321
|
+
* scripts and cycle-safe. Returns `{ tool, via, command }` for the first call in execution order (`via` is the scripts walked, `command` the command
|
|
322
|
+
* line that calls the tool), or null. Reads package.json `scripts` data only.
|
|
323
|
+
*/
|
|
324
|
+
export function scriptBrokerCall(scripts, entry = "test", { maxDepth = 8 } = {}) {
|
|
325
|
+
if (scripts === null || typeof scripts !== "object" || Array.isArray(scripts)) return null;
|
|
326
|
+
const textOf = (name) => (Object.hasOwn(scripts, name) && typeof scripts[name] === "string" ? scripts[name].slice(0, SCRIPT_TEXT_LIMIT) : null);
|
|
327
|
+
const seen = new Set();
|
|
328
|
+
const visit = (name, via) => {
|
|
329
|
+
if (via.length >= maxDepth || seen.has(name)) return null;
|
|
330
|
+
seen.add(name);
|
|
331
|
+
for (const part of [`pre${name}`, name, `post${name}`]) {
|
|
332
|
+
const text = textOf(part);
|
|
333
|
+
if (text === null) continue;
|
|
334
|
+
for (const words of shellCommands(text)) {
|
|
335
|
+
const found = inspectCommand(words, scripts);
|
|
336
|
+
if (found.call) return { tool: found.call, via: [...via, part], command: words.join(" ").slice(0, 200) };
|
|
337
|
+
if (found.run !== undefined) {
|
|
338
|
+
const deeper = visit(found.run, [...via, part]);
|
|
339
|
+
if (deeper) return deeper;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return null;
|
|
344
|
+
};
|
|
345
|
+
return visit(String(entry), []);
|
|
346
|
+
}
|
|
347
|
+
|
|
223
348
|
/**
|
|
224
349
|
* The scan plan for a change: derived from the changed files plus the manifests they belong to.
|
|
225
350
|
* `contentRoot` is a clean export of the commit (pure content checks); `liveRoot` is the checkout the
|
|
@@ -254,6 +379,12 @@ export function planScans({
|
|
|
254
379
|
// `crateFeatures` is read by `name` first, then by `crate`. Units of different workspaces never share a lock, a deny/audit run or a
|
|
255
380
|
// `cargo metadata`; a unit that sets no cargoDir belongs to the global `cargoDir` above.
|
|
256
381
|
rustUnits = null, npmUnits = null, cargoDir = ".",
|
|
382
|
+
// pnpm roots: repository-relative directories ("." for the repository root) that hold their own lock or workspace file. null keeps the single
|
|
383
|
+
// root "." (every npm unit belongs to it; one pnpm-lockfile and one pnpm-audit scan run there). With a list, each root gets its own
|
|
384
|
+
// `pnpm-lockfile:<root>` and `pnpm-audit:<root>` scans, run in that directory ("." keeps the unsuffixed ids), planned when one of its units is in
|
|
385
|
+
// scope or one of its own package.json / pnpm-lock.yaml / pnpm-workspace.yaml changed. An npm unit names its root as `root` (default "."); a
|
|
386
|
+
// repository with no pnpm root at all (npmRoots: []) plans no pnpm scan (CodeRight: two independent roots and nothing at the repository root).
|
|
387
|
+
npmRoots = null,
|
|
257
388
|
// Parsed Cargo.lock graph (lock-graph.mjs parseLock). With it, every workspace crate that depends on a crate in scope and is
|
|
258
389
|
// not itself in scope gets a `check-dependent` scan, as CI's rust-check rows do. null plans none.
|
|
259
390
|
rustLock = null,
|
|
@@ -319,6 +450,15 @@ export function planScans({
|
|
|
319
450
|
const dir = cleanDir(u.dir);
|
|
320
451
|
if (!plainDir(dir)) { refuse(`${kind} unit ${JSON.stringify(u.name)} directory ${JSON.stringify(u.dir)}`, "not a plain repository-relative path"); continue; }
|
|
321
452
|
const entry = { dir, publish: u.publish !== false };
|
|
453
|
+
if (kind === "npm") {
|
|
454
|
+
entry.root = ".";
|
|
455
|
+
if (u.root !== undefined) {
|
|
456
|
+
if (typeof u.root !== "string") throw new TypeError(`planScans: npm unit ${u.name} needs a string root`);
|
|
457
|
+
const r = cleanDir(u.root);
|
|
458
|
+
if (!plainDir(r) || !(r === "." || dir === r || dir.startsWith(`${r}/`))) { refuse(`npm unit ${JSON.stringify(u.name)} root ${JSON.stringify(u.root)}`, "not a plain repository-relative directory that holds the unit"); continue; }
|
|
459
|
+
entry.root = r;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
322
462
|
if (kind === "rust") {
|
|
323
463
|
if (u.crate !== undefined && typeof u.crate !== "string") throw new TypeError(`planScans: rust unit ${u.name} needs a string crate`);
|
|
324
464
|
entry.crate = u.crate ?? u.name;
|
|
@@ -334,6 +474,24 @@ export function planScans({
|
|
|
334
474
|
return map;
|
|
335
475
|
};
|
|
336
476
|
const rustMap = unitMap(rustUnits, "rust"); const npmMap = unitMap(npmUnits, "npm");
|
|
477
|
+
// The pnpm roots (see npmRoots): the listed ones and the roots the units name.
|
|
478
|
+
let pnpmRoots = ["."];
|
|
479
|
+
if (npmRoots !== null) {
|
|
480
|
+
if (!Array.isArray(npmRoots)) throw new TypeError("planScans: npmRoots must be null or an array of repository-relative directories");
|
|
481
|
+
const set = new Set(npmMap ? [...npmMap.values()].map((u) => u.root) : []);
|
|
482
|
+
for (const raw of npmRoots) {
|
|
483
|
+
const r = cleanDir(String(raw));
|
|
484
|
+
if (plainDir(r)) set.add(r); else refuse(`pnpm root ${JSON.stringify(raw)}`, "not a plain repository-relative path");
|
|
485
|
+
}
|
|
486
|
+
pnpmRoots = [...set].sort();
|
|
487
|
+
}
|
|
488
|
+
const rootOfUnit = (n) => (npmRoots !== null && npmMap ? npmMap.get(n).root : ".");
|
|
489
|
+
// The deepest pnpm root that holds a repository-relative file (every file is under "." when "." is a root).
|
|
490
|
+
const rootOfFile = (f) => {
|
|
491
|
+
let best = null;
|
|
492
|
+
for (const r of pnpmRoots) if ((r === "." || f.startsWith(`${r}/`)) && (best === null || r.length > best.length)) best = r;
|
|
493
|
+
return best;
|
|
494
|
+
};
|
|
337
495
|
// Units of an explicit-workspace layout (some unit names its cargoDir) get workspace-suffixed ids for the per-workspace scans.
|
|
338
496
|
const perWorkspaceIds = Array.isArray(rustUnits) && rustUnits.some((u) => u?.cargoDir !== undefined);
|
|
339
497
|
const wsIdSuffix = (ws) => (perWorkspaceIds && ws !== "." ? `:${ws}` : "");
|
|
@@ -358,26 +516,36 @@ export function planScans({
|
|
|
358
516
|
// ---- class A: content checks on the exported commit
|
|
359
517
|
// A vendor/ file keeps upstream formatting only when vendor/upstream-files.json records it and its
|
|
360
518
|
// bytes still match that upstream digest; any other vendor/ .rs (new, patched) is rustfmt-checked.
|
|
361
|
-
|
|
362
|
-
|
|
519
|
+
// A patched vendor/ file (rightkit#915) keeps upstream formatting only while its bytes match the
|
|
520
|
+
// reviewed `patched` record, which names its upstream base digest and the note describing the patch;
|
|
521
|
+
// reformatting it would bury the patch. Any edit changes the digest, so the file is checked until re-recorded.
|
|
522
|
+
let upstream = {}; let patched = {};
|
|
523
|
+
try {
|
|
524
|
+
const manifest = JSON.parse(readFile(path.join(contentRoot, "vendor/upstream-files.json")));
|
|
525
|
+
upstream = manifest.files ?? {}; patched = manifest.patched ?? {};
|
|
526
|
+
} catch { /* no manifest: nothing exempt */ }
|
|
527
|
+
const DIGEST = /^[0-9a-f]{64}$/;
|
|
363
528
|
const verbatimUpstream = (f) => f.startsWith("vendor/") && typeof upstream[f] === "string" && bytesDigest(f) === upstream[f];
|
|
364
|
-
const
|
|
529
|
+
const recordedPatch = (f) => f.startsWith("vendor/") && DIGEST.test(patched[f]?.base ?? "") && typeof patched[f]?.note === "string"
|
|
530
|
+
&& patched[f].note.startsWith("vendor/") && DIGEST.test(patched[f]?.sha256 ?? "") && bytesDigest(f) === patched[f].sha256;
|
|
531
|
+
// Generated Rust is not hand-formatted: bindgen output, or rustfmt's own `@generated` marker in the first five lines.
|
|
532
|
+
const generatedRust = (f) => {
|
|
533
|
+
let head = "";
|
|
534
|
+
try { head = readFile(path.join(contentRoot, f)).split("\n", 5).join("\n"); } catch { return false; }
|
|
535
|
+
return /@generated\b|automatically generated by rust-bindgen\b/.test(head);
|
|
536
|
+
};
|
|
537
|
+
const rustFiles = files.filter((f) => f.endsWith(".rs") && !verbatimUpstream(f) && !recordedPatch(f) && !generatedRust(f));
|
|
365
538
|
const byEdition = new Map();
|
|
539
|
+
const editionOf = rustEditionResolver({ contentRoot, exists, readFile });
|
|
540
|
+
const refusedEditions = new Set();
|
|
366
541
|
for (const f of rustFiles) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
const m = readFile(manifest).match(/^edition\s*=\s*"(\d{4})"/m);
|
|
373
|
-
if (m) edition = m[1];
|
|
374
|
-
break;
|
|
375
|
-
}
|
|
376
|
-
const parent = path.dirname(dir);
|
|
377
|
-
if (parent === dir) break;
|
|
378
|
-
dir = parent;
|
|
542
|
+
const found = editionOf(f);
|
|
543
|
+
if (found.problem) {
|
|
544
|
+
// Never guess an edition: a file whose edition cannot be resolved is not rustfmt-checked under a default, the gate fails on it.
|
|
545
|
+
if (!refusedEditions.has(found.manifest)) { refusedEditions.add(found.manifest); refuse(`rustfmt edition of ${found.manifest}`, found.problem); }
|
|
546
|
+
continue;
|
|
379
547
|
}
|
|
380
|
-
byEdition.set(edition, [...(byEdition.get(edition) ?? []), f]);
|
|
548
|
+
byEdition.set(found.edition, [...(byEdition.get(found.edition) ?? []), f]);
|
|
381
549
|
}
|
|
382
550
|
for (const [edition, group] of [...byEdition].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
383
551
|
add({ id: `rustfmt:${edition}`, class: A, tool: "rustfmt", args: ["--check", "--edition", edition, ...group.sort()], root: "content" });
|
|
@@ -401,7 +569,8 @@ export function planScans({
|
|
|
401
569
|
// A workspace is also wide when Rust configuration changed in ANY ancestor directory of it (a repository-level .cargo/config.toml,
|
|
402
570
|
// an intermediate apps/.cargo, a root rust-toolchain.toml, clippy.toml, rustfmt.toml or deny.toml reaches every nested workspace).
|
|
403
571
|
const wideWs = new Set(workspaces.filter((ws) => ancestorConfigChanged(ws, files) || files.some((f) => { const r = wsRel(ws, f); return r !== null && WORKSPACE_RUST_FILE.test(r); })));
|
|
404
|
-
|
|
572
|
+
// A pnpm root's own install graph (package.json, lock, workspace file, .npmrc) changed: every unit of that root is in scope.
|
|
573
|
+
const wideJsRoots = new Set(pnpmRoots.filter((r) => files.some((f) => { const rel = wsRel(r, f); return rel !== null && WORKSPACE_JS_FILE.test(rel); })));
|
|
405
574
|
const wideCrates = wideWs.size ? (rustMap ? [...rustMap.keys()].filter((n) => wideWs.has(wsOf(n))) : listDir(path.join(contentRoot, "crates"))).filter((n) => !rustScope || rustScope.has(n)) : [];
|
|
406
575
|
if (wideWs.size && rustScope) notes.push(`workspace Rust change reaches ${wideCrates.length} crate(s) through the lock graph`);
|
|
407
576
|
const crateNames = safeUnits([...new Set([...(rustMap ? files.map((f) => unitOf(rustMap, f)).filter(Boolean) : under("crates/").map((f) => f.split("/")[1])), ...wideCrates])].filter((n) => exists(path.join(contentRoot, rustDir(n), "Cargo.toml"))).sort(), "crate");
|
|
@@ -424,8 +593,9 @@ export function planScans({
|
|
|
424
593
|
if (n !== null && npmMap.get(n).dir === "." && rustMap) { const r = unitOf(rustMap, f); if (r !== null && rustMap.get(r).dir !== ".") return null; }
|
|
425
594
|
return n;
|
|
426
595
|
};
|
|
427
|
-
const pkgNames = safeUnits([...new Set([...(npmMap ? files.map(npmOwner).filter(Boolean) : under("packages/").map((f) => f.split("/")[1])), ...(
|
|
428
|
-
|
|
596
|
+
const pkgNames = safeUnits([...new Set([...(npmMap ? files.map(npmOwner).filter(Boolean) : under("packages/").map((f) => f.split("/")[1])), ...(wideJsRoots.size ? (npmMap ? [...npmMap.keys()].filter((n) => wideJsRoots.has(rootOfUnit(n))) : wideJsRoots.has(".") ? listDir(path.join(contentRoot, "packages")) : []) : [])])].filter((n) => exists(path.join(contentRoot, npmDir(n), "package.json"))).sort(), "package");
|
|
597
|
+
// The pnpm roots with something to check: a unit in scope, or a package.json / lock / workspace file that belongs to the root (the deepest one).
|
|
598
|
+
const jsRoots = [...new Set([...pkgNames.map(rootOfUnit), ...files.filter((f) => JS_TOOLING_FILE.test(f)).map(rootOfFile).filter((r) => r !== null)])].sort();
|
|
429
599
|
|
|
430
600
|
// Explicit files: the exported commit has no .git for actionlint's project discovery.
|
|
431
601
|
const workflowFiles = files.filter((f) => /^\.github\/workflows\/[^/]+\.ya?ml$/.test(f)).sort();
|
|
@@ -447,11 +617,13 @@ export function planScans({
|
|
|
447
617
|
add({ id: `cargo-deny${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, "deny", "check") });
|
|
448
618
|
add({ id: `cargo-audit${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, "audit") });
|
|
449
619
|
}
|
|
450
|
-
|
|
620
|
+
for (const r of jsRoots) {
|
|
451
621
|
// Class A runs no repository hooks: `install` accepts --ignore-pnpmfile and --ignore-scripts; `audit` accepts
|
|
452
622
|
// neither flag, so every class A pnpm scan also sets the same two settings through the environment.
|
|
453
|
-
|
|
454
|
-
|
|
623
|
+
// One pair per pnpm root, run in the root's directory; the repository root keeps the unsuffixed ids.
|
|
624
|
+
const where = r === "." ? {} : { cwd: r }; const suffix = r === "." ? "" : `:${r}`;
|
|
625
|
+
add({ id: `pnpm-lockfile${suffix}`, class: A, tool: "pnpm", args: ["install", "--frozen-lockfile", "--lockfile-only", "--ignore-pnpmfile", "--ignore-scripts"], env: PNPM_NO_HOOKS_ENV, root: "content", ...where });
|
|
626
|
+
add({ id: `pnpm-audit${suffix}`, class: A, tool: "pnpm", args: ["audit", "--prod", "--audit-level", "high"], env: PNPM_NO_HOOKS_ENV, root: "content", ...where });
|
|
455
627
|
}
|
|
456
628
|
const crateDirs = crateNames.map(rustDir); const pkgDirs = pkgNames.map(npmDir);
|
|
457
629
|
const withChangelog = crateNames.filter((n) => exists(path.join(contentRoot, rustDir(n), "CHANGELOG.md")) && exists(path.join(contentRoot, rustDir(n), "Cargo.toml")));
|
|
@@ -505,11 +677,13 @@ export function planScans({
|
|
|
505
677
|
const dependents = safeUnits(workspace.filter((n) => !inScope.has(n) && exists(path.join(contentRoot, rustDir(n), "Cargo.toml")) && scoped.some((t) => reach(lock, crateOf(n), memo).has(t))).sort(), "crate");
|
|
506
678
|
for (const name of dependents) add({ id: `check-dependent:${name}`, class: B, ...cargoIn(ws, ...checkArgs(crateOf(name), { locked: true })) });
|
|
507
679
|
}
|
|
508
|
-
|
|
680
|
+
// A linter config in the unit's directory or any ancestor of it (a repository-level config covers every unit, as the tools look it up).
|
|
681
|
+
const hasConfig = (dir, names) => ancestorDirsOf(dir).some((d) => names.some((f) => exists(path.join(liveRoot, inDir(d, f)))));
|
|
509
682
|
for (const name of pkgNames) {
|
|
510
683
|
const dir = npmDir(name);
|
|
511
684
|
if (exists(path.join(liveRoot, dir, "tsconfig.json"))) add({ id: `tsc:${name}`, class: B, tool: "pnpm", args: ["exec", "tsc", "--noEmit", "-p", "tsconfig.json"], cwd: dir });
|
|
512
|
-
if (
|
|
685
|
+
if (hasConfig(dir, ESLINT_CONFIG_FILES)) add({ id: `eslint:${name}`, class: B, tool: "pnpm", args: ["exec", "eslint", "."], cwd: dir });
|
|
686
|
+
if (hasConfig(dir, BIOME_CONFIG_FILES)) add({ id: `biome:${name}`, class: B, tool: "pnpm", args: ["exec", "biome", "lint", "."], cwd: dir });
|
|
513
687
|
}
|
|
514
688
|
if (files.some((f) => f.startsWith("swift/") || f === "Package.swift")) {
|
|
515
689
|
if (platform === "darwin") add({ id: "swift-build", class: B, tool: "swift", args: ["build"] });
|
|
@@ -542,7 +716,10 @@ export function planScans({
|
|
|
542
716
|
if (own.quietTests === true && quietTests === "ci") { deferredToCi.push(`test:npm:${name}`); continue; }
|
|
543
717
|
// A gate proves the repository's own content: sibling checkouts beside it (RightKit's suite-contract tests read the app
|
|
544
718
|
// repositories when present) are not inputs, so the package tests run as an isolated checkout, as they do in CI.
|
|
545
|
-
|
|
719
|
+
// `brokerCall` marks a test script that runs the broker (or cargo) through its script chain: rightkit-gate defers such a scan with the
|
|
720
|
+
// Rust gate where this host has no broker (scriptBrokerCall).
|
|
721
|
+
const brokerCall = scriptBrokerCall(manifest.scripts, "test");
|
|
722
|
+
add({ id: `test:npm:${name}`, class: B, ...(own.quietTests === true ? { quiet: true } : {}), ...(brokerCall ? { brokerCall } : {}), tool: "pnpm", args: ["--dir", npmDir(name), "run", "test"], env: { RIGHTKIT_ISOLATED_CHECKOUT: "1" } });
|
|
546
723
|
}
|
|
547
724
|
if (deferredToCi.length) notes.push(`${deferredToCi.length} quiet test scan(s) left to the CI shards (one crate per runner), not run here: ${deferredToCi.join(", ")}`);
|
|
548
725
|
// scripts/ci tests run when scripts/, ci/ or .github/ changed, or a file the scripts/ci sources reference (imports and
|
|
@@ -737,6 +914,90 @@ async function defaultScanSecrets() {
|
|
|
737
914
|
return (await import("./publish-cargo.mjs")).scanCrateForSecrets;
|
|
738
915
|
}
|
|
739
916
|
|
|
917
|
+
// What rustfmt needs from a Cargo.toml, read by table so an `edition` under [package.metadata.x] or [dependencies.x] is not the
|
|
918
|
+
// package's: `pkg` is the [package] edition (a literal year, or "workspace" for `edition.workspace = true` / `edition = { workspace = true }`),
|
|
919
|
+
// `ws` the [workspace.package] edition literal, `isWorkspace` whether the manifest declares a [workspace]. An `edition` entry in one of
|
|
920
|
+
// those two tables that is none of the recognised spellings (`edition = 2021`, `edition.workspace = false`) is kept as `pkgBad` / `wsBad`
|
|
921
|
+
// (the line), never skipped, so it cannot fall back to a default. Keys before any table header count as [package] keys (the fixtures of
|
|
922
|
+
// this package's tests write manifests that way; cargo itself rejects them).
|
|
923
|
+
export function cargoEditions(text) {
|
|
924
|
+
const out = { pkg: undefined, ws: undefined, pkgBad: undefined, wsBad: undefined, isWorkspace: false };
|
|
925
|
+
let table = "";
|
|
926
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
927
|
+
const header = /^\s*\[\[?([^\]]+)\]\]?\s*(?:#.*)?$/.exec(raw);
|
|
928
|
+
if (header) {
|
|
929
|
+
table = header[1].trim();
|
|
930
|
+
if (table === "workspace" || table.startsWith("workspace.")) out.isWorkspace = true;
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
if (table !== "" && table !== "package" && table !== "workspace.package") continue;
|
|
934
|
+
const line = raw.replace(/^(\s*)(["'])edition\2/, "$1edition");
|
|
935
|
+
if (!/^\s*edition\s*(?:[.=]|$)/.test(line)) continue;
|
|
936
|
+
let value;
|
|
937
|
+
if (/^\s*edition\.workspace\s*=\s*true\s*(?:#.*)?$/.test(line) || /^\s*edition\s*=\s*\{\s*workspace\s*=\s*true\s*\}\s*(?:#.*)?$/.test(line)) value = "workspace";
|
|
938
|
+
else value = (/^\s*edition\s*=\s*(?:"(\d{4})"|'(\d{4})')\s*(?:#.*)?$/.exec(line) || []).slice(1).find(Boolean);
|
|
939
|
+
if (table === "workspace.package") {
|
|
940
|
+
if (value !== undefined && value !== "workspace") out.ws = value; else out.wsBad = raw.trim();
|
|
941
|
+
} else if (value !== undefined) out.pkg = value;
|
|
942
|
+
else out.pkgBad = raw.trim();
|
|
943
|
+
}
|
|
944
|
+
return out;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* The edition rustfmt must run a Rust file under: `{ edition }`, or `{ manifest, problem }` when it cannot be resolved.
|
|
949
|
+
* The nearest Cargo.toml at or above the file decides. A literal `edition = "NNNN"` is that edition; `edition.workspace = true`
|
|
950
|
+
* (or `edition = { workspace = true }`) is the `[workspace.package] edition` of the owning workspace root, the nearest manifest at or
|
|
951
|
+
* above it that declares `[workspace]`, which is the manifest itself for a root package. An inheritance that cannot be followed
|
|
952
|
+
* (no workspace root, an unreadable manifest, or a root with no literal `[workspace.package] edition`) is a `problem`, never a default.
|
|
953
|
+
* A manifest that names no edition at all, and a file below no manifest, keep rustfmt's "2021" as before.
|
|
954
|
+
*/
|
|
955
|
+
function rustEditionResolver({ contentRoot, exists, readFile }) {
|
|
956
|
+
const root = path.normalize(contentRoot);
|
|
957
|
+
const inside = (dir) => { const rel = path.relative(root, dir); return rel === "" || !(rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)); };
|
|
958
|
+
const rel = (dir) => posix(path.relative(root, path.join(dir, "Cargo.toml")));
|
|
959
|
+
const parsed = new Map();
|
|
960
|
+
const manifestAt = (dir) => {
|
|
961
|
+
if (!parsed.has(dir)) {
|
|
962
|
+
let value;
|
|
963
|
+
try { value = cargoEditions(readFile(path.join(dir, "Cargo.toml"))); } catch (e) { value = { unreadable: String(e?.message ?? e).split("\n")[0] }; }
|
|
964
|
+
parsed.set(dir, value);
|
|
965
|
+
}
|
|
966
|
+
return parsed.get(dir);
|
|
967
|
+
};
|
|
968
|
+
const ancestors = function* (start) {
|
|
969
|
+
for (let dir = start; inside(dir); dir = path.dirname(dir)) {
|
|
970
|
+
yield dir;
|
|
971
|
+
if (path.dirname(dir) === dir) return;
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
return (file) => {
|
|
975
|
+
const dir = [...ancestors(path.dirname(path.join(root, file)))].find((d) => exists(path.join(d, "Cargo.toml")));
|
|
976
|
+
if (dir === undefined) return { edition: "2021" };
|
|
977
|
+
const manifest = rel(dir);
|
|
978
|
+
const info = manifestAt(dir);
|
|
979
|
+
if (info.unreadable) return { manifest, problem: `${manifest} cannot be read (${info.unreadable}), so its edition is unknown` };
|
|
980
|
+
if (info.pkgBad !== undefined) return { manifest, problem: `${manifest} has an edition entry the scan cannot read (${info.pkgBad}); write edition = "NNNN" or edition.workspace = true` };
|
|
981
|
+
if (info.pkg === undefined) return { edition: info.ws ?? "2021" };
|
|
982
|
+
if (info.pkg !== "workspace") return { edition: info.pkg };
|
|
983
|
+
const inherits = `${manifest} sets edition.workspace = true`;
|
|
984
|
+
for (const up of ancestors(dir)) {
|
|
985
|
+
if (!exists(path.join(up, "Cargo.toml"))) continue;
|
|
986
|
+
const w = manifestAt(up);
|
|
987
|
+
if (w.unreadable) return { manifest, problem: `${inherits}, but ${rel(up)} cannot be read (${w.unreadable}), so its edition is unknown` };
|
|
988
|
+
if (!w.isWorkspace) continue;
|
|
989
|
+
if (w.wsBad !== undefined) return { manifest, problem: `${inherits}, but its workspace root ${rel(up)} has a [workspace.package] edition entry the scan cannot read (${w.wsBad})` };
|
|
990
|
+
if (w.ws === undefined) return { manifest, problem: `${inherits}, but its workspace root ${rel(up)} declares no literal [workspace.package] edition = "NNNN" to inherit` };
|
|
991
|
+
return { edition: w.ws };
|
|
992
|
+
}
|
|
993
|
+
return { manifest, problem: `${inherits}, but no Cargo.toml at or above it declares a [workspace], so there is no [workspace.package] edition to inherit` };
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// A crate that can never be published (`publish = false` or `publish = []` in its [package] table) needs no license key, like a
|
|
998
|
+
// package.json with `"private": true`; cargo-manifest-text.mjs holds the one reading of that rule, which the secret scan shares.
|
|
999
|
+
function cargoUnpublished(text) { return cargoManifestState(text).unpublished; }
|
|
1000
|
+
|
|
740
1001
|
const INTERNAL = {
|
|
741
1002
|
"json-parse": (s, ctx) => {
|
|
742
1003
|
const bad = [];
|
|
@@ -781,22 +1042,25 @@ const INTERNAL = {
|
|
|
781
1042
|
for (const f of s.files) {
|
|
782
1043
|
const text = ctx.read(f);
|
|
783
1044
|
if (f.endsWith("Cargo.toml")) {
|
|
784
|
-
if (!/^\s*(license|license-file)\s*(=|\.workspace)/m.test(text)) bad.push(`${f}: no license or license-file`);
|
|
1045
|
+
if (!/^\s*(license|license-file)\s*(=|\.workspace)/m.test(text) && !cargoUnpublished(text)) bad.push(`${f}: no license or license-file`);
|
|
785
1046
|
} else {
|
|
786
1047
|
try { const m = JSON.parse(text); if (m.private !== true && !(typeof m.license === "string" && m.license.trim()) && !m.licenses) bad.push(`${f}: no license field`); } catch (e) { bad.push(`${f}: ${e.message}`); }
|
|
787
1048
|
}
|
|
788
1049
|
}
|
|
789
1050
|
return bad;
|
|
790
1051
|
},
|
|
1052
|
+
// The repository gate reads env templates and test vectors against the reviewed records of the gated commit
|
|
1053
|
+
// (.rightkit/secret-scan-records.json, read from the content root by the scanner); crate packaging stays strict.
|
|
1054
|
+
// Each violation names its repository-relative path, so a unit that is both a crate and a package reports a file once.
|
|
791
1055
|
"secret-scan": async (s, ctx) => {
|
|
792
1056
|
const scan = await ctx.deps.scanSecrets();
|
|
793
|
-
const bad =
|
|
1057
|
+
const bad = new Set();
|
|
794
1058
|
for (const dir of s.dirs) {
|
|
795
1059
|
const abs = path.join(ctx.contentRoot, dir);
|
|
796
|
-
if (!ctx.deps.exists(abs)) { bad.
|
|
797
|
-
for (const v of scan(abs)) bad.
|
|
1060
|
+
if (!ctx.deps.exists(abs)) { bad.add(`${dir}: directory not found`); continue; }
|
|
1061
|
+
for (const v of scan(abs, { envTemplates: true, repoRoot: ctx.contentRoot })) bad.add(v);
|
|
798
1062
|
}
|
|
799
|
-
return bad;
|
|
1063
|
+
return [...bad];
|
|
800
1064
|
},
|
|
801
1065
|
};
|
|
802
1066
|
|
package/development.mjs
CHANGED
|
@@ -3,7 +3,8 @@ import { spawnSync } from "./background-process.mjs";
|
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, lstatSync, readdirSync, realpathSync, renameSync, rmSync, statSync } from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import { fileURLToPath
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
|
|
7
8
|
import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
|
|
8
9
|
import { assertReleaseExecutionAllowed } from "./execution-policy.mjs";
|
|
9
10
|
import { acquireManagedRunLease, managedRuntimeClient, managedRuntimeCommand, runCargoSync } from "./managed-cargo.mjs";
|
|
@@ -348,9 +349,9 @@ function resolveJavaScriptEntrypoint(candidate) {
|
|
|
348
349
|
function git(cwd, args) { const result = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true }); if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed`); return result.stdout.trim(); }
|
|
349
350
|
function inside(root, child) { const rel = path.relative(path.resolve(root), path.resolve(child)); return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)); }
|
|
350
351
|
export async function main(args = process.argv.slice(2)) {
|
|
351
|
-
let platform, configPath =
|
|
352
|
+
let platform, configPath = defaultReleaseConfigFile(), dryRun = false, buildOnly = false, serial;
|
|
352
353
|
for (let i = 0; i < args.length; i++) {
|
|
353
|
-
if (args[i] === "-h" || args[i] === "--help") { console.log("usage: right-release dev --platform win|mac|android|ios [--config right-release.config.
|
|
354
|
+
if (args[i] === "-h" || args[i] === "--help") { console.log("usage: right-release dev --platform win|mac|android|ios [--config right-release.config.json] [--dry-run] [--build-only] [--serial <adb serial | iOS device id>]"); return; }
|
|
354
355
|
if (args[i] === "--platform") platform = args[++i];
|
|
355
356
|
else if (args[i] === "--config") configPath = args[++i];
|
|
356
357
|
else if (args[i] === "--dry-run") dryRun = true;
|
|
@@ -366,8 +367,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
366
367
|
delete process.env.CARGO_INCREMENTAL;
|
|
367
368
|
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path");
|
|
368
369
|
if (pathKey) process.env[pathKey] = safeEnv[pathKey] ?? process.env[pathKey];
|
|
369
|
-
const
|
|
370
|
-
const config = loaded.default ?? loaded.config ?? loaded;
|
|
370
|
+
const config = await importReleaseConfig(absoluteConfig, { lenient: true });
|
|
371
371
|
const result = await runDevelopment({ root: path.dirname(absoluteConfig), platform, config, dryRun, buildOnly, serial });
|
|
372
372
|
if (dryRun) console.log(JSON.stringify(result.plan, null, 2));
|
|
373
373
|
return result;
|
package/github-release.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import os from "node:os";
|
|
|
6
6
|
import { readVerifiedArtifact, assertArtifactUnchanged, digestBytes, verifyArtifactFile } from "./publication-artifacts.mjs";
|
|
7
7
|
import { spawnSync } from "./background-process.mjs";
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
+
import { defaultReleaseConfigFile } from "./release-config.mjs";
|
|
9
10
|
import { verifySealedRelease } from "./release-state.mjs";
|
|
10
11
|
import { admitInstalledQa } from "./release-qa-audit.mjs";
|
|
11
12
|
import { addonManifestSha256, validateAddonConfig, validateAddonManifest } from "./addon-contract.mjs";
|
|
@@ -368,7 +369,7 @@ function runCombined(command, args, commandRunner = spawnSync) {
|
|
|
368
369
|
|
|
369
370
|
function usage(code) {
|
|
370
371
|
console.log([
|
|
371
|
-
"usage: right-release github --release <sealed-id> --tier patch|update --platform mac|win [--config right-release.config.
|
|
372
|
+
"usage: right-release github --release <sealed-id> --tier patch|update --platform mac|win [--config right-release.config.json] [--repo owner/repo] [--dry-run]",
|
|
372
373
|
" right-release github --addon <config> --platform mac|win [--repo owner/repo] [--dry-run]",
|
|
373
374
|
].join("\n"));
|
|
374
375
|
process.exit(code);
|
|
@@ -379,7 +380,8 @@ if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(
|
|
|
379
380
|
const args = process.argv.slice(2);
|
|
380
381
|
const options = { platform: process.platform === "win32" ? "win" : "mac", releaseId: "", addon: "", repo: "", dryRun: false };
|
|
381
382
|
let tier = "";
|
|
382
|
-
|
|
383
|
+
const defaultConfigName = defaultReleaseConfigFile();
|
|
384
|
+
let configName = defaultConfigName;
|
|
383
385
|
for (let index = 0; index < args.length; index += 1) {
|
|
384
386
|
if (args[index] === "--platform") options.platform = args[++index];
|
|
385
387
|
else if (args[index] === "--release") options.releaseId = args[++index];
|
|
@@ -393,7 +395,7 @@ if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(
|
|
|
393
395
|
else throw new Error(`unknown argument: ${args[index]}`);
|
|
394
396
|
}
|
|
395
397
|
if (Boolean(options.releaseId) === Boolean(options.addon)) usage(2);
|
|
396
|
-
if (options.addon && (tier || configName !==
|
|
398
|
+
if (options.addon && (tier || configName !== defaultConfigName)) throw new Error("--tier and --config apply to --release only; an add-on takes its configuration from --addon");
|
|
397
399
|
let plan;
|
|
398
400
|
if (options.addon) {
|
|
399
401
|
const repoRoot = runCommand("git", ["rev-parse", "--show-toplevel"]);
|
package/github-upload-gate.mjs
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
// - sealed ownership evidence that still matches the committed source;
|
|
9
9
|
// - an explicit distribution.provider of github-releases and a distribution.repository, with any --repo
|
|
10
10
|
// argument required to equal it (the origin remote is never consulted).
|
|
11
|
-
// Order matters:
|
|
12
|
-
// proven clean and the sealed commit equals HEAD.
|
|
11
|
+
// Order matters: a legacy release config is a module that runs on import (a JSON config is data), so the config is
|
|
12
|
+
// loaded only after the tree is proven clean and the sealed commit equals HEAD.
|
|
13
13
|
import { existsSync } from "node:fs";
|
|
14
14
|
import path from "node:path";
|
|
15
|
-
import { pathToFileURL } from "node:url";
|
|
16
15
|
import { spawnSync } from "./background-process.mjs";
|
|
16
|
+
import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
|
|
17
17
|
import { verifyOwnershipEvidence } from "./ownership-evidence.mjs";
|
|
18
18
|
import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
|
|
19
19
|
import { verifySealedRelease } from "./release-state.mjs";
|
|
@@ -48,9 +48,9 @@ export async function authorizeGitHubUpload({
|
|
|
48
48
|
tier,
|
|
49
49
|
releaseId,
|
|
50
50
|
platform,
|
|
51
|
-
configName = "right-release.config.mjs",
|
|
52
|
-
repo = "",
|
|
53
51
|
cwd = process.cwd(),
|
|
52
|
+
configName = defaultReleaseConfigFile(cwd),
|
|
53
|
+
repo = "",
|
|
54
54
|
}) {
|
|
55
55
|
assertUploadTier(tier);
|
|
56
56
|
if (!releaseId) throw new Error("--release is required");
|
|
@@ -69,7 +69,7 @@ export async function authorizeGitHubUpload({
|
|
|
69
69
|
|
|
70
70
|
const configPath = path.resolve(cwd, configName);
|
|
71
71
|
if (!existsSync(configPath)) throw new Error(`release config is required: ${configName}`);
|
|
72
|
-
const releaseConfig =
|
|
72
|
+
const releaseConfig = await importReleaseConfig(configPath, { query: `upload=${Date.now()}` });
|
|
73
73
|
const ownership = verifyOwnershipEvidence(sealed.manifest.ownershipEvidence, repoRoot, path.dirname(configPath));
|
|
74
74
|
if (!ownership.ok) throw new Error(`sealed ownership evidence rejected: ${ownership.reason}`);
|
|
75
75
|
const configuredRepo = resolveGitHubDistribution(releaseConfig, repo);
|