@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/local-publish.mjs CHANGED
@@ -5,18 +5,25 @@
5
5
  // scripts/ci/gate.mjs semantics), packs the artifact, writes a schema-1 evidence file with one darwin receipt
6
6
  // (hostPolicy "darwin-only"), then calls the EXISTING enforcePublicationGate and the EXISTING publisher
7
7
  // (publishGatedTarball for npm, runCargoPublish for cargo). There is no way to skip a test or the evidence.
8
+ //
9
+ // Batch mode (`cargo --crates a,b,c | --crates-file f --local`): fmt, the secret scan and clippy run ONCE for
10
+ // the batch at one commit and tree and are recorded in every crate's evidence as a `batch` receipt that the
11
+ // publication gate validates (publication-gate.mjs batchReceiptProblems); each crate still runs its own tests,
12
+ // all-features check, packaging, gate, publish and registry readback, and writes its own evidence file. Crates
13
+ // of one dependency layer publish concurrently (bounded); layer N+1 starts only after every layer-N crate is
14
+ // visible in the registry index with the verified checksum.
8
15
  import crypto from "node:crypto";
9
16
  import fs from "node:fs";
10
17
  import os from "node:os";
11
18
  import path from "node:path";
12
19
  import { fileURLToPath, pathToFileURL } from "node:url";
13
20
  import { spawnSync } from "./background-process.mjs";
14
- import { runCargoSync } from "./managed-cargo.mjs";
21
+ import { runCargoAsync, runCargoSync, spawnCollect } from "./managed-cargo.mjs";
15
22
  import { localBuildGranted, LOCAL_BUILD_FILE } from "./execution-policy.mjs";
16
23
  import { GATE_SCHEMA, defaultEvidencePath, enforcePublicationGate, publishGatedTarball } from "./publication-gate.mjs";
17
- import { runCargoPublish } from "./publish-cargo.mjs";
24
+ import { runCargoPublish, scanCrateForSecrets } from "./publish-cargo.mjs";
18
25
  import { cargoIndexUrl } from "./cargo-registry-readback.mjs";
19
- import { readCargoPackage } from "./crate-graph.mjs";
26
+ import { pathDependencyDirs, readCargoPackage } from "./crate-graph.mjs";
20
27
  import { recordPublished, stageSourceVersions } from "./rightkit-sync.mjs";
21
28
  import { prepareSwapHelper, SWAP_HELPER_CARGO_ARGS, SWAP_HELPER_PACKAGE } from "./swap-helper-bundle.mjs";
22
29
 
@@ -29,9 +36,74 @@ export class LocalPublishError extends Error {
29
36
  }
30
37
  const refuse = (message) => { throw new LocalPublishError(message); };
31
38
 
39
+ export const DEFAULT_CONCURRENCY = 4;
40
+ const MAX_CONCURRENCY = 8;
41
+ const CRATE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
42
+
43
+ const crateList = (text, what) => {
44
+ const names = String(text ?? "").split(",").map((name) => name.trim()).filter(Boolean);
45
+ for (const name of names) if (!CRATE_NAME.test(name)) refuse(`${what}: invalid crate name ${JSON.stringify(name)}`);
46
+ return names;
47
+ };
48
+
49
+ /**
50
+ * A crates file: one crate per line, blank lines separate dependency layers (earlier layers publish first),
51
+ * `#` starts a comment. Returns the layers; refuses a bad name, a repeated crate or an empty file.
52
+ */
53
+ export function parseCrateLayers(text) {
54
+ const layers = []; let current = [];
55
+ const flush = () => { if (current.length) layers.push(current); current = []; };
56
+ for (const raw of String(text ?? "").split(/\r?\n/)) {
57
+ if (raw.trim().startsWith("#")) continue;
58
+ const line = raw.replace(/\s+#.*$/, "").trim();
59
+ if (!line) { flush(); continue; }
60
+ if (!CRATE_NAME.test(line)) refuse(`crates file: invalid crate name ${JSON.stringify(line)}`);
61
+ current.push(line);
62
+ }
63
+ flush();
64
+ const all = layers.flat();
65
+ const repeated = all.find((name, index) => all.indexOf(name) !== index);
66
+ if (repeated) refuse(`crates file names ${repeated} more than once`);
67
+ if (!all.length) refuse("crates file names no crate");
68
+ return layers;
69
+ }
70
+
71
+ /** An explicit layering must put every in-batch dependency of a crate in a STRICTLY earlier layer. */
72
+ export function checkCrateLayers(layers, dependsOn) {
73
+ const layerOf = new Map();
74
+ layers.forEach((layer, index) => layer.forEach((name) => layerOf.set(name, index)));
75
+ for (const [name, index] of layerOf) {
76
+ for (const dependency of dependsOn(name)) {
77
+ if (!layerOf.has(dependency)) continue;
78
+ if (layerOf.get(dependency) >= index) refuse(`${name} (layer ${index + 1}) depends on ${dependency} (layer ${layerOf.get(dependency) + 1}); a dependency must be in an earlier layer`);
79
+ }
80
+ }
81
+ return layers;
82
+ }
83
+
84
+ /** Layers derived from the in-batch dependency edges: layer = 1 + the deepest in-batch dependency. */
85
+ export function deriveCrateLayers(crates, dependsOn) {
86
+ const member = new Set(crates);
87
+ const level = new Map(); const visiting = new Set();
88
+ const visit = (name) => {
89
+ if (level.has(name)) return level.get(name);
90
+ if (visiting.has(name)) refuse(`dependency cycle among the batch crates at ${name}`);
91
+ visiting.add(name);
92
+ const depth = Math.max(-1, ...dependsOn(name).filter((dependency) => member.has(dependency) && dependency !== name).map(visit)) + 1;
93
+ visiting.delete(name); level.set(name, depth);
94
+ return depth;
95
+ };
96
+ crates.forEach(visit);
97
+ const layers = [];
98
+ for (const name of crates) (layers[level.get(name)] ??= []).push(name);
99
+ return layers;
100
+ }
101
+
32
102
  export function parseLocalPublishArgs(kind, argv, cwd = process.cwd()) {
33
103
  const flag = kind === "npm" ? "--package" : "--crate";
104
+ const batchFlags = kind === "cargo" ? ", --crates <a,b,c>, --crates-file <file>, --concurrency <n>, --allow-ignored-units <a,b>" : "";
34
105
  let unit; let workspace; let local = false; let allowIgnored = false;
106
+ let crates; let cratesFile; let concurrency = DEFAULT_CONCURRENCY; let allowIgnoredUnits = [];
35
107
  for (let i = 0; i < argv.length; i += 1) {
36
108
  const arg = argv[i];
37
109
  if (arg === "--local") local = true;
@@ -39,12 +111,27 @@ export function parseLocalPublishArgs(kind, argv, cwd = process.cwd()) {
39
111
  else if (arg === "--workspace") workspace = argv[++i];
40
112
  // Same meaning as publish.yml's allow_ignored: this unit may have ignored tests; the count is recorded.
41
113
  else if (arg === "--allow-ignored") allowIgnored = true;
42
- else refuse(`unknown publish ${kind} --local option: ${arg} (accepted: ${flag} <name>, --workspace <dir>, --allow-ignored, --local)`);
114
+ else if (kind === "cargo" && arg === "--crates") crates = crateList(argv[++i], "--crates");
115
+ else if (kind === "cargo" && arg === "--crates-file") cratesFile = argv[++i];
116
+ else if (kind === "cargo" && arg === "--concurrency") concurrency = Number(argv[++i]);
117
+ else if (kind === "cargo" && arg === "--allow-ignored-units") allowIgnoredUnits = crateList(argv[++i], "--allow-ignored-units");
118
+ else refuse(`unknown publish ${kind} --local option: ${arg} (accepted: ${flag} <name>${batchFlags}, --workspace <dir>, --allow-ignored, --local)`);
43
119
  }
44
120
  if (!local) refuse("--local is required");
121
+ if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) refuse(`--concurrency must be an integer from 1 to ${MAX_CONCURRENCY}`);
122
+ const workspaceRoot = path.resolve(workspace ?? cwd);
123
+ if (kind === "cargo" && (crates !== undefined || cratesFile !== undefined)) {
124
+ if (unit !== undefined) refuse("--crate cannot be combined with --crates or --crates-file");
125
+ if (crates !== undefined && cratesFile !== undefined) refuse("use --crates or --crates-file, not both");
126
+ if (crates !== undefined && !crates.length) refuse("--crates names no crate");
127
+ if (cratesFile !== undefined && !String(cratesFile ?? "").trim()) refuse("--crates-file needs a file");
128
+ const repeated = crates?.find((name, index) => crates.indexOf(name) !== index);
129
+ if (repeated) refuse(`--crates names ${repeated} more than once`);
130
+ return { kind, unit: undefined, allowIgnored, allowIgnoredUnits, workspaceRoot, concurrency, batch: { crates, file: cratesFile === undefined ? undefined : path.resolve(cwd, cratesFile) } };
131
+ }
45
132
  if (kind === "npm" && !/^@rightkit\/[a-z0-9-]+$/.test(unit ?? "")) refuse("publish npm --local requires --package @rightkit/<name>");
46
- if (kind === "cargo" && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(unit ?? "")) refuse("publish cargo --local requires --crate <name>");
47
- return { kind, unit, allowIgnored, workspaceRoot: path.resolve(workspace ?? cwd) };
133
+ if (kind === "cargo" && !CRATE_NAME.test(unit ?? "")) refuse("publish cargo --local requires --crate <name> (or --crates / --crates-file)");
134
+ return { kind, unit, allowIgnored, allowIgnoredUnits, workspaceRoot, concurrency, batch: undefined };
48
135
  }
49
136
 
50
137
  function defaultGit(args, cwd) {
@@ -74,6 +161,40 @@ function defaultRun(command, args, options) {
74
161
  return command === "cargo" ? runCargoSync(args, settings) : spawnSync(command, args, settings);
75
162
  }
76
163
 
164
+ // Async twin used by batch mode so crates of one layer can overlap their uploads and index waits. The broker
165
+ // still runs builds one at a time; `await` on a synchronous fake works the same.
166
+ function defaultRunAsync(command, args, options) {
167
+ const settings = { encoding: "utf8", maxBuffer: 512 * 1024 * 1024, timeout: STEP_TIMEOUT_MS, windowsHide: true, ...options };
168
+ return command === "cargo" ? runCargoAsync(args, settings) : spawnCollect(command, args, settings);
169
+ }
170
+
171
+ /**
172
+ * `execute` for runCargoPublish in batch mode: the async Cargo runner, with the output of a step that would
173
+ * inherit stdio echoed line by line under the crate name (concurrent crates would otherwise interleave raw).
174
+ */
175
+ export function batchCargoExecute(label, { run = runCargoAsync, out = process.stdout, err = process.stderr } = {}) {
176
+ return async (_command, args, settings) => {
177
+ const { stdio, ...rest } = settings;
178
+ const result = await run(args, { ...rest, timeout: STEP_TIMEOUT_MS, maxBuffer: 512 * 1024 * 1024 });
179
+ if (stdio === "inherit") {
180
+ for (const [stream, text] of [[out, result.stdout], [err, result.stderr]]) {
181
+ for (const line of String(text ?? "").split(/\r?\n/)) if (line) stream.write(`[${label}] ${line}\n`);
182
+ }
183
+ }
184
+ return result;
185
+ };
186
+ }
187
+
188
+ // Registry versions-file writers (recordPublished, stageSourceVersions) read-modify-write one shared JSON file
189
+ // and its consumer copies. They are synchronous, but the lock keeps the whole write one unit even if a writer
190
+ // becomes async, so concurrent crates of a layer can never interleave two updates.
191
+ let versionsTail = Promise.resolve();
192
+ const withVersionsLock = (task) => {
193
+ const result = versionsTail.then(task);
194
+ versionsTail = result.catch(() => {});
195
+ return result;
196
+ };
197
+
77
198
  async function defaultLoadWorkspaceModules(workspaceRoot) {
78
199
  const load = (file) => import(pathToFileURL(path.join(workspaceRoot, "scripts", "ci", file)).href);
79
200
  const [results, upload] = await Promise.all([load("test-results.mjs"), load("upload.mjs")]);
@@ -92,7 +213,7 @@ export function allFeatureCheckSets(entry = {}) {
92
213
  }
93
214
 
94
215
  /** The CI gate's commands for one unit (scripts/ci/gate.mjs npmGate/rustGate), mirrored exactly. */
95
- export function localTestPlan({ kind, unit, dir, config, manifest }) {
216
+ export function localTestPlan({ kind, unit, dir, config, manifest, batch = false }) {
96
217
  if (kind === "npm") {
97
218
  if (!manifest?.scripts?.test) refuse(`${unit} has no test script; --local never publishes an untested package`);
98
219
  return {
@@ -111,26 +232,75 @@ export function localTestPlan({ kind, unit, dir, config, manifest }) {
111
232
  steps.push({ command: "cargo", args: ["test", "-p", unit, ...features, "--no-fail-fast", "--", "--show-output"], test: true });
112
233
  if (entry.alsoSerial) steps.push({ command: "cargo", args: ["test", "-p", unit, ...features, "--no-fail-fast", "--", "--test-threads=1", "--show-output"], test: true });
113
234
  }
114
- steps.push({ command: "cargo", args: ["clippy", "-p", unit, "--no-deps", "--all-targets", ...featureSets[0], "--", "-D", "warnings"], test: false });
235
+ // Batch mode lints the whole batch once (batchClippyInvocations) and records it as a batch receipt.
236
+ if (!batch) steps.push({ command: "cargo", args: ["clippy", "-p", unit, "--no-deps", "--all-targets", ...featureSets[0], "--", "-D", "warnings"], test: false });
115
237
  for (const features of allFeatureCheckSets(entry)) steps.push({ command: "cargo", args: ["check", "-p", unit, "--all-targets", ...features], test: false });
116
238
  return { id: `rust:${unit}`, featureLabels: featureSets.map((features) => features.join(" ") || "default"), steps };
117
239
  }
118
240
 
241
+ /**
242
+ * The clippy invocations that replace each crate's own `cargo clippy -p <crate> --no-deps --all-targets
243
+ * <featureSets[0]> -- -D warnings`: same flags and lint level, so the same lints on the same code. Crates
244
+ * whose first feature set is empty or `--all-features` share one `-p a -p b` invocation (the flags mean the
245
+ * same for every crate); any other feature set runs per crate, because `--features x` must not reach a crate
246
+ * that lacks x. The caller passes ONE dependency layer: no selected crate depends on another, so `-p` does not
247
+ * unify extra features into a selected crate and each is linted with exactly the features it was before.
248
+ * Crates with a different RIGHTKIT_REQUIRE_ASSETS (asset-gated) stay apart so the environment is unchanged.
249
+ */
250
+ export function batchClippyInvocations(layer, { featuresOf = () => [], assetGatedOf = () => false } = {}) {
251
+ const groups = new Map();
252
+ for (const unit of layer) {
253
+ const features = featuresOf(unit) ?? [];
254
+ const shared = features.length === 0 || (features.length === 1 && features[0] === "--all-features");
255
+ const key = shared ? JSON.stringify([features, assetGatedOf(unit)]) : `unit:${unit}`;
256
+ if (!groups.has(key)) groups.set(key, { crates: [], features, assetGated: assetGatedOf(unit) });
257
+ groups.get(key).crates.push(unit);
258
+ }
259
+ return [...groups.values()].map(({ crates, features, assetGated }) => ({
260
+ crates, assetGated,
261
+ args: ["clippy", ...crates.flatMap((unit) => ["-p", unit]), "--no-deps", "--all-targets", ...features, "--", "-D", "warnings"],
262
+ }));
263
+ }
264
+
119
265
  const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
120
266
  const tarballName = (name, version) => `${name.replace(/^@/, "").replace("/", "-")}-${version}.tgz`;
121
267
  const tail = (text, lines = 40) => String(text ?? "").split(/\r?\n/).slice(-lines).join("\n");
122
268
 
269
+ // Tests run under hosted-CI conditions: no inherited credentials (a managed host refuses a token from the
270
+ // environment) and no sibling product checkouts (CI holds rightkit alone).
271
+ function testEnv(deps, isAssetGated) {
272
+ const inherited = Object.fromEntries(Object.entries(deps.env ?? process.env).filter(([key]) => !/(TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY)/i.test(key)));
273
+ return { ...inherited, RIGHTKIT_REQUIRE_ASSETS: isAssetGated ? "0" : "1", RIGHTKIT_ISOLATED_CHECKOUT: "1", CARGO_TERM_COLOR: "never" };
274
+ }
275
+
276
+ function assertLocalHost(deps, workspaceRoot) {
277
+ const platform = deps.platform ?? process.platform;
278
+ if (!(deps.localBuildGranted ?? localBuildGranted)(workspaceRoot)) refuse(`--local needs the local-build grant: a committed ${LOCAL_BUILD_FILE} (allow "build-test-publish") naming this repository at ${workspaceRoot}`);
279
+ if (platform !== "darwin") refuse(`--local publishes from the Mac only (host policy: darwin-only); this host is ${platform}`);
280
+ return platform;
281
+ }
282
+
123
283
  export async function runLocalPublish(kind, argv, deps = {}) {
284
+ const parsed = parseLocalPublishArgs(kind, argv, deps.cwd);
285
+ if (parsed.batch) return runLocalPublishBatch(parsed, deps);
286
+ return publishUnit(parsed, deps, null);
287
+ }
288
+
289
+ /**
290
+ * Qualify and publish ONE unit. `batch` (cargo batch mode only) is `{ head, tree, receipt, commands }`: the
291
+ * batch-level fmt, secret scan and clippy already ran at `head`/`tree`, so this unit skips its own clippy and
292
+ * copies the receipt into its evidence; everything else (tests, all-features check, packaging, the gate, the
293
+ * publish and the registry readback) runs for this unit exactly as in single mode.
294
+ */
295
+ async function publishUnit(parsed, deps, batch) {
124
296
  const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
125
- const { unit, allowIgnored, workspaceRoot } = parseLocalPublishArgs(kind, argv, deps.cwd);
126
- const platform = deps.platform ?? process.platform;
297
+ const { kind, unit, allowIgnored, workspaceRoot } = parsed;
127
298
  const git = deps.git ?? defaultGit;
128
299
  const readJson = deps.readJson ?? defaultReadJson;
129
- const run = deps.run ?? defaultRun;
300
+ const run = deps.run ?? (batch ? defaultRunAsync : defaultRun);
130
301
 
131
302
  // 1. Preconditions, fail closed.
132
- if (!(deps.localBuildGranted ?? localBuildGranted)(workspaceRoot)) refuse(`--local needs the local-build grant: a committed ${LOCAL_BUILD_FILE} (allow "build-test-publish") naming this repository at ${workspaceRoot}`);
133
- if (platform !== "darwin") refuse(`--local publishes from the Mac only (host policy: darwin-only); this host is ${platform}`);
303
+ const platform = assertLocalHost(deps, workspaceRoot);
134
304
  const dir = kind === "npm" ? `packages/${unit.slice("@rightkit/".length)}` : `crates/${unit}`;
135
305
  const unitPath = path.join(workspaceRoot, dir);
136
306
  if (!fs.existsSync(unitPath)) refuse(`unit directory not found: ${unitPath}`);
@@ -148,6 +318,12 @@ export async function runLocalPublish(kind, argv, deps = {}) {
148
318
  if (typeof version !== "string" || !version.trim()) refuse(`${unit} has no literal version`);
149
319
  const head = git(["rev-parse", "HEAD"], workspaceRoot);
150
320
  if (!head || !/^[0-9a-f]{40}$/.test(head)) refuse("cannot resolve HEAD");
321
+ if (batch) {
322
+ // The batch receipt is valid for one commit and tree only; a moved HEAD would make it describe other code.
323
+ if (head !== batch.head) refuse(`${unit}: HEAD moved to ${head} during the batch (batch commit ${batch.head})`);
324
+ const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
325
+ if (tree !== batch.tree) refuse(`${unit}: HEAD tree ${tree} differs from the batch tree ${batch.tree}`);
326
+ }
151
327
  const dirty = git(["status", "--porcelain", "--", dir], workspaceRoot);
152
328
  if (dirty === null) refuse(`cannot read git status for ${dir}`);
153
329
  if (dirty !== "") refuse(`tree is not clean for ${dir}; commit first:\n${dirty}`);
@@ -159,18 +335,19 @@ export async function runLocalPublish(kind, argv, deps = {}) {
159
335
  const assetGated = readJson(path.join(workspaceRoot, "ci", "asset-gated.json")) ?? {};
160
336
  const gatedSection = assetGated[kind === "npm" ? "npm" : "crates"] ?? {};
161
337
  const isAssetGated = Object.hasOwn(gatedSection, unit);
162
- const plan = localTestPlan({ kind, unit, dir, config: gateConfig, manifest });
338
+ const plan = localTestPlan({ kind, unit, dir, config: gateConfig, manifest, batch: Boolean(batch) });
163
339
  const modules = await (deps.loadWorkspaceModules ?? defaultLoadWorkspaceModules)(workspaceRoot);
164
340
  const { ResultScanner, TEST_COMMAND, judgeUnit } = modules.results;
165
- // Run the unit's tests under hosted-CI conditions: no inherited credentials (a managed host refuses a
166
- // token from the environment) and no sibling product checkouts (CI holds rightkit alone).
167
- const inherited = Object.fromEntries(Object.entries(deps.env ?? process.env).filter(([key]) => !/(TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY)/i.test(key)));
168
- const env = { ...inherited, RIGHTKIT_REQUIRE_ASSETS: isAssetGated ? "0" : "1", RIGHTKIT_ISOLATED_CHECKOUT: "1", CARGO_TERM_COLOR: "never" };
341
+ const env = testEnv(deps, isAssetGated);
342
+ // Ignored tests are refused unless the unit is named: --allow-ignored / --allow-ignored-units, or a
343
+ // written reason in ci/gate.config.json (rust.<crate>.allowIgnored). The count stays in the evidence.
344
+ const configReason = kind === "cargo" && typeof gateConfig?.rust?.[unit]?.allowIgnored === "string" ? gateConfig.rust[unit].allowIgnored.trim() : "";
345
+ const ignoredAllowed = Boolean(allowIgnored || parsed.allowIgnoredUnits?.includes(unit) || configReason);
169
346
  const steps = [];
170
347
  for (const step of plan.steps) {
171
348
  const commandText = `${step.command} ${step.args.join(" ")}`;
172
349
  log(`[right-release] ${plan.id}: ${commandText}`);
173
- const result = run(step.command, step.args, { cwd: workspaceRoot, env });
350
+ const result = await run(step.command, step.args, { cwd: workspaceRoot, env });
174
351
  const row = { command: commandText, ok: false, isTest: step.test && TEST_COMMAND.test(commandText) };
175
352
  if (result.error || result.status !== 0) {
176
353
  steps.push(row);
@@ -185,7 +362,7 @@ export async function runLocalPublish(kind, argv, deps = {}) {
185
362
  }
186
363
  steps.push(row);
187
364
  }
188
- const verdict = judgeUnit({ name: unit, steps: steps.map((s) => ({ ...s, unit: plan.id })), allowIgnored: allowIgnored ? [unit] : [], allowMissingAssets: isAssetGated ? [unit] : [] });
365
+ const verdict = judgeUnit({ name: unit, steps: steps.map((s) => ({ ...s, unit: plan.id })), allowIgnored: ignoredAllowed ? [unit] : [], allowMissingAssets: isAssetGated ? [unit] : [] });
189
366
  if (verdict.status === "fail" || steps.some((s) => s.failed > 0)) refuse(`${plan.id} not qualified: ${verdict.problems.join("; ") || "failed tests reported"}`);
190
367
  if (!(verdict.executed > 0)) refuse(`${plan.id} executed no test`);
191
368
  log(`[right-release] ${plan.id}: ${verdict.executed} passed, 0 failed, ${verdict.skipped} skipped, ${verdict.ignored} ignored${isAssetGated ? " (asset-gated: RIGHTKIT_REQUIRE_ASSETS=0, skips recorded)" : ""}`);
@@ -204,21 +381,23 @@ export async function runLocalPublish(kind, argv, deps = {}) {
204
381
  if (kind === "npm") {
205
382
  // The feed a tarball ships must list the version it ships with (#319): stage it before packing.
206
383
  const feedFile = path.join(workspaceRoot, "packages", "release", "rightkit-versions.json");
207
- if (fs.existsSync(feedFile)) (deps.stageSourceVersions ?? stageSourceVersions)([{ registry: "npm", name: unit, version }], { file: feedFile, root: workspaceRoot, log });
384
+ if (fs.existsSync(feedFile)) await withVersionsLock(() => (deps.stageSourceVersions ?? stageSourceVersions)([{ registry: "npm", name: unit, version }], { file: feedFile, root: workspaceRoot, log }));
208
385
  const args = ["--dir", dir, "pack", "--pack-destination", scratch];
209
386
  packCommands.push(`pnpm ${args.join(" ")}`);
210
- const packed = run("pnpm", args, { cwd: workspaceRoot, env });
387
+ const packed = await run("pnpm", args, { cwd: workspaceRoot, env });
211
388
  if (packed.error || packed.status !== 0) refuse(`pnpm pack failed for ${unit}\n${tail(`${packed.stdout ?? ""}\n${packed.stderr ?? ""}`)}`);
212
389
  artifactFile = path.join(scratch, tarballName(unit, version));
213
390
  if (!fs.existsSync(artifactFile)) refuse(`pnpm pack did not produce ${path.basename(artifactFile)}`);
214
391
  const checked = modules.checkNpmTarball({ file: artifactFile, name: unit, version, dir, spawn: deps.spawnSync ?? spawnSync });
215
392
  if (checked.problems.length) refuse(`${unit}: packed tarball refused:\n - ${checked.problems.join("\n - ")}`);
216
393
  } else {
217
- packCommands.push(`cargo package --locked -p ${unit}`);
218
- const packed = run("cargo", ["package", "--locked", "-p", unit], { cwd: workspaceRoot, env });
394
+ // The archive is hashed here; Cargo's verify build runs once, in the real `cargo publish` (before any
395
+ // upload), instead of here and again in a registry dry-run (see runCargoPublish verifyOnPublish).
396
+ packCommands.push(`cargo package --locked --no-verify -p ${unit}`);
397
+ const packed = await run("cargo", ["package", "--locked", "--no-verify", "-p", unit], { cwd: workspaceRoot, env });
219
398
  if (packed.error || packed.status !== 0) refuse(`cargo package failed for ${unit}\n${tail(`${packed.stdout ?? ""}\n${packed.stderr ?? ""}`)}`);
220
399
  packCommands.push("cargo metadata --format-version 1 --no-deps");
221
- const meta = run("cargo", ["metadata", "--format-version", "1", "--no-deps"], { cwd: workspaceRoot, env });
400
+ const meta = await run("cargo", ["metadata", "--format-version", "1", "--no-deps"], { cwd: workspaceRoot, env });
222
401
  let targetDir;
223
402
  try { targetDir = JSON.parse(meta.stdout).target_directory; } catch { /* refused below */ }
224
403
  if (meta.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir)) refuse("cannot read the Cargo target directory");
@@ -228,7 +407,7 @@ export async function runLocalPublish(kind, argv, deps = {}) {
228
407
  const artifactSha256 = sha256(fs.readFileSync(artifactFile));
229
408
 
230
409
  // 4. Evidence in the normal schema, then the EXISTING gate.
231
- const commands = [...plan.steps.map((s) => `${s.command} ${s.args.join(" ")}`), ...packCommands];
410
+ const commands = [...plan.steps.map((s) => `${s.command} ${s.args.join(" ")}`), ...packCommands, ...(batch ? batch.commands : [])];
232
411
  const timestamp = (deps.now ?? (() => new Date().toISOString()))();
233
412
  const evidence = {
234
413
  schema: GATE_SCHEMA, kind, name: unit, version, commit: head, artifactSha256,
@@ -236,8 +415,10 @@ export async function runLocalPublish(kind, argv, deps = {}) {
236
415
  runs: [{
237
416
  host: { os: "darwin", arch: deps.arch ?? process.arch }, commit: head, features: plan.featureLabels, artifactSha256,
238
417
  command: commands.join(" && "), operator: LOCAL_OPERATOR, timestamp,
239
- passed: verdict.executed, failed: 0, skipped: verdict.skipped, ignored: verdict.ignored, allowIgnored,
418
+ passed: verdict.executed, failed: 0, skipped: verdict.skipped, ignored: verdict.ignored, allowIgnored: ignoredAllowed,
419
+ ...(configReason ? { allowIgnoredReason: configReason } : {}),
240
420
  requireAssets: env.RIGHTKIT_REQUIRE_ASSETS, assetGated: isAssetGated,
421
+ ...(batch ? { batch: structuredClone(batch.receipt) } : {}),
241
422
  steps: steps.map(({ command, ok, executed, failed, ignored, skipped, binaries }) => ({ command, ok, ...(executed === undefined ? {} : { executed, failed, ignored, skipped, binaries }) })),
242
423
  }],
243
424
  };
@@ -252,8 +433,12 @@ export async function runLocalPublish(kind, argv, deps = {}) {
252
433
  if (kind === "npm") {
253
434
  (deps.publishGatedTarball ?? publishGatedTarball)({ name: unit, version, tarball: artifactFile, evidencePath }, deps.publishDeps ?? {});
254
435
  } else {
255
- const published = await (deps.runCargoPublish ?? runCargoPublish)({ crate: unit, dryRun: false, allowDirty: false, workspaceRoot, evidence: evidencePath }, undefined, deps.cargoDeps ?? {});
256
- (deps.recordPublished ?? recordPublished)({ registry: "cargo", name: published.name, version: published.version });
436
+ // trustEvidenceSteps: the evidence just written (and validated by the gate above) proves this commit's tests
437
+ // and clippy, so the publisher does not repeat them; verifyOnPublish: Cargo's verify build runs in the
438
+ // publish itself. Package contents, the runtime budget, the archive hash and the readback always run.
439
+ const execute = deps.cargoExecute ?? (batch ? batchCargoExecute(unit) : undefined);
440
+ const published = await (deps.runCargoPublish ?? runCargoPublish)({ crate: unit, dryRun: false, allowDirty: false, workspaceRoot, evidence: evidencePath, trustEvidenceSteps: true, verifyOnPublish: true }, execute, deps.cargoDeps ?? {});
441
+ await withVersionsLock(() => (deps.recordPublished ?? recordPublished)({ registry: "cargo", name: published.name, version: published.version }));
257
442
  }
258
443
  log(`[right-release] published ${unit}@${version}`);
259
444
  return { kind, name: unit, version, commit: head, artifactSha256, evidencePath };
@@ -262,6 +447,109 @@ export async function runLocalPublish(kind, argv, deps = {}) {
262
447
  }
263
448
  }
264
449
 
450
+ /**
451
+ * Batch publish (cargo only): validate the whole batch and run fmt, the secret scan and clippy once at one
452
+ * commit and tree, failing the batch closed before anything is uploaded; then publish layer by layer, the
453
+ * crates of a layer concurrently (bounded by --concurrency). A failure stops new work: no crate of a later
454
+ * layer starts, crates of the failing layer that were already running finish, and the refusal lists what
455
+ * published. Re-run with the published crates removed from the list.
456
+ */
457
+ async function runLocalPublishBatch(parsed, deps) {
458
+ const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
459
+ const { workspaceRoot, concurrency } = parsed;
460
+ const git = deps.git ?? defaultGit;
461
+ const readJson = deps.readJson ?? defaultReadJson;
462
+ const run = deps.run ?? defaultRunAsync;
463
+ assertLocalHost(deps, workspaceRoot);
464
+
465
+ // 1. The crate list and its dependency layers.
466
+ const requested = parsed.batch.file ? parseCrateLayers((deps.readText ?? ((file) => fs.readFileSync(file, "utf8")))(parsed.batch.file)) : [parsed.batch.crates];
467
+ const crates = requested.flat();
468
+ const dependenciesOf = deps.batchDependencies ?? ((unit) => {
469
+ const members = new Set(crates);
470
+ return pathDependencyDirs(path.join(workspaceRoot, "crates", unit)).map((dir) => path.basename(dir)).filter((name) => members.has(name) && name !== unit);
471
+ });
472
+ const layers = parsed.batch.file ? checkCrateLayers(requested, dependenciesOf) : deriveCrateLayers(crates, dependenciesOf);
473
+
474
+ // 2. Preconditions for every crate before anything runs, fail closed.
475
+ const gateConfig = readJson(path.join(workspaceRoot, "ci", "gate.config.json")) ?? {};
476
+ const assetGated = readJson(path.join(workspaceRoot, "ci", "asset-gated.json")) ?? {};
477
+ const isAssetGated = (unit) => Object.hasOwn(assetGated.crates ?? {}, unit);
478
+ const versions = new Map();
479
+ for (const unit of crates) {
480
+ const dir = `crates/${unit}`;
481
+ if (!fs.existsSync(path.join(workspaceRoot, dir))) refuse(`unit directory not found: ${path.join(workspaceRoot, dir)}`);
482
+ const pkg = (deps.readCargoPackage ?? readCargoPackage)(path.join(workspaceRoot, dir));
483
+ if (pkg.name !== unit) refuse(`Cargo.toml [package].name ${JSON.stringify(pkg.name)} differs from --crates entry ${unit}`);
484
+ if (typeof pkg.version !== "string" || !pkg.version.trim()) refuse(`${unit} has no literal version`);
485
+ versions.set(unit, pkg.version);
486
+ localTestPlan({ kind: "cargo", unit, dir, config: gateConfig, batch: true }); // refuses an OS-restricted crate now, not mid-batch
487
+ }
488
+ const head = git(["rev-parse", "HEAD"], workspaceRoot);
489
+ if (!head || !/^[0-9a-f]{40}$/.test(head)) refuse("cannot resolve HEAD");
490
+ const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
491
+ if (!tree || !/^[0-9a-f]{40}$/.test(tree)) refuse("cannot resolve the HEAD tree");
492
+ // Every batch crate and the workspace manifest and lock must match the commit the batch checks are bound to.
493
+ const dirty = git(["status", "--porcelain", "--", ...crates.map((unit) => `crates/${unit}`), "Cargo.toml", "Cargo.lock"], workspaceRoot);
494
+ if (dirty === null) refuse("cannot read git status for the batch");
495
+ if (dirty !== "") refuse(`tree is not clean for the batch crates or the workspace manifest; commit first:\n${dirty}`);
496
+ const registryHas = deps.registryHas ?? defaultRegistryHas;
497
+ const published = (await Promise.all(crates.map(async (unit) => ((await registryHas({ kind: "cargo", name: unit, version: versions.get(unit) })) ? `${unit}@${versions.get(unit)}` : null)))).filter(Boolean);
498
+ if (published.length) refuse(`already on the crates.io registry: ${published.join(", ")}; bump the version or remove them from the batch`);
499
+ log(`[right-release] local publish cargo batch: ${crates.length} crate(s) in ${layers.length} layer(s) at ${head.slice(0, 12)}, concurrency ${concurrency} (local-build grant, darwin-only policy)`);
500
+
501
+ // 3. Batch-level checks, once, at the batch commit. Each is recorded in every crate's evidence.
502
+ const env = testEnv(deps, false);
503
+ const stamp = { commit: head, tree };
504
+ const checks = []; const commands = [];
505
+ const batchCheck = async (name, command, args, crateNames, runEnv = env) => {
506
+ const text = `${command} ${args.join(" ")}`;
507
+ log(`[right-release] batch ${name}: ${text}`);
508
+ const result = await run(command, args, { cwd: workspaceRoot, env: runEnv });
509
+ if (result.error || result.status !== 0) refuse(`batch ${name} failed (${result.error?.message ?? `exit ${result.status}`}): ${text}\n${tail(`${result.stdout ?? ""}\n${result.stderr ?? ""}`)}`);
510
+ checks.push({ name, command: text, ok: true, ...stamp, ...(crateNames ? { crates: crateNames } : {}) });
511
+ commands.push(text);
512
+ };
513
+ await batchCheck("fmt", "cargo", ["fmt", "--all", "--check"]);
514
+ log("[right-release] batch secret-scan: crate directories of the batch");
515
+ const scan = deps.scanSecrets ?? scanCrateForSecrets;
516
+ const violations = crates.flatMap((unit) => scan(path.join(workspaceRoot, "crates", unit)).map((file) => `${unit}: ${file}`));
517
+ if (violations.length) refuse(`batch secret scan rejected: ${violations.join(", ")}`);
518
+ const scanText = `scanCrateForSecrets ${crates.map((unit) => `crates/${unit}`).join(" ")}`;
519
+ checks.push({ name: "secret-scan", command: scanText, ok: true, ...stamp, crates });
520
+ commands.push(scanText);
521
+ const featuresOf = (unit) => (gateConfig?.rust?.[unit]?.featureSets ?? [[]])[0];
522
+ for (const layer of layers) {
523
+ for (const invocation of batchClippyInvocations(layer, { featuresOf, assetGatedOf: isAssetGated })) {
524
+ await batchCheck("clippy", "cargo", invocation.args, invocation.crates, testEnv(deps, invocation.assetGated));
525
+ }
526
+ }
527
+ const receipt = { id: `batch-${head.slice(0, 12)}-${sha256(JSON.stringify(layers)).slice(0, 8)}`, ...stamp, crates, checks };
528
+
529
+ // 4. Publish layer by layer. A crate's publish returns only after its registry readback proved the exact
530
+ // checksum visible in the index, so layer N+1 starts only once every layer-N dependency is visible.
531
+ const batch = { head, tree, receipt, commands };
532
+ const results = [];
533
+ for (const [index, layer] of layers.entries()) {
534
+ log(`[right-release] batch layer ${index + 1}/${layers.length}: ${layer.join(", ")}`);
535
+ const queue = [...layer]; const failures = [];
536
+ const worker = async () => {
537
+ while (!failures.length && queue.length) {
538
+ const unit = queue.shift();
539
+ try { results.push(await publishUnit({ ...parsed, kind: "cargo", unit, batch: undefined }, deps, batch)); }
540
+ catch (error) { failures.push({ unit, message: error.message }); }
541
+ }
542
+ };
543
+ await Promise.all(Array.from({ length: Math.min(concurrency, layer.length) }, worker));
544
+ if (failures.length) {
545
+ const done = results.map((r) => `${r.name}@${r.version}`);
546
+ refuse(`batch stopped in layer ${index + 1}/${layers.length}; published so far: ${done.join(", ") || "none"}\n${failures.map((f) => `- ${f.unit}: ${f.message}`).join("\n")}`);
547
+ }
548
+ }
549
+ log(`[right-release] batch published ${results.length} crate(s): ${results.map((r) => `${r.name}@${r.version}`).join(", ")}`);
550
+ return { kind: "cargo", batch: true, commit: head, tree, results };
551
+ }
552
+
265
553
  const realOrSelf = (file) => { try { return fs.realpathSync(file); } catch { return path.resolve(file); } };
266
554
  if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
267
555
  const [kind, ...rest] = process.argv.slice(2);