@rightkit/release 0.2.173 → 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.
Files changed (50) hide show
  1. package/README.md +188 -5
  2. package/bin/darwin/rightkit-swap +0 -0
  3. package/bin/darwin/rightkit-swap.sign.json +1 -1
  4. package/cli/right-release.mjs +34 -6
  5. package/command-spec.mjs +80 -0
  6. package/deps-cargo.mjs +55 -0
  7. package/deps.mjs +7 -0
  8. package/development-signing.mjs +135 -4
  9. package/development.mjs +12 -11
  10. package/dist-scan.mjs +102 -0
  11. package/dmg-build.mjs +53 -4
  12. package/icon-pack.mjs +117 -0
  13. package/in-place-install.mjs +32 -7
  14. package/init-command.mjs +116 -0
  15. package/install-app-windows.mjs +108 -17
  16. package/install-app.mjs +68 -11
  17. package/install-tauri-devid.mjs +106 -47
  18. package/local-publish.mjs +317 -29
  19. package/mac-bundle-signing.mjs +143 -1
  20. package/mac-signing-matrix.mjs +73 -6
  21. package/macho-approval-hash.mjs +181 -0
  22. package/macos-signing-session.mjs +12 -2
  23. package/managed-cargo.mjs +49 -1
  24. package/media-sidecar.mjs +20 -4
  25. package/mirror-root-artifact.mjs +69 -21
  26. package/npm-license-notices.mjs +159 -0
  27. package/ownership-dependency-allowlist.json +1 -0
  28. package/ownership.mjs +37 -4
  29. package/package-recipe-targets.mjs +180 -16
  30. package/package.json +1 -1
  31. package/pe-content.mjs +133 -0
  32. package/portable-development.mjs +9 -9
  33. package/publication-gate.mjs +70 -3
  34. package/publish-cargo.mjs +26 -7
  35. package/qa-build-features.mjs +7 -1
  36. package/recipe-build.mjs +346 -0
  37. package/release-dependencies-command.mjs +45 -0
  38. package/release-dependencies.mjs +60 -0
  39. package/release-switch-scan.mjs +17 -7
  40. package/release.mjs +98 -12
  41. package/rightkit-ownership.json +46 -2
  42. package/rightkit-signing-agent.mjs +10 -4
  43. package/rightkit-versions.json +11 -6
  44. package/runtime-budgets.mjs +28 -12
  45. package/stage-binary.mjs +1 -1
  46. package/stage-sidecar-command.mjs +36 -4
  47. package/stage-sidecar.mjs +926 -74
  48. package/test-command.mjs +190 -0
  49. package/toolchain-swift.mjs +239 -0
  50. package/web-build.mjs +67 -0
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());
@@ -1,15 +1,16 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
- import { previousPathOf, stagingPathOf, swapInPlace } from "./in-place-install.mjs";
4
+ import { stagingPathOf, swapInPlace } from "./in-place-install.mjs";
5
5
 
6
6
  const CHUNK_BYTES = 1024 * 1024;
7
7
 
8
8
  /**
9
9
  * Install an unsigned development payload in place at the fixed path `<installRoot>/current` (a real directory).
10
- * No versions/ folder, no digest folder, no symlink or junction. The previous copy is kept once, as
11
- * `<installRoot>/current.previous` (rename it back to roll back); nothing ever runs from it. A legacy `current`
12
- * symlink or junction from the old layout is moved aside to `.previous` the same way.
10
+ * No versions/ folder, no digest folder, no symlink or junction. NO old copy is kept (Orthic-Labs/rightkit#348): the
11
+ * old `current` is removed and the verified staging copy is renamed over it, and a stale `current.previous` left by an
12
+ * older RightKit is removed. A failed install leaves `current` absent or partial; reinstall from an older build. A
13
+ * legacy `current` symlink or junction from the old layout is removed the same way.
13
14
  */
14
15
  export function installPortableDevelopment(descriptor, { root, platform = process.platform, env = process.env } = {}) {
15
16
  const emit = (event, fields = {}) => process.stdout.write(`${JSON.stringify({ event: `DEV_INSTALL_${event}`, ...fields })}
@@ -33,7 +34,6 @@ export function installPortableDevelopment(descriptor, { root, platform = proces
33
34
  digest = digestTree(source);
34
35
  current = path.join(installRoot, "current");
35
36
  const staging = stagingPathOf(current);
36
- const previous = previousPathOf(current);
37
37
  emit("STAGE", { sourceDirectory: source, current, staging, digest });
38
38
  if (exists(current) && !fs.lstatSync(current).isDirectory() && !fs.lstatSync(current).isSymbolicLink()) throw new Error(`development current is not a directory; refusing to replace: ${current}`);
39
39
 
@@ -49,15 +49,15 @@ export function installPortableDevelopment(descriptor, { root, platform = proces
49
49
  const sourceEntryDigest = hashFile(sourceEntrypoint);
50
50
  let entryDigest;
51
51
  swapInPlace({
52
- staging, destination: current, previous,
52
+ staging, destination: current, previous: null,
53
53
  verify: (destination) => {
54
54
  entryDigest = hashFile(resolveContained(destination, descriptor.entrypoint, "entrypoint"));
55
55
  if (entryDigest !== sourceEntryDigest) throw new Error("development entrypoint readback did not match source");
56
56
  },
57
57
  });
58
- emit("ACTIVATE", { current, previous });
59
- emit("SUCCEEDED", { installRoot, current, previous, digest, entrypoint: descriptor.entrypoint, entrypointDigest: entryDigest });
60
- return { installRoot, current, previous, versionRoot: current, digest, entrypoint: path.join(current, descriptor.entrypoint), entrypointDigest: entryDigest };
58
+ emit("ACTIVATE", { current });
59
+ emit("SUCCEEDED", { installRoot, current, digest, entrypoint: descriptor.entrypoint, entrypointDigest: entryDigest });
60
+ return { installRoot, current, versionRoot: current, digest, entrypoint: path.join(current, descriptor.entrypoint), entrypointDigest: entryDigest };
61
61
  } catch (error) {
62
62
  emit("FAILED", { installRoot: descriptor?.installRoot, current, digest, message: error?.message ?? String(error) });
63
63
  throw error;
@@ -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,
@@ -10,6 +10,7 @@
10
10
  // Dev builds are unaffected: `development.mjs` never calls this.
11
11
  import { existsSync, readFileSync } from "node:fs";
12
12
  import path from "node:path";
13
+ import { expandRecipeBuildCommands } from "./recipe-build.mjs";
13
14
 
14
15
  export const FORBIDDEN_FEATURES = Object.freeze(["devtools", "content-devtools", "qa-native", "qa-control", "dev-native", "dev-inspect",
15
16
  // Per-app test-automation features (M20 owner gap): HeardRight `qa-shot`/`qa-tools`, ViewRight `dev-control`/`bench-native`.
@@ -206,7 +207,12 @@ export function buildFeatureIssues({ root, commands, devtools, cargoManifest, ma
206
207
  let scripts = {};
207
208
  try { scripts = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).scripts ?? {}; } catch { /* no package.json */ }
208
209
  const issues = [];
209
- for (const { label, command } of commands) if (command) issues.push(...commandFeatureIssues(command, { label, scripts, allowDevtools }));
210
+ for (const { label, command } of commands) {
211
+ if (!command) continue;
212
+ // A `node recipe-build.mjs --plan <JSON>` wrapper hides every step's flags in one argument: scan each step too.
213
+ const expanded = expandRecipeBuildCommands(command, { root });
214
+ expanded.forEach((part, index) => issues.push(...commandFeatureIssues(part, { label: index === 0 ? label : `${label} -> recipe-build step ${index}`, scripts, allowDevtools })));
215
+ }
210
216
  const { rel } = resolveCargoManifestPath({ root, cargoManifest, manifest });
211
217
  if (rel) issues.push(...cargoManifestIssues(readFileSync(path.join(root, rel), "utf8"), rel, { allowDevtools }));
212
218
  return issues;