@rightkit/release 0.2.174 → 0.3.0
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 +178 -1
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +1 -1
- package/cli/right-release.mjs +29 -3
- package/command-spec.mjs +80 -0
- package/deps-cargo.mjs +55 -0
- package/deps.mjs +7 -0
- package/dist-scan.mjs +102 -0
- package/dmg-build.mjs +53 -4
- package/icon-pack.mjs +117 -0
- package/init-command.mjs +116 -0
- package/install-app.mjs +36 -3
- package/local-publish.mjs +317 -29
- package/macho-approval-hash.mjs +181 -0
- package/managed-cargo.mjs +49 -1
- package/media-sidecar.mjs +20 -4
- package/mirror-root-artifact.mjs +69 -21
- package/npm-license-notices.mjs +159 -0
- package/ownership-dependency-allowlist.json +1 -0
- package/ownership.mjs +37 -4
- package/package-recipe-targets.mjs +15 -1
- package/package.json +1 -1
- package/pe-content.mjs +133 -0
- package/publication-gate.mjs +70 -3
- package/publish-cargo.mjs +26 -7
- package/release-dependencies-command.mjs +45 -0
- package/release-dependencies.mjs +60 -0
- package/release-switch-scan.mjs +17 -7
- package/release.mjs +75 -4
- package/rightkit-ownership.json +46 -2
- package/rightkit-versions.json +5 -4
- package/runtime-budgets.mjs +14 -7
- package/stage-sidecar.mjs +505 -68
- package/test-command.mjs +190 -0
- package/toolchain-swift.mjs +239 -0
- package/web-build.mjs +67 -0
package/ownership.mjs
CHANGED
|
@@ -156,7 +156,31 @@ function resolveLocalPath(root, sourceFile, pathValue, coverage, display, invent
|
|
|
156
156
|
} catch { coverage.push({ path: display, status: "unscanned", reason: `${reasonPrefix}-path-unresolved${detail ? `:${detail}` : ""}` }); }
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
|
|
159
|
+
// A Git dependency pinned by a full 40-hex `rev` is immutable. When its URL is third-party it is equivalent to a registry
|
|
160
|
+
// dependency and is not a gap (Orthic-Labs/rightkit#677). A first-party URL (Orthic-Labs, bogusyogi, or an org listed in
|
|
161
|
+
// ownership-repositories.json) stays an unscanned fatal gap because first-party code must be scanned. A branch, a tag, a
|
|
162
|
+
// short rev or no rev is always an unscanned coverage gap.
|
|
163
|
+
const FULL_GIT_REV = /^[0-9a-f]{40}$/i;
|
|
164
|
+
export const PINNED_GIT_DEPENDENCY = "pinned-git-dependency";
|
|
165
|
+
const FIRST_PARTY_GIT_ORGS = new Set(["github.com/orthic-labs", "github.com/bogusyogi"]);
|
|
166
|
+
const gitHostOrg = (url) => {
|
|
167
|
+
const text = String(url).trim();
|
|
168
|
+
const match = /^(?:[a-z][a-z0-9+.-]*:\/\/)?(?:[^@/\s]+@)?([^/:\s]+)[/:]+([^/\s]+)/i.exec(text);
|
|
169
|
+
return match ? `${match[1]}/${match[2]}`.toLowerCase() : null;
|
|
170
|
+
};
|
|
171
|
+
let configuredFirstParty;
|
|
172
|
+
function firstPartyGitOrgs() {
|
|
173
|
+
if (configuredFirstParty) return configuredFirstParty;
|
|
174
|
+
const orgs = new Set(FIRST_PARTY_GIT_ORGS);
|
|
175
|
+
try {
|
|
176
|
+
const config = JSON.parse(readFileSync(new URL("./ownership-repositories.json", import.meta.url), "utf8"));
|
|
177
|
+
for (const repo of config.repositories ?? []) { const key = gitHostOrg(repo.repositoryIdentity ?? ""); if (key) orgs.add(key); }
|
|
178
|
+
} catch { /* the built-in first-party orgs still apply */ }
|
|
179
|
+
return (configuredFirstParty = orgs);
|
|
180
|
+
}
|
|
181
|
+
export const isFirstPartyGitUrl = (url) => { const key = gitHostOrg(url); return key === null || firstPartyGitOrgs().has(key); };
|
|
182
|
+
|
|
183
|
+
function parseCargo(parsed, file, out, workspace, root, coverage, inventory, covered = []) {
|
|
160
184
|
const pathSpec = (pathValue, sourceFile, display) => {
|
|
161
185
|
resolveLocalPath(root, sourceFile, pathValue, coverage, display, inventory);
|
|
162
186
|
};
|
|
@@ -176,7 +200,15 @@ function parseCargo(parsed, file, out, workspace, root, coverage, inventory) {
|
|
|
176
200
|
if (!raw || typeof raw !== "string" && (typeof raw !== "object" || Array.isArray(raw)) || features.some((x) => typeof x !== "string") || requirement !== null && typeof requirement !== "string") throw new Error(`malformed dependency declaration: ${alias}`);
|
|
177
201
|
addDependency(out, "cargo", name, file, { alias: name === alias ? null : alias, section: `${prefix}${section}`, requirement, features, defaultFeatures: spec["default-features"] ?? inherited["default-features"] ?? null, source, resolvedVersion: null, local: !!pathValue || !!git });
|
|
178
202
|
if (pathValue) pathSpec(pathValue, spec.path ? file : path.posix.join(workspace.dir ?? ".", "Cargo.toml"), file);
|
|
179
|
-
if (git)
|
|
203
|
+
if (git) {
|
|
204
|
+
const rev = spec.rev ?? inherited.rev;
|
|
205
|
+
const moving = [spec.branch, inherited.branch, spec.tag, inherited.tag].some((value) => value !== undefined);
|
|
206
|
+
if (typeof git === "string" && typeof rev === "string" && FULL_GIT_REV.test(rev) && !moving && !isFirstPartyGitUrl(git)) {
|
|
207
|
+
// Immutable third-party source, not a gap: kept out of `coverage` (every coverage row blocks the gate) and reported on
|
|
208
|
+
// `deps.covered`. `scanned: false` records that the source is pinned, not examined.
|
|
209
|
+
covered.push({ path: file, status: PINNED_GIT_DEPENDENCY, reason: `git-dependency-source:${name}`, fingerprint: `${git}@${rev.toLowerCase()}`, scanned: false });
|
|
210
|
+
} else coverage.push({ path: file, status: "unscanned", reason: `git-dependency-source:${name}` });
|
|
211
|
+
}
|
|
180
212
|
}
|
|
181
213
|
for (const [target, value] of Object.entries(table.target ?? {})) addTable(value, `${prefix}target.'${target}'.`);
|
|
182
214
|
};
|
|
@@ -375,7 +407,7 @@ function resolveDependencyVersions(appRoot, inventory, deps, coverage, contentDi
|
|
|
375
407
|
}
|
|
376
408
|
|
|
377
409
|
function dependenciesFromInventory(appRoot, inventory) {
|
|
378
|
-
const deps = [], coverage = [], contentDigests = [];
|
|
410
|
+
const deps = [], coverage = [], contentDigests = [], covered = [];
|
|
379
411
|
// Workspace inheritance is loaded before member manifests.
|
|
380
412
|
const manifestNames = new Set(["Cargo.toml", "package.json", "Package.swift"]);
|
|
381
413
|
const manifests = inventory.files.filter((x) => manifestNames.has(path.posix.basename(x.rel)));
|
|
@@ -419,7 +451,7 @@ function dependenciesFromInventory(appRoot, inventory) {
|
|
|
419
451
|
if (base === "Cargo.toml") {
|
|
420
452
|
const dir = path.posix.dirname(rel);
|
|
421
453
|
const workspace = workspaces.filter((x) => x.dir === "." || dir === x.dir || dir.startsWith(`${x.dir}/`)).sort((a, b) => b.dir.length - a.dir.length)[0] ?? { dir: ".", deps: {} };
|
|
422
|
-
try { parseCargo(parseToml(text), rel, deps, workspace, appRoot, coverage, inventory); }
|
|
454
|
+
try { parseCargo(parseToml(text), rel, deps, workspace, appRoot, coverage, inventory, covered); }
|
|
423
455
|
catch { coverage.push({ path: rel, status: "unscanned", reason: "invalid-cargo-toml" }); }
|
|
424
456
|
}
|
|
425
457
|
else if (base === "package.json") {
|
|
@@ -453,6 +485,7 @@ function dependenciesFromInventory(appRoot, inventory) {
|
|
|
453
485
|
resolveDependencyVersions(appRoot, inventory, deps, coverage, contentDigests);
|
|
454
486
|
Object.defineProperty(deps, "coverage", { value: coverage, enumerable: false });
|
|
455
487
|
Object.defineProperty(deps, "contentDigests", { value: contentDigests, enumerable: false });
|
|
488
|
+
Object.defineProperty(deps, "covered", { value: covered, enumerable: false });
|
|
456
489
|
Object.defineProperty(deps, "manifests", { value: files, enumerable: false });
|
|
457
490
|
return deps;
|
|
458
491
|
}
|
|
@@ -25,6 +25,7 @@ const withoutRecipe = (target) => {
|
|
|
25
25
|
};
|
|
26
26
|
const QUIET_TAURI_ENV = { TAURI_SIGNING_PRIVATE_KEY: "", TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "" };
|
|
27
27
|
const RECIPE_BUILD = fileURLToPath(new URL("./recipe-build.mjs", import.meta.url));
|
|
28
|
+
const CREATE_MAC_UPDATER = fileURLToPath(new URL("./create-mac-updater.mjs", import.meta.url));
|
|
28
29
|
const PROFILE = /^[a-z][a-z0-9_-]*$/;
|
|
29
30
|
|
|
30
31
|
function relativeInside(value, label) {
|
|
@@ -160,7 +161,7 @@ export function expandWinPackageTarget(target, { appRoot, config = {}, env = pro
|
|
|
160
161
|
};
|
|
161
162
|
}
|
|
162
163
|
|
|
163
|
-
const MAC_KEYS = new Set(["universal", "triple", "tauriCwd", "features", "profile", "tauriConfig", "prepare", "postBuild", "cleanup", "cargoTargetRoot"]);
|
|
164
|
+
const MAC_KEYS = new Set(["universal", "triple", "tauriCwd", "features", "profile", "tauriConfig", "prepare", "postBuild", "cleanup", "cargoTargetRoot", "updater"]);
|
|
164
165
|
const MAC_TRIPLES = ["aarch64-apple-darwin", "x86_64-apple-darwin"];
|
|
165
166
|
const MAC_CLEANUP_KEYS = new Set(["staleApps", "staleDmgVolumes", "lsclean"]);
|
|
166
167
|
|
|
@@ -179,6 +180,8 @@ function macCleanupSpec(value) {
|
|
|
179
180
|
* The app path and DMG path are `notarize.app` and `notarize.file`; DMG branding is `targets.mac.dmg`.
|
|
180
181
|
* Architecture: `triple` (an explicit arch triple) beats `universal`; `universal: false` means this host's arch.
|
|
181
182
|
* `cleanup` runs built-in steps in recipe-build before the build (stale DMG volumes, stale .app bundles, lsregister).
|
|
183
|
+
* `updater: true | "<app-relative .tar.gz>"` emits the Tauri updater tarball and its signature from the signed, stapled .app:
|
|
184
|
+
* it prepends a `create-mac-updater` command to `postSign` (default output `<App>.app.tar.gz` beside the app's working root).
|
|
182
185
|
*/
|
|
183
186
|
export function expandMacPackageTarget(target, { appRoot, env = process.env, resolveTargetRoot = cargoResolveTargetRoot }) {
|
|
184
187
|
if (!target?.macPackage) return withoutRecipe(target);
|
|
@@ -196,6 +199,8 @@ export function expandMacPackageTarget(target, { appRoot, env = process.env, res
|
|
|
196
199
|
const cwd = spec.tauriCwd ?? ".";
|
|
197
200
|
if (typeof cwd !== "string" || path.isAbsolute(cwd) || cwd.split(/[\\/]/).includes("..")) fail("targets.mac.macPackage.tauriCwd must be a relative path inside the app");
|
|
198
201
|
const cleanupSpec = macCleanupSpec(spec.cleanup);
|
|
202
|
+
if (spec.updater !== undefined && spec.updater !== true && spec.updater !== false && typeof spec.updater !== "string") fail("targets.mac.macPackage.updater must be true or an app-relative .tar.gz path");
|
|
203
|
+
if (typeof spec.updater === "string" && !/\.tar\.gz$/i.test(relativeInside(spec.updater, "targets.mac.macPackage.updater"))) fail("targets.mac.macPackage.updater must end in .tar.gz");
|
|
199
204
|
// Tauri's universal-apple-darwin target builds both slices and lipos them itself.
|
|
200
205
|
const triple = spec.triple ?? (spec.universal === false ? (process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin") : "universal-apple-darwin");
|
|
201
206
|
if (spec.triple !== undefined) {
|
|
@@ -225,8 +230,17 @@ export function expandMacPackageTarget(target, { appRoot, env = process.env, res
|
|
|
225
230
|
: undefined;
|
|
226
231
|
const wrapped = extras.prepare.length || extras.postBuild.length || cleanup;
|
|
227
232
|
const { macPackage, recipe: _ignoredMac, ...rest } = target;
|
|
233
|
+
// The updater tarball is made from the signed .app, so it runs in postSign, ahead of any postSign the app declares.
|
|
234
|
+
const updaterOutput = spec.updater === true ? `${productName}.app.tar.gz` : typeof spec.updater === "string" ? relativeInside(spec.updater, "targets.mac.macPackage.updater") : undefined;
|
|
235
|
+
const postSign = updaterOutput === undefined ? {} : {
|
|
236
|
+
postSign: [
|
|
237
|
+
{ cmd: process.execPath, args: [CREATE_MAC_UPDATER, "--app", app, "--output", updaterOutput] },
|
|
238
|
+
...(target.postSign === undefined ? [] : Array.isArray(target.postSign) ? target.postSign : [target.postSign]),
|
|
239
|
+
],
|
|
240
|
+
};
|
|
228
241
|
return {
|
|
229
242
|
...rest,
|
|
243
|
+
...postSign,
|
|
230
244
|
prePackage: wrapped ? recipeBuildCommand({ extras, build: { cmd: "pnpm", args, cwd }, env: QUIET_TAURI_ENV, cleanup }) : { cmd: "pnpm", args, cwd, env: QUIET_TAURI_ENV },
|
|
231
245
|
sign: { ...(target.sign ?? {}), prePackageFiles },
|
|
232
246
|
notarize: { ...target.notarize, app, appBeforePackage: true },
|
package/package.json
CHANGED
package/pe-content.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Signature-independent PE content digest and import-closure check (bogusyogi/rightkit#445).
|
|
3
|
+
// right-release pe digest <file> [--algorithm sha256|sha384|sha512] [--json]
|
|
4
|
+
// right-release pe imports <file> [--allow <dll[,dll...]>]... [--allow-file <path>] [--json]
|
|
5
|
+
// Pure Buffer parsing, so both run on any host. The digest follows the Authenticode image hash: the CheckSum field,
|
|
6
|
+
// the Certificate Table directory entry and the certificate data itself are excluded, so re-signing or timestamping
|
|
7
|
+
// a binary never changes it.
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { readPeImports } from "./pe-imports.mjs";
|
|
11
|
+
|
|
12
|
+
const ALGORITHMS = new Set(["sha256", "sha384", "sha512"]);
|
|
13
|
+
function fail(message, code = "PE_INVALID") { throw Object.assign(new Error(message), { code }); }
|
|
14
|
+
|
|
15
|
+
function layout(buf) {
|
|
16
|
+
if (buf.length < 0x40 || buf.readUInt16LE(0) !== 0x5a4d) fail("not a PE file (missing MZ)");
|
|
17
|
+
const pe = buf.readUInt32LE(0x3c);
|
|
18
|
+
if (pe + 24 > buf.length || buf.readUInt32LE(pe) !== 0x00004550) fail("not a PE file (missing PE signature)");
|
|
19
|
+
const sectionCount = buf.readUInt16LE(pe + 6);
|
|
20
|
+
const optionalSize = buf.readUInt16LE(pe + 20);
|
|
21
|
+
const optional = pe + 24;
|
|
22
|
+
const magic = buf.readUInt16LE(optional);
|
|
23
|
+
if (magic !== 0x20b && magic !== 0x10b) fail("unsupported PE optional header");
|
|
24
|
+
const directories = optional + (magic === 0x20b ? 112 : 96);
|
|
25
|
+
const numberOfDirectories = buf.readUInt32LE(directories - 4);
|
|
26
|
+
const checksumAt = optional + 64;
|
|
27
|
+
const sizeOfHeaders = buf.readUInt32LE(optional + 60);
|
|
28
|
+
const certEntryAt = directories + 4 * 8;
|
|
29
|
+
const hasCert = numberOfDirectories > 4;
|
|
30
|
+
if (checksumAt + 4 > buf.length || sizeOfHeaders > buf.length || (hasCert && certEntryAt + 8 > buf.length)) fail("truncated PE headers");
|
|
31
|
+
const sectionsAt = optional + optionalSize;
|
|
32
|
+
const sections = [];
|
|
33
|
+
for (let i = 0; i < sectionCount; i++) {
|
|
34
|
+
const at = sectionsAt + i * 40;
|
|
35
|
+
if (at + 40 > buf.length) fail("truncated PE section table");
|
|
36
|
+
const rawSize = buf.readUInt32LE(at + 16);
|
|
37
|
+
const rawPtr = buf.readUInt32LE(at + 20);
|
|
38
|
+
if (rawSize > 0 && rawPtr + rawSize > buf.length) fail("PE section extends past the end of the file");
|
|
39
|
+
sections.push({ rawSize, rawPtr });
|
|
40
|
+
}
|
|
41
|
+
// The certificate table directory holds a FILE OFFSET (not an RVA).
|
|
42
|
+
const cert = hasCert ? { offset: buf.readUInt32LE(certEntryAt), size: buf.readUInt32LE(certEntryAt + 4) } : { offset: 0, size: 0 };
|
|
43
|
+
if (cert.size === 0 && cert.offset !== 0) fail("PE certificate table has an offset but no size");
|
|
44
|
+
if (cert.size > 0) {
|
|
45
|
+
if (cert.offset + cert.size > buf.length) fail("PE certificate table extends past the end of the file");
|
|
46
|
+
// The certificate table must be the terminal region: bytes after it would otherwise escape the digest.
|
|
47
|
+
if (cert.offset + cert.size !== buf.length) fail("PE certificate table is not the terminal region of the file (bytes follow it)");
|
|
48
|
+
if (cert.offset < sizeOfHeaders) fail("PE certificate table overlaps the headers");
|
|
49
|
+
for (const s of sections) if (s.rawSize > 0 && s.rawPtr + s.rawSize > cert.offset) fail("PE certificate table overlaps a section");
|
|
50
|
+
}
|
|
51
|
+
return { checksumAt, sizeOfHeaders, certEntryAt, hasCert, sections, cert };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Authenticode-style SHA-2 digest of a PE image, independent of its signature, timestamp and CheckSum. */
|
|
55
|
+
export function peContentDigest(bytes, { algorithm = "sha256" } = {}) {
|
|
56
|
+
if (!ALGORITHMS.has(algorithm)) fail(`unsupported digest algorithm: ${algorithm}`, "PE_USAGE");
|
|
57
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
58
|
+
const l = layout(buf);
|
|
59
|
+
const hash = createHash(algorithm);
|
|
60
|
+
hash.update(buf.subarray(0, l.checksumAt));
|
|
61
|
+
if (l.hasCert) {
|
|
62
|
+
hash.update(buf.subarray(l.checksumAt + 4, l.certEntryAt));
|
|
63
|
+
hash.update(buf.subarray(l.certEntryAt + 8, l.sizeOfHeaders));
|
|
64
|
+
} else {
|
|
65
|
+
hash.update(buf.subarray(l.checksumAt + 4, l.sizeOfHeaders));
|
|
66
|
+
}
|
|
67
|
+
// Every byte after the headers up to the certificate table (or end of file) is hashed: sections, inter-section gaps and overlay.
|
|
68
|
+
const trailEnd = l.cert.size > 0 ? l.cert.offset : buf.length;
|
|
69
|
+
if (trailEnd > l.sizeOfHeaders) hash.update(buf.subarray(l.sizeOfHeaders, trailEnd));
|
|
70
|
+
return hash.digest("hex");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const normalize = (name) => String(name).trim().toLowerCase();
|
|
74
|
+
|
|
75
|
+
function allowMatcher(allowed) {
|
|
76
|
+
const entries = allowed.map(normalize).filter(Boolean);
|
|
77
|
+
return (dll) => entries.some((entry) => (entry.endsWith("*") ? dll.startsWith(entry.slice(0, -1)) : dll === entry));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check that every DLL a PE imports (regular and delay-load) is in `allowedDlls`. Names compare case-insensitively;
|
|
82
|
+
* a trailing `*` allows a prefix (`api-ms-win-*`). An empty allow list refuses every import.
|
|
83
|
+
* @returns {{ok: boolean, architecture: string, imports: string[], delayImports: string[], unexpected: string[]}}
|
|
84
|
+
*/
|
|
85
|
+
export function verifyImportClosure(bytes, allowedDlls) {
|
|
86
|
+
if (!Array.isArray(allowedDlls)) fail("allowedDlls must be an array", "PE_USAGE");
|
|
87
|
+
const info = readPeImports(bytes);
|
|
88
|
+
const allowed = allowMatcher(allowedDlls);
|
|
89
|
+
const all = [...new Set([...info.imports, ...info.delayImports].map(normalize))].sort();
|
|
90
|
+
const unexpected = all.filter((dll) => !allowed(dll));
|
|
91
|
+
return { ok: unexpected.length === 0, architecture: info.architecture, imports: info.imports, delayImports: info.delayImports, unexpected };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const USAGE = "usage: right-release pe digest <file> [--algorithm sha256|sha384|sha512] [--json] | pe imports <file> [--allow dll,...] [--allow-file <path>] [--json]";
|
|
95
|
+
|
|
96
|
+
function parseArgs(argv) {
|
|
97
|
+
const [sub, file, ...rest] = argv;
|
|
98
|
+
const opts = { sub, file, algorithm: "sha256", allow: [], json: false };
|
|
99
|
+
for (let i = 0; i < rest.length; i++) {
|
|
100
|
+
const arg = rest[i];
|
|
101
|
+
if (arg === "--json") opts.json = true;
|
|
102
|
+
else if (["--algorithm", "--allow", "--allow-file"].includes(arg)) {
|
|
103
|
+
const value = rest[++i];
|
|
104
|
+
if (value === undefined || value.startsWith("--")) fail(`${arg} needs a value`, "PE_USAGE");
|
|
105
|
+
if (arg === "--algorithm") opts.algorithm = value;
|
|
106
|
+
else if (arg === "--allow") opts.allow.push(...value.split(","));
|
|
107
|
+
else opts.allow.push(...readFileSync(value, "utf8").split(/\r?\n/).map((l) => l.replace(/#.*/, "").trim()).filter(Boolean));
|
|
108
|
+
} else fail(`unknown pe argument: ${arg}`, "PE_USAGE");
|
|
109
|
+
}
|
|
110
|
+
if (!["digest", "imports"].includes(sub) || !file) fail(USAGE, "PE_USAGE");
|
|
111
|
+
return opts;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function main(argv = process.argv.slice(2), out = console) {
|
|
115
|
+
let opts;
|
|
116
|
+
try { opts = parseArgs(argv); } catch (error) { out.error(error.message); return 2; }
|
|
117
|
+
try {
|
|
118
|
+
const bytes = readFileSync(opts.file);
|
|
119
|
+
if (opts.sub === "digest") {
|
|
120
|
+
const digest = peContentDigest(bytes, { algorithm: opts.algorithm });
|
|
121
|
+
out.log(opts.json ? JSON.stringify({ file: opts.file, algorithm: opts.algorithm, digest }) : `${opts.algorithm}:${digest}`);
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
const result = verifyImportClosure(bytes, opts.allow);
|
|
125
|
+
out.log(opts.json ? JSON.stringify({ file: opts.file, ...result }) : [`${opts.file} (${result.architecture})`, ...result.imports.map((d) => ` import ${d}`), ...result.delayImports.map((d) => ` delay-import ${d}`), ...result.unexpected.map((d) => ` UNEXPECTED ${d}`)].join("\n"));
|
|
126
|
+
return result.ok ? 0 : 1;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
out.error(error.message);
|
|
129
|
+
return error.code === "PE_USAGE" ? 2 : 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) process.exit(main());
|
package/publication-gate.mjs
CHANGED
|
@@ -48,11 +48,73 @@ function describeCommitMismatch(evidenceCommit, head, packagePath, git) {
|
|
|
48
48
|
return parts.join("; ");
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
const text = (value) => typeof value === "string" && value.trim() !== "";
|
|
52
|
+
const escapeRegExp = (value) => String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
53
|
+
|
|
54
|
+
// A batch publication (local-publish.mjs --crates) runs fmt, the secret scan and clippy ONCE for the whole
|
|
55
|
+
// batch at one commit and tree, then copies that receipt into every crate's evidence run as `run.batch`.
|
|
56
|
+
// The receipt proves, for THIS crate, the same three checks a per-crate run proves; it is bound to the evidence
|
|
57
|
+
// commit and to the HEAD tree (the git tree of that commit), so it cannot be carried to another commit.
|
|
58
|
+
export const BATCH_CHECKS = Object.freeze(["fmt", "secret-scan", "clippy"]);
|
|
59
|
+
const BATCH_COMMAND = Object.freeze({
|
|
60
|
+
fmt: /\bfmt\b.*--check\b/,
|
|
61
|
+
"secret-scan": /\S/,
|
|
62
|
+
clippy: /\bclippy\b.*--all-targets\b.*-D warnings\s*$/,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** Problems with a batch receipt for the crate `evidence.name` (empty when it proves fmt, secret scan and clippy).
|
|
66
|
+
* `tree` is the current HEAD tree when known; `undefined` skips only that comparison. */
|
|
67
|
+
export function batchReceiptProblems(batch, evidence, tree) {
|
|
68
|
+
if (!batch || typeof batch !== "object" || Array.isArray(batch)) return ["batch receipt must be an object"];
|
|
69
|
+
const problems = [];
|
|
70
|
+
if (!text(batch.id)) problems.push("batch.id");
|
|
71
|
+
if (batch.commit !== evidence.commit) problems.push(`batch.commit (must equal ${evidence.commit})`);
|
|
72
|
+
if (!SHA.test(String(batch.tree ?? ""))) problems.push("batch.tree (must be a 40-hex git tree)");
|
|
73
|
+
else if (tree !== undefined && batch.tree !== tree) problems.push(`batch.tree (must equal the HEAD tree ${tree})`);
|
|
74
|
+
if (!(Array.isArray(batch.crates) && batch.crates.includes(evidence.name))) problems.push("batch.crates (must name this crate)");
|
|
75
|
+
const checks = Array.isArray(batch.checks) ? batch.checks : [];
|
|
76
|
+
for (const name of BATCH_CHECKS) {
|
|
77
|
+
const rows = checks.filter((row) => row?.name === name);
|
|
78
|
+
if (!rows.length) { problems.push(`batch check ${name} missing`); continue; }
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
if (row.ok !== true) problems.push(`batch check ${name} not ok`);
|
|
81
|
+
if (row.commit !== evidence.commit || row.tree !== batch.tree) problems.push(`batch check ${name} not bound to the batch commit and tree`);
|
|
82
|
+
if (!(text(row.command) && BATCH_COMMAND[name].test(row.command))) problems.push(`batch check ${name} command`);
|
|
83
|
+
}
|
|
84
|
+
// fmt is workspace-wide; clippy and the secret scan name the crates they covered.
|
|
85
|
+
if (name !== "fmt" && !rows.some((row) => Array.isArray(row.crates) && row.crates.includes(evidence.name))) problems.push(`batch check ${name} does not cover this crate`);
|
|
86
|
+
}
|
|
87
|
+
return problems;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Which per-crate publication steps the evidence ALREADY proves for this exact commit, so the publisher may
|
|
92
|
+
* skip repeating them: `tests` (a passing `cargo test -p <name>` step that executed tests), `clippy` (a passing
|
|
93
|
+
* `cargo clippy -p <name> ... -D warnings` step, or a valid batch receipt covering the crate), and `fmt` and
|
|
94
|
+
* `secretScan` (a valid batch receipt covering the crate). Every darwin run at the evidence commit must prove a
|
|
95
|
+
* step for it to count. Call after the gate accepted the evidence (the gate binds commit, tree and artifact).
|
|
96
|
+
*/
|
|
97
|
+
export function provenGateSteps(evidence) {
|
|
98
|
+
const none = { tests: false, clippy: false, fmt: false, secretScan: false };
|
|
99
|
+
if (!evidence || !SHA.test(String(evidence.commit ?? "")) || !text(evidence.name)) return none;
|
|
100
|
+
const runs = (Array.isArray(evidence.runs) ? evidence.runs : []).filter((run) => run?.host?.os === "darwin");
|
|
101
|
+
if (!runs.length || runs.some((run) => run.commit !== evidence.commit)) return none;
|
|
102
|
+
const name = escapeRegExp(evidence.name);
|
|
103
|
+
const stepProves = (run, pattern, needTests) => (Array.isArray(run.steps) ? run.steps : []).some((step) => step?.ok === true
|
|
104
|
+
&& pattern.test(String(step.command ?? "")) && (!needTests || (step.executed > 0 && step.failed === 0)));
|
|
105
|
+
const batchOk = (run) => run.batch !== undefined && batchReceiptProblems(run.batch, evidence, undefined).length === 0;
|
|
106
|
+
return {
|
|
107
|
+
tests: runs.every((run) => stepProves(run, new RegExp(`^cargo test -p ${name}( |$)`), true)),
|
|
108
|
+
clippy: runs.every((run) => batchOk(run) || stepProves(run, new RegExp(`^cargo clippy -p ${name} .*-- -D warnings$`), false)),
|
|
109
|
+
fmt: runs.every(batchOk),
|
|
110
|
+
secretScan: runs.every(batchOk),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
51
114
|
// Names of the fields of one host run that do not qualify (empty when the run qualifies). Every run binds to
|
|
52
115
|
// the same unit, commit and artifact: commit, host arch, feature set and artifactSha256 equal to the evidence.
|
|
53
|
-
function invalidRunFields(run, evidence) {
|
|
116
|
+
function invalidRunFields(run, evidence, tree) {
|
|
54
117
|
const bad = [];
|
|
55
|
-
const text = (value) => typeof value === "string" && value.trim() !== "";
|
|
56
118
|
if (run.commit !== evidence.commit) bad.push(`commit (must equal ${evidence.commit})`);
|
|
57
119
|
if (!text(run.host?.arch)) bad.push("host.arch");
|
|
58
120
|
if (!(Array.isArray(run.features) && run.features.length > 0 && run.features.every(text))) bad.push("features");
|
|
@@ -62,6 +124,7 @@ function invalidRunFields(run, evidence) {
|
|
|
62
124
|
if (!text(run.timestamp)) bad.push("timestamp");
|
|
63
125
|
if (!(Number.isInteger(run.passed) && run.passed > 0)) bad.push("passed (must be > 0)");
|
|
64
126
|
if (run.failed !== 0) bad.push("failed (must be 0)");
|
|
127
|
+
if (run.batch !== undefined) bad.push(...batchReceiptProblems(run.batch, evidence, tree ?? null).map((problem) => `batch: ${problem}`));
|
|
65
128
|
return bad;
|
|
66
129
|
}
|
|
67
130
|
|
|
@@ -88,6 +151,10 @@ export function checkPublicationGate({ kind, name, version, packagePath, evidenc
|
|
|
88
151
|
missing.push(`evidence commit ${evidence.commit} != current HEAD ${head} (${describeCommitMismatch(evidence.commit, head, packagePath, git)})`);
|
|
89
152
|
}
|
|
90
153
|
const runs = Array.isArray(evidence.runs) ? evidence.runs : [];
|
|
154
|
+
// A batch receipt is bound to the HEAD tree as well as the commit; read it only when a run carries one.
|
|
155
|
+
// `tree ?? null` below makes an unreadable tree fail the comparison (null never equals a 40-hex tree).
|
|
156
|
+
const tree = runs.some((run) => run?.batch !== undefined) ? git(["rev-parse", "HEAD^{tree}"], packagePath) : undefined;
|
|
157
|
+
if (tree === null) missing.push(`cannot read the HEAD tree for ${packagePath}`);
|
|
91
158
|
// npm packages and crates may ship on macOS evidence first when Adrian decides so; the decision must be
|
|
92
159
|
// recorded in the evidence (hostPolicy "darwin-only" + a non-empty hostPolicyReason).
|
|
93
160
|
// Crates may ship Mac-first too (Adrian 2026-10-07): the Windows run follows; the reason is recorded.
|
|
@@ -98,7 +165,7 @@ export function checkPublicationGate({ kind, name, version, packagePath, evidenc
|
|
|
98
165
|
if (!matching.length) { missing.push(`no ${label} host run receipt`); continue; }
|
|
99
166
|
// Every run on the host must qualify: a failing run is not masked by a passing run beside it.
|
|
100
167
|
matching.forEach((run, index) => {
|
|
101
|
-
const fields = invalidRunFields(run, evidence);
|
|
168
|
+
const fields = invalidRunFields(run, evidence, tree);
|
|
102
169
|
if (fields.length) missing.push(`${label} run receipt ${index} invalid: ${fields.join(", ")}`);
|
|
103
170
|
});
|
|
104
171
|
}
|
package/publish-cargo.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { enforcePublicationGate, defaultEvidencePath } from "./publication-gate.mjs";
|
|
5
|
+
import { enforcePublicationGate, defaultEvidencePath, provenGateSteps } from "./publication-gate.mjs";
|
|
6
6
|
import { runCargoSync } from "./managed-cargo.mjs";
|
|
7
7
|
import { readVerifiedArtifact, assertArtifactUnchanged } from "./publication-artifacts.mjs";
|
|
8
8
|
import { verifyCargoRegistry } from "./cargo-registry-readback.mjs";
|
|
@@ -53,11 +53,22 @@ export function runtimeBudgetDeferral(crateDir, crate) {
|
|
|
53
53
|
return `${data.reason} (${data.approvedBy}, ${data.approved}; ci/runtime-budget-deferred.json)`;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
// Plan step label -> the `provenGateSteps` key whose proof lets that step be skipped.
|
|
57
|
+
const PROVEN_STEP = Object.freeze({ "secret scan": "secretScan", format: "fmt", tests: "tests", clippy: "clippy" });
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `proven` (from `provenGateSteps(evidence)`) removes a secret scan, format, tests or clippy step the evidence
|
|
61
|
+
* already proves for this exact commit; the runtime budget, package contents, archive hash and registry steps
|
|
62
|
+
* are never skippable. `verifyOnPublish` (real publish only) drops the separate registry dry-run and lets the
|
|
63
|
+
* real `cargo publish` run Cargo's own verify build (no `--no-verify`), so the package compiles once, before
|
|
64
|
+
* the upload, instead of in a dry-run and not at all in the publish.
|
|
65
|
+
*/
|
|
66
|
+
export function buildCargoPublishPlan({ crate, crateDir, dryRun, allowDirty = false, proven = {}, verifyOnPublish = false }) {
|
|
57
67
|
const dryRunArgs = ["publish", "--dry-run", "-p", crate];
|
|
58
68
|
const packageArgs = ["package", "--list", "-p", crate];
|
|
59
69
|
if (allowDirty) dryRunArgs.push("--allow-dirty");
|
|
60
70
|
if (allowDirty) packageArgs.push("--allow-dirty");
|
|
71
|
+
const verifyHere = verifyOnPublish && !dryRun;
|
|
61
72
|
const plan = [
|
|
62
73
|
{ label: "secret scan", internal: "secret-scan", crateDir },
|
|
63
74
|
// Runtime-budget crates (runtime-budgets.mjs) must carry a measured runtime-budget.json for this
|
|
@@ -67,15 +78,15 @@ export function buildCargoPublishPlan({ crate, crateDir, dryRun, allowDirty = fa
|
|
|
67
78
|
{ label: "tests", command: "cargo", args: ["test", "-p", crate, "--", "--test-threads=1"] },
|
|
68
79
|
{ label: "clippy", command: "cargo", args: ["clippy", "-p", crate, "--all-targets", "--", "-D", "warnings"] },
|
|
69
80
|
{ label: "package contents", command: "cargo", args: packageArgs, capture: true },
|
|
70
|
-
{ label: "registry dry-run", command: "cargo", args: dryRunArgs },
|
|
71
|
-
];
|
|
81
|
+
...(verifyHere ? [] : [{ label: "registry dry-run", command: "cargo", args: dryRunArgs }]),
|
|
82
|
+
].filter((step) => !(PROVEN_STEP[step.label] && proven[PROVEN_STEP[step.label]] === true));
|
|
72
83
|
if (!dryRun) plan.push(
|
|
73
84
|
// `publish --dry-run` leaves its archive under target/package/tmp-crate on current Cargo; package
|
|
74
85
|
// explicitly to bind target/package/<name>-<version>.crate to evidence before Cargo uploads.
|
|
75
86
|
{ label: "package archive", command: "cargo", args: ["package", "--no-verify", "-p", crate, ...(allowDirty ? ["--allow-dirty"] : [])] },
|
|
76
87
|
{ label: "package location", command: "cargo", args: ["metadata", "--format-version", "1", "--no-deps"], capture: true },
|
|
77
88
|
{ label: "archive hash check", internal: "verify-archive" },
|
|
78
|
-
{ label: "registry publish", command: "cargo", args: ["publish", "--no-verify", "-p", crate] },
|
|
89
|
+
{ label: "registry publish", command: "cargo", args: verifyHere ? ["publish", "--locked", "-p", crate] : ["publish", "--no-verify", "-p", crate] },
|
|
79
90
|
{ label: "registry readback", internal: "registry-readback" },
|
|
80
91
|
);
|
|
81
92
|
return plan;
|
|
@@ -119,7 +130,14 @@ export async function runCargoPublish(options, execute = (_command, args, settin
|
|
|
119
130
|
};
|
|
120
131
|
const gate = (deps.enforceGate ?? enforcePublicationGate)(gateOptions, { dryRun: options.dryRun });
|
|
121
132
|
const expectedSha256 = gate.evidence?.artifactSha256;
|
|
122
|
-
|
|
133
|
+
// Only the local publisher (local-publish.mjs) sets `trustEvidenceSteps`: it ran the tests and clippy for this
|
|
134
|
+
// exact commit itself and wrote them into the evidence the gate above just validated. A plain publish proves
|
|
135
|
+
// nothing in advance, so `proven` stays empty and every step runs.
|
|
136
|
+
const proven = options.trustEvidenceSteps === true && !options.dryRun ? provenGateSteps(gate.evidence) : {};
|
|
137
|
+
const plan = buildCargoPublishPlan({ ...options, crateDir, proven, verifyOnPublish: options.verifyOnPublish === true });
|
|
138
|
+
for (const [label, key] of Object.entries(PROVEN_STEP)) {
|
|
139
|
+
if (proven[key] === true) process.stdout.write(`[right-release] cargo ${label}: proven by the gate evidence for this commit, not repeated\n`);
|
|
140
|
+
}
|
|
123
141
|
|
|
124
142
|
let targetDir;
|
|
125
143
|
let artifact;
|
|
@@ -159,7 +177,8 @@ export async function runCargoPublish(options, execute = (_command, args, settin
|
|
|
159
177
|
continue;
|
|
160
178
|
}
|
|
161
179
|
if (step.label === "registry publish") assertArtifactUnchanged(artifact);
|
|
162
|
-
|
|
180
|
+
// `execute` may be synchronous (the default) or async (the batch publisher overlaps uploads).
|
|
181
|
+
const result = await execute(step.command, step.args, {
|
|
163
182
|
cwd: options.workspaceRoot,
|
|
164
183
|
encoding: "utf8",
|
|
165
184
|
windowsHide: true,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
|
+
import { assertReleaseDependencies } from "./release-dependencies.mjs";
|
|
5
|
+
import { nodeHostTriple } from "./stage-sidecar-command.mjs";
|
|
6
|
+
import { expandPackageRecipeTarget } from "./package-recipe-targets.mjs";
|
|
7
|
+
|
|
8
|
+
/** Standalone policy check: host target by default, all declared release targets on request. */
|
|
9
|
+
export async function main(argv, { cwd = process.cwd(), platform = process.platform, arch = process.arch, run } = {}) {
|
|
10
|
+
let configPath = "right-release.config.mjs", selected, allTargets = false;
|
|
11
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
12
|
+
const arg = argv[i];
|
|
13
|
+
if (["--config", "--platform"].includes(arg)) {
|
|
14
|
+
const value = argv[++i];
|
|
15
|
+
if (!value || value.startsWith("--")) throw new Error(`${arg} needs a value`);
|
|
16
|
+
if (arg === "--config") configPath = value; else selected = value;
|
|
17
|
+
} else if (arg === "--all-targets") allTargets = true;
|
|
18
|
+
else throw new Error(`unknown check-release-dependencies argument: ${arg}`);
|
|
19
|
+
}
|
|
20
|
+
if (allTargets && selected) throw new Error("use --platform or --all-targets");
|
|
21
|
+
const file = path.resolve(cwd, configPath);
|
|
22
|
+
const root = path.dirname(file);
|
|
23
|
+
const config = (await import(pathToFileURL(file).href)).default;
|
|
24
|
+
if (config?.releaseDependencies === undefined) throw new Error("check-release-dependencies requires releaseDependencies in config");
|
|
25
|
+
const host = { darwin: "mac", win32: "win", linux: "linux" }[platform];
|
|
26
|
+
const platforms = allTargets ? Object.keys(config.targets ?? {}) : [selected ?? host];
|
|
27
|
+
if (!platforms.length) throw new Error("no release targets declared");
|
|
28
|
+
const results = [];
|
|
29
|
+
for (const name of platforms) {
|
|
30
|
+
let target = config.targets?.[name];
|
|
31
|
+
if (!name || !target) throw new Error(`no release target for ${name ?? platform}; use --platform or --all-targets`);
|
|
32
|
+
target = expandPackageRecipeTarget(target, { platform: name, appRoot: root, config });
|
|
33
|
+
if (name !== host && !(target.cargoTarget ?? target.targetTriple ?? target.rustTarget)) throw new Error(`${name} must declare cargoTarget, targetTriple or rustTarget`);
|
|
34
|
+
if (!allTargets && !selected) target = { ...target, cargoTarget: nodeHostTriple(platform, arch) };
|
|
35
|
+
results.push(assertReleaseDependencies({ root, config, target, run }));
|
|
36
|
+
}
|
|
37
|
+
return results;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
|
|
41
|
+
main(process.argv.slice(2)).then((results) => console.log(JSON.stringify(results, null, 2))).catch((error) => {
|
|
42
|
+
console.error(`right-release check-release-dependencies: ${error.message}`);
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// App-specific release dependency policy. Development builds never call this gate.
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { resolveCargoTree } from "./release-switch-scan.mjs";
|
|
5
|
+
import { expandRecipeBuildCommands } from "./recipe-build.mjs";
|
|
6
|
+
import { nodeHostTriple } from "./stage-sidecar-command.mjs";
|
|
7
|
+
import { resolveNativeFinalizerConfig } from "./native-release-finalization.mjs";
|
|
8
|
+
|
|
9
|
+
function crateNames(names) {
|
|
10
|
+
if (!Array.isArray(names) || names.some((name) => typeof name !== "string" || !/^[A-Za-z0-9_][A-Za-z0-9_-]*$/.test(name))) {
|
|
11
|
+
throw new TypeError("releaseDependencies.forbiddenCrates must be an array of exact Cargo package names");
|
|
12
|
+
}
|
|
13
|
+
return [...new Set(names)];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Exact package matches from `cargo tree -e normal --prefix none`, deduplicated across versions. */
|
|
17
|
+
export function forbiddenCrateFindings(treeText, forbiddenCrates) {
|
|
18
|
+
const denied = new Set(crateNames(forbiddenCrates));
|
|
19
|
+
const found = new Set();
|
|
20
|
+
for (const line of String(treeText).split(/\r?\n/)) {
|
|
21
|
+
const match = line.match(/^([A-Za-z0-9_][A-Za-z0-9_-]*) v\d[^\s]*(?:\s|$)/);
|
|
22
|
+
if (match && denied.has(match[1])) found.add(match[1]);
|
|
23
|
+
}
|
|
24
|
+
return [...found].sort();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Enforce config.releaseDependencies for one release target before packaging.
|
|
29
|
+
* `commands` may supply already-expanded release hooks; `run` injects the Cargo runner for tests.
|
|
30
|
+
* Returns { skipped } without Cargo when no policy is declared, else { manifest, target, trees, forbiddenCrates }.
|
|
31
|
+
* Cargo errors, empty output, invalid policy & forbidden packages throw; dry runs obey this gate too.
|
|
32
|
+
*/
|
|
33
|
+
export function assertReleaseDependencies({ root, config = {}, target = {}, commands, run } = {}) {
|
|
34
|
+
const policy = config.releaseDependencies;
|
|
35
|
+
if (policy === undefined) return { skipped: "no releaseDependencies policy" };
|
|
36
|
+
if (!policy || typeof policy !== "object" || Array.isArray(policy)) throw new TypeError("releaseDependencies must be an object");
|
|
37
|
+
const forbiddenCrates = crateNames(policy.forbiddenCrates);
|
|
38
|
+
if (policy.features !== undefined && (!Array.isArray(policy.features) || policy.features.some((feature) => typeof feature !== "string" || !feature.trim() || feature.startsWith("-")))) {
|
|
39
|
+
throw new TypeError("releaseDependencies.features must be an array of Cargo feature names");
|
|
40
|
+
}
|
|
41
|
+
if (policy.noDefaultFeatures !== undefined && typeof policy.noDefaultFeatures !== "boolean") throw new TypeError("releaseDependencies.noDefaultFeatures must be boolean");
|
|
42
|
+
const manifest = policy.manifest ?? target.cargoManifest ?? config.releaseSwitches?.manifest ?? config.nativeAssembly?.cargoManifest ?? config.nativeAssembly?.manifest;
|
|
43
|
+
if (manifest !== undefined && (typeof manifest !== "string" || !manifest || !existsSync(path.resolve(root, manifest)))) {
|
|
44
|
+
throw new Error(`release dependencies: declared Cargo manifest does not exist: ${manifest}`);
|
|
45
|
+
}
|
|
46
|
+
const triple = target.cargoTarget ?? target.targetTriple ?? target.rustTarget ?? nodeHostTriple();
|
|
47
|
+
const commandList = (value) => value == null ? [] : Array.isArray(value) ? value : [value];
|
|
48
|
+
const hooks = commands ?? [target.prePackage, target.package, config.nativeAssembly?.packageHook,
|
|
49
|
+
resolveNativeFinalizerConfig(config.nativeAssembly), ...commandList(target.postSign), target.gradle].filter(Boolean);
|
|
50
|
+
const closure = resolveCargoTree({
|
|
51
|
+
root, manifest: manifest === undefined ? undefined : path.relative(root, path.resolve(root, manifest)), features: [...(config.releaseSwitches?.features ?? []), ...(policy.features ?? [])],
|
|
52
|
+
noDefaultFeatures: policy.noDefaultFeatures ?? false,
|
|
53
|
+
triple, commandTexts: hooks.flatMap((command) => expandRecipeBuildCommands(command, { root })),
|
|
54
|
+
edges: "normal", prefix: "none", label: "release dependencies", run,
|
|
55
|
+
});
|
|
56
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9_-]* v\d[^\s]*(?:\s|$)/m.test(closure.treeText)) throw new Error("release dependencies: cargo tree returned no package records");
|
|
57
|
+
const found = forbiddenCrateFindings(closure.treeText, forbiddenCrates);
|
|
58
|
+
if (found.length) throw new Error(found.map((crate) => `FAIL: ${crate} is in the release dependency tree (${triple})`).join("\n"));
|
|
59
|
+
return { manifest: closure.manifest, target: triple, trees: closure.trees.length, forbiddenCrates };
|
|
60
|
+
}
|
package/release-switch-scan.mjs
CHANGED
|
@@ -120,7 +120,13 @@ function readPackageScripts(root) {
|
|
|
120
120
|
// skip is `rust === false` (releaseSwitches.rust: false, an explicit "this app has no Rust") and only when no candidate
|
|
121
121
|
// manifest exists. Returns { treeText, manifest } or { skipped: <reason> }. Throws when cargo fails: the caller fails closed.
|
|
122
122
|
// `run` is injectable for tests: (command, args, options) => { status, stdout, stderr, error }.
|
|
123
|
-
export function resolveFeatureClosure(
|
|
123
|
+
export function resolveFeatureClosure(options = {}) {
|
|
124
|
+
return resolveCargoTree(options);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Shared Cargo tree resolver; keeps each build command's feature selection separate. */
|
|
128
|
+
export function resolveCargoTree({ root, manifest, cargoManifest, rust, features = [], scripts, triple, commandTexts = [],
|
|
129
|
+
edges = "features,no-dev", prefix, noDefaultFeatures = false, label = "feature closure", run = spawnSync } = {}) {
|
|
124
130
|
const { rel, declared, candidates } = resolveCargoManifestPath({ root, cargoManifest, manifest });
|
|
125
131
|
if (!rel) {
|
|
126
132
|
if (rust === false) return { skipped: "releaseSwitches.rust is false: the app declares no Rust" };
|
|
@@ -129,7 +135,8 @@ export function resolveFeatureClosure({ root, manifest, cargoManifest, rust, fea
|
|
|
129
135
|
if (declared.length && !declared.some((c) => existsSync(path.join(root, c)))) {
|
|
130
136
|
throw new Error(`release-switch-manifest-missing: declared manifest ${declared.join(", ")} does not exist under ${root}`);
|
|
131
137
|
}
|
|
132
|
-
const baseArgs = ["tree", "
|
|
138
|
+
const baseArgs = ["tree", "--color", "never", "-e", edges, "--locked", "--manifest-path", path.join(root, rel), "--target", triple || "all"];
|
|
139
|
+
if (prefix) baseArgs.push("--prefix", prefix);
|
|
133
140
|
const pkgScripts = scripts ?? readPackageScripts(root);
|
|
134
141
|
// One cargo tree per distinct (features, no-default-features, manifest) combination, one combination per build command
|
|
135
142
|
// (flags gathered over the command and the package.json scripts it runs). A `--no-default-features` in one command never
|
|
@@ -143,30 +150,33 @@ export function resolveFeatureClosure({ root, manifest, cargoManifest, rust, fea
|
|
|
143
150
|
if (script && depth < 3 && Object.hasOwn(pkgScripts, script) && !seen.has(script)) expand(String(pkgScripts[script]), depth + 1, new Set([...seen, script]));
|
|
144
151
|
};
|
|
145
152
|
expand(text, 0, new Set());
|
|
146
|
-
let noDefault =
|
|
153
|
+
let noDefault = noDefaultFeatures;
|
|
154
|
+
let allFeatures = false;
|
|
147
155
|
const enabled = [...features];
|
|
148
156
|
for (const t of expanded) {
|
|
149
157
|
const tokens = commandTokens(t);
|
|
150
158
|
if (tokens.includes("--no-default-features")) noDefault = true;
|
|
159
|
+
if (tokens.includes("--all-features")) allFeatures = true;
|
|
151
160
|
enabled.push(...featuresInTokens(tokens).enabled);
|
|
152
161
|
}
|
|
153
162
|
const list = [...new Set(enabled)].sort();
|
|
154
|
-
combos.set(`${rel}|${noDefault}|${list.join(",")}`, { noDefault, list });
|
|
163
|
+
combos.set(`${rel}|${noDefault}|${allFeatures}|${list.join(",")}`, { noDefault, allFeatures, list });
|
|
155
164
|
};
|
|
156
165
|
const texts = commandTexts.filter(Boolean);
|
|
157
166
|
if (texts.length === 0) comboFor("");
|
|
158
167
|
for (const text of texts) comboFor(text);
|
|
159
168
|
const trees = [];
|
|
160
|
-
for (const { noDefault, list } of combos.values()) {
|
|
169
|
+
for (const { noDefault, allFeatures, list } of combos.values()) {
|
|
161
170
|
const args = [...baseArgs];
|
|
162
171
|
if (noDefault) args.push("--no-default-features");
|
|
172
|
+
if (allFeatures) args.push("--all-features");
|
|
163
173
|
if (list.length) args.push("--features", list.join(","));
|
|
164
174
|
const result = run("cargo", args, { cwd: root, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
165
175
|
if (result.error || result.status !== 0) {
|
|
166
|
-
throw new Error(
|
|
176
|
+
throw new Error(`${label}: \`cargo ${args.join(" ")}\` failed (${result.error?.message ?? `exit ${result.status}`}): ${String(result.stderr ?? "").trim().slice(0, 600)}. The resolved feature graph is the authoritative test-mode check, so a release cannot continue without it.`);
|
|
167
177
|
}
|
|
168
178
|
const out = String(result.stdout ?? "");
|
|
169
|
-
if (!out.trim()) throw new Error(
|
|
179
|
+
if (!out.trim()) throw new Error(`${label}: \`cargo tree\` printed nothing; cannot verify`);
|
|
170
180
|
trees.push(out);
|
|
171
181
|
}
|
|
172
182
|
const treeText = trees.join("\n");
|