@rightkit/release 0.6.16 → 0.6.21

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 (65) hide show
  1. package/README.md +7 -3
  2. package/admission-host.mjs +9 -5
  3. package/app-closure.mjs +9 -3
  4. package/bin/darwin/rightkit-swap +0 -0
  5. package/bin/rightkit-gate.mjs +481 -103
  6. package/bin/rightkit-linux-setup.mjs +15 -0
  7. package/broker-admission.mjs +184 -0
  8. package/broker-wire.mjs +15 -3
  9. package/build-launch.mjs +4 -1
  10. package/build-ownership.mjs +125 -36
  11. package/build-release.mjs +39 -7
  12. package/cargo-contract.mjs +4 -1
  13. package/cargo-guard.mjs +25 -5
  14. package/cargo-target-shim.mjs +56 -7
  15. package/clippy-route.mjs +8 -7
  16. package/crate-graph.mjs +5 -1
  17. package/credential-provider.mjs +174 -0
  18. package/deterministic-scans.mjs +253 -42
  19. package/development.mjs +8 -0
  20. package/disk-policy.mjs +2 -2
  21. package/gate-executor.mjs +5 -3
  22. package/gate-stages.mjs +34 -3
  23. package/gradle-lock.mjs +206 -0
  24. package/heavy-command.mjs +33 -2
  25. package/linux-artifacts.mjs +2 -1
  26. package/linux-setup.mjs +106 -0
  27. package/local-publish.mjs +21 -10
  28. package/local-qualify.mjs +9 -3
  29. package/managed-cargo.mjs +54 -18
  30. package/ownership-lockfiles.mjs +14 -3
  31. package/ownership.mjs +5 -1
  32. package/package.json +17 -11
  33. package/protected-credentials.mjs +13 -5
  34. package/publication-gate.mjs +9 -4
  35. package/python-lock.mjs +209 -0
  36. package/qa-admission-policy.json +32 -0
  37. package/qa-layout.mjs +229 -0
  38. package/qa-runner.mjs +156 -0
  39. package/release-artifact-path.mjs +29 -3
  40. package/release-config.mjs +1 -1
  41. package/release-qa-command.mjs +58 -8
  42. package/release-qa-policy.mjs +5 -3
  43. package/release-source-identity.mjs +1 -0
  44. package/release.mjs +10 -2
  45. package/rightkit-ownership.json +1465 -79
  46. package/rightkit-registry-resolver.mjs +7 -3
  47. package/rightkit-versions.json +173 -36
  48. package/runtime-budget-classes.json +4 -0
  49. package/runtime-budgets.mjs +5 -1
  50. package/shell-contract.mjs +1 -1
  51. package/snapshot-broker.mjs +114 -9
  52. package/snapshot-check.mjs +6 -1
  53. package/snapshot-request.mjs +20 -3
  54. package/snapshot.mjs +11 -4
  55. package/stage-commands.mjs +2 -1
  56. package/stage-ledger.mjs +15 -0
  57. package/stage-sidecar-command.mjs +7 -1
  58. package/stage-sidecar.mjs +17 -3
  59. package/suite-signing.mjs +40 -4
  60. package/swift-lock.mjs +334 -0
  61. package/test-command.mjs +13 -3
  62. package/tool-build.mjs +156 -0
  63. package/unit-gate.mjs +9 -2
  64. package/windows-identity-plan.mjs +119 -0
  65. package/windows-identity.mjs +136 -0
@@ -231,6 +231,8 @@ export function installSnapshotDependencies({ checkout, snapshot, commit, tree,
231
231
  const relative = path.relative(base, path.resolve(base, selected)).split(path.sep).join("/");
232
232
  const actual = manifests.get(fold(relative));
233
233
  const owner = nearestLock(fold(relative));
234
+ // A package under an install root that commits no pnpm-lock.yaml has nothing to install from: not an error here (the root loop below records it as skipped and the pnpm-lockfile scan judges it).
235
+ if (actual !== undefined && owner === undefined && [...installRoots].some((r) => !r || fold(relative) === r || fold(relative).startsWith(`${r}/`))) continue;
234
236
  if (actual === undefined || owner === undefined || !installRoots.has(owner) || /[*?\[\]{}!^()\\]/.test(actual) || actual.includes("...")) throw new CheckError(`dependency package ${selected} is not an exact committed manifest directory owned by an install root`);
235
237
  if (!selectedByRoot.has(owner)) selectedByRoot.set(owner, new Set());
236
238
  selectedByRoot.get(owner).add(actual);
@@ -303,7 +305,10 @@ export function installSnapshotDependencies({ checkout, snapshot, commit, tree,
303
305
  const ms = now() - started;
304
306
  if (failed(r) || /No projects matched the filters/.test(`${r?.stdout ?? ""}\n${r?.stderr ?? ""}`)) {
305
307
  const out = `${r?.stdout ?? ""}\n${r?.stderr ?? ""}`;
306
- const miss = /ERR_PNPM_NO_OFFLINE_TARBALL[^\n]*?(https?:\/\/\S+?\/-\/(\S+?)\.tgz)/.exec(out);
308
+ // pnpm 12 prints the code on its own line and wraps the tarball URL ("from https://\n registry.npmjs.org/..."): the URL is rejoined first,
309
+ // and the code and its URL may be a few lines apart (Dell, 2026-11-02: the specific message never fired; only progress lines showed).
310
+ const joined = out.replace(/(https?:\/\/)\s+/g, "$1");
311
+ const miss = /ERR_PNPM_NO_OFFLINE_TARBALL[\s\S]{0,600}?(https?:\/\/\S+?\/-\/(\S+?)\.tgz)/.exec(joined);
307
312
  const message = miss
308
313
  ? `root ${root}: ${miss[2]} is missing from the store ${storeDir} (offline install, no network fallback); install the checkout's dependencies so the store holds it, then run the check again`
309
314
  : `root ${root}: offline dependency install failed (store ${storeDir}): ${tail(r) || r?.error?.message || `exit ${r?.status}`}`;
@@ -294,7 +294,10 @@ const lstatOrNull = (p) => { try { return fs.lstatSync(p); } catch (e) { if (e.c
294
294
  * `..` or empty component, not inside `.git`) and no input that is, or holds, either declaration file. A present but invalid canonical declaration is an
295
295
  * error: it is never read as the legacy one.
296
296
  */
297
- export function readDeclaredInputs(snapshotRoot) {
297
+ /** Host platform names a declared input may scope itself to (`{ path, platforms }`). */
298
+ export const DECLARED_INPUT_PLATFORMS = Object.freeze({ darwin: "mac", win32: "win", linux: "linux" });
299
+
300
+ export function readDeclaredInputs(snapshotRoot, { platform = process.platform } = {}) {
298
301
  let file = null; let at = null; let meta = null;
299
302
  for (const candidate of COMPILE_INPUT_DECLARATIONS) {
300
303
  let here = snapshotRoot; let found = true;
@@ -313,8 +316,21 @@ export function readDeclaredInputs(snapshotRoot) {
313
316
  let value;
314
317
  try { value = JSON.parse(fs.readFileSync(at, "utf8")); } catch (e) { fail("declared-inputs", `${file} is not JSON (${e.message})`); }
315
318
  if (!value || typeof value !== "object" || !Array.isArray(value.inputs)) fail("declared-inputs", `${file}: \`inputs\` must be a list of paths`);
316
- for (const input of value.inputs) {
319
+ // An entry is a path (every platform) or { path, platforms }: a platform-scoped input (a Windows-only identity package) is declared
320
+ // only on the hosts it names, so another host neither requires nor digests it. Unknown keys or platform names refuse.
321
+ const host = DECLARED_INPUT_PLATFORMS[platform] ?? platform;
322
+ const kept = [];
323
+ for (const entry of value.inputs) {
324
+ let input = entry;
325
+ if (entry && typeof entry === "object" && !Array.isArray(entry)) {
326
+ const extra = Object.keys(entry).filter((k) => k !== "path" && k !== "platforms");
327
+ if (extra.length) fail("declared-inputs", `${file}: an input object has only path and platforms (found ${extra.join(", ")})`);
328
+ const names = Object.values(DECLARED_INPUT_PLATFORMS);
329
+ if (!Array.isArray(entry.platforms) || !entry.platforms.length || entry.platforms.some((x) => !names.includes(x))) fail("declared-inputs", `${file}: ${JSON.stringify(entry.path)}: platforms must be a non-empty list of ${names.join(", ")}`);
330
+ input = entry.path;
331
+ }
317
332
  if (typeof input !== "string" || !input) fail("declared-inputs", `${file}: every input is a non-empty relative path`);
333
+ const here = !(entry && typeof entry === "object") || entry.platforms.includes(host);
318
334
  const components = input.split(/[\\/]/);
319
335
  if (path.isAbsolute(input) || path.posix.isAbsolute(input) || path.win32.isAbsolute(input) || components.some((c) => c === "" || c === "." || c === "..")) fail("declared-inputs", `${file}: ${input} is not a plain relative path inside the snapshot`);
320
336
  if (components.some((c) => c.toLowerCase() === ".git")) fail("declared-inputs", `${file}: ${input} is inside .git; repository metadata is never a compile input`);
@@ -322,8 +338,9 @@ export function readDeclaredInputs(snapshotRoot) {
322
338
  const parts = declaration.split("/");
323
339
  if (components.length <= parts.length && components.every((c, i) => c === parts[i])) fail("declared-inputs", `${file}: ${input} overlaps the declaration file itself (${declaration})`);
324
340
  }
341
+ if (here) kept.push(input);
325
342
  }
326
- return [...value.inputs];
343
+ return kept;
327
344
  }
328
345
 
329
346
  /** The inputs a request supplied must be exactly the declaration the snapshot holds (the broker's check): a throw naming the difference. */
package/snapshot.mjs CHANGED
@@ -27,8 +27,8 @@
27
27
  // unreadable processes, build-ownership.mjs).
28
28
  //
29
29
  // Exclusion: one sync per snapshot at a time (`<root>/<repo-key>.lock`, re-checked before every write and before the verdict).
30
- // It narrows, but cannot close, the window in which two processes both replace one stale lock; host-wide build ownership held
31
- // from before the sync through the stage (joint plan step 4) is the exclusion builds rely on, and the broker re-verifies the tree.
30
+ // It narrows, but cannot close, the window in which two processes both replace one stale lock; the per-repository build ownership
31
+ // record held from before the sync through the stage (joint plan step 4) closes it, and the broker re-verifies the tree.
32
32
 
33
33
  import crypto from "node:crypto";
34
34
  import fs from "node:fs";
@@ -543,6 +543,13 @@ function inspectSnapshot({ dir, entries, checkout, platform = process.platform,
543
543
  }
544
544
  // Every candidate, ledger or new, must be ignored by the commit's own rules, as the neutral oracle judges them.
545
545
  const ignored = neutralIgnored(dir, candidates.map((c) => c.path));
546
+ // Git's own semantics for a directory: `git status` shows nothing for one whose every entry is ignored (or that holds nothing), even
547
+ // when no rule names the directory itself (a root rule `src-tauri/gen/schemas/` leaves `src-tauri/gen` unnamed). Judged deepest
548
+ // first, so a directory counts as ignored exactly when every file and subdirectory below it does; a single unignored file still refuses.
549
+ for (const d of candidates.filter((c) => c.type === "dir" && !ignored.has(c.path)).sort((a, b) => b.path.length - a.path.length)) {
550
+ const below = candidates.filter((c) => c.path.startsWith(`${d.path}/`));
551
+ if (below.every((c) => ignored.has(c.path))) ignored.add(d.path);
552
+ }
546
553
  for (const c of candidates) if (!ignored.has(c.path)) return problem(`${c.path} is not in the commit's tree and is not ignored by the commit's own ignore rules (the checkout's info/exclude and global excludes do not count)`);
547
554
  return { problem: null, admitted: [...candidates].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) };
548
555
  }
@@ -942,11 +949,11 @@ export function snapshotCommand(o, args, deps = {}) {
942
949
  // The managed root exists (the gate never creates it) and the root is private, BEFORE build ownership is taken: taking it
943
950
  // creates directories of its own, and must never be what brings a missing managed root into being.
944
951
  guardRoot(root, managedRoot, { device: o.device });
945
- // A sync replaces snapshot bytes, so it runs only under the host's build ownership, taken before the first write (joined when
952
+ // A sync replaces snapshot bytes, so it runs only under the repository's build ownership, taken before the first write (joined when
946
953
  // this gate already holds it); a verify only reads.
947
954
  if (!verify) {
948
955
  const repo = repositoryOf(o.cwd);
949
- ownership = (o.acquireOwnership ?? acquireBuildOwnership)({ file: ownershipFile({ platform: o.platform, beside: o.snapshotRoot }), stage: "snapshot", snapshot: path.join(root, repo.key), commit: resolveCommit(repo.checkout, rev).commit, platform: o.platform, env: o.env });
956
+ ownership = (o.acquireOwnership ?? acquireBuildOwnership)({ file: ownershipFile({ platform: o.platform, beside: o.snapshotRoot, repository: repo.key }), stage: "snapshot", snapshot: path.join(root, repo.key), commit: resolveCommit(repo.checkout, rev).commit, platform: o.platform, env: o.env });
950
957
  }
951
958
  const r = verify ? verifySnapshot({ checkout: o.cwd, rev, root, managedRoot, device: o.device, ...(envAt >= 0 ? { environment: { expected: args[envAt + 1], compute: deps.environmentDigest } } : {}) })
952
959
  : syncSnapshot({ checkout: o.cwd, rev, root, managedRoot, device: o.device });
@@ -41,7 +41,8 @@ export function expectedStageCommands({ kind, unit, config, manifest, platform }
41
41
  const pack = [];
42
42
  if (kind === "npm") {
43
43
  // The swap helper builds and signs a binary into its own directory of the snapshot (a declared stage output), then packs from there like any unit.
44
- if (unit === SWAP_HELPER_PACKAGE) pack.push(CARGO(".", SWAP_HELPER_CARGO_ARGS), CARGO(".", SWAP_HELPER_METADATA_ARGS));
44
+ // A Linux direct origin builds none: the helper is built and signed hosted (publish.yml build-swap/sign-swap).
45
+ if (unit === SWAP_HELPER_PACKAGE && platform !== "linux") pack.push(CARGO(".", SWAP_HELPER_CARGO_ARGS), CARGO(".", SWAP_HELPER_METADATA_ARGS));
45
46
  pack.push(PNPM(npmPackArgs(dir, ANY_ABSOLUTE_PATH)));
46
47
  } else {
47
48
  compile.unshift(CARGO(crateDir, qualifyFmtArgs(unit)));
package/stage-ledger.mjs CHANGED
@@ -148,6 +148,21 @@ export function attributionProblems({ before, after, reported, receipt }) {
148
148
  return problems;
149
149
  }
150
150
 
151
+ /**
152
+ * The attribution of a step that reports no file list (an app's web build, `right-release web-build`): the declared compile inputs
153
+ * (`.rightkit/compile-inputs.json`) are the only paths it may write, so every path it added or changed must lie under one of them and
154
+ * nothing may be removed. Anything else is an unattributed write. `declared` are snapshot-relative posix paths.
155
+ */
156
+ export function declaredOutputProblems({ before, after, declared }) {
157
+ const d = diffEntries(before, after);
158
+ const roots = declared.map((x) => x.replaceAll("\\", "/").replace(/\/+$/, ""));
159
+ const under = (p) => roots.some((r) => p === r || p.startsWith(`${r}/`));
160
+ const problems = [];
161
+ for (const p of [...d.added, ...d.changed]) if (!under(p)) problems.push(`${p} was written by the web build but lies under no declared compile input`);
162
+ for (const p of d.removed) problems.push(`${p} was removed by the web build`);
163
+ return problems;
164
+ }
165
+
151
166
  /** Every `path` string a staging receipt or its `--json` result carries (files, their notices), app-relative. */
152
167
  export function reportedPaths(result) {
153
168
  const out = new Set();
@@ -74,5 +74,11 @@ async function main() {
74
74
  }
75
75
 
76
76
  if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
77
- main().catch((error) => { console.error(`right-release stage-sidecar: ${error.code ? `[${error.code}] ` : ""}${error.message}`); process.exitCode = 1; });
77
+ main().catch((error) => {
78
+ console.error(`right-release stage-sidecar: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
79
+ // The failed tool's own last words (a captured rustright or cargo stderr), which the message alone does not carry.
80
+ const tail = String(error.details?.stderr ?? "").trim().split(/\r?\n/).slice(-8).join("\n");
81
+ if (tail) console.error(`right-release stage-sidecar: its stderr ended:\n${tail}`);
82
+ process.exitCode = 1;
83
+ });
78
84
  }
package/stage-sidecar.mjs CHANGED
@@ -603,17 +603,25 @@ export function defaultRun(command, args, { cwd, env = process.env } = {}) {
603
603
  */
604
604
  const isDebugSymbolMember = (member) => member.split("/").some((part) => part.toLowerCase().endsWith(".dsym"));
605
605
 
606
- export function defaultExtractMember({ archive, fileName, destDir, run = defaultRun }) {
606
+ /**
607
+ * The tar that lists and unpacks archives. On Windows it is the System32 bsdtar, named explicitly: PATH often finds Git for Windows'
608
+ * GNU tar first, which reads the colon in `D:\...` as a remote host and cannot list the cached archive (as direct-bootstrap.mjs).
609
+ */
610
+ export function archiveTar({ platform = process.platform, env = process.env } = {}) {
611
+ return platform === "win32" ? path.win32.join(env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe") : "tar";
612
+ }
613
+
614
+ export function defaultExtractMember({ archive, fileName, destDir, run = defaultRun, tar = archiveTar() }) {
607
615
  const wanted = normalizeArchiveMember(fileName);
608
616
  if (wanted.error) fail("STAGE_SIDECAR_EXTRACT_FAILED", `archive member ${JSON.stringify(fileName)} ${wanted.error}`);
609
- const listing = run("tar", ["-tf", archive], { cwd: path.dirname(archive) });
617
+ const listing = run(tar, ["-tf", archive], { cwd: path.dirname(archive) });
610
618
  if (listing.status !== 0) fail("STAGE_SIDECAR_EXTRACT_FAILED", `cannot list ${archive}`);
611
619
  const lines = String(listing.stdout).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
612
620
  const members = wanted.byPath
613
621
  ? lines.filter((line) => (line.startsWith("./") ? line.slice(2) : line) === wanted.value)
614
622
  : lines.filter((line) => path.posix.basename(line) === wanted.value && !isDebugSymbolMember(line));
615
623
  if (members.length !== 1) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${archive} has ${members.length} members ${wanted.byPath ? "at the path" : "named"} ${wanted.value}, expected exactly one`);
616
- const extracted = run("tar", ["-xf", archive, "-C", destDir, members[0]], { cwd: path.dirname(archive) });
624
+ const extracted = run(tar, ["-xf", archive, "-C", destDir, members[0]], { cwd: path.dirname(archive) });
617
625
  if (extracted.status !== 0) fail("STAGE_SIDECAR_EXTRACT_FAILED", `cannot extract ${members[0]} from ${archive}`);
618
626
  const out = path.join(destDir, ...members[0].split("/"));
619
627
  const inside = path.relative(path.resolve(destDir), path.resolve(out));
@@ -1990,6 +1998,12 @@ export async function stageSidecars({ appRoot, manifest, target, cacheRoot, deps
1990
1998
  };
1991
1999
  validateStageSidecarReceipt(receipt, { target: plan.target.triple });
1992
2000
  const receiptFile = path.resolve(appRoot, plan.receiptPath);
2001
+ // A receipt that would differ only in `stagedAt` is kept as it is: staging the same target again with the same results (the gate staged it
2002
+ // first in a split, rightkit-gate stageSidecarsOnce) leaves every byte unchanged, so the gate's stage ledger sees no change to its staged outputs.
2003
+ const sameAs = (old) => { const { stagedAt: _a, ...a } = old; const { stagedAt: _b, ...b } = receipt; return JSON.stringify(a) === JSON.stringify(b); };
2004
+ let previous = null;
2005
+ try { previous = JSON.parse(fs.readFileSync(receiptFile, "utf8")); } catch { previous = null; }
2006
+ if (previous && sameAs(previous)) return { receipt: previous, receiptPath: receiptFile, files: previous.files, externalBinNames: previous.files.filter((file) => file.externalBin).map((file) => file.name), unchanged: true };
1993
2007
  fs.mkdirSync(path.dirname(receiptFile), { recursive: true });
1994
2008
  fs.writeFileSync(receiptFile, `${JSON.stringify(receipt, null, 2)}\n`);
1995
2009
  return { receipt, receiptPath: receiptFile, files, externalBinNames: files.filter((file) => file.externalBin).map((file) => file.name) };
package/suite-signing.mjs CHANGED
@@ -34,6 +34,7 @@ import { signMacosEntries } from "./mac-sign-core.mjs";
34
34
  import { runMacCodesignCommands } from "./macos-signing-session.mjs";
35
35
  import { resolveManagedRoots } from "./managed-roots.mjs";
36
36
  import { isInside, realish } from "./qa-snapshot.mjs";
37
+ import { GATE_TARGET_DIRS_ENV } from "./qa-layout.mjs";
37
38
  import { requireSignerToken, signWindowsFiles, verifyWindowsFiles } from "./sign-windows.mjs";
38
39
  import { QA_APP_BINARY_ENV, QA_SIGNED_FILES_ENV, SIGN_SUBJECT, SIGN_TEAM, SuiteSigningError, assertBundleIdMatches, containedRelative, productIdentity } from "./suite-signing-policy.mjs";
39
40
 
@@ -52,6 +53,31 @@ function codesignReport(file, { run, env, root }) {
52
53
  return { identifier: /^Identifier=(.+)$/m.exec(text)?.[1]?.trim(), team: /^TeamIdentifier=(.+)$/m.exec(text)?.[1]?.trim(), adhoc: /^Signature=adhoc$/m.test(text) };
53
54
  }
54
55
 
56
+ // The QA bundle runs under app.rightkit.qa.<id> with no embedded.provisionprofile, so a provisioning profile bound to the product id cannot apply to it, and
57
+ // AMFI refuses to spawn a bundle that claims a profile-gated entitlement without one (POSIX 163, "Launchd job spawn failed"). Suite (QA) signing therefore
58
+ // drops the profile-gated keys; profile-gated capabilities (iCloud/CloudKit, push) are not exercisable in Mac source QA. Release signing never goes through here.
59
+ const isProfileGatedEntitlement = (key) => key === "com.apple.application-identifier" || key === "com.apple.developer.team-identifier" || key.startsWith("com.apple.developer.") || key === "aps-environment" || key === "keychain-access-groups";
60
+
61
+ /** The entitlements file to sign `index` with: the original when no key is profile-gated, else a derived plist in signedDir without them. */
62
+ function qaEntitlements({ file, index, signedDir, run, env, root }) {
63
+ const options = { cwd: root, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: false };
64
+ const failed = (why) => refuse("SIGN_ENTITLEMENTS_UNREADABLE", `cannot read the entitlements ${file}: ${why}`, { file });
65
+ const read = run("plutil", ["-convert", "json", "-o", "-", file], options);
66
+ if (read?.error || read?.status !== 0) failed(read?.error?.message || output(read).trim() || `plutil exit ${read?.status ?? "unavailable"}`);
67
+ let parsed;
68
+ try { parsed = JSON.parse(read.stdout); } catch (cause) { failed(`plutil did not return JSON (${cause.message})`); }
69
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) failed("the plist is not a dictionary");
70
+ const dropped = Object.keys(parsed).filter(isProfileGatedEntitlement);
71
+ if (!dropped.length) return { file, dropped };
72
+ const derivedDir = path.join(signedDir, "qa-entitlements");
73
+ fs.mkdirSync(derivedDir, { recursive: true });
74
+ const derived = path.join(derivedDir, `${index}.plist`);
75
+ fs.writeFileSync(derived, JSON.stringify(Object.fromEntries(Object.entries(parsed).filter(([key]) => !isProfileGatedEntitlement(key)))));
76
+ const write = run("plutil", ["-convert", "xml1", derived], options);
77
+ if (write?.error || write?.status !== 0) failed(`cannot write ${derived}: ${write?.error?.message || output(write).trim() || `plutil exit ${write?.status ?? "unavailable"}`}`);
78
+ return { file: derived, dropped };
79
+ }
80
+
55
81
  /** Mac: sign each copy and read it back against the release-shaped identity. Returns one evidence record per file. */
56
82
  function signMac({ copies, name, config, appRoot, env, run, deps, identifier, install, signedDir, mainIndex }) {
57
83
  const settings = readDevSigningSettings(install, config, { root: appRoot });
@@ -78,6 +104,13 @@ function signMac({ copies, name, config, appRoot, env, run, deps, identifier, in
78
104
  const entitlements = (index === mainIndex ? lookupBundlePath(settings.entitlements, ".") : undefined) ?? lookupBundlePath(settings.entitlements, relative);
79
105
  return { file: copy.file, ...(id !== undefined ? { identifier: id } : {}), expectedIdentifier: id ?? path.basename(copy.file), ...(entitlements ? { entitlements } : {}) };
80
106
  });
107
+ entries.forEach((entry, index) => {
108
+ entry.entitlementsDropped = [];
109
+ if (!entry.entitlements) return;
110
+ const derived = qaEntitlements({ file: entry.entitlements, index, signedDir, run, env, root: appRoot });
111
+ entry.entitlements = derived.file;
112
+ entry.entitlementsDropped = derived.dropped;
113
+ });
81
114
  const signOne = deps.signOne ?? ((args) => runMacCodesignCommands([args], { env, run, cwd: appRoot })[0]);
82
115
  const inspect = deps.inspect ?? ((command, args) => {
83
116
  const result = run(command, args, { cwd: appRoot, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: false });
@@ -85,7 +118,7 @@ function signMac({ copies, name, config, appRoot, env, run, deps, identifier, in
85
118
  return output(result);
86
119
  });
87
120
  let records;
88
- try { records = signMacosEntries(entries.map(({ expectedIdentifier: _derived, ...entry }) => entry), { identity: signing.identity, signOne, inspect, bundle: signedDir }); }
121
+ try { records = signMacosEntries(entries.map(({ expectedIdentifier: _derived, entitlementsDropped: _dropped, ...entry }) => entry), { identity: signing.identity, signOne, inspect, bundle: signedDir }); }
89
122
  catch (cause) { refuse("SIGN_FAILED", `${name}: ${cause.message}`, { cause }); }
90
123
  return records.map((record, index) => {
91
124
  const entry = entries[index];
@@ -99,7 +132,7 @@ function signMac({ copies, name, config, appRoot, env, run, deps, identifier, in
99
132
  catch (error) { if (!(error instanceof MacosDevelopmentSigningError)) throw error; refuse("SIGN_REQUIREMENT_MISMATCH", `${copies[index].path}: ${error.message}`, { expected, actual: error.installedRequirement }); }
100
133
  // assertStableDesignatedRequirement is silent about an unreadable or cdhash-bound requirement (it has no grants to protect); here either is a refusal.
101
134
  if (requirement !== expected) refuse("SIGN_REQUIREMENT_MISMATCH", `${copies[index].path}: the designated requirement could not be read back, or is cdhash-bound, instead of ${JSON.stringify(expected)}`, { expected });
102
- return { platform: "mac", file: copies[index].path, signed: record.file, sha256Before: record.before.sha256, sha256After: record.after.sha256, identifier: entry.expectedIdentifier, team: report.team, identity: signing.identity, requirement };
135
+ return { platform: "mac", file: copies[index].path, signed: record.file, sha256Before: record.before.sha256, sha256After: record.after.sha256, identifier: entry.expectedIdentifier, team: report.team, identity: signing.identity, requirement, entitlementsDropped: entry.entitlementsDropped };
103
136
  });
104
137
  }
105
138
 
@@ -164,7 +197,9 @@ export function runSignStep({ name, sign, appRoot, config, env = process.env, pl
164
197
  const { identifier, mainBinaryName } = productIdentity({ readFile: read, manifestPath: install.manifestPath });
165
198
  assertBundleIdMatches(config, identifier);
166
199
  // 2. Where each file comes from.
167
- if (!carried?.map) refuse("SIGN_RECEIPT_MISSING", `${name}: no carried receipts in this environment (${carried?.error ?? "RIGHTKIT_CARRIED_CARGO_MAP is not set"}); a sign step signs a file named by an earlier suite's signed receipt`);
200
+ // A broker host names a suite's outputs by its carried receipts; a direct host by the output directories the gate pinned for the stage
201
+ // (RIGHTKIT_GATE_TARGET_DIRS, test-command.mjs). With neither, nothing says where a suite's files are, and nothing is signed.
202
+ if (!carried?.map && !env[GATE_TARGET_DIRS_ENV]) refuse("SIGN_RECEIPT_MISSING", `${name}: no carried receipts in this environment (${carried?.error ?? "RIGHTKIT_CARRIED_CARGO_MAP is not set"}) and no gate-pinned output directories (${GATE_TARGET_DIRS_ENV}); a sign step signs a file an earlier suite built`);
168
203
  if (typeof outputDirsOf !== "function") refuse("SIGN_RECEIPT_MISSING", `${name}: the run has no receipt reader`);
169
204
  const sources = sign.files.map((item, index) => {
170
205
  const file = { ...item, path: containedRelative(item.path, `${name}.files[${index}].path`) };
@@ -175,7 +210,8 @@ export function runSignStep({ name, sign, appRoot, config, env = process.env, pl
175
210
  // 3. A gate-managed copy, outside everything the build owns.
176
211
  const signingRoot = deps.signingDirectory ?? defaultSigningDirectory({ env, platform });
177
212
  signedDir = path.join(signingRoot, `${name}-${process.pid}-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`);
178
- const section = carried.map.broker ?? {};
213
+ // A direct host (no carrier) has no broker section: the checkout and the suites' own output directories are still forbidden.
214
+ const section = carried?.map?.broker ?? {};
179
215
  const forbidden = [section.snapshot?.path, section.checkout, env.RK_GATE_CHECKOUT, ...sources.flatMap((source) => source.dirs)].filter((p) => typeof p === "string" && p);
180
216
  for (const place of forbidden) if (isInside(signedDir, place)) refuse("SIGN_COPY_PLACE", `${name}: the signing directory ${signedDir} is inside ${place} (the snapshot, the checkout or a broker output directory)`);
181
217
  fs.mkdirSync(signedDir, { recursive: true, mode: 0o700 });