@rightkit/release 0.2.174 → 0.4.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 (82) hide show
  1. package/README.md +200 -2
  2. package/addon-command.mjs +3 -4
  3. package/android-play-upload.mjs +2 -1
  4. package/apple-profile-renew.mjs +11 -3
  5. package/artifact-storage.mjs +35 -2
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/darwin/rightkit-swap.sign.json +1 -1
  8. package/build-release.mjs +138 -47
  9. package/cli/right-release.mjs +40 -5
  10. package/codesign-environment.mjs +2 -2
  11. package/command-spec.mjs +80 -0
  12. package/content-digest.mjs +209 -0
  13. package/deps-cargo.mjs +55 -0
  14. package/deps.mjs +7 -0
  15. package/development.mjs +17 -6
  16. package/direct-bootstrap.mjs +5 -6
  17. package/disk-command.mjs +1 -0
  18. package/disk-policy.json +6 -2
  19. package/disk-stable-root.mjs +13 -9
  20. package/dist-scan.mjs +102 -0
  21. package/dmg-build.mjs +53 -4
  22. package/github-updater-release.mjs +70 -7
  23. package/icon-pack.mjs +117 -0
  24. package/in-place-install.mjs +17 -14
  25. package/init-command.mjs +116 -0
  26. package/install-app-windows.mjs +24 -7
  27. package/install-app.mjs +56 -42
  28. package/ios-asc-credentials.mjs +163 -0
  29. package/ios-build-context.mjs +129 -0
  30. package/ios-build-upload.mjs +282 -0
  31. package/ios-credentials-command.mjs +70 -0
  32. package/ios-dev.mjs +248 -0
  33. package/ios-host.mjs +76 -0
  34. package/ios-lock.mjs +75 -0
  35. package/ios-package-recipe.mjs +707 -0
  36. package/ios-testflight.mjs +266 -0
  37. package/ios-upload.mjs +313 -0
  38. package/local-publish.mjs +390 -47
  39. package/macho-approval-hash.mjs +181 -0
  40. package/managed-cargo.mjs +57 -1
  41. package/media-sidecar.mjs +20 -4
  42. package/mirror-root-artifact.mjs +74 -22
  43. package/native-cargo-layout.mjs +47 -4
  44. package/npm-license-notices.mjs +159 -0
  45. package/ownership-command.mjs +2 -1
  46. package/ownership-dependency-allowlist.json +2 -1
  47. package/ownership-evidence.mjs +30 -18
  48. package/ownership-key-command.mjs +69 -2
  49. package/ownership-mandatory-set.mjs +66 -16
  50. package/ownership-repositories.json +1 -1
  51. package/ownership-scope.mjs +94 -0
  52. package/ownership.mjs +86 -11
  53. package/package-recipe-targets.mjs +56 -2
  54. package/package.json +1 -1
  55. package/pe-content.mjs +133 -0
  56. package/preflight.mjs +14 -7
  57. package/project-type.mjs +3 -1
  58. package/protected-credentials.mjs +28 -1
  59. package/publication-artifacts.mjs +30 -5
  60. package/publication-gate.mjs +83 -5
  61. package/publish-cargo.mjs +124 -18
  62. package/publish-update.mjs +15 -2
  63. package/qa-build-features.mjs +16 -13
  64. package/qa-contract.mjs +30 -2
  65. package/qa-control-scan.mjs +4 -0
  66. package/release-dependencies-command.mjs +45 -0
  67. package/release-dependencies.mjs +60 -0
  68. package/release-invocation.mjs +118 -8
  69. package/release-switch-registry.json +126 -27
  70. package/release-switch-scan.mjs +62 -24
  71. package/release.mjs +144 -21
  72. package/rightkit-ownership.json +46 -2
  73. package/rightkit-versions.json +380 -189
  74. package/runtime-budgets.mjs +134 -23
  75. package/sign-updater.mjs +56 -59
  76. package/source-gate.mjs +8 -1
  77. package/stage-sidecar.mjs +634 -69
  78. package/target-distribution.mjs +5 -0
  79. package/test-command.mjs +190 -0
  80. package/toolchain-swift.mjs +239 -0
  81. package/upload-release.mjs +7 -2
  82. package/web-build.mjs +67 -0
package/local-publish.mjs CHANGED
@@ -1,27 +1,39 @@
1
1
  #!/usr/bin/env node
2
- // `right-release publish npm|cargo ... --local`: publish ONE @rightkit npm package or crate from the Mac under
3
- // Adrian's local-build grant (2026-10-14, committed .rightkit-local-build.json). It does not weaken the
4
- // publication gate: it runs the unit's own CI tests (ci/gate.config.json, ci/asset-gated.json,
5
- // scripts/ci/gate.mjs semantics), packs the artifact, writes a schema-1 evidence file with one darwin receipt
6
- // (hostPolicy "darwin-only"), then calls the EXISTING enforcePublicationGate and the EXISTING publisher
2
+ // `right-release publish npm|cargo ... --local`: publish ONE @rightkit npm package or crate from the Mac (npm and
3
+ // crates) or from Windows (crates only) under Adrian's local-build grant (2026-10-14, committed
4
+ // .rightkit-local-build.json). It does not weaken the publication gate: it runs the unit's own CI tests
5
+ // (ci/gate.config.json, ci/asset-gated.json, scripts/ci/gate.mjs semantics), packs the artifact, writes a
6
+ // schema-1 evidence file with one receipt for the publishing host (hostPolicy "darwin-only" on the Mac,
7
+ // "win32-only" on Windows), then calls the EXISTING enforcePublicationGate and the EXISTING publisher
7
8
  // (publishGatedTarball for npm, runCargoPublish for cargo). There is no way to skip a test or the evidence.
9
+ //
10
+ // Batch mode (`cargo --crates a,b,c | --crates-file f --local`): fmt, the secret scan and clippy run ONCE for
11
+ // the batch at one commit and tree and are recorded in every crate's evidence as a `batch` receipt that the
12
+ // publication gate validates (publication-gate.mjs batchReceiptProblems); each crate still runs its own tests,
13
+ // all-features check, packaging, gate, publish and registry readback, and writes its own evidence file. Crates
14
+ // of one dependency layer publish concurrently (bounded); layer N+1 starts only after every layer-N crate is
15
+ // visible in the registry index with the verified checksum.
8
16
  import crypto from "node:crypto";
9
17
  import fs from "node:fs";
10
18
  import os from "node:os";
11
19
  import path from "node:path";
12
20
  import { fileURLToPath, pathToFileURL } from "node:url";
13
21
  import { spawnSync } from "./background-process.mjs";
14
- import { runCargoSync } from "./managed-cargo.mjs";
22
+ import { runCargoAsync, runCargoSync, spawnCollect } from "./managed-cargo.mjs";
15
23
  import { localBuildGranted, LOCAL_BUILD_FILE } from "./execution-policy.mjs";
16
- import { GATE_SCHEMA, defaultEvidencePath, enforcePublicationGate, publishGatedTarball } from "./publication-gate.mjs";
17
- import { runCargoPublish } from "./publish-cargo.mjs";
24
+ import { GATE_SCHEMA, MAX_CARGO_JOBS, defaultEvidencePath, enforcePublicationGate, publishGatedTarball } from "./publication-gate.mjs";
25
+ import { runCargoPublish, runCargoReadOnly, scanCrateForSecrets } from "./publish-cargo.mjs";
18
26
  import { cargoIndexUrl } from "./cargo-registry-readback.mjs";
19
- import { readCargoPackage } from "./crate-graph.mjs";
27
+ import { pathDependencyDirs, readCargoPackage } from "./crate-graph.mjs";
20
28
  import { recordPublished, stageSourceVersions } from "./rightkit-sync.mjs";
21
29
  import { prepareSwapHelper, SWAP_HELPER_CARGO_ARGS, SWAP_HELPER_PACKAGE } from "./swap-helper-bundle.mjs";
22
30
 
23
31
  export const LOCAL_OPERATOR = "agent (local-build grant)";
24
32
  export const LOCAL_HOST_POLICY_REASON = "Adrian 2026-10-14 local-build grant (.rightkit-local-build.json); Windows covered by the next CI run";
33
+ export const LOCAL_WIN32_HOST_POLICY_REASON = "Adrian 2026-10-14 local-build grant (.rightkit-local-build.json); published from the Windows host, the macOS run is not part of this receipt";
34
+ // The hosts `--local` accepts and the host policy each records in the evidence.
35
+ export const LOCAL_HOST_POLICIES = Object.freeze({ darwin: "darwin-only", win32: "win32-only" });
36
+ const localHostPolicyReason = (platform) => (platform === "win32" ? LOCAL_WIN32_HOST_POLICY_REASON : LOCAL_HOST_POLICY_REASON);
25
37
  const STEP_TIMEOUT_MS = 30 * 60 * 1000;
26
38
 
27
39
  export class LocalPublishError extends Error {
@@ -29,22 +41,121 @@ export class LocalPublishError extends Error {
29
41
  }
30
42
  const refuse = (message) => { throw new LocalPublishError(message); };
31
43
 
44
+ export const DEFAULT_CONCURRENCY = 4;
45
+ const MAX_CONCURRENCY = 8;
46
+ const CRATE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
47
+ const JOBS_SUBCOMMANDS = new Set(["test", "clippy", "check", "package"]);
48
+ export function withCargoJobs(args, jobs) {
49
+ return jobs !== undefined && JOBS_SUBCOMMANDS.has(args[0])
50
+ ? [args[0], "--jobs", String(jobs), ...args.slice(1)] : args;
51
+ }
52
+
53
+ const crateList = (text, what) => {
54
+ const names = String(text ?? "").split(",").map((name) => name.trim()).filter(Boolean);
55
+ for (const name of names) if (!CRATE_NAME.test(name)) refuse(`${what}: invalid crate name ${JSON.stringify(name)}`);
56
+ return names;
57
+ };
58
+
59
+ /**
60
+ * A crates file: one crate per line, blank lines separate dependency layers (earlier layers publish first),
61
+ * `#` starts a comment. Returns the layers; refuses a bad name, a repeated crate or an empty file.
62
+ */
63
+ export function parseCrateLayers(text) {
64
+ const layers = []; let current = [];
65
+ const flush = () => { if (current.length) layers.push(current); current = []; };
66
+ for (const raw of String(text ?? "").split(/\r?\n/)) {
67
+ if (raw.trim().startsWith("#")) continue;
68
+ const line = raw.replace(/\s+#.*$/, "").trim();
69
+ if (!line) { flush(); continue; }
70
+ if (!CRATE_NAME.test(line)) refuse(`crates file: invalid crate name ${JSON.stringify(line)}`);
71
+ current.push(line);
72
+ }
73
+ flush();
74
+ const all = layers.flat();
75
+ const repeated = all.find((name, index) => all.indexOf(name) !== index);
76
+ if (repeated) refuse(`crates file names ${repeated} more than once`);
77
+ if (!all.length) refuse("crates file names no crate");
78
+ return layers;
79
+ }
80
+
81
+ /** An explicit layering must put every in-batch dependency of a crate in a STRICTLY earlier layer. */
82
+ export function checkCrateLayers(layers, dependsOn) {
83
+ const layerOf = new Map();
84
+ layers.forEach((layer, index) => layer.forEach((name) => layerOf.set(name, index)));
85
+ for (const [name, index] of layerOf) {
86
+ for (const dependency of dependsOn(name)) {
87
+ if (!layerOf.has(dependency)) continue;
88
+ 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`);
89
+ }
90
+ }
91
+ return layers;
92
+ }
93
+
94
+ /** Layers derived from the in-batch dependency edges: layer = 1 + the deepest in-batch dependency. */
95
+ export function deriveCrateLayers(crates, dependsOn) {
96
+ const member = new Set(crates);
97
+ const level = new Map(); const visiting = new Set();
98
+ const visit = (name) => {
99
+ if (level.has(name)) return level.get(name);
100
+ if (visiting.has(name)) refuse(`dependency cycle among the batch crates at ${name}`);
101
+ visiting.add(name);
102
+ const depth = Math.max(-1, ...dependsOn(name).filter((dependency) => member.has(dependency) && dependency !== name).map(visit)) + 1;
103
+ visiting.delete(name); level.set(name, depth);
104
+ return depth;
105
+ };
106
+ crates.forEach(visit);
107
+ const layers = [];
108
+ for (const name of crates) (layers[level.get(name)] ??= []).push(name);
109
+ return layers;
110
+ }
111
+
32
112
  export function parseLocalPublishArgs(kind, argv, cwd = process.cwd()) {
33
113
  const flag = kind === "npm" ? "--package" : "--crate";
114
+ const batchFlags = kind === "cargo" ? ", --crates <a,b,c>, --crates-file <file>, --concurrency <n>, --allow-ignored-units <a,b>" : "";
34
115
  let unit; let workspace; let local = false; let allowIgnored = false;
116
+ let qualifyOnly = false; let bundle; let jobsText;
117
+ let crates; let cratesFile; let concurrency = DEFAULT_CONCURRENCY; let allowIgnoredUnits = [];
35
118
  for (let i = 0; i < argv.length; i += 1) {
36
119
  const arg = argv[i];
37
120
  if (arg === "--local") local = true;
38
121
  else if (arg === flag) unit = argv[++i];
39
122
  else if (arg === "--workspace") workspace = argv[++i];
123
+ else if (kind === "cargo" && arg === "--qualify-only") qualifyOnly = true;
124
+ else if (kind === "cargo" && arg === "--bundle") bundle = argv[++i];
40
125
  // Same meaning as publish.yml's allow_ignored: this unit may have ignored tests; the count is recorded.
41
126
  else if (arg === "--allow-ignored") allowIgnored = true;
42
- else refuse(`unknown publish ${kind} --local option: ${arg} (accepted: ${flag} <name>, --workspace <dir>, --allow-ignored, --local)`);
127
+ else if (kind === "cargo" && arg === "--crates") crates = crateList(argv[++i], "--crates");
128
+ else if (kind === "cargo" && arg === "--crates-file") cratesFile = argv[++i];
129
+ else if (kind === "cargo" && arg === "--concurrency") concurrency = Number(argv[++i]);
130
+ else if (kind === "cargo" && arg === "--allow-ignored-units") allowIgnoredUnits = crateList(argv[++i], "--allow-ignored-units");
131
+ else if (kind === "cargo" && arg === "--jobs") jobsText = argv[++i] ?? "";
132
+ else refuse(`unknown publish ${kind} --local option: ${arg} (accepted: ${flag} <name>${batchFlags}, --workspace <dir>, --allow-ignored, --local)`);
43
133
  }
44
134
  if (!local) refuse("--local is required");
135
+ if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) refuse(`--concurrency must be an integer from 1 to ${MAX_CONCURRENCY}`);
136
+ const workspaceRoot = path.resolve(workspace ?? cwd);
137
+ if (qualifyOnly && (crates !== undefined || cratesFile !== undefined)) refuse("--qualify-only currently requires one --crate");
138
+ if (qualifyOnly && !String(bundle ?? "").trim()) refuse("--qualify-only requires --bundle <new-directory>");
139
+ if (bundle !== undefined && !qualifyOnly) refuse("--bundle requires --qualify-only");
140
+ const bundleDir = bundle === undefined ? undefined : path.resolve(cwd, bundle);
141
+ let jobs;
142
+ if (jobsText !== undefined) {
143
+ if (!/^[0-9]+$/.test(jobsText) || Number(jobsText) < 1 || Number(jobsText) > MAX_CARGO_JOBS) refuse(`--jobs must be an integer from 1 to ${MAX_CARGO_JOBS}`);
144
+ if (!qualifyOnly) refuse("--jobs requires --qualify-only");
145
+ jobs = Number(jobsText);
146
+ }
147
+ if (kind === "cargo" && (crates !== undefined || cratesFile !== undefined)) {
148
+ if (unit !== undefined) refuse("--crate cannot be combined with --crates or --crates-file");
149
+ if (crates !== undefined && cratesFile !== undefined) refuse("use --crates or --crates-file, not both");
150
+ if (crates !== undefined && !crates.length) refuse("--crates names no crate");
151
+ if (cratesFile !== undefined && !String(cratesFile ?? "").trim()) refuse("--crates-file needs a file");
152
+ const repeated = crates?.find((name, index) => crates.indexOf(name) !== index);
153
+ if (repeated) refuse(`--crates names ${repeated} more than once`);
154
+ return { kind, unit: undefined, allowIgnored, allowIgnoredUnits, workspaceRoot, concurrency, batch: { crates, file: cratesFile === undefined ? undefined : path.resolve(cwd, cratesFile) } };
155
+ }
45
156
  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) };
157
+ if (kind === "cargo" && !CRATE_NAME.test(unit ?? "")) refuse("publish cargo --local requires --crate <name> (or --crates / --crates-file)");
158
+ return { kind, unit, allowIgnored, allowIgnoredUnits, workspaceRoot, concurrency, batch: undefined, ...(qualifyOnly ? { qualifyOnly, bundleDir } : {}), ...(jobs === undefined ? {} : { jobs }) };
48
159
  }
49
160
 
50
161
  function defaultGit(args, cwd) {
@@ -74,6 +185,40 @@ function defaultRun(command, args, options) {
74
185
  return command === "cargo" ? runCargoSync(args, settings) : spawnSync(command, args, settings);
75
186
  }
76
187
 
188
+ // Async twin used by batch mode so crates of one layer can overlap their uploads and index waits. The broker
189
+ // still runs builds one at a time; `await` on a synchronous fake works the same.
190
+ function defaultRunAsync(command, args, options) {
191
+ const settings = { encoding: "utf8", maxBuffer: 512 * 1024 * 1024, timeout: STEP_TIMEOUT_MS, windowsHide: true, ...options };
192
+ return command === "cargo" ? runCargoAsync(args, settings) : spawnCollect(command, args, settings);
193
+ }
194
+
195
+ /**
196
+ * `execute` for runCargoPublish in batch mode: the async Cargo runner, with the output of a step that would
197
+ * inherit stdio echoed line by line under the crate name (concurrent crates would otherwise interleave raw).
198
+ */
199
+ export function batchCargoExecute(label, { run = runCargoAsync, out = process.stdout, err = process.stderr } = {}) {
200
+ return async (_command, args, settings) => {
201
+ const { stdio, ...rest } = settings;
202
+ const result = await run(args, { ...rest, timeout: STEP_TIMEOUT_MS, maxBuffer: 512 * 1024 * 1024 });
203
+ if (stdio === "inherit") {
204
+ for (const [stream, text] of [[out, result.stdout], [err, result.stderr]]) {
205
+ for (const line of String(text ?? "").split(/\r?\n/)) if (line) stream.write(`[${label}] ${line}\n`);
206
+ }
207
+ }
208
+ return result;
209
+ };
210
+ }
211
+
212
+ // Registry versions-file writers (recordPublished, stageSourceVersions) read-modify-write one shared JSON file
213
+ // and its consumer copies. They are synchronous, but the lock keeps the whole write one unit even if a writer
214
+ // becomes async, so concurrent crates of a layer can never interleave two updates.
215
+ let versionsTail = Promise.resolve();
216
+ const withVersionsLock = (task) => {
217
+ const result = versionsTail.then(task);
218
+ versionsTail = result.catch(() => {});
219
+ return result;
220
+ };
221
+
77
222
  async function defaultLoadWorkspaceModules(workspaceRoot) {
78
223
  const load = (file) => import(pathToFileURL(path.join(workspaceRoot, "scripts", "ci", file)).href);
79
224
  const [results, upload] = await Promise.all([load("test-results.mjs"), load("upload.mjs")]);
@@ -92,7 +237,7 @@ export function allFeatureCheckSets(entry = {}) {
92
237
  }
93
238
 
94
239
  /** 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 }) {
240
+ export function localTestPlan({ kind, unit, dir, config, manifest, batch = false, platform = "darwin" }) {
96
241
  if (kind === "npm") {
97
242
  if (!manifest?.scripts?.test) refuse(`${unit} has no test script; --local never publishes an untested package`);
98
243
  return {
@@ -104,33 +249,88 @@ export function localTestPlan({ kind, unit, dir, config, manifest }) {
104
249
  };
105
250
  }
106
251
  const entry = config?.rust?.[unit] ?? {};
107
- if (entry.os && !entry.os.includes("macos")) refuse(`${unit} is restricted to ${entry.os.join(", ")} by ci/gate.config.json; it cannot be qualified on this Mac`);
252
+ // gate.config.json names hosts "macos" and "windows" (scripts/ci/gate.mjs HOST).
253
+ const gateHost = platform === "win32" ? "windows" : "macos";
254
+ if (entry.os && !entry.os.includes(gateHost)) refuse(`${unit} is restricted to ${entry.os.join(", ")} by ci/gate.config.json; it cannot be qualified on this ${platform === "win32" ? "Windows host" : "Mac"}`);
108
255
  const featureSets = entry.featureSets ?? [[]];
256
+ // Stream Cargo artifact records to the broker; libtest summaries remain plain text for ResultScanner.
109
257
  const steps = [];
110
258
  for (const features of featureSets) {
111
- steps.push({ command: "cargo", args: ["test", "-p", unit, ...features, "--no-fail-fast", "--", "--show-output"], test: true });
112
- if (entry.alsoSerial) steps.push({ command: "cargo", args: ["test", "-p", unit, ...features, "--no-fail-fast", "--", "--test-threads=1", "--show-output"], test: true });
259
+ steps.push({ command: "cargo", args: ["test", "-p", unit, "--message-format=json-render-diagnostics", ...features, "--no-fail-fast", "--", "--show-output"], test: true });
260
+ if (entry.alsoSerial) steps.push({ command: "cargo", args: ["test", "-p", unit, "--message-format=json-render-diagnostics", ...features, "--no-fail-fast", "--", "--test-threads=1", "--show-output"], test: true });
113
261
  }
114
- steps.push({ command: "cargo", args: ["clippy", "-p", unit, "--no-deps", "--all-targets", ...featureSets[0], "--", "-D", "warnings"], test: false });
115
- for (const features of allFeatureCheckSets(entry)) steps.push({ command: "cargo", args: ["check", "-p", unit, "--all-targets", ...features], test: false });
262
+ // Batch mode lints the whole batch once (batchClippyInvocations) and records it as a batch receipt.
263
+ if (!batch) steps.push({ command: "cargo", args: ["clippy", "-p", unit, "--message-format=json-render-diagnostics", "--no-deps", "--all-targets", ...featureSets[0], "--", "-D", "warnings"], test: false });
264
+ for (const features of allFeatureCheckSets(entry)) steps.push({ command: "cargo", args: ["check", "-p", unit, "--message-format=json-render-diagnostics", "--all-targets", ...features], test: false });
116
265
  return { id: `rust:${unit}`, featureLabels: featureSets.map((features) => features.join(" ") || "default"), steps };
117
266
  }
118
267
 
268
+ /**
269
+ * The clippy invocations that replace each crate's own `cargo clippy -p <crate> --no-deps --all-targets
270
+ * <featureSets[0]> -- -D warnings`: same flags and lint level, so the same lints on the same code. Crates
271
+ * whose first feature set is empty or `--all-features` share one `-p a -p b` invocation (the flags mean the
272
+ * same for every crate); any other feature set runs per crate, because `--features x` must not reach a crate
273
+ * that lacks x. The caller passes ONE dependency layer: no selected crate depends on another, so `-p` does not
274
+ * unify extra features into a selected crate and each is linted with exactly the features it was before.
275
+ * Crates with a different RIGHTKIT_REQUIRE_ASSETS (asset-gated) stay apart so the environment is unchanged.
276
+ */
277
+ export function batchClippyInvocations(layer, { featuresOf = () => [], assetGatedOf = () => false } = {}) {
278
+ const groups = new Map();
279
+ for (const unit of layer) {
280
+ const features = featuresOf(unit) ?? [];
281
+ const shared = features.length === 0 || (features.length === 1 && features[0] === "--all-features");
282
+ const key = shared ? JSON.stringify([features, assetGatedOf(unit)]) : `unit:${unit}`;
283
+ if (!groups.has(key)) groups.set(key, { crates: [], features, assetGated: assetGatedOf(unit) });
284
+ groups.get(key).crates.push(unit);
285
+ }
286
+ return [...groups.values()].map(({ crates, features, assetGated }) => ({
287
+ crates, assetGated,
288
+ args: ["clippy", ...crates.flatMap((unit) => ["-p", unit]), "--message-format=json-render-diagnostics", "--no-deps", "--all-targets", ...features, "--", "-D", "warnings"],
289
+ }));
290
+ }
291
+
119
292
  const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
120
293
  const tarballName = (name, version) => `${name.replace(/^@/, "").replace("/", "-")}-${version}.tgz`;
121
294
  const tail = (text, lines = 40) => String(text ?? "").split(/\r?\n/).slice(-lines).join("\n");
122
295
 
296
+ // Tests run under hosted-CI conditions: no inherited credentials (a managed host refuses a token from the
297
+ // environment) and no sibling product checkouts (CI holds rightkit alone).
298
+ function testEnv(deps, isAssetGated) {
299
+ const inherited = Object.fromEntries(Object.entries(deps.env ?? process.env).filter(([key]) => !/(TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY)/i.test(key)));
300
+ return { ...inherited, RIGHTKIT_REQUIRE_ASSETS: isAssetGated ? "0" : "1", RIGHTKIT_ISOLATED_CHECKOUT: "1", CARGO_TERM_COLOR: "never" };
301
+ }
302
+
303
+ function assertLocalHost(deps, workspaceRoot, kind) {
304
+ const platform = deps.platform ?? process.platform;
305
+ 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}`);
306
+ if (!Object.hasOwn(LOCAL_HOST_POLICIES, platform)) refuse(`--local publishes from the Mac only (host policy: darwin-only) or from Windows (host policy: win32-only); this host is ${platform}`);
307
+ // The npm path reads the registry token from the macOS Keychain and ships a Developer-ID-signed helper: Mac only.
308
+ if (kind === "npm" && platform !== "darwin") refuse(`publish npm --local needs the macOS Keychain npm token and runs on the Mac only; Windows publishes crates (publish cargo --local); this host is ${platform}`);
309
+ return platform;
310
+ }
311
+
123
312
  export async function runLocalPublish(kind, argv, deps = {}) {
313
+ const parsed = parseLocalPublishArgs(kind, argv, deps.cwd);
314
+ if (parsed.batch) return runLocalPublishBatch(parsed, deps);
315
+ return publishUnit(parsed, deps, null);
316
+ }
317
+
318
+ /**
319
+ * Qualify and publish ONE unit. `batch` (cargo batch mode only) is `{ head, tree, receipt, commands }`: the
320
+ * batch-level fmt, secret scan and clippy already ran at `head`/`tree`, so this unit skips its own clippy and
321
+ * copies the receipt into its evidence; everything else (tests, all-features check, packaging, the gate, the
322
+ * publish and the registry readback) runs for this unit exactly as in single mode.
323
+ */
324
+ async function publishUnit(parsed, deps, batch) {
124
325
  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;
326
+ const { kind, unit, allowIgnored, workspaceRoot } = parsed;
127
327
  const git = deps.git ?? defaultGit;
128
328
  const readJson = deps.readJson ?? defaultReadJson;
129
- const run = deps.run ?? defaultRun;
329
+ const run = deps.run ?? (batch ? defaultRunAsync : defaultRun);
130
330
 
131
331
  // 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}`);
332
+ const platform = assertLocalHost(deps, workspaceRoot, kind);
333
+ if (parsed.qualifyOnly && fs.existsSync(parsed.bundleDir)) refuse(`qualification bundle already exists: ${parsed.bundleDir}`);
134
334
  const dir = kind === "npm" ? `packages/${unit.slice("@rightkit/".length)}` : `crates/${unit}`;
135
335
  const unitPath = path.join(workspaceRoot, dir);
136
336
  if (!fs.existsSync(unitPath)) refuse(`unit directory not found: ${unitPath}`);
@@ -148,33 +348,45 @@ export async function runLocalPublish(kind, argv, deps = {}) {
148
348
  if (typeof version !== "string" || !version.trim()) refuse(`${unit} has no literal version`);
149
349
  const head = git(["rev-parse", "HEAD"], workspaceRoot);
150
350
  if (!head || !/^[0-9a-f]{40}$/.test(head)) refuse("cannot resolve HEAD");
151
- const dirty = git(["status", "--porcelain", "--", dir], workspaceRoot);
351
+ if (batch) {
352
+ // The batch receipt is valid for one commit and tree only; a moved HEAD would make it describe other code.
353
+ if (head !== batch.head) refuse(`${unit}: HEAD moved to ${head} during the batch (batch commit ${batch.head})`);
354
+ const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
355
+ if (tree !== batch.tree) refuse(`${unit}: HEAD tree ${tree} differs from the batch tree ${batch.tree}`);
356
+ }
357
+ const dirty = git(["status", "--porcelain", "--", dir, ...(parsed.qualifyOnly ? ["Cargo.toml", "Cargo.lock"] : [])], workspaceRoot);
152
358
  if (dirty === null) refuse(`cannot read git status for ${dir}`);
153
359
  if (dirty !== "") refuse(`tree is not clean for ${dir}; commit first:\n${dirty}`);
154
360
  if (await (deps.registryHas ?? defaultRegistryHas)({ kind, name: unit, version })) refuse(`${unit}@${version} is already on the ${kind === "npm" ? "npm" : "crates.io"} registry; bump the version`);
155
- log(`[right-release] local publish ${kind}:${unit}@${version} at ${head.slice(0, 12)} (local-build grant, darwin-only policy)`);
361
+ log(`[right-release] local publish ${kind}:${unit}@${version} at ${head.slice(0, 12)} (local-build grant, ${LOCAL_HOST_POLICIES[platform]} policy)`);
156
362
 
157
363
  // 2. The unit's own tests, exactly as the CI gate defines them.
158
364
  const gateConfig = readJson(path.join(workspaceRoot, "ci", "gate.config.json")) ?? {};
159
365
  const assetGated = readJson(path.join(workspaceRoot, "ci", "asset-gated.json")) ?? {};
160
366
  const gatedSection = assetGated[kind === "npm" ? "npm" : "crates"] ?? {};
161
367
  const isAssetGated = Object.hasOwn(gatedSection, unit);
162
- const plan = localTestPlan({ kind, unit, dir, config: gateConfig, manifest });
368
+ const plan = localTestPlan({ kind, unit, dir, config: gateConfig, manifest, batch: Boolean(batch), platform });
369
+ if (parsed.qualifyOnly) {
370
+ const violations = (deps.scanSecrets ?? scanCrateForSecrets)(unitPath);
371
+ if (violations.length) refuse(`qualification secret scan rejected: ${violations.join(", ")}`);
372
+ plan.steps.unshift({ command: "cargo", args: ["fmt", "-p", unit, "--check"], test: false });
373
+ }
163
374
  const modules = await (deps.loadWorkspaceModules ?? defaultLoadWorkspaceModules)(workspaceRoot);
164
375
  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" };
169
- const steps = [];
376
+ const env = testEnv(deps, isAssetGated);
377
+ // Ignored tests are refused unless the unit is named: --allow-ignored / --allow-ignored-units, or a
378
+ // written reason in ci/gate.config.json (rust.<crate>.allowIgnored). The count stays in the evidence.
379
+ const configReason = kind === "cargo" && typeof gateConfig?.rust?.[unit]?.allowIgnored === "string" ? gateConfig.rust[unit].allowIgnored.trim() : "";
380
+ const ignoredAllowed = Boolean(allowIgnored || parsed.allowIgnoredUnits?.includes(unit) || configReason);
381
+ const steps = parsed.qualifyOnly ? [{ command: `scanCrateForSecrets ${dir}`, ok: true }] : [];
170
382
  for (const step of plan.steps) {
171
383
  const commandText = `${step.command} ${step.args.join(" ")}`;
172
384
  log(`[right-release] ${plan.id}: ${commandText}`);
173
- const result = run(step.command, step.args, { cwd: workspaceRoot, env });
385
+ const result = await run(step.command, step.command === "cargo" ? withCargoJobs(step.args, parsed.jobs) : step.args, { cwd: workspaceRoot, env });
174
386
  const row = { command: commandText, ok: false, isTest: step.test && TEST_COMMAND.test(commandText) };
175
387
  if (result.error || result.status !== 0) {
176
388
  steps.push(row);
177
- refuse(`${plan.id} step failed (${result.error?.message ?? `exit ${result.status}`}): ${commandText}\n${tail(`${result.stdout ?? ""}\n${result.stderr ?? ""}`)}`);
389
+ refuse(`${plan.id} step failed (${result.error?.message ?? `exit ${result.status}`}): ${commandText}\nstdout:\n${result.stdout ?? ""}\nstderr:\n${result.stderr ?? ""}`);
178
390
  }
179
391
  row.ok = true;
180
392
  if (row.isTest) {
@@ -185,7 +397,7 @@ export async function runLocalPublish(kind, argv, deps = {}) {
185
397
  }
186
398
  steps.push(row);
187
399
  }
188
- const verdict = judgeUnit({ name: unit, steps: steps.map((s) => ({ ...s, unit: plan.id })), allowIgnored: allowIgnored ? [unit] : [], allowMissingAssets: isAssetGated ? [unit] : [] });
400
+ const verdict = judgeUnit({ name: unit, steps: steps.map((s) => ({ ...s, unit: plan.id })), allowIgnored: ignoredAllowed ? [unit] : [], allowMissingAssets: isAssetGated ? [unit] : [] });
189
401
  if (verdict.status === "fail" || steps.some((s) => s.failed > 0)) refuse(`${plan.id} not qualified: ${verdict.problems.join("; ") || "failed tests reported"}`);
190
402
  if (!(verdict.executed > 0)) refuse(`${plan.id} executed no test`);
191
403
  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,40 +416,52 @@ export async function runLocalPublish(kind, argv, deps = {}) {
204
416
  if (kind === "npm") {
205
417
  // The feed a tarball ships must list the version it ships with (#319): stage it before packing.
206
418
  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 });
419
+ if (fs.existsSync(feedFile)) await withVersionsLock(() => (deps.stageSourceVersions ?? stageSourceVersions)([{ registry: "npm", name: unit, version }], { file: feedFile, root: workspaceRoot, log }));
208
420
  const args = ["--dir", dir, "pack", "--pack-destination", scratch];
209
421
  packCommands.push(`pnpm ${args.join(" ")}`);
210
- const packed = run("pnpm", args, { cwd: workspaceRoot, env });
422
+ const packed = await run("pnpm", args, { cwd: workspaceRoot, env });
211
423
  if (packed.error || packed.status !== 0) refuse(`pnpm pack failed for ${unit}\n${tail(`${packed.stdout ?? ""}\n${packed.stderr ?? ""}`)}`);
212
424
  artifactFile = path.join(scratch, tarballName(unit, version));
213
425
  if (!fs.existsSync(artifactFile)) refuse(`pnpm pack did not produce ${path.basename(artifactFile)}`);
214
426
  const checked = modules.checkNpmTarball({ file: artifactFile, name: unit, version, dir, spawn: deps.spawnSync ?? spawnSync });
215
427
  if (checked.problems.length) refuse(`${unit}: packed tarball refused:\n - ${checked.problems.join("\n - ")}`);
216
428
  } else {
217
- packCommands.push(`cargo package --locked -p ${unit}`);
218
- const packed = run("cargo", ["package", "--locked", "-p", unit], { cwd: workspaceRoot, env });
429
+ // A combined publish verifies once in `cargo publish`; detached qualification verifies here before
430
+ // its evidence leaves this host, so the publishing host can reuse that exact archive's proof.
431
+ const packageArgs = ["package", "--locked", ...(parsed.qualifyOnly ? [] : ["--no-verify"]), "-p", unit];
432
+ packCommands.push(`cargo ${packageArgs.join(" ")}`);
433
+ const packed = await run("cargo", withCargoJobs(packageArgs, parsed.jobs), { cwd: workspaceRoot, env });
219
434
  if (packed.error || packed.status !== 0) refuse(`cargo package failed for ${unit}\n${tail(`${packed.stdout ?? ""}\n${packed.stderr ?? ""}`)}`);
220
- packCommands.push("cargo metadata --format-version 1 --no-deps");
221
- const meta = run("cargo", ["metadata", "--format-version", "1", "--no-deps"], { cwd: workspaceRoot, env });
435
+ if (parsed.qualifyOnly) steps.push({ command: `cargo ${packageArgs.join(" ")}`, ok: true });
436
+ const metadataArgs = ["metadata", "--offline", "--format-version", "1", "--no-deps"];
437
+ const meta = await runCargoReadOnly(metadataArgs, { cwd: workspaceRoot, env }, (_command, args, settings) => run("cargo", args, settings),
438
+ (failed) => log(`[right-release] offline Cargo metadata missed cache; retrying online once\n${failed.stdout ?? ""}${failed.stderr ? `\n${failed.stderr}` : ""}`));
439
+ const actualMetadataArgs = meta.usedOfflineFallback ? metadataArgs.filter((arg) => arg !== "--offline") : metadataArgs;
440
+ packCommands.push(`cargo ${actualMetadataArgs.join(" ")}`);
222
441
  let targetDir;
223
442
  try { targetDir = JSON.parse(meta.stdout).target_directory; } catch { /* refused below */ }
224
- if (meta.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir)) refuse("cannot read the Cargo target directory");
443
+ if (meta.error || meta.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir)) {
444
+ refuse(`cannot read the Cargo target directory\n${tail(`${meta.error?.message ?? ""}\n${meta.stdout ?? ""}\n${meta.stderr ?? ""}`)}`);
445
+ }
225
446
  artifactFile = path.join(targetDir, "package", `${unit}-${version}.crate`);
226
447
  if (!fs.existsSync(artifactFile)) refuse(`cargo package did not produce ${artifactFile}`);
227
448
  }
228
449
  const artifactSha256 = sha256(fs.readFileSync(artifactFile));
229
450
 
230
451
  // 4. Evidence in the normal schema, then the EXISTING gate.
231
- const commands = [...plan.steps.map((s) => `${s.command} ${s.args.join(" ")}`), ...packCommands];
452
+ const commands = [...plan.steps.map((s) => `${s.command} ${s.args.join(" ")}`), ...packCommands, ...(batch ? batch.commands : [])];
232
453
  const timestamp = (deps.now ?? (() => new Date().toISOString()))();
233
454
  const evidence = {
234
455
  schema: GATE_SCHEMA, kind, name: unit, version, commit: head, artifactSha256,
235
- hostPolicy: "darwin-only", hostPolicyReason: LOCAL_HOST_POLICY_REASON,
456
+ hostPolicy: LOCAL_HOST_POLICIES[platform], hostPolicyReason: localHostPolicyReason(platform),
236
457
  runs: [{
237
- host: { os: "darwin", arch: deps.arch ?? process.arch }, commit: head, features: plan.featureLabels, artifactSha256,
458
+ host: { os: platform, arch: deps.arch ?? process.arch }, commit: head, features: plan.featureLabels, artifactSha256,
238
459
  command: commands.join(" && "), operator: LOCAL_OPERATOR, timestamp,
239
- passed: verdict.executed, failed: 0, skipped: verdict.skipped, ignored: verdict.ignored, allowIgnored,
460
+ passed: verdict.executed, failed: 0, skipped: verdict.skipped, ignored: verdict.ignored, allowIgnored: ignoredAllowed,
461
+ ...(configReason ? { allowIgnoredReason: configReason } : {}),
240
462
  requireAssets: env.RIGHTKIT_REQUIRE_ASSETS, assetGated: isAssetGated,
463
+ ...(parsed.jobs === undefined ? {} : { jobs: parsed.jobs }),
464
+ ...(batch ? { batch: structuredClone(batch.receipt) } : {}),
241
465
  steps: steps.map(({ command, ok, executed, failed, ignored, skipped, binaries }) => ({ command, ok, ...(executed === undefined ? {} : { executed, failed, ignored, skipped, binaries }) })),
242
466
  }],
243
467
  };
@@ -248,12 +472,28 @@ export async function runLocalPublish(kind, argv, deps = {}) {
248
472
  const gateOptions = { kind, name: unit, version, packagePath: unitPath, evidencePath };
249
473
  (deps.enforceGate ?? enforcePublicationGate)(gateOptions, { write: (line) => log(line.replace(/\n$/, "")) });
250
474
 
475
+ if (parsed.qualifyOnly) {
476
+ // Keep bytes and their normal gate evidence together outside the checkout. Never overwrite a bundle.
477
+ fs.mkdirSync(parsed.bundleDir, { recursive: false });
478
+ const bundledArtifact = path.join(parsed.bundleDir, path.basename(artifactFile));
479
+ const bundledEvidence = path.join(parsed.bundleDir, "gate.json");
480
+ fs.copyFileSync(artifactFile, bundledArtifact, fs.constants.COPYFILE_EXCL);
481
+ if (sha256(fs.readFileSync(bundledArtifact)) !== artifactSha256) refuse("qualification archive changed while copying");
482
+ fs.copyFileSync(evidencePath, bundledEvidence, fs.constants.COPYFILE_EXCL);
483
+ log(`[right-release] qualified ${unit}@${version}; bundle ${parsed.bundleDir}; publish cargo --crate ${unit} --from-evidence ${bundledEvidence}`);
484
+ return { kind, name: unit, version, commit: head, artifactSha256, evidencePath: bundledEvidence, artifactFile: bundledArtifact, qualifiedOnly: true };
485
+ }
486
+
251
487
  // 5. The EXISTING publish path.
252
488
  if (kind === "npm") {
253
489
  (deps.publishGatedTarball ?? publishGatedTarball)({ name: unit, version, tarball: artifactFile, evidencePath }, deps.publishDeps ?? {});
254
490
  } 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 });
491
+ // trustEvidenceSteps: the evidence just written (and validated by the gate above) proves this commit's tests
492
+ // and clippy, so the publisher does not repeat them; verifyOnPublish: Cargo's verify build runs in the
493
+ // publish itself. Package contents, the runtime budget, the archive hash and the readback always run.
494
+ const execute = deps.cargoExecute ?? (batch ? batchCargoExecute(unit) : undefined);
495
+ const published = await (deps.runCargoPublish ?? runCargoPublish)({ crate: unit, dryRun: false, allowDirty: false, workspaceRoot, evidence: evidencePath, trustEvidenceSteps: true, verifyOnPublish: true, offlineReadOnly: true }, execute, deps.cargoDeps ?? {});
496
+ await withVersionsLock(() => (deps.recordPublished ?? recordPublished)({ registry: "cargo", name: published.name, version: published.version }));
257
497
  }
258
498
  log(`[right-release] published ${unit}@${version}`);
259
499
  return { kind, name: unit, version, commit: head, artifactSha256, evidencePath };
@@ -262,6 +502,109 @@ export async function runLocalPublish(kind, argv, deps = {}) {
262
502
  }
263
503
  }
264
504
 
505
+ /**
506
+ * Batch publish (cargo only): validate the whole batch and run fmt, the secret scan and clippy once at one
507
+ * commit and tree, failing the batch closed before anything is uploaded; then publish layer by layer, the
508
+ * crates of a layer concurrently (bounded by --concurrency). A failure stops new work: no crate of a later
509
+ * layer starts, crates of the failing layer that were already running finish, and the refusal lists what
510
+ * published. Re-run with the published crates removed from the list.
511
+ */
512
+ async function runLocalPublishBatch(parsed, deps) {
513
+ const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
514
+ const { workspaceRoot, concurrency } = parsed;
515
+ const git = deps.git ?? defaultGit;
516
+ const readJson = deps.readJson ?? defaultReadJson;
517
+ const run = deps.run ?? defaultRunAsync;
518
+ const platform = assertLocalHost(deps, workspaceRoot, "cargo");
519
+
520
+ // 1. The crate list and its dependency layers.
521
+ const requested = parsed.batch.file ? parseCrateLayers((deps.readText ?? ((file) => fs.readFileSync(file, "utf8")))(parsed.batch.file)) : [parsed.batch.crates];
522
+ const crates = requested.flat();
523
+ const dependenciesOf = deps.batchDependencies ?? ((unit) => {
524
+ const members = new Set(crates);
525
+ return pathDependencyDirs(path.join(workspaceRoot, "crates", unit)).map((dir) => path.basename(dir)).filter((name) => members.has(name) && name !== unit);
526
+ });
527
+ const layers = parsed.batch.file ? checkCrateLayers(requested, dependenciesOf) : deriveCrateLayers(crates, dependenciesOf);
528
+
529
+ // 2. Preconditions for every crate before anything runs, fail closed.
530
+ const gateConfig = readJson(path.join(workspaceRoot, "ci", "gate.config.json")) ?? {};
531
+ const assetGated = readJson(path.join(workspaceRoot, "ci", "asset-gated.json")) ?? {};
532
+ const isAssetGated = (unit) => Object.hasOwn(assetGated.crates ?? {}, unit);
533
+ const versions = new Map();
534
+ for (const unit of crates) {
535
+ const dir = `crates/${unit}`;
536
+ if (!fs.existsSync(path.join(workspaceRoot, dir))) refuse(`unit directory not found: ${path.join(workspaceRoot, dir)}`);
537
+ const pkg = (deps.readCargoPackage ?? readCargoPackage)(path.join(workspaceRoot, dir));
538
+ if (pkg.name !== unit) refuse(`Cargo.toml [package].name ${JSON.stringify(pkg.name)} differs from --crates entry ${unit}`);
539
+ if (typeof pkg.version !== "string" || !pkg.version.trim()) refuse(`${unit} has no literal version`);
540
+ versions.set(unit, pkg.version);
541
+ localTestPlan({ kind: "cargo", unit, dir, config: gateConfig, batch: true, platform }); // refuses an OS-restricted crate now, not mid-batch
542
+ }
543
+ const head = git(["rev-parse", "HEAD"], workspaceRoot);
544
+ if (!head || !/^[0-9a-f]{40}$/.test(head)) refuse("cannot resolve HEAD");
545
+ const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
546
+ if (!tree || !/^[0-9a-f]{40}$/.test(tree)) refuse("cannot resolve the HEAD tree");
547
+ // Every batch crate and the workspace manifest and lock must match the commit the batch checks are bound to.
548
+ const dirty = git(["status", "--porcelain", "--", ...crates.map((unit) => `crates/${unit}`), "Cargo.toml", "Cargo.lock"], workspaceRoot);
549
+ if (dirty === null) refuse("cannot read git status for the batch");
550
+ if (dirty !== "") refuse(`tree is not clean for the batch crates or the workspace manifest; commit first:\n${dirty}`);
551
+ const registryHas = deps.registryHas ?? defaultRegistryHas;
552
+ 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);
553
+ if (published.length) refuse(`already on the crates.io registry: ${published.join(", ")}; bump the version or remove them from the batch`);
554
+ 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, ${LOCAL_HOST_POLICIES[platform]} policy)`);
555
+
556
+ // 3. Batch-level checks, once, at the batch commit. Each is recorded in every crate's evidence.
557
+ const env = testEnv(deps, false);
558
+ const stamp = { commit: head, tree };
559
+ const checks = []; const commands = [];
560
+ const batchCheck = async (name, command, args, crateNames, runEnv = env) => {
561
+ const text = `${command} ${args.join(" ")}`;
562
+ log(`[right-release] batch ${name}: ${text}`);
563
+ const result = await run(command, args, { cwd: workspaceRoot, env: runEnv });
564
+ if (result.error || result.status !== 0) refuse(`batch ${name} failed (${result.error?.message ?? `exit ${result.status}`}): ${text}\n${tail(`${result.stdout ?? ""}\n${result.stderr ?? ""}`)}`);
565
+ checks.push({ name, command: text, ok: true, ...stamp, ...(crateNames ? { crates: crateNames } : {}) });
566
+ commands.push(text);
567
+ };
568
+ await batchCheck("fmt", "cargo", ["fmt", "--all", "--check"]);
569
+ log("[right-release] batch secret-scan: crate directories of the batch");
570
+ const scan = deps.scanSecrets ?? scanCrateForSecrets;
571
+ const violations = crates.flatMap((unit) => scan(path.join(workspaceRoot, "crates", unit)).map((file) => `${unit}: ${file}`));
572
+ if (violations.length) refuse(`batch secret scan rejected: ${violations.join(", ")}`);
573
+ const scanText = `scanCrateForSecrets ${crates.map((unit) => `crates/${unit}`).join(" ")}`;
574
+ checks.push({ name: "secret-scan", command: scanText, ok: true, ...stamp, crates });
575
+ commands.push(scanText);
576
+ const featuresOf = (unit) => (gateConfig?.rust?.[unit]?.featureSets ?? [[]])[0];
577
+ for (const layer of layers) {
578
+ for (const invocation of batchClippyInvocations(layer, { featuresOf, assetGatedOf: isAssetGated })) {
579
+ await batchCheck("clippy", "cargo", invocation.args, invocation.crates, testEnv(deps, invocation.assetGated));
580
+ }
581
+ }
582
+ const receipt = { id: `batch-${head.slice(0, 12)}-${sha256(JSON.stringify(layers)).slice(0, 8)}`, ...stamp, crates, checks };
583
+
584
+ // 4. Publish layer by layer. A crate's publish returns only after its registry readback proved the exact
585
+ // checksum visible in the index, so layer N+1 starts only once every layer-N dependency is visible.
586
+ const batch = { head, tree, receipt, commands };
587
+ const results = [];
588
+ for (const [index, layer] of layers.entries()) {
589
+ log(`[right-release] batch layer ${index + 1}/${layers.length}: ${layer.join(", ")}`);
590
+ const queue = [...layer]; const failures = [];
591
+ const worker = async () => {
592
+ while (!failures.length && queue.length) {
593
+ const unit = queue.shift();
594
+ try { results.push(await publishUnit({ ...parsed, kind: "cargo", unit, batch: undefined }, deps, batch)); }
595
+ catch (error) { failures.push({ unit, message: error.message }); }
596
+ }
597
+ };
598
+ await Promise.all(Array.from({ length: Math.min(concurrency, layer.length) }, worker));
599
+ if (failures.length) {
600
+ const done = results.map((r) => `${r.name}@${r.version}`);
601
+ 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")}`);
602
+ }
603
+ }
604
+ log(`[right-release] batch published ${results.length} crate(s): ${results.map((r) => `${r.name}@${r.version}`).join(", ")}`);
605
+ return { kind: "cargo", batch: true, commit: head, tree, results };
606
+ }
607
+
265
608
  const realOrSelf = (file) => { try { return fs.realpathSync(file); } catch { return path.resolve(file); } };
266
609
  if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
267
610
  const [kind, ...rest] = process.argv.slice(2);