@rightkit/release 0.3.0 → 0.4.1

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 (83) hide show
  1. package/README.md +74 -10
  2. package/addon-command.mjs +3 -4
  3. package/adoption-evidence.mjs +650 -0
  4. package/android-play-upload.mjs +2 -1
  5. package/apple-profile-create.mjs +113 -10
  6. package/apple-profile-renew.mjs +11 -3
  7. package/artifact-storage.mjs +35 -2
  8. package/bin/darwin/rightkit-swap +0 -0
  9. package/binary-license-notices.mjs +161 -0
  10. package/build-release.mjs +138 -47
  11. package/cargo-license-notices.mjs +111 -0
  12. package/cli/right-release.mjs +21 -2
  13. package/codesign-environment.mjs +2 -2
  14. package/content-digest.mjs +209 -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/github-updater-release.mjs +70 -7
  21. package/in-place-install.mjs +17 -14
  22. package/install-app-windows.mjs +24 -7
  23. package/install-app.mjs +21 -40
  24. package/ios-asc-credentials.mjs +163 -0
  25. package/ios-build-context.mjs +129 -0
  26. package/ios-build-upload.mjs +282 -0
  27. package/ios-credentials-command.mjs +70 -0
  28. package/ios-dev.mjs +248 -0
  29. package/ios-host.mjs +76 -0
  30. package/ios-lock.mjs +75 -0
  31. package/ios-package-recipe.mjs +707 -0
  32. package/ios-testflight.mjs +266 -0
  33. package/ios-upload.mjs +313 -0
  34. package/local-publish.mjs +222 -53
  35. package/managed-cargo.mjs +8 -0
  36. package/mirror-root-artifact.mjs +5 -1
  37. package/native-cargo-layout.mjs +47 -4
  38. package/ownership-command.mjs +2 -1
  39. package/ownership-dependency-allowlist.json +3 -1
  40. package/ownership-evidence.mjs +30 -18
  41. package/ownership-key-command.mjs +69 -2
  42. package/ownership-mandatory-set.json +117 -67
  43. package/ownership-mandatory-set.mjs +141 -21
  44. package/ownership-repositories.json +1 -1
  45. package/ownership-scope.mjs +94 -0
  46. package/ownership.mjs +49 -7
  47. package/package-recipe-targets.mjs +41 -1
  48. package/package.json +1 -1
  49. package/pipeline-admission.mjs +375 -0
  50. package/pipeline-config.mjs +100 -0
  51. package/pipeline-generators.json +4 -0
  52. package/pipeline-generators.mjs +137 -0
  53. package/pipeline-scan.mjs +285 -0
  54. package/pipeline-tools.mjs +171 -0
  55. package/preflight.mjs +14 -7
  56. package/production-mode.mjs +42 -0
  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 +94 -32
  61. package/publish-cargo.mjs +117 -25
  62. package/publish-swift.mjs +12 -3
  63. package/publish-update.mjs +15 -2
  64. package/qa-build-features.mjs +16 -13
  65. package/qa-contract.mjs +30 -2
  66. package/qa-control-scan.mjs +8 -0
  67. package/release-config.mjs +330 -0
  68. package/release-invocation.mjs +118 -8
  69. package/release-switch-registry.json +126 -27
  70. package/release-switch-scan.mjs +88 -21
  71. package/release.mjs +82 -20
  72. package/rightkit-ownership.json +1 -1
  73. package/rightkit-sync.mjs +8 -0
  74. package/rightkit-versions.json +428 -194
  75. package/runtime-budget-crates.mjs +14 -0
  76. package/runtime-budgets.mjs +248 -47
  77. package/sign-updater.mjs +56 -59
  78. package/source-gate.mjs +8 -1
  79. package/stage-sidecar.mjs +207 -40
  80. package/swap-helper-bundle.mjs +7 -3
  81. package/target-distribution.mjs +5 -0
  82. package/upload-release.mjs +7 -2
  83. package/bin/darwin/rightkit-swap.sign.json +0 -6
package/local-publish.mjs CHANGED
@@ -1,9 +1,10 @@
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.
8
9
  //
9
10
  // Batch mode (`cargo --crates a,b,c | --crates-file f --local`): fmt, the secret scan and clippy run ONCE for
@@ -20,15 +21,19 @@ import { fileURLToPath, pathToFileURL } from "node:url";
20
21
  import { spawnSync } from "./background-process.mjs";
21
22
  import { runCargoAsync, runCargoSync, spawnCollect } from "./managed-cargo.mjs";
22
23
  import { localBuildGranted, LOCAL_BUILD_FILE } from "./execution-policy.mjs";
23
- import { GATE_SCHEMA, defaultEvidencePath, enforcePublicationGate, publishGatedTarball } from "./publication-gate.mjs";
24
- import { runCargoPublish, scanCrateForSecrets } 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";
25
26
  import { cargoIndexUrl } from "./cargo-registry-readback.mjs";
26
27
  import { pathDependencyDirs, readCargoPackage } from "./crate-graph.mjs";
27
- import { recordPublished, stageSourceVersions } from "./rightkit-sync.mjs";
28
+ import { recordPublished, stageSourceVersions, workspaceFeedOptions } from "./rightkit-sync.mjs";
28
29
  import { prepareSwapHelper, SWAP_HELPER_CARGO_ARGS, SWAP_HELPER_PACKAGE } from "./swap-helper-bundle.mjs";
29
30
 
30
31
  export const LOCAL_OPERATOR = "agent (local-build grant)";
31
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);
32
37
  const STEP_TIMEOUT_MS = 30 * 60 * 1000;
33
38
 
34
39
  export class LocalPublishError extends Error {
@@ -39,6 +44,11 @@ const refuse = (message) => { throw new LocalPublishError(message); };
39
44
  export const DEFAULT_CONCURRENCY = 4;
40
45
  const MAX_CONCURRENCY = 8;
41
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
+ }
42
52
 
43
53
  const crateList = (text, what) => {
44
54
  const names = String(text ?? "").split(",").map((name) => name.trim()).filter(Boolean);
@@ -103,23 +113,37 @@ export function parseLocalPublishArgs(kind, argv, cwd = process.cwd()) {
103
113
  const flag = kind === "npm" ? "--package" : "--crate";
104
114
  const batchFlags = kind === "cargo" ? ", --crates <a,b,c>, --crates-file <file>, --concurrency <n>, --allow-ignored-units <a,b>" : "";
105
115
  let unit; let workspace; let local = false; let allowIgnored = false;
116
+ let qualifyOnly = false; let bundle; let jobsText;
106
117
  let crates; let cratesFile; let concurrency = DEFAULT_CONCURRENCY; let allowIgnoredUnits = [];
107
118
  for (let i = 0; i < argv.length; i += 1) {
108
119
  const arg = argv[i];
109
120
  if (arg === "--local") local = true;
110
121
  else if (arg === flag) unit = argv[++i];
111
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];
112
125
  // Same meaning as publish.yml's allow_ignored: this unit may have ignored tests; the count is recorded.
113
126
  else if (arg === "--allow-ignored") allowIgnored = true;
114
127
  else if (kind === "cargo" && arg === "--crates") crates = crateList(argv[++i], "--crates");
115
128
  else if (kind === "cargo" && arg === "--crates-file") cratesFile = argv[++i];
116
129
  else if (kind === "cargo" && arg === "--concurrency") concurrency = Number(argv[++i]);
117
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] ?? "";
118
132
  else refuse(`unknown publish ${kind} --local option: ${arg} (accepted: ${flag} <name>${batchFlags}, --workspace <dir>, --allow-ignored, --local)`);
119
133
  }
120
134
  if (!local) refuse("--local is required");
121
135
  if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) refuse(`--concurrency must be an integer from 1 to ${MAX_CONCURRENCY}`);
122
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
+ }
123
147
  if (kind === "cargo" && (crates !== undefined || cratesFile !== undefined)) {
124
148
  if (unit !== undefined) refuse("--crate cannot be combined with --crates or --crates-file");
125
149
  if (crates !== undefined && cratesFile !== undefined) refuse("use --crates or --crates-file, not both");
@@ -131,7 +155,7 @@ export function parseLocalPublishArgs(kind, argv, cwd = process.cwd()) {
131
155
  }
132
156
  if (kind === "npm" && !/^@rightkit\/[a-z0-9-]+$/.test(unit ?? "")) refuse("publish npm --local requires --package @rightkit/<name>");
133
157
  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 };
158
+ return { kind, unit, allowIgnored, allowIgnoredUnits, workspaceRoot, concurrency, batch: undefined, ...(qualifyOnly ? { qualifyOnly, bundleDir } : {}), ...(jobs === undefined ? {} : { jobs }) };
135
159
  }
136
160
 
137
161
  function defaultGit(args, cwd) {
@@ -212,8 +236,101 @@ export function allFeatureCheckSets(entry = {}) {
212
236
  return entry.checkFeatureSets ?? [["--all-features"]];
213
237
  }
214
238
 
239
+ const CONSUMER_ROW_KEYS = new Set(["manifestPath", "args", "kind", "targets", "locked"]);
240
+ const CONSUMER_KINDS = new Set(["check"]);
241
+ const CONSUMER_MANIFEST = /^ci\/consumers\/(?:[A-Za-z0-9._-]+\/)+Cargo\.toml$/;
242
+ const CONSUMER_TARGET = /^[A-Za-z0-9_]+(?:-[A-Za-z0-9_.]+){2,3}$/;
243
+ // The row owns manifest, lock, target and output format; arguments may only choose features and target kinds.
244
+ // An ALLOWLIST (0.4.1 review A1): any token not listed is refused, so --help/-h/--version (a "passing" no-op),
245
+ // --target-dir, -C/--directory, --color, --timings, -Z and every other Cargo option stay out of a row.
246
+ // Keep in step with scripts/ci/gate.mjs.
247
+ const CONSUMER_ARG_FLAGS = new Set(["--no-default-features", "--all-features", "--lib", "--bins", "--tests", "--examples", "--benches", "--all-targets"]);
248
+ const CONSUMER_FEATURE_FLAGS = new Set(["--features", "-F"]);
249
+ const CONSUMER_FEATURE_LIST = /^[A-Za-z0-9_][A-Za-z0-9_.+/?:,-]*$/;
250
+ const CONSUMER_ARGS_ALLOWED = `--features <list> (or --features=<list>, -F <list>), ${[...CONSUMER_ARG_FLAGS].join(", ")}`;
251
+
252
+ /**
253
+ * Validate the `consumerRows` of one crate's ci/gate.config.json entry and return them normalised. A consumer row
254
+ * checks the owning crate through a SEPARATE Cargo workspace (its own Cargo.lock), run as
255
+ * `cargo check --manifest-path <manifestPath> --locked [--target <t>] <args>`. Unknown fields are refused.
256
+ */
257
+ export function consumerRows(unit, entry = {}) {
258
+ const rows = entry.consumerRows;
259
+ if (rows === undefined) return [];
260
+ const where = (index) => `${unit} consumerRows[${index}]`;
261
+ if (!Array.isArray(rows)) refuse(`${unit}: consumerRows must be an array`);
262
+ const seen = new Set();
263
+ return rows.map((row, index) => {
264
+ if (row === null || typeof row !== "object" || Array.isArray(row)) refuse(`${where(index)} must be an object`);
265
+ 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(", ")})`);
266
+ const manifestPath = row.manifestPath;
267
+ if (typeof manifestPath !== "string" || !CONSUMER_MANIFEST.test(manifestPath) || manifestPath.split("/").some((part) => part === "." || part === "..")) {
268
+ refuse(`${where(index)}: manifestPath must be a relative ci/consumers/<name>/Cargo.toml path with forward slashes, got ${JSON.stringify(manifestPath)}`);
269
+ }
270
+ const kind = row.kind ?? "check";
271
+ if (!CONSUMER_KINDS.has(kind)) refuse(`${where(index)}: kind must be one of ${[...CONSUMER_KINDS].join(", ")}, got ${JSON.stringify(kind)}`);
272
+ if (row.locked !== undefined && row.locked !== true) refuse(`${where(index)}: locked must be true; a consumer row never runs without its lock file`);
273
+ const args = row.args ?? [];
274
+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string" || arg === "")) refuse(`${where(index)}: args must be an array of non-empty strings`);
275
+ for (let position = 0; position < args.length; position += 1) {
276
+ const arg = args[position];
277
+ if (CONSUMER_ARG_FLAGS.has(arg)) continue;
278
+ if (CONSUMER_FEATURE_FLAGS.has(arg)) {
279
+ const list = args[position + 1];
280
+ 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)}`);
281
+ position += 1;
282
+ continue;
283
+ }
284
+ if (arg.startsWith("--features=") && CONSUMER_FEATURE_LIST.test(arg.slice("--features=".length))) continue;
285
+ 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}`);
286
+ }
287
+ const targets = row.targets ?? [];
288
+ 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`);
289
+ if (new Set(targets).size !== targets.length) refuse(`${where(index)}: targets lists a triple twice`);
290
+ const key = JSON.stringify([manifestPath, args, targets]);
291
+ if (seen.has(key)) refuse(`${where(index)} repeats an earlier row`);
292
+ seen.add(key);
293
+ return { manifestPath, kind, args: [...args], targets: [...targets], locked: true };
294
+ });
295
+ }
296
+
297
+ /** One Cargo step per target (or one host step when no target is named) for one consumer row. */
298
+ export function consumerRowInvocations(row) {
299
+ const targets = row.targets.length ? row.targets : [null];
300
+ return targets.map((target) => ({ command: "cargo", args: [row.kind, "--manifest-path", row.manifestPath, "--locked", ...(target ? ["--target", target] : []), ...row.args], test: false }));
301
+ }
302
+
303
+ /**
304
+ * Refuse a consumer row whose manifest or lock is missing from the workspace, is not tracked by git, or resolves
305
+ * (through a symlink) to anywhere but its own path inside the workspace (fail closed before any command;
306
+ * 0.4.1 review A5). The clean-tree check covers only the files git sees under the consumer directory, so a
307
+ * symlinked or ignored file would otherwise be a checked file nobody reviewed.
308
+ */
309
+ function assertConsumerFiles(workspaceRoot, unit, rows, git = defaultGit) {
310
+ let realRoot;
311
+ try { realRoot = fs.realpathSync(workspaceRoot); } catch { refuse(`${unit}: cannot resolve the workspace ${workspaceRoot}`); }
312
+ for (const row of rows) {
313
+ for (const file of [row.manifestPath, row.manifestPath.replace(/Cargo\.toml$/, "Cargo.lock")]) {
314
+ const absolute = path.join(workspaceRoot, file);
315
+ if (!fs.existsSync(absolute)) refuse(`${unit}: consumer row needs ${file}, which is not in the workspace`);
316
+ let real;
317
+ try { real = fs.realpathSync(absolute); } catch { refuse(`${unit}: consumer row file ${file} cannot be resolved`); }
318
+ 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`);
319
+ if (git(["ls-files", "--error-unmatch", "--", file], workspaceRoot) === null) refuse(`${unit}: consumer row file ${file} is not tracked by git; commit it first`);
320
+ }
321
+ }
322
+ }
323
+
324
+ /** The directories whose cleanliness a set of consumer rows depends on. */
325
+ const consumerDirs = (rows) => [...new Set(rows.map((row) => path.posix.dirname(row.manifestPath)))];
326
+
327
+ // The files that decide WHICH steps a publish runs: an uncommitted edit would change the plan the evidence is bound to
328
+ // (0.4.1 review A3). Chosen over reading the HEAD blob so the publisher, the replay and the batch apply one rule:
329
+ // the file on disk must be the committed file, or the publish is refused.
330
+ export const GATE_CONFIG_FILES = Object.freeze(["ci/gate.config.json", "ci/asset-gated.json"]);
331
+
215
332
  /** The CI gate's commands for one unit (scripts/ci/gate.mjs npmGate/rustGate), mirrored exactly. */
216
- export function localTestPlan({ kind, unit, dir, config, manifest, batch = false }) {
333
+ export function localTestPlan({ kind, unit, dir, config, manifest, batch = false, platform = "darwin" }) {
217
334
  if (kind === "npm") {
218
335
  if (!manifest?.scripts?.test) refuse(`${unit} has no test script; --local never publishes an untested package`);
219
336
  return {
@@ -225,17 +342,24 @@ export function localTestPlan({ kind, unit, dir, config, manifest, batch = false
225
342
  };
226
343
  }
227
344
  const entry = config?.rust?.[unit] ?? {};
228
- 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`);
345
+ // gate.config.json names hosts "macos" and "windows" (scripts/ci/gate.mjs HOST).
346
+ const gateHost = platform === "win32" ? "windows" : "macos";
347
+ 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"}`);
229
348
  const featureSets = entry.featureSets ?? [[]];
349
+ // Stream Cargo artifact records to the broker; libtest summaries remain plain text for ResultScanner.
230
350
  const steps = [];
231
351
  for (const features of featureSets) {
232
- steps.push({ command: "cargo", args: ["test", "-p", unit, ...features, "--no-fail-fast", "--", "--show-output"], test: true });
233
- if (entry.alsoSerial) steps.push({ command: "cargo", args: ["test", "-p", unit, ...features, "--no-fail-fast", "--", "--test-threads=1", "--show-output"], test: true });
352
+ steps.push({ command: "cargo", args: ["test", "-p", unit, "--message-format=json-render-diagnostics", ...features, "--no-fail-fast", "--", "--show-output"], test: true });
353
+ 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 });
234
354
  }
235
355
  // 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 });
237
- for (const features of allFeatureCheckSets(entry)) steps.push({ command: "cargo", args: ["check", "-p", unit, "--all-targets", ...features], test: false });
238
- return { id: `rust:${unit}`, featureLabels: featureSets.map((features) => features.join(" ") || "default"), steps };
356
+ // Every tested feature set is linted (-D warnings), not only the first.
357
+ 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 });
358
+ for (const features of allFeatureCheckSets(entry)) steps.push({ command: "cargo", args: ["check", "-p", unit, "--message-format=json-render-diagnostics", "--all-targets", ...features], test: false });
359
+ // Consumer rows (separate Cargo workspaces) gate only this crate: they come from this crate's own entry.
360
+ const consumers = consumerRows(unit, entry);
361
+ for (const row of consumers) steps.push(...consumerRowInvocations(row));
362
+ return { id: `rust:${unit}`, featureLabels: featureSets.map((features) => features.join(" ") || "default"), consumerRows: consumers, steps };
239
363
  }
240
364
 
241
365
  /**
@@ -247,18 +371,23 @@ export function localTestPlan({ kind, unit, dir, config, manifest, batch = false
247
371
  * unify extra features into a selected crate and each is linted with exactly the features it was before.
248
372
  * Crates with a different RIGHTKIT_REQUIRE_ASSETS (asset-gated) stay apart so the environment is unchanged.
249
373
  */
250
- export function batchClippyInvocations(layer, { featuresOf = () => [], assetGatedOf = () => false } = {}) {
374
+ export function batchClippyInvocations(layer, { featuresOf = () => [], featureSetsOf = (unit) => [featuresOf(unit)], assetGatedOf = () => false } = {}) {
251
375
  const groups = new Map();
252
376
  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);
377
+ // Every tested feature set of a crate is linted, in order; identical sets of one crate collapse to one run.
378
+ const sets = featureSetsOf(unit) ?? [[]];
379
+ const unique = sets.filter((set, index) => sets.findIndex((other) => JSON.stringify(other ?? []) === JSON.stringify(set ?? [])) === index);
380
+ for (const set of unique) {
381
+ const features = set ?? [];
382
+ const shared = features.length === 0 || (features.length === 1 && features[0] === "--all-features");
383
+ const key = shared ? JSON.stringify([features, assetGatedOf(unit)]) : `unit:${unit}:${JSON.stringify(features)}`;
384
+ if (!groups.has(key)) groups.set(key, { crates: [], features, assetGated: assetGatedOf(unit) });
385
+ groups.get(key).crates.push(unit);
386
+ }
258
387
  }
259
388
  return [...groups.values()].map(({ crates, features, assetGated }) => ({
260
389
  crates, assetGated,
261
- args: ["clippy", ...crates.flatMap((unit) => ["-p", unit]), "--no-deps", "--all-targets", ...features, "--", "-D", "warnings"],
390
+ args: ["clippy", ...crates.flatMap((unit) => ["-p", unit]), "--message-format=json-render-diagnostics", "--no-deps", "--all-targets", ...features, "--", "-D", "warnings"],
262
391
  }));
263
392
  }
264
393
 
@@ -273,10 +402,12 @@ function testEnv(deps, isAssetGated) {
273
402
  return { ...inherited, RIGHTKIT_REQUIRE_ASSETS: isAssetGated ? "0" : "1", RIGHTKIT_ISOLATED_CHECKOUT: "1", CARGO_TERM_COLOR: "never" };
274
403
  }
275
404
 
276
- function assertLocalHost(deps, workspaceRoot) {
405
+ function assertLocalHost(deps, workspaceRoot, kind) {
277
406
  const platform = deps.platform ?? process.platform;
278
407
  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}`);
408
+ 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}`);
409
+ // The npm path reads the registry token from the macOS Keychain and ships a Developer-ID-signed helper: Mac only.
410
+ 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}`);
280
411
  return platform;
281
412
  }
282
413
 
@@ -300,7 +431,8 @@ async function publishUnit(parsed, deps, batch) {
300
431
  const run = deps.run ?? (batch ? defaultRunAsync : defaultRun);
301
432
 
302
433
  // 1. Preconditions, fail closed.
303
- const platform = assertLocalHost(deps, workspaceRoot);
434
+ const platform = assertLocalHost(deps, workspaceRoot, kind);
435
+ if (parsed.qualifyOnly && fs.existsSync(parsed.bundleDir)) refuse(`qualification bundle already exists: ${parsed.bundleDir}`);
304
436
  const dir = kind === "npm" ? `packages/${unit.slice("@rightkit/".length)}` : `crates/${unit}`;
305
437
  const unitPath = path.join(workspaceRoot, dir);
306
438
  if (!fs.existsSync(unitPath)) refuse(`unit directory not found: ${unitPath}`);
@@ -324,18 +456,30 @@ async function publishUnit(parsed, deps, batch) {
324
456
  const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
325
457
  if (tree !== batch.tree) refuse(`${unit}: HEAD tree ${tree} differs from the batch tree ${batch.tree}`);
326
458
  }
327
- const dirty = git(["status", "--porcelain", "--", dir], workspaceRoot);
459
+ const dirty = git(["status", "--porcelain", "--", dir, ...GATE_CONFIG_FILES, ...(parsed.qualifyOnly ? ["Cargo.toml", "Cargo.lock"] : [])], workspaceRoot);
328
460
  if (dirty === null) refuse(`cannot read git status for ${dir}`);
329
- if (dirty !== "") refuse(`tree is not clean for ${dir}; commit first:\n${dirty}`);
461
+ if (dirty !== "") refuse(`tree is not clean for ${dir} or the gate config (${GATE_CONFIG_FILES.join(", ")}); commit first:\n${dirty}`);
330
462
  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`);
331
- log(`[right-release] local publish ${kind}:${unit}@${version} at ${head.slice(0, 12)} (local-build grant, darwin-only policy)`);
463
+ log(`[right-release] local publish ${kind}:${unit}@${version} at ${head.slice(0, 12)} (local-build grant, ${LOCAL_HOST_POLICIES[platform]} policy)`);
332
464
 
333
465
  // 2. The unit's own tests, exactly as the CI gate defines them.
334
466
  const gateConfig = readJson(path.join(workspaceRoot, "ci", "gate.config.json")) ?? {};
335
467
  const assetGated = readJson(path.join(workspaceRoot, "ci", "asset-gated.json")) ?? {};
336
468
  const gatedSection = assetGated[kind === "npm" ? "npm" : "crates"] ?? {};
337
469
  const isAssetGated = Object.hasOwn(gatedSection, unit);
338
- const plan = localTestPlan({ kind, unit, dir, config: gateConfig, manifest, batch: Boolean(batch) });
470
+ const plan = localTestPlan({ kind, unit, dir, config: gateConfig, manifest, batch: Boolean(batch), platform });
471
+ if (plan.consumerRows?.length) {
472
+ // The consumer workspaces are part of the qualified commit: present, and unmodified like the unit itself.
473
+ assertConsumerFiles(workspaceRoot, unit, plan.consumerRows, git);
474
+ const consumerDirty = git(["status", "--porcelain", "--", ...consumerDirs(plan.consumerRows)], workspaceRoot);
475
+ if (consumerDirty === null) refuse(`cannot read git status for the consumer rows of ${unit}`);
476
+ if (consumerDirty !== "") refuse(`tree is not clean for the consumer rows of ${unit}; commit first:\n${consumerDirty}`);
477
+ }
478
+ if (parsed.qualifyOnly) {
479
+ const violations = (deps.scanSecrets ?? scanCrateForSecrets)(unitPath);
480
+ if (violations.length) refuse(`qualification secret scan rejected: ${violations.join(", ")}`);
481
+ plan.steps.unshift({ command: "cargo", args: ["fmt", "-p", unit, "--check"], test: false });
482
+ }
339
483
  const modules = await (deps.loadWorkspaceModules ?? defaultLoadWorkspaceModules)(workspaceRoot);
340
484
  const { ResultScanner, TEST_COMMAND, judgeUnit } = modules.results;
341
485
  const env = testEnv(deps, isAssetGated);
@@ -343,15 +487,15 @@ async function publishUnit(parsed, deps, batch) {
343
487
  // written reason in ci/gate.config.json (rust.<crate>.allowIgnored). The count stays in the evidence.
344
488
  const configReason = kind === "cargo" && typeof gateConfig?.rust?.[unit]?.allowIgnored === "string" ? gateConfig.rust[unit].allowIgnored.trim() : "";
345
489
  const ignoredAllowed = Boolean(allowIgnored || parsed.allowIgnoredUnits?.includes(unit) || configReason);
346
- const steps = [];
490
+ const steps = parsed.qualifyOnly ? [{ command: `scanCrateForSecrets ${dir}`, ok: true }] : [];
347
491
  for (const step of plan.steps) {
348
492
  const commandText = `${step.command} ${step.args.join(" ")}`;
349
493
  log(`[right-release] ${plan.id}: ${commandText}`);
350
- const result = await run(step.command, step.args, { cwd: workspaceRoot, env });
494
+ const result = await run(step.command, step.command === "cargo" ? withCargoJobs(step.args, parsed.jobs) : step.args, { cwd: workspaceRoot, env });
351
495
  const row = { command: commandText, ok: false, isTest: step.test && TEST_COMMAND.test(commandText) };
352
496
  if (result.error || result.status !== 0) {
353
497
  steps.push(row);
354
- refuse(`${plan.id} step failed (${result.error?.message ?? `exit ${result.status}`}): ${commandText}\n${tail(`${result.stdout ?? ""}\n${result.stderr ?? ""}`)}`);
498
+ refuse(`${plan.id} step failed (${result.error?.message ?? `exit ${result.status}`}): ${commandText}\nstdout:\n${result.stdout ?? ""}\nstderr:\n${result.stderr ?? ""}`);
355
499
  }
356
500
  row.ok = true;
357
501
  if (row.isTest) {
@@ -391,16 +535,23 @@ async function publishUnit(parsed, deps, batch) {
391
535
  const checked = modules.checkNpmTarball({ file: artifactFile, name: unit, version, dir, spawn: deps.spawnSync ?? spawnSync });
392
536
  if (checked.problems.length) refuse(`${unit}: packed tarball refused:\n - ${checked.problems.join("\n - ")}`);
393
537
  } else {
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 });
538
+ // A combined publish verifies once in `cargo publish`; detached qualification verifies here before
539
+ // its evidence leaves this host, so the publishing host can reuse that exact archive's proof.
540
+ const packageArgs = ["package", "--locked", ...(parsed.qualifyOnly ? [] : ["--no-verify"]), "-p", unit];
541
+ packCommands.push(`cargo ${packageArgs.join(" ")}`);
542
+ const packed = await run("cargo", withCargoJobs(packageArgs, parsed.jobs), { cwd: workspaceRoot, env });
398
543
  if (packed.error || packed.status !== 0) refuse(`cargo package failed for ${unit}\n${tail(`${packed.stdout ?? ""}\n${packed.stderr ?? ""}`)}`);
399
- packCommands.push("cargo metadata --format-version 1 --no-deps");
400
- const meta = await run("cargo", ["metadata", "--format-version", "1", "--no-deps"], { cwd: workspaceRoot, env });
544
+ if (parsed.qualifyOnly) steps.push({ command: `cargo ${packageArgs.join(" ")}`, ok: true });
545
+ const metadataArgs = ["metadata", "--offline", "--format-version", "1", "--no-deps"];
546
+ const meta = await runCargoReadOnly(metadataArgs, { cwd: workspaceRoot, env }, (_command, args, settings) => run("cargo", args, settings),
547
+ (failed) => log(`[right-release] offline Cargo metadata missed cache; retrying online once\n${failed.stdout ?? ""}${failed.stderr ? `\n${failed.stderr}` : ""}`));
548
+ const actualMetadataArgs = meta.usedOfflineFallback ? metadataArgs.filter((arg) => arg !== "--offline") : metadataArgs;
549
+ packCommands.push(`cargo ${actualMetadataArgs.join(" ")}`);
401
550
  let targetDir;
402
551
  try { targetDir = JSON.parse(meta.stdout).target_directory; } catch { /* refused below */ }
403
- if (meta.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir)) refuse("cannot read the Cargo target directory");
552
+ if (meta.error || meta.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir)) {
553
+ refuse(`cannot read the Cargo target directory\n${tail(`${meta.error?.message ?? ""}\n${meta.stdout ?? ""}\n${meta.stderr ?? ""}`)}`);
554
+ }
404
555
  artifactFile = path.join(targetDir, "package", `${unit}-${version}.crate`);
405
556
  if (!fs.existsSync(artifactFile)) refuse(`cargo package did not produce ${artifactFile}`);
406
557
  }
@@ -411,13 +562,14 @@ async function publishUnit(parsed, deps, batch) {
411
562
  const timestamp = (deps.now ?? (() => new Date().toISOString()))();
412
563
  const evidence = {
413
564
  schema: GATE_SCHEMA, kind, name: unit, version, commit: head, artifactSha256,
414
- hostPolicy: "darwin-only", hostPolicyReason: LOCAL_HOST_POLICY_REASON,
565
+ hostPolicy: LOCAL_HOST_POLICIES[platform], hostPolicyReason: localHostPolicyReason(platform),
415
566
  runs: [{
416
- host: { os: "darwin", arch: deps.arch ?? process.arch }, commit: head, features: plan.featureLabels, artifactSha256,
567
+ host: { os: platform, arch: deps.arch ?? process.arch }, commit: head, features: plan.featureLabels, artifactSha256,
417
568
  command: commands.join(" && "), operator: LOCAL_OPERATOR, timestamp,
418
569
  passed: verdict.executed, failed: 0, skipped: verdict.skipped, ignored: verdict.ignored, allowIgnored: ignoredAllowed,
419
570
  ...(configReason ? { allowIgnoredReason: configReason } : {}),
420
571
  requireAssets: env.RIGHTKIT_REQUIRE_ASSETS, assetGated: isAssetGated,
572
+ ...(parsed.jobs === undefined ? {} : { jobs: parsed.jobs }),
421
573
  ...(batch ? { batch: structuredClone(batch.receipt) } : {}),
422
574
  steps: steps.map(({ command, ok, executed, failed, ignored, skipped, binaries }) => ({ command, ok, ...(executed === undefined ? {} : { executed, failed, ignored, skipped, binaries }) })),
423
575
  }],
@@ -429,16 +581,28 @@ async function publishUnit(parsed, deps, batch) {
429
581
  const gateOptions = { kind, name: unit, version, packagePath: unitPath, evidencePath };
430
582
  (deps.enforceGate ?? enforcePublicationGate)(gateOptions, { write: (line) => log(line.replace(/\n$/, "")) });
431
583
 
584
+ if (parsed.qualifyOnly) {
585
+ // Keep bytes and their normal gate evidence together outside the checkout. Never overwrite a bundle.
586
+ fs.mkdirSync(parsed.bundleDir, { recursive: false });
587
+ const bundledArtifact = path.join(parsed.bundleDir, path.basename(artifactFile));
588
+ const bundledEvidence = path.join(parsed.bundleDir, "gate.json");
589
+ fs.copyFileSync(artifactFile, bundledArtifact, fs.constants.COPYFILE_EXCL);
590
+ if (sha256(fs.readFileSync(bundledArtifact)) !== artifactSha256) refuse("qualification archive changed while copying");
591
+ fs.copyFileSync(evidencePath, bundledEvidence, fs.constants.COPYFILE_EXCL);
592
+ log(`[right-release] qualified ${unit}@${version}; bundle ${parsed.bundleDir}; publish cargo --crate ${unit} --from-evidence ${bundledEvidence}`);
593
+ return { kind, name: unit, version, commit: head, artifactSha256, evidencePath: bundledEvidence, artifactFile: bundledArtifact, qualifiedOnly: true };
594
+ }
595
+
432
596
  // 5. The EXISTING publish path.
433
597
  if (kind === "npm") {
434
- (deps.publishGatedTarball ?? publishGatedTarball)({ name: unit, version, tarball: artifactFile, evidencePath }, deps.publishDeps ?? {});
598
+ (deps.publishGatedTarball ?? publishGatedTarball)({ name: unit, version, tarball: artifactFile, evidencePath, workspaceRoot }, deps.publishDeps ?? {});
435
599
  } else {
436
600
  // trustEvidenceSteps: the evidence just written (and validated by the gate above) proves this commit's tests
437
601
  // and clippy, so the publisher does not repeat them; verifyOnPublish: Cargo's verify build runs in the
438
602
  // publish itself. Package contents, the runtime budget, the archive hash and the readback always run.
439
603
  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 }));
604
+ const published = await (deps.runCargoPublish ?? runCargoPublish)({ crate: unit, dryRun: false, allowDirty: false, workspaceRoot, evidence: evidencePath, trustEvidenceSteps: true, verifyOnPublish: true, offlineReadOnly: true }, execute, deps.cargoDeps ?? {});
605
+ await withVersionsLock(() => (deps.recordPublished ?? recordPublished)({ registry: "cargo", name: published.name, version: published.version }, workspaceFeedOptions(workspaceRoot)));
442
606
  }
443
607
  log(`[right-release] published ${unit}@${version}`);
444
608
  return { kind, name: unit, version, commit: head, artifactSha256, evidencePath };
@@ -460,7 +624,7 @@ async function runLocalPublishBatch(parsed, deps) {
460
624
  const git = deps.git ?? defaultGit;
461
625
  const readJson = deps.readJson ?? defaultReadJson;
462
626
  const run = deps.run ?? defaultRunAsync;
463
- assertLocalHost(deps, workspaceRoot);
627
+ const platform = assertLocalHost(deps, workspaceRoot, "cargo");
464
628
 
465
629
  // 1. The crate list and its dependency layers.
466
630
  const requested = parsed.batch.file ? parseCrateLayers((deps.readText ?? ((file) => fs.readFileSync(file, "utf8")))(parsed.batch.file)) : [parsed.batch.crates];
@@ -476,6 +640,7 @@ async function runLocalPublishBatch(parsed, deps) {
476
640
  const assetGated = readJson(path.join(workspaceRoot, "ci", "asset-gated.json")) ?? {};
477
641
  const isAssetGated = (unit) => Object.hasOwn(assetGated.crates ?? {}, unit);
478
642
  const versions = new Map();
643
+ const batchConsumerDirs = new Set();
479
644
  for (const unit of crates) {
480
645
  const dir = `crates/${unit}`;
481
646
  if (!fs.existsSync(path.join(workspaceRoot, dir))) refuse(`unit directory not found: ${path.join(workspaceRoot, dir)}`);
@@ -483,20 +648,24 @@ async function runLocalPublishBatch(parsed, deps) {
483
648
  if (pkg.name !== unit) refuse(`Cargo.toml [package].name ${JSON.stringify(pkg.name)} differs from --crates entry ${unit}`);
484
649
  if (typeof pkg.version !== "string" || !pkg.version.trim()) refuse(`${unit} has no literal version`);
485
650
  versions.set(unit, pkg.version);
486
- localTestPlan({ kind: "cargo", unit, dir, config: gateConfig, batch: true }); // refuses an OS-restricted crate now, not mid-batch
651
+ // Refuses an OS-restricted crate, a malformed consumer row, or an untracked or symlinked consumer file now, not mid-batch.
652
+ const rows = localTestPlan({ kind: "cargo", unit, dir, config: gateConfig, batch: true, platform }).consumerRows ?? [];
653
+ assertConsumerFiles(workspaceRoot, unit, rows, git);
654
+ for (const consumerDir of consumerDirs(rows)) batchConsumerDirs.add(consumerDir);
487
655
  }
488
656
  const head = git(["rev-parse", "HEAD"], workspaceRoot);
489
657
  if (!head || !/^[0-9a-f]{40}$/.test(head)) refuse("cannot resolve HEAD");
490
658
  const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
491
659
  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);
660
+ // Every batch crate, the workspace manifest and lock, the gate config and EVERY crate's consumer directories must
661
+ // match the commit the batch checks are bound to, before the first layer publishes (0.4.1 review A3, A4).
662
+ const dirty = git(["status", "--porcelain", "--", ...crates.map((unit) => `crates/${unit}`), "Cargo.toml", "Cargo.lock", ...GATE_CONFIG_FILES, ...batchConsumerDirs], workspaceRoot);
494
663
  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}`);
664
+ 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}`);
496
665
  const registryHas = deps.registryHas ?? defaultRegistryHas;
497
666
  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
667
  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)`);
668
+ 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)`);
500
669
 
501
670
  // 3. Batch-level checks, once, at the batch commit. Each is recorded in every crate's evidence.
502
671
  const env = testEnv(deps, false);
@@ -518,9 +687,9 @@ async function runLocalPublishBatch(parsed, deps) {
518
687
  const scanText = `scanCrateForSecrets ${crates.map((unit) => `crates/${unit}`).join(" ")}`;
519
688
  checks.push({ name: "secret-scan", command: scanText, ok: true, ...stamp, crates });
520
689
  commands.push(scanText);
521
- const featuresOf = (unit) => (gateConfig?.rust?.[unit]?.featureSets ?? [[]])[0];
690
+ const featureSetsOf = (unit) => gateConfig?.rust?.[unit]?.featureSets ?? [[]];
522
691
  for (const layer of layers) {
523
- for (const invocation of batchClippyInvocations(layer, { featuresOf, assetGatedOf: isAssetGated })) {
692
+ for (const invocation of batchClippyInvocations(layer, { featureSetsOf, assetGatedOf: isAssetGated })) {
524
693
  await batchCheck("clippy", "cargo", invocation.args, invocation.crates, testEnv(deps, invocation.assetGated));
525
694
  }
526
695
  }
package/managed-cargo.mjs CHANGED
@@ -18,6 +18,14 @@ export function managedRuntimeClient(env = process.env, platform = process.platf
18
18
  const client = path.posix.join(env.HOME || homedir(), 'Library', 'Application Support', 'RustRight', 'runtime', 'rustright');
19
19
  if (exists(client)) return client;
20
20
  }
21
+ // RustRight's own install on Windows (2026-10-18 cutover): the runtime moved out of the control root to
22
+ // <drive>\.rustright\runtime, beside the control root's drive, and runtime-current was retired.
23
+ if (platform === 'win32') {
24
+ for (const root of roots) {
25
+ const client = path.win32.join(path.win32.parse(root).root, '.rustright', 'runtime', 'rustright.exe');
26
+ if (exists(client)) return client;
27
+ }
28
+ }
21
29
  return undefined;
22
30
  }
23
31
 
@@ -6,6 +6,7 @@
6
6
  // `{ file, name?, pruneSuffix? }`. `pruneSuffix` (for example "-setup.exe") then removes the other files with that suffix
7
7
  // from the managed directory, so it holds exactly the current artifact. Run it as `targets.<platform>.postSign`.
8
8
  // Output goes only to RIGHT_RELEASE_ARTIFACT_DIR (outside the repository); it never writes into the repository root.
9
+ import { configuredManagedRootSpellings } from "./artifact-storage.mjs";
9
10
  import { copyFileSync, lstatSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync } from "node:fs";
10
11
  import { execFileSync } from "./background-process.mjs";
11
12
  import path from "node:path";
@@ -113,11 +114,14 @@ function atomicCopy(from, to) {
113
114
  function assertSafePath(target) {
114
115
  const absolute = path.resolve(target);
115
116
  const root = path.parse(absolute).root;
117
+ // Only the configured managed root itself may be a symlink (same rule as artifact-storage assertSafeMutation).
118
+ const managedRoots = configuredManagedRootSpellings();
119
+ const configured = (candidate) => managedRoots.has(process.platform === "win32" ? path.resolve(candidate).toLowerCase() : path.resolve(candidate));
116
120
  let cursor = root;
117
121
  for (const segment of path.relative(root, absolute).split(path.sep).filter(Boolean)) {
118
122
  cursor = path.join(cursor, segment);
119
123
  try {
120
- if (lstatSync(cursor).isSymbolicLink()) throw new Error(`refusing filesystem mutation through symlink: ${cursor}`);
124
+ if (lstatSync(cursor).isSymbolicLink() && !configured(cursor)) throw new Error(`refusing filesystem mutation through symlink: ${cursor}`);
121
125
  } catch (error) {
122
126
  if (error.code !== "ENOENT") throw error;
123
127
  }
@@ -18,6 +18,8 @@ export function resolveNativeCargoLayout({
18
18
  read = (file) => readFileSync(file, "utf8"),
19
19
  requireManifest = false,
20
20
  requireLock = false,
21
+ // The repository the app lives in. A manifest outside the app searches for its workspace only up to this directory.
22
+ repoRoot,
21
23
  } = {}) {
22
24
  if (!appRoot) throw new TypeError("native Cargo layout requires appRoot");
23
25
  const resolvedAppRoot = path.resolve(appRoot);
@@ -31,7 +33,7 @@ export function resolveNativeCargoLayout({
31
33
  const explicitWorkspace = nativeAssembly?.workspaceRoot ?? nativeAssembly?.cargoWorkspaceRoot;
32
34
  const workspaceRoot = explicitWorkspace
33
35
  ? normalizeWorkspaceRoot(resolveDeclaredPath(resolvedAppRoot, explicitWorkspace), exists)
34
- : discoverWorkspaceRoot({ appRoot: resolvedAppRoot, manifestPath, manifestDir, manifestText, exists, read });
36
+ : discoverWorkspaceRoot({ appRoot: resolvedAppRoot, manifestPath, manifestDir, manifestText, exists, read, repoRoot: repoRoot ? path.resolve(repoRoot) : undefined });
35
37
 
36
38
  const lock = resolveCargoLock({
37
39
  appRoot: resolvedAppRoot,
@@ -84,6 +86,28 @@ export function resolveNativeCargoLayout({
84
86
  return layout;
85
87
  }
86
88
 
89
+ /**
90
+ * True when the app has a native Cargo manifest the release must hash and build against: the config declares one
91
+ * (nativeAssembly.cargoManifest / manifest) or the historical src-tauri/Cargo.toml exists. A native iOS app (xcodegen
92
+ * project, Rust static library built by iosPackage.prepare) has neither, and then owns no Cargo layout at all.
93
+ */
94
+ export function nativeCargoDeclared({ appRoot, config, nativeAssembly = config?.nativeAssembly, exists = existsSync } = {}) {
95
+ if (!appRoot) throw new TypeError("native Cargo layout requires appRoot");
96
+ if (nativeAssembly?.cargoManifest ?? nativeAssembly?.manifest) return true;
97
+ return exists(path.resolve(appRoot, DEFAULT_MANIFEST));
98
+ }
99
+
100
+ /** The manifest nativeAssembly names, if any (a path relative to the app root, or absolute). */
101
+ export function declaredCargoManifest(config, nativeAssembly = config?.nativeAssembly) {
102
+ return nativeAssembly?.cargoManifest ?? nativeAssembly?.manifest;
103
+ }
104
+
105
+ /** True when the historical Tauri host manifest exists. */
106
+ export function hasDefaultTauriManifest({ appRoot, exists = existsSync } = {}) {
107
+ if (!appRoot) throw new TypeError("native Cargo layout requires appRoot");
108
+ return exists(path.resolve(appRoot, DEFAULT_MANIFEST));
109
+ }
110
+
87
111
  export const resolveCargoLayout = resolveNativeCargoLayout;
88
112
  export const resolveNativeCargoManifest = resolveNativeCargoLayout;
89
113
  export const resolveCargoManifest = resolveNativeCargoLayout;
@@ -160,20 +184,29 @@ function resolveCargoLock({ appRoot, manifestPath, manifestDir, workspaceRoot, n
160
184
  return { path: found, source: found ? orderedSources[candidates.indexOf(found)] : undefined, candidates };
161
185
  }
162
186
 
163
- function discoverWorkspaceRoot({ appRoot, manifestPath, manifestDir, manifestText, exists, read }) {
187
+ function discoverWorkspaceRoot({ appRoot, manifestPath, manifestDir, manifestText, exists, read, repoRoot }) {
164
188
  const parsed = parseCargoManifest(manifestText);
165
189
  const declaredWorkspace = parsed.workspacePath ?? parsed.packageWorkspace;
166
190
  if (declaredWorkspace) {
167
191
  return normalizeWorkspaceRoot(resolveDeclaredPath(manifestDir, declaredWorkspace), exists);
168
192
  }
169
193
  if (parsed.hasWorkspace) return manifestDir;
194
+ // A manifest inside the app searches up to the app root (unchanged); one outside it (a shared crates/ tree) searches up to the
195
+ // nearest directory that contains both, which is where its workspace and Cargo.lock live.
196
+ // Only a caller that names the repository opts in to the climb above the app (native iOS); every other caller keeps the
197
+ // exact historical search, which never leaves the app root.
198
+ let boundary = appRoot;
199
+ if (repoRoot && !isWithin(appRoot, manifestDir)) {
200
+ boundary = commonAncestor(appRoot, manifestDir);
201
+ if (!isWithin(repoRoot, boundary)) boundary = repoRoot;
202
+ }
170
203
  let current = manifestDir;
171
204
  for (;;) {
172
205
  const candidate = path.join(current, "Cargo.toml");
173
206
  if (candidate !== manifestPath && exists(candidate) && parseCargoManifest(readIfPresent(candidate, exists, read)).hasWorkspace) return current;
174
- if (current === appRoot) break;
207
+ if (current === boundary) break;
175
208
  const parent = path.dirname(current);
176
- if (parent === current || !isWithin(appRoot, parent)) break;
209
+ if (parent === current || !isWithin(boundary, parent)) break;
177
210
  current = parent;
178
211
  }
179
212
  return manifestDir;
@@ -231,6 +264,16 @@ function readIfPresent(file, exists, read) {
231
264
  try { return exists(file) ? read(file) : ""; } catch { return ""; }
232
265
  }
233
266
 
267
+ function commonAncestor(left, right) {
268
+ let current = path.resolve(left);
269
+ while (!isWithin(current, path.resolve(right))) {
270
+ const parent = path.dirname(current);
271
+ if (parent === current) return current;
272
+ current = parent;
273
+ }
274
+ return current;
275
+ }
276
+
234
277
  function isWithin(root, candidate) {
235
278
  const relative = path.relative(root, candidate);
236
279
  return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));