@rightkit/release 0.4.0 → 0.5.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 +59 -7
  2. package/adhoc-entitlements.mjs +8 -0
  3. package/adoption-evidence.mjs +650 -0
  4. package/advisory-exceptions.mjs +371 -0
  5. package/apple-profile-create.mjs +113 -10
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/rightkit-gate.mjs +1079 -0
  8. package/binary-license-notices.mjs +161 -0
  9. package/cargo-license-notices.mjs +111 -0
  10. package/cli/right-release.mjs +39 -0
  11. package/deterministic-scans.mjs +966 -0
  12. package/development-signing.mjs +2 -1
  13. package/host-upload-refusal.mjs +90 -0
  14. package/local-publish.mjs +142 -19
  15. package/local-qualify.mjs +728 -0
  16. package/local-runtime.mjs +319 -0
  17. package/lock-graph.mjs +51 -0
  18. package/managed-cargo.mjs +4 -1
  19. package/ownership-approval.mjs +23 -6
  20. package/ownership-dependency-allowlist.json +2 -0
  21. package/ownership-mandatory-set.json +158 -68
  22. package/ownership-mandatory-set.mjs +82 -6
  23. package/package.json +6 -2
  24. package/pipeline-admission.mjs +375 -0
  25. package/pipeline-config.mjs +100 -0
  26. package/pipeline-generators.json +4 -0
  27. package/pipeline-generators.mjs +137 -0
  28. package/pipeline-receipts.mjs +302 -0
  29. package/pipeline-scan.mjs +285 -0
  30. package/pipeline-tools.mjs +171 -0
  31. package/production-mode.mjs +42 -0
  32. package/publication-gate.mjs +94 -32
  33. package/publish-cargo.mjs +24 -7
  34. package/publish-swift.mjs +12 -3
  35. package/qa-control-scan.mjs +4 -0
  36. package/release-config.mjs +330 -0
  37. package/release-switch-scan.mjs +44 -5
  38. package/release.mjs +14 -4
  39. package/rightkit-ownership.json +57 -0
  40. package/rightkit-sync.mjs +8 -0
  41. package/rightkit-versions.json +191 -131
  42. package/runtime-budget-classes.json +8 -0
  43. package/runtime-budget-crates.mjs +14 -0
  44. package/runtime-budgets.mjs +128 -31
  45. package/scan-digest.mjs +107 -0
  46. package/sign-qa-bundle.mjs +210 -0
  47. package/stage-sidecar.mjs +99 -15
  48. package/swap-helper-bundle.mjs +7 -3
  49. package/workflow-yaml.mjs +177 -0
  50. package/bin/darwin/rightkit-swap.sign.json +0 -6
@@ -3,6 +3,7 @@ import { closeSync, copyFileSync, existsSync, mkdtempSync, openSync, readFileSyn
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { spawnSync } from "./background-process.mjs";
6
+ import { adhocSafeEntitlements } from "./adhoc-entitlements.mjs";
6
7
  import { runMacCodesignCommands } from "./macos-signing-session.mjs";
7
8
  import { bundleRelativePath, compileBundlePathMap, globToRegExp, isGlobPattern, lookupBundlePath, resolveBundleIdentifier, validateBundleRelativeKey, validateIdentifierTemplate, validateSigningIdentifier } from "./mac-bundle-signing.mjs";
8
9
 
@@ -308,7 +309,7 @@ export function signMacosDevelopmentBundle(options) {
308
309
  const entitlementFile = (file) => {
309
310
  if (!signing.adhoc) return file;
310
311
  if (!safeFiles.has(file)) {
311
- const safe = Object.fromEntries(Object.entries(readPlist(file, context)).filter(([key]) => key.startsWith("com.apple.security.cs.") || key === "com.apple.security.device.audio-input"));
312
+ const safe = adhocSafeEntitlements(readPlist(file, context));
312
313
  const out = path.join(scratch, `adhoc-${safeFiles.size}.plist`);
313
314
  writeFileSync(out, JSON.stringify(safe));
314
315
  checked("plutil", ["-convert", "xml1", out], context, "DEV_MAC_SIGNING_CONFIG_INVALID", identity);
@@ -0,0 +1,90 @@
1
+ // Dependency-free (no imports) refusal shared by every module that can put bytes in a registry. RightKit crates and @rightkit
2
+ // packages reach a registry from CI only (publish.yml qualification, the approval commit in ci/approved-artifacts.json,
3
+ // publish-reviewed.yml). No environment variable, flag or caller-set GITHUB_ACTIONS lifts this: CI uploads go through
4
+ // scripts/ci/publish.mjs and scripts/ci/reviewed-candidate.mjs, never through right-release.
5
+ export const HOST_UPLOAD_REFUSED = "RK_HOST_UPLOAD_REFUSED";
6
+ export function hostUploadRefusalMessage(what) {
7
+ return `${what}: host uploads are retired; RightKit publishes only through CI's approved-hash path (publish.yml, approval in ci/approved-artifacts.json, publish-reviewed.yml). Use --dry-run, preflight or --local --qualify-only to qualify locally.`;
8
+ }
9
+ export function refuseHostUpload(what) {
10
+ throw Object.assign(new Error(hostUploadRefusalMessage(what)), { code: HOST_UPLOAD_REFUSED });
11
+ }
12
+
13
+ // ---- publish capability -------------------------------------------------------------------------------------------------
14
+ // The publisher internals (publishGatedTarball in publication-gate.mjs, runCargoPublish in publish-cargo.mjs) are exported and read
15
+ // registry credentials, so being importable must not mean being able to publish. Each of them refuses (refuseHostUpload) unless
16
+ // it is handed a CAPABILITY, and a capability exists only when createPublishCapability accepted a validated admission:
17
+ // - the hashed admission record the credentialed job builds (schema 1, "semantic-admission" or "local-admission": original
18
+ // binding, source tree, verifier SHA, run and attempt, one passing verdict per archive);
19
+ // - an approval commit entry (ci/approved-artifacts.json: registry, name, version, sha256, reviewer) for every archive in it;
20
+ // - the CI run it names: GITHUB_ACTIONS=true on refs/heads/main of the repository, GITHUB_SHA equal to the admission's verifier
21
+ // SHA, GITHUB_RUN_ID and GITHUB_RUN_ATTEMPT equal to its run.
22
+ // The capability is an object registered in a module-private WeakMap at creation: an object literal, a copy or a mutated clone is
23
+ // not registered and is refused. It is bound to the units of the admission (name, version, archive sha256, registry), so one
24
+ // capability publishes nothing else, and the publisher also compares the sha256 of the bytes it is about to send. Nothing here
25
+ // reads an environment variable to LIFT a refusal: the environment is one of the conditions the admission must match.
26
+ const HEX64 = /^[0-9a-f]{64}$/;
27
+ const HEX40 = /^[0-9a-f]{40}$/;
28
+ const POSITIVE = /^[1-9][0-9]*$/;
29
+ export const PUBLISH_REPOSITORY = "bogusyogi/rightkit";
30
+ const CAPABILITY_REGISTRIES = ["npm", "crates"];
31
+ const ADMISSION_KINDS = ["semantic-admission", "local-admission"];
32
+ const minted = new WeakMap();
33
+ const plain = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
34
+
35
+ /** Every reason `admission` is not a valid, passing admission record for `registry` in this CI run (empty means valid). */
36
+ export function admissionProblems(admission, { registry, env = process.env, approved = [] } = {}) {
37
+ const problems = [];
38
+ const bad = (text) => problems.push(text);
39
+ if (!CAPABILITY_REGISTRIES.includes(registry)) bad(`registry ${JSON.stringify(registry)} is not npm or crates`);
40
+ if (!plain(admission)) return [...problems, "the admission is not an object"];
41
+ if (admission.schema !== 1 || !ADMISSION_KINDS.includes(admission.kind)) bad("the admission schema or kind is not a reviewed admission");
42
+ const original = admission.original;
43
+ if (!plain(original) || typeof original.candidateId !== "string" || !original.candidateId || !HEX64.test(original.candidateSha256 ?? "") || !HEX40.test(original.sourceSha ?? "") || !HEX64.test(original.manifestSha256 ?? "") || !Array.isArray(original.archives) || original.archives.length === 0) bad("the admission does not bind an original candidate, manifest and archives");
44
+ if (!HEX40.test(admission.sourceTree ?? "") || !HEX40.test(admission.verifierSha ?? "") || !POSITIVE.test(String(admission.runId ?? "")) || !POSITIVE.test(String(admission.runAttempt ?? ""))) bad("the admission does not name its source tree, verifier commit and run");
45
+ const archives = Array.isArray(original?.archives) ? original.archives : [];
46
+ const units = Array.isArray(admission.units) ? admission.units : [];
47
+ if (units.length !== archives.length) bad("the admission holds no verdict for exactly its archives");
48
+ const names = new Set();
49
+ for (const archive of archives) {
50
+ const label = `${archive?.name}@${archive?.version}`;
51
+ if (!plain(archive) || typeof archive.name !== "string" || !archive.name || typeof archive.version !== "string" || !archive.version || !HEX64.test(archive.sha256 ?? "")) { bad(`archive ${label} is malformed`); continue; }
52
+ if (names.has(archive.name)) bad(`archive ${label} is listed twice`);
53
+ names.add(archive.name);
54
+ const verdict = units.find((row) => row?.name === archive.name);
55
+ if (!verdict || verdict.version !== archive.version || verdict.archiveSha256 !== archive.sha256 || verdict.ok !== true || !Array.isArray(verdict.failures) || verdict.failures.length !== 0 || !HEX64.test(verdict.evidenceSha256 ?? "")) bad(`${label} has no passing verdict for its archive`);
56
+ const entries = (Array.isArray(approved) ? approved : []).filter((entry) => entry?.registry === registry && entry.name === archive.name && entry.version === archive.version);
57
+ if (entries.length === 0 || entries.some((entry) => entry.sha256 !== archive.sha256)) bad(`${label} is not approved in ci/approved-artifacts.json with this archive hash`);
58
+ }
59
+ if (env.GITHUB_ACTIONS !== "true" || env.GITHUB_REF !== "refs/heads/main" || env.GITHUB_REPOSITORY !== PUBLISH_REPOSITORY) bad("not running in GitHub Actions on refs/heads/main of the RightKit repository");
60
+ if (!HEX40.test(env.GITHUB_SHA ?? "") || env.GITHUB_SHA !== admission.verifierSha || env.GITHUB_RUN_ID !== String(admission.runId) || env.GITHUB_RUN_ATTEMPT !== String(admission.runAttempt)) bad("the admission was built by another commit, run or attempt");
61
+ return problems;
62
+ }
63
+
64
+ /**
65
+ * The capability to publish exactly the archives of one validated admission to `registry`. Throws (HOST_UPLOAD_REFUSED) unless
66
+ * `admission` passes admissionProblems against the current CI environment and the approval entries in `approved`
67
+ * (ci/approved-artifacts.json `artifacts`, as scripts/ci/upload.mjs loadApprovedArtifacts returns them).
68
+ */
69
+ export function createPublishCapability({ admission, approved, registry }) {
70
+ const problems = admissionProblems(admission, { registry, env: process.env, approved });
71
+ if (problems.length) refuseHostUpload(`a publish capability needs a validated admission (${problems.join("; ")})`);
72
+ const record = Object.freeze({ kind: "rightkit-publish-capability", registry });
73
+ minted.set(record, { registry, units: new Map(admission.original.archives.map((archive) => [archive.name, { version: archive.version, sha256: archive.sha256 }])), admissionVerifier: admission.verifierSha });
74
+ return record;
75
+ }
76
+
77
+ /**
78
+ * Refuses (HOST_UPLOAD_REFUSED) unless `capability` was created by createPublishCapability for this `registry` and names the
79
+ * unit. When `version` and `sha256` are given they must equal the admitted archive's: the publisher passes the version and the
80
+ * sha256 of the bytes it is about to send.
81
+ */
82
+ export function assertPublishCapability(capability, { what, registry, name, version, sha256 }) {
83
+ const grant = typeof capability === "object" && capability !== null ? minted.get(capability) : undefined;
84
+ if (!grant) refuseHostUpload(`${what}: no publish capability (a validated admission is required)`);
85
+ if (grant.registry !== registry) refuseHostUpload(`${what}: the publish capability is for ${grant.registry}, not ${registry}`);
86
+ const unit = grant.units.get(name);
87
+ if (!unit) refuseHostUpload(`${what}: ${name} is not an archive of the admission behind the publish capability`);
88
+ if (version !== undefined && unit.version !== version) refuseHostUpload(`${what}: ${name}@${version} is not the admitted ${unit.version}`);
89
+ if (sha256 !== undefined && unit.sha256 !== sha256) refuseHostUpload(`${what}: the bytes of ${name} (${String(sha256).slice(0, 12)}) are not the admitted archive (${unit.sha256.slice(0, 12)})`);
90
+ }
package/local-publish.mjs CHANGED
@@ -24,8 +24,9 @@ import { localBuildGranted, LOCAL_BUILD_FILE } from "./execution-policy.mjs";
24
24
  import { GATE_SCHEMA, MAX_CARGO_JOBS, defaultEvidencePath, enforcePublicationGate, publishGatedTarball } from "./publication-gate.mjs";
25
25
  import { runCargoPublish, runCargoReadOnly, scanCrateForSecrets } from "./publish-cargo.mjs";
26
26
  import { cargoIndexUrl } from "./cargo-registry-readback.mjs";
27
+ import { refuseHostUpload } from "./host-upload-refusal.mjs";
27
28
  import { pathDependencyDirs, readCargoPackage } from "./crate-graph.mjs";
28
- import { recordPublished, stageSourceVersions } from "./rightkit-sync.mjs";
29
+ import { recordPublished, stageSourceVersions, workspaceFeedOptions } from "./rightkit-sync.mjs";
29
30
  import { prepareSwapHelper, SWAP_HELPER_CARGO_ARGS, SWAP_HELPER_PACKAGE } from "./swap-helper-bundle.mjs";
30
31
 
31
32
  export const LOCAL_OPERATOR = "agent (local-build grant)";
@@ -236,6 +237,99 @@ export function allFeatureCheckSets(entry = {}) {
236
237
  return entry.checkFeatureSets ?? [["--all-features"]];
237
238
  }
238
239
 
240
+ const CONSUMER_ROW_KEYS = new Set(["manifestPath", "args", "kind", "targets", "locked"]);
241
+ const CONSUMER_KINDS = new Set(["check"]);
242
+ const CONSUMER_MANIFEST = /^ci\/consumers\/(?:[A-Za-z0-9._-]+\/)+Cargo\.toml$/;
243
+ const CONSUMER_TARGET = /^[A-Za-z0-9_]+(?:-[A-Za-z0-9_.]+){2,3}$/;
244
+ // The row owns manifest, lock, target and output format; arguments may only choose features and target kinds.
245
+ // An ALLOWLIST (0.4.1 review A1): any token not listed is refused, so --help/-h/--version (a "passing" no-op),
246
+ // --target-dir, -C/--directory, --color, --timings, -Z and every other Cargo option stay out of a row.
247
+ // Keep in step with scripts/ci/gate.mjs.
248
+ const CONSUMER_ARG_FLAGS = new Set(["--no-default-features", "--all-features", "--lib", "--bins", "--tests", "--examples", "--benches", "--all-targets"]);
249
+ const CONSUMER_FEATURE_FLAGS = new Set(["--features", "-F"]);
250
+ const CONSUMER_FEATURE_LIST = /^[A-Za-z0-9_][A-Za-z0-9_.+/?:,-]*$/;
251
+ const CONSUMER_ARGS_ALLOWED = `--features <list> (or --features=<list>, -F <list>), ${[...CONSUMER_ARG_FLAGS].join(", ")}`;
252
+
253
+ /**
254
+ * Validate the `consumerRows` of one crate's ci/gate.config.json entry and return them normalised. A consumer row
255
+ * checks the owning crate through a SEPARATE Cargo workspace (its own Cargo.lock), run as
256
+ * `cargo check --manifest-path <manifestPath> --locked [--target <t>] <args>`. Unknown fields are refused.
257
+ */
258
+ export function consumerRows(unit, entry = {}) {
259
+ const rows = entry.consumerRows;
260
+ if (rows === undefined) return [];
261
+ const where = (index) => `${unit} consumerRows[${index}]`;
262
+ if (!Array.isArray(rows)) refuse(`${unit}: consumerRows must be an array`);
263
+ const seen = new Set();
264
+ return rows.map((row, index) => {
265
+ if (row === null || typeof row !== "object" || Array.isArray(row)) refuse(`${where(index)} must be an object`);
266
+ for (const key of Object.keys(row)) if (!CONSUMER_ROW_KEYS.has(key)) refuse(`${where(index)}: unknown field ${JSON.stringify(key)} (allowed: ${[...CONSUMER_ROW_KEYS].join(", ")})`);
267
+ const manifestPath = row.manifestPath;
268
+ if (typeof manifestPath !== "string" || !CONSUMER_MANIFEST.test(manifestPath) || manifestPath.split("/").some((part) => part === "." || part === "..")) {
269
+ refuse(`${where(index)}: manifestPath must be a relative ci/consumers/<name>/Cargo.toml path with forward slashes, got ${JSON.stringify(manifestPath)}`);
270
+ }
271
+ const kind = row.kind ?? "check";
272
+ if (!CONSUMER_KINDS.has(kind)) refuse(`${where(index)}: kind must be one of ${[...CONSUMER_KINDS].join(", ")}, got ${JSON.stringify(kind)}`);
273
+ if (row.locked !== undefined && row.locked !== true) refuse(`${where(index)}: locked must be true; a consumer row never runs without its lock file`);
274
+ const args = row.args ?? [];
275
+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string" || arg === "")) refuse(`${where(index)}: args must be an array of non-empty strings`);
276
+ for (let position = 0; position < args.length; position += 1) {
277
+ const arg = args[position];
278
+ if (CONSUMER_ARG_FLAGS.has(arg)) continue;
279
+ if (CONSUMER_FEATURE_FLAGS.has(arg)) {
280
+ const list = args[position + 1];
281
+ if (typeof list !== "string" || !CONSUMER_FEATURE_LIST.test(list)) refuse(`${where(index)}: ${arg} needs a feature list (names separated by commas, not starting with "-"), got ${JSON.stringify(list)}`);
282
+ position += 1;
283
+ continue;
284
+ }
285
+ if (arg.startsWith("--features=") && CONSUMER_FEATURE_LIST.test(arg.slice("--features=".length))) continue;
286
+ refuse(`${where(index)}: args may not contain ${JSON.stringify(arg)}; the row sets manifest path, lock, target and output itself, and allows only ${CONSUMER_ARGS_ALLOWED}`);
287
+ }
288
+ const targets = row.targets ?? [];
289
+ if (!Array.isArray(targets) || targets.some((target) => typeof target !== "string" || !CONSUMER_TARGET.test(target))) refuse(`${where(index)}: targets must be an array of target triples`);
290
+ if (new Set(targets).size !== targets.length) refuse(`${where(index)}: targets lists a triple twice`);
291
+ const key = JSON.stringify([manifestPath, args, targets]);
292
+ if (seen.has(key)) refuse(`${where(index)} repeats an earlier row`);
293
+ seen.add(key);
294
+ return { manifestPath, kind, args: [...args], targets: [...targets], locked: true };
295
+ });
296
+ }
297
+
298
+ /** One Cargo step per target (or one host step when no target is named) for one consumer row. */
299
+ export function consumerRowInvocations(row) {
300
+ const targets = row.targets.length ? row.targets : [null];
301
+ return targets.map((target) => ({ command: "cargo", args: [row.kind, "--manifest-path", row.manifestPath, "--locked", ...(target ? ["--target", target] : []), ...row.args], test: false }));
302
+ }
303
+
304
+ /**
305
+ * Refuse a consumer row whose manifest or lock is missing from the workspace, is not tracked by git, or resolves
306
+ * (through a symlink) to anywhere but its own path inside the workspace (fail closed before any command;
307
+ * 0.4.1 review A5). The clean-tree check covers only the files git sees under the consumer directory, so a
308
+ * symlinked or ignored file would otherwise be a checked file nobody reviewed.
309
+ */
310
+ function assertConsumerFiles(workspaceRoot, unit, rows, git = defaultGit) {
311
+ let realRoot;
312
+ try { realRoot = fs.realpathSync(workspaceRoot); } catch { refuse(`${unit}: cannot resolve the workspace ${workspaceRoot}`); }
313
+ for (const row of rows) {
314
+ for (const file of [row.manifestPath, row.manifestPath.replace(/Cargo\.toml$/, "Cargo.lock")]) {
315
+ const absolute = path.join(workspaceRoot, file);
316
+ if (!fs.existsSync(absolute)) refuse(`${unit}: consumer row needs ${file}, which is not in the workspace`);
317
+ let real;
318
+ try { real = fs.realpathSync(absolute); } catch { refuse(`${unit}: consumer row file ${file} cannot be resolved`); }
319
+ if (path.relative(realRoot, real) !== path.normalize(file)) refuse(`${unit}: consumer row file ${file} resolves outside its own path in the workspace (${real}); a symlink is not allowed`);
320
+ if (git(["ls-files", "--error-unmatch", "--", file], workspaceRoot) === null) refuse(`${unit}: consumer row file ${file} is not tracked by git; commit it first`);
321
+ }
322
+ }
323
+ }
324
+
325
+ /** The directories whose cleanliness a set of consumer rows depends on. */
326
+ const consumerDirs = (rows) => [...new Set(rows.map((row) => path.posix.dirname(row.manifestPath)))];
327
+
328
+ // The files that decide WHICH steps a publish runs: an uncommitted edit would change the plan the evidence is bound to
329
+ // (0.4.1 review A3). Chosen over reading the HEAD blob so the publisher, the replay and the batch apply one rule:
330
+ // the file on disk must be the committed file, or the publish is refused.
331
+ export const GATE_CONFIG_FILES = Object.freeze(["ci/gate.config.json", "ci/asset-gated.json"]);
332
+
239
333
  /** The CI gate's commands for one unit (scripts/ci/gate.mjs npmGate/rustGate), mirrored exactly. */
240
334
  export function localTestPlan({ kind, unit, dir, config, manifest, batch = false, platform = "darwin" }) {
241
335
  if (kind === "npm") {
@@ -260,9 +354,13 @@ export function localTestPlan({ kind, unit, dir, config, manifest, batch = false
260
354
  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 });
261
355
  }
262
356
  // 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 });
357
+ // Every tested feature set is linted (-D warnings), not only the first.
358
+ if (!batch) for (const features of featureSets) steps.push({ command: "cargo", args: ["clippy", "-p", unit, "--message-format=json-render-diagnostics", "--no-deps", "--all-targets", ...features, "--", "-D", "warnings"], test: false });
264
359
  for (const features of allFeatureCheckSets(entry)) steps.push({ command: "cargo", args: ["check", "-p", unit, "--message-format=json-render-diagnostics", "--all-targets", ...features], test: false });
265
- return { id: `rust:${unit}`, featureLabels: featureSets.map((features) => features.join(" ") || "default"), steps };
360
+ // Consumer rows (separate Cargo workspaces) gate only this crate: they come from this crate's own entry.
361
+ const consumers = consumerRows(unit, entry);
362
+ for (const row of consumers) steps.push(...consumerRowInvocations(row));
363
+ return { id: `rust:${unit}`, featureLabels: featureSets.map((features) => features.join(" ") || "default"), consumerRows: consumers, steps };
266
364
  }
267
365
 
268
366
  /**
@@ -274,14 +372,19 @@ export function localTestPlan({ kind, unit, dir, config, manifest, batch = false
274
372
  * unify extra features into a selected crate and each is linted with exactly the features it was before.
275
373
  * Crates with a different RIGHTKIT_REQUIRE_ASSETS (asset-gated) stay apart so the environment is unchanged.
276
374
  */
277
- export function batchClippyInvocations(layer, { featuresOf = () => [], assetGatedOf = () => false } = {}) {
375
+ export function batchClippyInvocations(layer, { featuresOf = () => [], featureSetsOf = (unit) => [featuresOf(unit)], assetGatedOf = () => false } = {}) {
278
376
  const groups = new Map();
279
377
  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);
378
+ // Every tested feature set of a crate is linted, in order; identical sets of one crate collapse to one run.
379
+ const sets = featureSetsOf(unit) ?? [[]];
380
+ const unique = sets.filter((set, index) => sets.findIndex((other) => JSON.stringify(other ?? []) === JSON.stringify(set ?? [])) === index);
381
+ for (const set of unique) {
382
+ const features = set ?? [];
383
+ const shared = features.length === 0 || (features.length === 1 && features[0] === "--all-features");
384
+ const key = shared ? JSON.stringify([features, assetGatedOf(unit)]) : `unit:${unit}:${JSON.stringify(features)}`;
385
+ if (!groups.has(key)) groups.set(key, { crates: [], features, assetGated: assetGatedOf(unit) });
386
+ groups.get(key).crates.push(unit);
387
+ }
285
388
  }
286
389
  return [...groups.values()].map(({ crates, features, assetGated }) => ({
287
390
  crates, assetGated,
@@ -309,7 +412,15 @@ function assertLocalHost(deps, workspaceRoot, kind) {
309
412
  return platform;
310
413
  }
311
414
 
415
+ // Host uploads are retired: only --qualify-only (no upload) runs. The upload engine below is kept for direct calls in
416
+ // tests; no CLI path reaches it (see host-upload-refusal.mjs).
312
417
  export async function runLocalPublish(kind, argv, deps = {}) {
418
+ const parsed = parseLocalPublishArgs(kind, argv, deps.cwd);
419
+ if (!parsed.qualifyOnly) refuseHostUpload(`right-release publish ${kind} --local`);
420
+ return runLocalPublishUploadEngine(kind, argv, deps);
421
+ }
422
+
423
+ export async function runLocalPublishUploadEngine(kind, argv, deps = {}) {
313
424
  const parsed = parseLocalPublishArgs(kind, argv, deps.cwd);
314
425
  if (parsed.batch) return runLocalPublishBatch(parsed, deps);
315
426
  return publishUnit(parsed, deps, null);
@@ -354,9 +465,9 @@ async function publishUnit(parsed, deps, batch) {
354
465
  const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
355
466
  if (tree !== batch.tree) refuse(`${unit}: HEAD tree ${tree} differs from the batch tree ${batch.tree}`);
356
467
  }
357
- const dirty = git(["status", "--porcelain", "--", dir, ...(parsed.qualifyOnly ? ["Cargo.toml", "Cargo.lock"] : [])], workspaceRoot);
468
+ const dirty = git(["status", "--porcelain", "--", dir, ...GATE_CONFIG_FILES, ...(parsed.qualifyOnly ? ["Cargo.toml", "Cargo.lock"] : [])], workspaceRoot);
358
469
  if (dirty === null) refuse(`cannot read git status for ${dir}`);
359
- if (dirty !== "") refuse(`tree is not clean for ${dir}; commit first:\n${dirty}`);
470
+ if (dirty !== "") refuse(`tree is not clean for ${dir} or the gate config (${GATE_CONFIG_FILES.join(", ")}); commit first:\n${dirty}`);
360
471
  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`);
361
472
  log(`[right-release] local publish ${kind}:${unit}@${version} at ${head.slice(0, 12)} (local-build grant, ${LOCAL_HOST_POLICIES[platform]} policy)`);
362
473
 
@@ -366,6 +477,13 @@ async function publishUnit(parsed, deps, batch) {
366
477
  const gatedSection = assetGated[kind === "npm" ? "npm" : "crates"] ?? {};
367
478
  const isAssetGated = Object.hasOwn(gatedSection, unit);
368
479
  const plan = localTestPlan({ kind, unit, dir, config: gateConfig, manifest, batch: Boolean(batch), platform });
480
+ if (plan.consumerRows?.length) {
481
+ // The consumer workspaces are part of the qualified commit: present, and unmodified like the unit itself.
482
+ assertConsumerFiles(workspaceRoot, unit, plan.consumerRows, git);
483
+ const consumerDirty = git(["status", "--porcelain", "--", ...consumerDirs(plan.consumerRows)], workspaceRoot);
484
+ if (consumerDirty === null) refuse(`cannot read git status for the consumer rows of ${unit}`);
485
+ if (consumerDirty !== "") refuse(`tree is not clean for the consumer rows of ${unit}; commit first:\n${consumerDirty}`);
486
+ }
369
487
  if (parsed.qualifyOnly) {
370
488
  const violations = (deps.scanSecrets ?? scanCrateForSecrets)(unitPath);
371
489
  if (violations.length) refuse(`qualification secret scan rejected: ${violations.join(", ")}`);
@@ -486,14 +604,14 @@ async function publishUnit(parsed, deps, batch) {
486
604
 
487
605
  // 5. The EXISTING publish path.
488
606
  if (kind === "npm") {
489
- (deps.publishGatedTarball ?? publishGatedTarball)({ name: unit, version, tarball: artifactFile, evidencePath }, deps.publishDeps ?? {});
607
+ (deps.publishGatedTarball ?? publishGatedTarball)({ name: unit, version, tarball: artifactFile, evidencePath, workspaceRoot }, deps.publishDeps ?? {});
490
608
  } else {
491
609
  // trustEvidenceSteps: the evidence just written (and validated by the gate above) proves this commit's tests
492
610
  // and clippy, so the publisher does not repeat them; verifyOnPublish: Cargo's verify build runs in the
493
611
  // publish itself. Package contents, the runtime budget, the archive hash and the readback always run.
494
612
  const execute = deps.cargoExecute ?? (batch ? batchCargoExecute(unit) : undefined);
495
613
  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 }));
614
+ await withVersionsLock(() => (deps.recordPublished ?? recordPublished)({ registry: "cargo", name: published.name, version: published.version }, workspaceFeedOptions(workspaceRoot)));
497
615
  }
498
616
  log(`[right-release] published ${unit}@${version}`);
499
617
  return { kind, name: unit, version, commit: head, artifactSha256, evidencePath };
@@ -531,6 +649,7 @@ async function runLocalPublishBatch(parsed, deps) {
531
649
  const assetGated = readJson(path.join(workspaceRoot, "ci", "asset-gated.json")) ?? {};
532
650
  const isAssetGated = (unit) => Object.hasOwn(assetGated.crates ?? {}, unit);
533
651
  const versions = new Map();
652
+ const batchConsumerDirs = new Set();
534
653
  for (const unit of crates) {
535
654
  const dir = `crates/${unit}`;
536
655
  if (!fs.existsSync(path.join(workspaceRoot, dir))) refuse(`unit directory not found: ${path.join(workspaceRoot, dir)}`);
@@ -538,16 +657,20 @@ async function runLocalPublishBatch(parsed, deps) {
538
657
  if (pkg.name !== unit) refuse(`Cargo.toml [package].name ${JSON.stringify(pkg.name)} differs from --crates entry ${unit}`);
539
658
  if (typeof pkg.version !== "string" || !pkg.version.trim()) refuse(`${unit} has no literal version`);
540
659
  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
660
+ // Refuses an OS-restricted crate, a malformed consumer row, or an untracked or symlinked consumer file now, not mid-batch.
661
+ const rows = localTestPlan({ kind: "cargo", unit, dir, config: gateConfig, batch: true, platform }).consumerRows ?? [];
662
+ assertConsumerFiles(workspaceRoot, unit, rows, git);
663
+ for (const consumerDir of consumerDirs(rows)) batchConsumerDirs.add(consumerDir);
542
664
  }
543
665
  const head = git(["rev-parse", "HEAD"], workspaceRoot);
544
666
  if (!head || !/^[0-9a-f]{40}$/.test(head)) refuse("cannot resolve HEAD");
545
667
  const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
546
668
  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);
669
+ // Every batch crate, the workspace manifest and lock, the gate config and EVERY crate's consumer directories must
670
+ // match the commit the batch checks are bound to, before the first layer publishes (0.4.1 review A3, A4).
671
+ const dirty = git(["status", "--porcelain", "--", ...crates.map((unit) => `crates/${unit}`), "Cargo.toml", "Cargo.lock", ...GATE_CONFIG_FILES, ...batchConsumerDirs], workspaceRoot);
549
672
  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}`);
673
+ if (dirty !== "") refuse(`tree is not clean for the batch crates, the workspace manifest, the gate config or a consumer directory; commit first:\n${dirty}`);
551
674
  const registryHas = deps.registryHas ?? defaultRegistryHas;
552
675
  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
676
  if (published.length) refuse(`already on the crates.io registry: ${published.join(", ")}; bump the version or remove them from the batch`);
@@ -573,9 +696,9 @@ async function runLocalPublishBatch(parsed, deps) {
573
696
  const scanText = `scanCrateForSecrets ${crates.map((unit) => `crates/${unit}`).join(" ")}`;
574
697
  checks.push({ name: "secret-scan", command: scanText, ok: true, ...stamp, crates });
575
698
  commands.push(scanText);
576
- const featuresOf = (unit) => (gateConfig?.rust?.[unit]?.featureSets ?? [[]])[0];
699
+ const featureSetsOf = (unit) => gateConfig?.rust?.[unit]?.featureSets ?? [[]];
577
700
  for (const layer of layers) {
578
- for (const invocation of batchClippyInvocations(layer, { featuresOf, assetGatedOf: isAssetGated })) {
701
+ for (const invocation of batchClippyInvocations(layer, { featureSetsOf, assetGatedOf: isAssetGated })) {
579
702
  await batchCheck("clippy", "cargo", invocation.args, invocation.crates, testEnv(deps, invocation.assetGated));
580
703
  }
581
704
  }