@rightkit/release 0.3.0 → 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 (66) hide show
  1. package/README.md +25 -4
  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 +11 -2
  10. package/codesign-environment.mjs +2 -2
  11. package/content-digest.mjs +209 -0
  12. package/development.mjs +17 -6
  13. package/direct-bootstrap.mjs +5 -6
  14. package/disk-command.mjs +1 -0
  15. package/disk-policy.json +6 -2
  16. package/disk-stable-root.mjs +13 -9
  17. package/github-updater-release.mjs +70 -7
  18. package/in-place-install.mjs +17 -14
  19. package/install-app-windows.mjs +24 -7
  20. package/install-app.mjs +21 -40
  21. package/ios-asc-credentials.mjs +163 -0
  22. package/ios-build-context.mjs +129 -0
  23. package/ios-build-upload.mjs +282 -0
  24. package/ios-credentials-command.mjs +70 -0
  25. package/ios-dev.mjs +248 -0
  26. package/ios-host.mjs +76 -0
  27. package/ios-lock.mjs +75 -0
  28. package/ios-package-recipe.mjs +707 -0
  29. package/ios-testflight.mjs +266 -0
  30. package/ios-upload.mjs +313 -0
  31. package/local-publish.mjs +92 -37
  32. package/managed-cargo.mjs +8 -0
  33. package/mirror-root-artifact.mjs +5 -1
  34. package/native-cargo-layout.mjs +47 -4
  35. package/ownership-command.mjs +2 -1
  36. package/ownership-dependency-allowlist.json +1 -1
  37. package/ownership-evidence.mjs +30 -18
  38. package/ownership-key-command.mjs +69 -2
  39. package/ownership-mandatory-set.mjs +66 -16
  40. package/ownership-repositories.json +1 -1
  41. package/ownership-scope.mjs +94 -0
  42. package/ownership.mjs +49 -7
  43. package/package-recipe-targets.mjs +41 -1
  44. package/package.json +1 -1
  45. package/preflight.mjs +14 -7
  46. package/project-type.mjs +3 -1
  47. package/protected-credentials.mjs +28 -1
  48. package/publication-artifacts.mjs +30 -5
  49. package/publication-gate.mjs +15 -4
  50. package/publish-cargo.mjs +109 -22
  51. package/publish-update.mjs +15 -2
  52. package/qa-build-features.mjs +16 -13
  53. package/qa-contract.mjs +30 -2
  54. package/qa-control-scan.mjs +4 -0
  55. package/release-invocation.mjs +118 -8
  56. package/release-switch-registry.json +126 -27
  57. package/release-switch-scan.mjs +45 -17
  58. package/release.mjs +69 -17
  59. package/rightkit-ownership.json +1 -1
  60. package/rightkit-versions.json +379 -189
  61. package/runtime-budgets.mjs +120 -16
  62. package/sign-updater.mjs +56 -59
  63. package/source-gate.mjs +8 -1
  64. package/stage-sidecar.mjs +160 -32
  65. package/target-distribution.mjs +5 -0
  66. package/upload-release.mjs +7 -2
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,8 +21,8 @@ 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
28
  import { recordPublished, stageSourceVersions } from "./rightkit-sync.mjs";
@@ -29,6 +30,10 @@ import { prepareSwapHelper, SWAP_HELPER_CARGO_ARGS, SWAP_HELPER_PACKAGE } from "
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) {
@@ -213,7 +237,7 @@ export function allFeatureCheckSets(entry = {}) {
213
237
  }
214
238
 
215
239
  /** 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 }) {
240
+ export function localTestPlan({ kind, unit, dir, config, manifest, batch = false, platform = "darwin" }) {
217
241
  if (kind === "npm") {
218
242
  if (!manifest?.scripts?.test) refuse(`${unit} has no test script; --local never publishes an untested package`);
219
243
  return {
@@ -225,16 +249,19 @@ export function localTestPlan({ kind, unit, dir, config, manifest, batch = false
225
249
  };
226
250
  }
227
251
  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`);
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"}`);
229
255
  const featureSets = entry.featureSets ?? [[]];
256
+ // Stream Cargo artifact records to the broker; libtest summaries remain plain text for ResultScanner.
230
257
  const steps = [];
231
258
  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 });
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 });
234
261
  }
235
262
  // 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 });
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 });
238
265
  return { id: `rust:${unit}`, featureLabels: featureSets.map((features) => features.join(" ") || "default"), steps };
239
266
  }
240
267
 
@@ -258,7 +285,7 @@ export function batchClippyInvocations(layer, { featuresOf = () => [], assetGate
258
285
  }
259
286
  return [...groups.values()].map(({ crates, features, assetGated }) => ({
260
287
  crates, assetGated,
261
- args: ["clippy", ...crates.flatMap((unit) => ["-p", unit]), "--no-deps", "--all-targets", ...features, "--", "-D", "warnings"],
288
+ args: ["clippy", ...crates.flatMap((unit) => ["-p", unit]), "--message-format=json-render-diagnostics", "--no-deps", "--all-targets", ...features, "--", "-D", "warnings"],
262
289
  }));
263
290
  }
264
291
 
@@ -273,10 +300,12 @@ function testEnv(deps, isAssetGated) {
273
300
  return { ...inherited, RIGHTKIT_REQUIRE_ASSETS: isAssetGated ? "0" : "1", RIGHTKIT_ISOLATED_CHECKOUT: "1", CARGO_TERM_COLOR: "never" };
274
301
  }
275
302
 
276
- function assertLocalHost(deps, workspaceRoot) {
303
+ function assertLocalHost(deps, workspaceRoot, kind) {
277
304
  const platform = deps.platform ?? process.platform;
278
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}`);
279
- if (platform !== "darwin") refuse(`--local publishes from the Mac only (host policy: darwin-only); this host is ${platform}`);
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}`);
280
309
  return platform;
281
310
  }
282
311
 
@@ -300,7 +329,8 @@ async function publishUnit(parsed, deps, batch) {
300
329
  const run = deps.run ?? (batch ? defaultRunAsync : defaultRun);
301
330
 
302
331
  // 1. Preconditions, fail closed.
303
- const platform = assertLocalHost(deps, workspaceRoot);
332
+ const platform = assertLocalHost(deps, workspaceRoot, kind);
333
+ if (parsed.qualifyOnly && fs.existsSync(parsed.bundleDir)) refuse(`qualification bundle already exists: ${parsed.bundleDir}`);
304
334
  const dir = kind === "npm" ? `packages/${unit.slice("@rightkit/".length)}` : `crates/${unit}`;
305
335
  const unitPath = path.join(workspaceRoot, dir);
306
336
  if (!fs.existsSync(unitPath)) refuse(`unit directory not found: ${unitPath}`);
@@ -324,18 +354,23 @@ async function publishUnit(parsed, deps, batch) {
324
354
  const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
325
355
  if (tree !== batch.tree) refuse(`${unit}: HEAD tree ${tree} differs from the batch tree ${batch.tree}`);
326
356
  }
327
- const dirty = git(["status", "--porcelain", "--", dir], workspaceRoot);
357
+ const dirty = git(["status", "--porcelain", "--", dir, ...(parsed.qualifyOnly ? ["Cargo.toml", "Cargo.lock"] : [])], workspaceRoot);
328
358
  if (dirty === null) refuse(`cannot read git status for ${dir}`);
329
359
  if (dirty !== "") refuse(`tree is not clean for ${dir}; commit first:\n${dirty}`);
330
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`);
331
- 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)`);
332
362
 
333
363
  // 2. The unit's own tests, exactly as the CI gate defines them.
334
364
  const gateConfig = readJson(path.join(workspaceRoot, "ci", "gate.config.json")) ?? {};
335
365
  const assetGated = readJson(path.join(workspaceRoot, "ci", "asset-gated.json")) ?? {};
336
366
  const gatedSection = assetGated[kind === "npm" ? "npm" : "crates"] ?? {};
337
367
  const isAssetGated = Object.hasOwn(gatedSection, unit);
338
- const plan = localTestPlan({ kind, unit, dir, config: gateConfig, manifest, batch: Boolean(batch) });
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
+ }
339
374
  const modules = await (deps.loadWorkspaceModules ?? defaultLoadWorkspaceModules)(workspaceRoot);
340
375
  const { ResultScanner, TEST_COMMAND, judgeUnit } = modules.results;
341
376
  const env = testEnv(deps, isAssetGated);
@@ -343,15 +378,15 @@ async function publishUnit(parsed, deps, batch) {
343
378
  // written reason in ci/gate.config.json (rust.<crate>.allowIgnored). The count stays in the evidence.
344
379
  const configReason = kind === "cargo" && typeof gateConfig?.rust?.[unit]?.allowIgnored === "string" ? gateConfig.rust[unit].allowIgnored.trim() : "";
345
380
  const ignoredAllowed = Boolean(allowIgnored || parsed.allowIgnoredUnits?.includes(unit) || configReason);
346
- const steps = [];
381
+ const steps = parsed.qualifyOnly ? [{ command: `scanCrateForSecrets ${dir}`, ok: true }] : [];
347
382
  for (const step of plan.steps) {
348
383
  const commandText = `${step.command} ${step.args.join(" ")}`;
349
384
  log(`[right-release] ${plan.id}: ${commandText}`);
350
- const result = await 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 });
351
386
  const row = { command: commandText, ok: false, isTest: step.test && TEST_COMMAND.test(commandText) };
352
387
  if (result.error || result.status !== 0) {
353
388
  steps.push(row);
354
- 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 ?? ""}`);
355
390
  }
356
391
  row.ok = true;
357
392
  if (row.isTest) {
@@ -391,16 +426,23 @@ async function publishUnit(parsed, deps, batch) {
391
426
  const checked = modules.checkNpmTarball({ file: artifactFile, name: unit, version, dir, spawn: deps.spawnSync ?? spawnSync });
392
427
  if (checked.problems.length) refuse(`${unit}: packed tarball refused:\n - ${checked.problems.join("\n - ")}`);
393
428
  } 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 });
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 });
398
434
  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 });
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(" ")}`);
401
441
  let targetDir;
402
442
  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");
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
+ }
404
446
  artifactFile = path.join(targetDir, "package", `${unit}-${version}.crate`);
405
447
  if (!fs.existsSync(artifactFile)) refuse(`cargo package did not produce ${artifactFile}`);
406
448
  }
@@ -411,13 +453,14 @@ async function publishUnit(parsed, deps, batch) {
411
453
  const timestamp = (deps.now ?? (() => new Date().toISOString()))();
412
454
  const evidence = {
413
455
  schema: GATE_SCHEMA, kind, name: unit, version, commit: head, artifactSha256,
414
- hostPolicy: "darwin-only", hostPolicyReason: LOCAL_HOST_POLICY_REASON,
456
+ hostPolicy: LOCAL_HOST_POLICIES[platform], hostPolicyReason: localHostPolicyReason(platform),
415
457
  runs: [{
416
- 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,
417
459
  command: commands.join(" && "), operator: LOCAL_OPERATOR, timestamp,
418
460
  passed: verdict.executed, failed: 0, skipped: verdict.skipped, ignored: verdict.ignored, allowIgnored: ignoredAllowed,
419
461
  ...(configReason ? { allowIgnoredReason: configReason } : {}),
420
462
  requireAssets: env.RIGHTKIT_REQUIRE_ASSETS, assetGated: isAssetGated,
463
+ ...(parsed.jobs === undefined ? {} : { jobs: parsed.jobs }),
421
464
  ...(batch ? { batch: structuredClone(batch.receipt) } : {}),
422
465
  steps: steps.map(({ command, ok, executed, failed, ignored, skipped, binaries }) => ({ command, ok, ...(executed === undefined ? {} : { executed, failed, ignored, skipped, binaries }) })),
423
466
  }],
@@ -429,6 +472,18 @@ async function publishUnit(parsed, deps, batch) {
429
472
  const gateOptions = { kind, name: unit, version, packagePath: unitPath, evidencePath };
430
473
  (deps.enforceGate ?? enforcePublicationGate)(gateOptions, { write: (line) => log(line.replace(/\n$/, "")) });
431
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
+
432
487
  // 5. The EXISTING publish path.
433
488
  if (kind === "npm") {
434
489
  (deps.publishGatedTarball ?? publishGatedTarball)({ name: unit, version, tarball: artifactFile, evidencePath }, deps.publishDeps ?? {});
@@ -437,7 +492,7 @@ async function publishUnit(parsed, deps, batch) {
437
492
  // and clippy, so the publisher does not repeat them; verifyOnPublish: Cargo's verify build runs in the
438
493
  // publish itself. Package contents, the runtime budget, the archive hash and the readback always run.
439
494
  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 ?? {});
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 ?? {});
441
496
  await withVersionsLock(() => (deps.recordPublished ?? recordPublished)({ registry: "cargo", name: published.name, version: published.version }));
442
497
  }
443
498
  log(`[right-release] published ${unit}@${version}`);
@@ -460,7 +515,7 @@ async function runLocalPublishBatch(parsed, deps) {
460
515
  const git = deps.git ?? defaultGit;
461
516
  const readJson = deps.readJson ?? defaultReadJson;
462
517
  const run = deps.run ?? defaultRunAsync;
463
- assertLocalHost(deps, workspaceRoot);
518
+ const platform = assertLocalHost(deps, workspaceRoot, "cargo");
464
519
 
465
520
  // 1. The crate list and its dependency layers.
466
521
  const requested = parsed.batch.file ? parseCrateLayers((deps.readText ?? ((file) => fs.readFileSync(file, "utf8")))(parsed.batch.file)) : [parsed.batch.crates];
@@ -483,7 +538,7 @@ async function runLocalPublishBatch(parsed, deps) {
483
538
  if (pkg.name !== unit) refuse(`Cargo.toml [package].name ${JSON.stringify(pkg.name)} differs from --crates entry ${unit}`);
484
539
  if (typeof pkg.version !== "string" || !pkg.version.trim()) refuse(`${unit} has no literal version`);
485
540
  versions.set(unit, pkg.version);
486
- localTestPlan({ kind: "cargo", unit, dir, config: gateConfig, batch: true }); // refuses an OS-restricted crate now, not mid-batch
541
+ localTestPlan({ kind: "cargo", unit, dir, config: gateConfig, batch: true, platform }); // refuses an OS-restricted crate now, not mid-batch
487
542
  }
488
543
  const head = git(["rev-parse", "HEAD"], workspaceRoot);
489
544
  if (!head || !/^[0-9a-f]{40}$/.test(head)) refuse("cannot resolve HEAD");
@@ -496,7 +551,7 @@ async function runLocalPublishBatch(parsed, deps) {
496
551
  const registryHas = deps.registryHas ?? defaultRegistryHas;
497
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);
498
553
  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)`);
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)`);
500
555
 
501
556
  // 3. Batch-level checks, once, at the batch commit. Each is recorded in every crate's evidence.
502
557
  const env = testEnv(deps, false);
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));
@@ -6,6 +6,7 @@
6
6
  // right-release ownership [--app <dir>] --audit-json (Legion governance.capability-ownership provider)
7
7
  // right-release ownership trust-compare [--json] (print this host's protected-trust digest; policy-bundle drift is a warning, never a block)
8
8
  // right-release ownership trust-sync (retired: prints why and exits 0)
9
+ // right-release ownership trust-refresh [--dry-run] [--app <dir>] (elevated, key-neutral: merge new repository registrations and floors from the installed release into protected trust)
9
10
  // right-release ownership --write-manifest [--rightkit <rightkit>] [--approve-new-capabilities]
10
11
  // --approve-new-capabilities permits new ids during local generation only.
11
12
  // Existing owner changes are always refused; installation needs protected authorization.
@@ -75,7 +76,7 @@ export async function main(args, { evidenceFactory = createOwnershipEvidence, gi
75
76
  if (args[0] === "trust-compare") return trustCompare(args, { print, trustLoader });
76
77
  if (args.includes("--staged")) return stagedMain(args, { print });
77
78
  if (args[0] === "trust-sync") { print(`[right-release] ${TRUST_SYNC_RETIRED}`); return 0; }
78
- if (["enroll", "approve-key", "revoke-key"].includes(args[0])) {
79
+ if (["enroll", "approve-key", "revoke-key", "trust-refresh"].includes(args[0])) {
79
80
  const { main: keyMain } = await import("./ownership-key-command.mjs");
80
81
  return keyMain(args, { runner: gitRunner, print });
81
82
  }
@@ -1036,7 +1036,7 @@
1036
1036
  ]
1037
1037
  },
1038
1038
  {
1039
- "repositoryIdentity": "https://github.com/orthic-labs/pulse",
1039
+ "repositoryIdentity": "https://github.com/bogusyogi/pulse",
1040
1040
  "dependencies": [
1041
1041
  "cargo:chrono",
1042
1042
  "cargo:dirs",
@@ -23,6 +23,7 @@ function rootContains(root, candidate) {
23
23
 
24
24
  function createEvidence(repoRoot, {
25
25
  scanRoot = repoRoot,
26
+ scope: requestedScope = null,
26
27
  manifestPath = path.join(path.dirname(fileURLToPath(import.meta.url)), MANIFEST_FILE),
27
28
  scan = checkApp,
28
29
  trustLoader = loadTrustedExternal,
@@ -33,6 +34,7 @@ function createEvidence(repoRoot, {
33
34
  policyBundleProvider = runningPolicyBundle,
34
35
  } = {}, readAuthority = readCommittedKeyAuthority, scanCommitted = checkCommittedApp) {
35
36
  const root = path.resolve(repoRoot);
37
+ const scope = requestedScope ? [...new Set(requestedScope)].sort() : null;
36
38
  const manifestBytes = readFileSync(manifestPath);
37
39
  const manifest = JSON.parse(manifestBytes.toString("utf8"));
38
40
  let trustState = trustLoader();
@@ -114,21 +116,27 @@ function createEvidence(repoRoot, {
114
116
  }
115
117
  }
116
118
  const registration = registrationCheck?.registration ?? null;
117
- const requestedRootRegistered = registration?.roots.some((registered) => rootContains(registered, requestedRoot)) ?? false;
118
- if (registration && !requestedRootRegistered) {
119
- if (!enrollmentPending) throw new Error(`release app root is outside protected ownership roots: ${requestedRoot}`);
120
- evidenceDiagnostics.push(`requested scan root is outside protected ownership roots during pending enrollment: ${requestedRoot}`);
119
+ if (scope && !useCommittedSource) throw new Error("a scoped ownership scan requires the committed-source scan; this host's trust enrollment cannot provide it");
120
+ // A scoped scan covers its scope roots; the unscoped scan covers the requested root.
121
+ const coverageTargets = scope ?? [requestedRoot];
122
+ const unregisteredTargets = registration ? coverageTargets.filter((target) => !registration.roots.some((registered) => rootContains(registered, target))) : [];
123
+ const requestedRootRegistered = unregisteredTargets.length === 0;
124
+ for (const target of unregisteredTargets) {
125
+ if (!enrollmentPending) throw new Error(`release app root is outside protected ownership roots: ${target}`);
126
+ evidenceDiagnostics.push(`requested scan root is outside protected ownership roots during pending enrollment: ${target}`);
121
127
  }
122
128
 
123
129
  // The release app's manifest/config can be nested, but the external registry
124
130
  // defines repository coverage. Scan every protected root so a nested release
125
131
  // cannot omit sibling packages or engine source by narrowing scanRoot.
126
- const scanRoots = registration?.roots ? [...registration.roots] : [];
127
- if (!requestedRootRegistered && !scanRoots.includes(requestedRoot)) scanRoots.push(requestedRoot);
128
- const scans = (scanRoots.length ? scanRoots : [requestedRoot]).map((registered) => {
132
+ const overlapsScope = (registered) => !scope || scope.some((target) => rootContains(registered, target) || rootContains(target, registered));
133
+ const scanRoots = registration?.roots ? registration.roots.filter(overlapsScope) : [];
134
+ if (scope) { for (const target of unregisteredTargets) if (!scanRoots.includes(target)) scanRoots.push(target); }
135
+ else if (!requestedRootRegistered && !scanRoots.includes(requestedRoot)) scanRoots.push(requestedRoot);
136
+ const scans = (scanRoots.length ? scanRoots : [scope ? "." : requestedRoot]).map((registered) => {
129
137
  const absolute = registered === "." ? root : path.resolve(root, ...registered.split("/"));
130
138
  const scanned = useCommittedSource
131
- ? scanCommitted(absolute, manifest, { trust: trustState.trust, expectedCommit: commit, expectedTree: tree, source })
139
+ ? scanCommitted(absolute, manifest, { trust: trustState.trust, expectedCommit: commit, expectedTree: tree, source, ...(scope ? { scope } : {}) })
132
140
  : scan(absolute, manifest, { trust: { trust: trustState.trust, enrolled: trustState.enrolled, pending: enrollmentPending }, scannerVersion, catalogVersion, ...(enrollmentPending ? { enrollmentPendingAdvisory: true } : {}) });
133
141
  if (repositoryIdentity && canonicalRepositoryIdentity(scanned.metadata?.repositoryIdentity) !== repositoryIdentity) throw new Error(`ownership evidence: scanner observed a different repository identity under ${registered}`);
134
142
  if (useCommittedSource && scanned.metadata?.commit !== commit) throw new Error(`ownership evidence: scanner observed a different source commit under ${registered}`);
@@ -181,6 +189,7 @@ function createEvidence(repoRoot, {
181
189
  floorOk: floor.ok,
182
190
  visibility: registration?.visibility ?? null,
183
191
  registrationDigest: registration ? sha256(registration) : null,
192
+ ...(scope ? { ownershipScope: scope, ownershipScopeClosed: [...new Set(scans.flatMap(({ scanned }) => scanned.metadata?.ownershipScopeClosed ?? scope))].sort() } : {}),
184
193
  registeredRoots: registration ? [...registration.roots] : [],
185
194
  contentDigest: enrollmentPending ? null : contentDigest,
186
195
  checkedAt: new Date().toISOString(),
@@ -190,13 +199,13 @@ function createEvidence(repoRoot, {
190
199
  }
191
200
 
192
201
  /** Create scan evidence bound to committed source, catalog, and signed exceptions. */
193
- export function createOwnershipEvidence(repoRoot, scanRoot = repoRoot) {
194
- return createEvidence(repoRoot, { scanRoot, committed: true, requireCleanCheckout: true, allowPendingAdvisory: true });
202
+ export function createOwnershipEvidence(repoRoot, scanRoot = repoRoot, scope = null) {
203
+ return createEvidence(repoRoot, { scanRoot, scope, committed: true, requireCleanCheckout: true, allowPendingAdvisory: true });
195
204
  }
196
205
 
197
206
  /** Alias spelling makes the immutable-source contract explicit at adapter boundaries. */
198
- export function createCommittedOwnershipEvidence(repoRoot, scanRoot = repoRoot) {
199
- return createEvidence(repoRoot, { scanRoot, committed: true, requireCleanCheckout: true });
207
+ export function createCommittedOwnershipEvidence(repoRoot, scanRoot = repoRoot, scope = null) {
208
+ return createEvidence(repoRoot, { scanRoot, scope, committed: true, requireCleanCheckout: true });
200
209
  }
201
210
 
202
211
  // Injection seam is deliberately named for tests and is not used by release commands.
@@ -219,21 +228,24 @@ export function runProtectedLocalOwnershipCheck(repoRoot, scanRoot = repoRoot) {
219
228
  }
220
229
 
221
230
  /** Re-scan exact source and compare every sealed binding before release upload. */
222
- export function verifyOwnershipEvidence(expected, repoRoot, scanRoot = repoRoot) {
223
- return verifyEvidence(expected, repoRoot, scanRoot, () => createCommittedOwnershipEvidence(repoRoot, scanRoot));
231
+ export function verifyOwnershipEvidence(expected, repoRoot, scanRoot = repoRoot, scope = null) {
232
+ return verifyEvidence(expected, repoRoot, scanRoot, () => createCommittedOwnershipEvidence(repoRoot, scanRoot, scope), scope);
224
233
  }
225
234
 
226
235
  export function verifyOwnershipEvidenceForTest(expected, repoRoot, options) {
227
- return verifyEvidence(expected, repoRoot, options.scanRoot ?? repoRoot, () => createOwnershipEvidenceForTest(repoRoot, options));
236
+ return verifyEvidence(expected, repoRoot, options.scanRoot ?? repoRoot, () => createOwnershipEvidenceForTest(repoRoot, options), options.scope ?? null);
228
237
  }
229
238
 
230
- function verifyEvidence(expected, repoRoot, _scanRoot, createCurrent) {
239
+ function verifyEvidence(expected, repoRoot, _scanRoot, createCurrent, scope = null) {
231
240
  if (!expected || expected.schema !== 1) return { ok: false, reason: "sealed ownership evidence is missing or malformed" };
241
+ // The sealed scope must equal what the current config yields; the re-scan below uses that same scope.
242
+ const wanted = scope ? [...new Set(scope)].sort() : null;
243
+ if (JSON.stringify(expected.ownershipScope ?? null) !== JSON.stringify(wanted)) return { ok: false, reason: "ownership evidence ownershipScope changed after build" };
232
244
  let current;
233
245
  try { current = createCurrent(); }
234
246
  catch (error) { return { ok: false, reason: error.message }; }
235
- for (const key of ["repositoryIdentity", "source", "commit", "tree", "catalogDigest", "catalogVersion", "scannerVersion", "approvalsDigest", "keyLogDigest", "keyCheckpoint", "rootFingerprint", "keyAuthority", "visibility", "registrationDigest", "registeredRoots", "contentDigest"]) {
236
- const unchanged = key === "registeredRoots" || key === "keyCheckpoint" || key === "keyAuthority"
247
+ for (const key of ["repositoryIdentity", "source", "commit", "tree", "catalogDigest", "catalogVersion", "scannerVersion", "approvalsDigest", "keyLogDigest", "keyCheckpoint", "rootFingerprint", "keyAuthority", "visibility", "registrationDigest", "registeredRoots", "contentDigest", "ownershipScopeClosed"]) {
248
+ const unchanged = key === "registeredRoots" || key === "ownershipScopeClosed" || key === "keyCheckpoint" || key === "keyAuthority"
237
249
  ? JSON.stringify(expected[key]) === JSON.stringify(current.evidence[key])
238
250
  : expected[key] === current.evidence[key];
239
251
  if (!unchanged) return { ok: false, reason: `ownership evidence ${key} changed after build` };