@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/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
- function parseCargo(parsed, file, out, workspace, root, coverage, inventory) {
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) coverage.push({ path: file, status: "unscanned", reason: `git-dependency-source:${name}` });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.174",
3
+ "version": "0.3.0",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
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());
@@ -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
- export function buildCargoPublishPlan({ crate, crateDir, dryRun, allowDirty = false }) {
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
- const plan = buildCargoPublishPlan({ ...options, crateDir });
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
- const result = execute(step.command, step.args, {
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
+ }
@@ -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({ root, manifest, cargoManifest, rust, features = [], scripts, triple, commandTexts = [], run = spawnSync } = {}) {
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", "-e", "features,no-dev", "--locked", "--manifest-path", path.join(root, rel), "--target", triple || "all"];
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 = false;
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(`feature closure: \`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.`);
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("feature closure: `cargo tree` printed nothing; cannot verify");
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");