@rightkit/release 0.6.12 → 0.6.14

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 (109) hide show
  1. package/README.md +49 -1
  2. package/adapter-env.mjs +165 -0
  3. package/android-ndk.mjs +5 -1
  4. package/android-release.mjs +73 -8
  5. package/app-check.mjs +22 -8
  6. package/app-closure.mjs +214 -0
  7. package/assemble-app-command.mjs +24 -7
  8. package/atomic-write.mjs +57 -0
  9. package/bin/darwin/rightkit-swap +0 -0
  10. package/bin/rightkit-gate.mjs +1190 -335
  11. package/broker-client.mjs +106 -0
  12. package/broker-exception.mjs +211 -0
  13. package/broker-handshake.mjs +160 -0
  14. package/broker-pins.mjs +27 -8
  15. package/broker-wire.mjs +262 -0
  16. package/build-launch.mjs +364 -12
  17. package/build-ownership.mjs +117 -49
  18. package/build-release.mjs +3 -2
  19. package/cache-command.mjs +7 -2
  20. package/candidate-build-receipt.mjs +96 -0
  21. package/candidate-receipt-command.mjs +29 -0
  22. package/cargo-contract.mjs +4 -3
  23. package/cargo-ledger.mjs +1 -1
  24. package/cargo-license-notices.mjs +2 -2
  25. package/cargo-shapes.mjs +332 -0
  26. package/cargo-target-shim.mjs +44 -5
  27. package/cargo-target.mjs +5 -0
  28. package/carried-adapter-run.mjs +31 -0
  29. package/carried-cargo-run.mjs +9 -0
  30. package/carried-cargo.mjs +237 -0
  31. package/carrier-census.mjs +124 -0
  32. package/class-a-launch-guard.mjs +266 -33
  33. package/cli/right-release.mjs +2 -0
  34. package/clippy-route.mjs +23 -11
  35. package/compile-inputs.mjs +9 -12
  36. package/control-plane.mjs +18 -0
  37. package/deps-cargo-lock.mjs +208 -0
  38. package/deps.mjs +5 -0
  39. package/deterministic-scans.mjs +273 -44
  40. package/development-signing.mjs +14 -1
  41. package/development.mjs +109 -24
  42. package/direct-origin.mjs +107 -0
  43. package/disk-command.mjs +1 -0
  44. package/disk-policy.mjs +18 -3
  45. package/freshness-census.mjs +206 -58
  46. package/gate-executor.mjs +30 -12
  47. package/gate-stages.mjs +93 -16
  48. package/github-release.mjs +2 -1
  49. package/github-upload-gate.mjs +2 -1
  50. package/install-tauri-authenticode.mjs +346 -0
  51. package/ios-dev.mjs +77 -23
  52. package/ios-package-recipe.mjs +100 -41
  53. package/linux-artifacts.mjs +17 -2
  54. package/linux-qualifier.mjs +83 -0
  55. package/local-publish.mjs +36 -14
  56. package/local-runtime.mjs +2 -2
  57. package/lock-identity.mjs +138 -0
  58. package/lsclean.sh +0 -0
  59. package/managed-cargo.mjs +151 -44
  60. package/managed-roots.mjs +130 -12
  61. package/media-sidecar-catalog.mjs +1 -1
  62. package/native-adapters.mjs +411 -0
  63. package/native-helpers.mjs +26 -18
  64. package/native-tool-planning.mjs +94 -0
  65. package/ownership-mark.mjs +182 -0
  66. package/pack-digest.mjs +122 -0
  67. package/package.json +12 -12
  68. package/platform-dir.mjs +14 -0
  69. package/pnpm-adapter-grammar.mjs +226 -0
  70. package/progress-control.mjs +7 -2
  71. package/provenance-real-receipt.pins.json +7 -0
  72. package/provenance-verify.mjs +262 -1
  73. package/publication-outcomes.mjs +21 -0
  74. package/publish-update.mjs +2 -1
  75. package/qa-journeys-prebuilt-run.mjs +18 -0
  76. package/qa-journeys-prebuilt.mjs +148 -0
  77. package/qa-snapshot-run.mjs +53 -0
  78. package/qa-snapshot.mjs +164 -0
  79. package/qualification-verify.mjs +144 -0
  80. package/qualify-reuse.mjs +38 -0
  81. package/recipe-build.mjs +16 -3
  82. package/release-qa-audit.mjs +2 -2
  83. package/release-qa-command.mjs +2 -1
  84. package/release-qa-receipt.mjs +3 -2
  85. package/release-state.mjs +3 -2
  86. package/release.mjs +14 -3
  87. package/rightkit-drift.mjs +27 -5
  88. package/rightkit-versions.json +15 -10
  89. package/rustright-names.mjs +32 -0
  90. package/scan-tool-enrolment.mjs +167 -0
  91. package/sign-windows.mjs +212 -112
  92. package/signing-files.mjs +35 -0
  93. package/signing-identity.mjs +7 -0
  94. package/snapshot-broker.mjs +528 -57
  95. package/snapshot-check.mjs +95 -11
  96. package/snapshot-metadata.mjs +123 -0
  97. package/snapshot-request.mjs +389 -96
  98. package/stage-binary.mjs +22 -4
  99. package/stage-commands.mjs +56 -13
  100. package/stage-sidecar.mjs +93 -21
  101. package/suite-signing-policy.mjs +657 -0
  102. package/suite-signing.mjs +223 -0
  103. package/swift-scratch.mjs +25 -0
  104. package/tauri-locked.mjs +25 -0
  105. package/test-command.mjs +164 -29
  106. package/unit-gate.mjs +201 -31
  107. package/upload-large.mjs +0 -0
  108. package/web-build.mjs +2 -2
  109. package/win-package-recipe.mjs +26 -2
@@ -13,14 +13,24 @@
13
13
  //
14
14
  // Upload is a separate command (ios-upload.mjs): `build` never uploads, `upload` never builds. Nothing here reads an App Store
15
15
  // Connect credential: archive and export sign through the Xcode account of the GUI session. Fails closed: every problem
16
- // throws IosPackageError, and once an archive or an IPA exists a failure deletes them (and any stale receipt) so a later
17
- // seal cannot pick up a half-verified artifact. Every external effect is injectable through `deps`.
16
+ // throws IosPackageError, and once an IPA exists a failure deletes it (and any stale receipt) so a later
17
+ // seal cannot pick up a half-verified artifact (the broker-owned archive and export directory are the broker's to keep and reap). Every external effect is injectable through `deps`.
18
+ //
19
+ // RA-09 / R3-M02: every compiler-capable phase (xcodegen generate, xcodebuild archive, xcodebuild -exportArchive and any prepare step that names a native build
20
+ // tool) is a typed native adapter job of the production runner (`deps.native`, build-launch.mjs createNativeRunner: the carrier or the resolved executor's client),
21
+ // never a spawn of this recipe's own. The broker owns the output root, so the call names neither -derivedDataPath, -archivePath, -exportPath nor -resultBundlePath:
22
+ // the archive and the export directory are READ BACK from the verified receipt's managed output roles (`outputs.byRole.archive` / `.export`), and the export must read
23
+ // the very archive that was verified. The ExportOptions.plist is a regular XML file inside the step directory (the snapshot), not under the receipt directory. Signing stays
24
+ // the provisioned GUI session's (assertGuiSigningSession) with automatic signing and -allowProvisioningUpdates; no alternate signer exists. A missing runner, an unadvertised
25
+ // capability or an argv the broker's grammar refuses is refused BEFORE the first tool is launched. The non-compiling queries (`xcodebuild -version`, `xcodegen --version`,
26
+ // plutil, codesign, security, ditto, git) are this recipe's own direct steps through `deps.run`.
18
27
  import { createHash } from "node:crypto";
19
28
  import * as nodeFs from "node:fs";
20
29
  import os from "node:os";
21
30
  import path from "node:path";
22
31
  import { spawnSync } from "./background-process.mjs";
23
- import { assertGuiSigningSession, IosHostError, outputTail, resolveXcodeStorage } from "./ios-host.mjs";
32
+ import { assertGuiSigningSession, IosHostError, outputTail } from "./ios-host.mjs";
33
+ import { nativeToolOf } from "./native-adapters.mjs";
24
34
 
25
35
  export const IOS_SIGNING_CONTRACT = "ios-xcodebuild-archive-automatic-v1";
26
36
  export const IOS_RECEIPT_NAME = "ios-archive.json";
@@ -30,7 +40,8 @@ export const IOS_DEFAULT_TIMEOUT_MS = 60 * 60 * 1000;
30
40
  /**
31
41
  * Codes: IOS_PACKAGE_CONFIG_INVALID, IOS_PACKAGE_HOST_UNSUPPORTED, IOS_PACKAGE_TOOL_MISSING, IOS_PACKAGE_GENERATE_FAILED,
32
42
  * IOS_PACKAGE_PREPARE_FAILED, IOS_PACKAGE_ARCHIVE_FAILED, IOS_PACKAGE_SOURCE_CHANGED, IOS_PACKAGE_VERIFY_FAILED,
33
- * IOS_PACKAGE_EXPORT_FAILED, IOS_PACKAGE_BUILD_NUMBER, IOS_PACKAGE_RECEIPT_FAILED.
43
+ * IOS_PACKAGE_EXPORT_FAILED, IOS_PACKAGE_BUILD_NUMBER, IOS_PACKAGE_RECEIPT_FAILED, IOS_PACKAGE_NATIVE_REQUIRED (no production native runner was supplied),
44
+ * IOS_PACKAGE_NATIVE_REFUSED (the door or the broker's grammar refused a native call before any launch; the message names the capability).
34
45
  */
35
46
  export class IosPackageError extends Error {
36
47
  constructor(code, message, options) { super(message, options); this.name = "IosPackageError"; this.code = code; }
@@ -39,6 +50,8 @@ const invalid = (message) => new IosPackageError("IOS_PACKAGE_CONFIG_INVALID", `
39
50
  const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
40
51
 
41
52
  // ---- config ------------------------------------------------------------------------------------------------------
53
+ /** The ExportOptions.plist the export reads: a regular XML file inside the step directory (the snapshot), relative to the app root. It is never under the receipt directory. */
54
+ export const IOS_EXPORT_OPTIONS_RELATIVE = ".right-release/ios-export-options.plist";
42
55
  const IOS_KEYS = ["project", "projectSpec", "scheme", "configuration", "teamId", "bundleId", "exportMethod", "signing", "buildNumber", "extraBuildSettings", "packageResolved", "trustedSwiftMacros", "trustedSwiftPlugins", "prepare", "cleanup", "checks", "archive", "ipa", "timeoutMs"];
43
56
  const CLEANUP_KEYS = ["derivedData"];
44
57
  const CHECK_KEYS = ["versionIdentity", "forbidStrings", "plist"];
@@ -138,7 +151,9 @@ export function validateIosPackageConfig(spec) {
138
151
  const bad = Object.keys(spec.cleanup).filter((key) => !CLEANUP_KEYS.includes(key));
139
152
  if (bad.length) throw invalid(`cleanup has unknown key(s): ${bad.join(", ")} (allowed: ${CLEANUP_KEYS.join(", ")})`);
140
153
  if (spec.cleanup.derivedData !== undefined && typeof spec.cleanup.derivedData !== "boolean") throw invalid("cleanup.derivedData must be a boolean");
141
- cleanup.derivedData = spec.cleanup.derivedData === true;
154
+ // The broker owns DerivedData and its lifetime (the xcodebuild adapter's managed output root): a recipe cannot delete it, and silently ignoring the request would be a lie.
155
+ if (spec.cleanup.derivedData === true) throw invalid("cleanup.derivedData cannot be honoured: the broker owns the DerivedData root and its lifetime (RA-09); remove it");
156
+ cleanup.derivedData = false;
142
157
  }
143
158
 
144
159
  const checks = { versionIdentity: true, forbidStrings: [], plist: {} };
@@ -183,6 +198,7 @@ export function validateIosPackageConfig(spec) {
183
198
  prepare: spec.prepare === undefined ? [] : commandList(spec.prepare, "prepare"),
184
199
  cleanup,
185
200
  checks,
201
+ // Kept for config compatibility only: the broker owns where the archive is written (a caller names no -archivePath), so this path is validated but never used to build or read.
186
202
  archive: relativeInside(spec.archive, "archive", /\.xcarchive$/i),
187
203
  ipa: relativeInside(spec.ipa, "ipa", /\.ipa$/i),
188
204
  timeoutMs,
@@ -343,16 +359,17 @@ export function installSwiftTrustSource({ trust, root, fs = nodeFs }) {
343
359
  assertSwiftTrustUnchanged({ trust, root, fs });
344
360
  }
345
361
 
346
- export function archiveArgv(spec, { root, buildNumber, storage, trusted }) {
362
+ /**
363
+ * The archive call as the broker's xcodebuild adapter admits it: no -archivePath, -derivedDataPath or -resultBundlePath (the broker owns the output root and names the archive by
364
+ * its managed `archive` role in the signed receipt). Signing is automatic through the provisioned session.
365
+ */
366
+ export function archiveArgv(spec, { root, buildNumber, trusted }) {
347
367
  const trustFlags = swiftTrustFlags(spec, trusted);
348
368
  return [
349
369
  "-project", path.resolve(root, spec.project),
350
370
  "-scheme", spec.scheme,
351
371
  "-configuration", spec.configuration,
352
372
  "-destination", "generic/platform=iOS",
353
- "-archivePath", path.resolve(root, spec.archive),
354
- "-derivedDataPath", storage.derivedData,
355
- ...(storage.resultBundle ? ["-resultBundlePath", storage.resultBundle] : []),
356
373
  `DEVELOPMENT_TEAM=${spec.teamId}`,
357
374
  `CURRENT_PROJECT_VERSION=${buildNumber}`,
358
375
  ...Object.entries(spec.extraBuildSettings).map(([name, value]) => `${name}=${value}`),
@@ -362,11 +379,9 @@ export function archiveArgv(spec, { root, buildNumber, storage, trusted }) {
362
379
  ];
363
380
  }
364
381
 
365
- /** A dedicated directory beside the IPA (never the IPA's own directory, which may be the app root): xcodebuild fills it with logs and plists. */
366
- export const exportDirectory = (spec, root) => path.join(path.dirname(path.resolve(root, spec.ipa)), `${path.basename(spec.ipa, ".ipa")}.export`);
367
-
368
- export function exportArgv(spec, { root, optionsFile }) {
369
- return ["-exportArchive", "-archivePath", path.resolve(root, spec.archive), "-exportPath", exportDirectory(spec, root), "-exportOptionsPlist", optionsFile, "-allowProvisioningUpdates"];
382
+ /** The export call: `-exportArchive` stands alone with its options file; the broker reads the managed archive the archive call wrote and names the export directory (role `export`). */
383
+ export function exportArgv(spec, { optionsFile }) {
384
+ return ["-exportArchive", "-exportOptionsPlist", optionsFile, "-allowProvisioningUpdates"];
370
385
  }
371
386
 
372
387
  /** Dry-run display only: the flags the run would add once its own check passes. The real run never uses this. */
@@ -377,27 +392,26 @@ const plannedTrust = (spec) => {
377
392
  return planned;
378
393
  };
379
394
 
380
- /** The ordered plan (pure; no secret is part of any argument). Used by --dry-run and by the run itself. */
381
- export function iosPackagePlan(spec, { root, buildNumber = "<YYYYMMDDNN>", storage = { derivedData: "<rustright storage xcode-args -derivedDataPath>" }, receiptDir }) {
382
- const optionsFile = path.join(receiptDir, "ios-export-options.plist");
395
+ /** The ordered plan (pure; no secret is part of any argument). Used by --dry-run and by the run itself. `native` names the broker adapter that runs the step. */
396
+ export function iosPackagePlan(spec, { root, buildNumber = "<YYYYMMDDNN>", receiptDir }) {
397
+ const optionsFile = path.join(root, IOS_EXPORT_OPTIONS_RELATIVE);
383
398
  return {
384
399
  steps: [
385
- { id: "preflight", description: "macOS host, GUI signing session, xcodebuild" + (spec.projectSpec ? ", xcodegen (required)" : "") },
386
- ...(spec.projectSpec ? [{ id: "generate", cmd: "xcodegen", args: xcodegenArgv(spec, root) }] : []),
387
- ...spec.prepare.map((step, index) => ({ id: "prepare", index, cmd: step.cmd, args: step.args, cwd: step.cwd ?? "." })),
400
+ { id: "preflight", description: "macOS host, GUI signing session, xcodebuild" + (spec.projectSpec ? ", xcodegen (required)" : "") + "; every compiler-capable call is planned against the broker's adapter grammar before the first launch" },
401
+ ...(spec.projectSpec ? [{ id: "generate", cmd: "xcodegen", args: xcodegenArgv(spec, root), native: "rightkit-xcodegen" }] : []),
402
+ ...spec.prepare.map((step, index) => ({ id: "prepare", index, cmd: step.cmd, args: step.args, cwd: step.cwd ?? ".", ...(nativeToolOf(step.cmd) ? { native: true } : {}) })),
388
403
  ...(spec.trustedSwiftMacros.length || spec.trustedSwiftPlugins.length
389
404
  ? [{ id: "trust-check", description: `Package.resolved (${spec.packageResolved ?? defaultPackageResolvedPath(spec.project)}) tracked and equal to HEAD; ${[...spec.trustedSwiftMacros, ...spec.trustedSwiftPlugins].map((entry) => entry.package).join(", ")} pinned to the reviewed revisions; the skip flags below are added only after this passes` }]
390
405
  : []),
391
- { id: "archive", cmd: "xcodebuild", args: archiveArgv(spec, { root, buildNumber, storage, trusted: plannedTrust(spec) }) },
406
+ { id: "archive", cmd: "xcodebuild", args: archiveArgv(spec, { root, buildNumber, trusted: plannedTrust(spec) }), native: "rightkit-xcodebuild", outputs: "managed role archive (read back from the signed receipt)" },
392
407
  { id: "verify-archive", description: "version identity across app, appex and Watch plists; bundle id; build number; QA scan; forbidden strings" },
393
- { id: "export", cmd: "xcodebuild", args: exportArgv(spec, { root, optionsFile }), optionsFile },
408
+ { id: "export", cmd: "xcodebuild", args: exportArgv(spec, { optionsFile }), optionsFile, native: "rightkit-xcodebuild", outputs: "managed roles archive (must equal the verified one) and export (read back from the signed receipt)" },
394
409
  { id: "verify-ipa", description: "extracted IPA: version identity, entitlements, embedded.mobileprovision, code signature" },
395
410
  { id: "receipt", file: path.join(receiptDir, IOS_RECEIPT_NAME) },
396
411
  ],
397
412
  teamId: spec.teamId,
398
413
  bundleId: spec.bundleId,
399
414
  ipa: path.resolve(root, spec.ipa),
400
- archive: path.resolve(root, spec.archive),
401
415
  };
402
416
  }
403
417
 
@@ -499,24 +513,23 @@ export function scanForbiddenStrings(dir, strings, fs = nodeFs) {
499
513
  // ---- the recipe --------------------------------------------------------------------------------------------------
500
514
  /**
501
515
  * Runs the steps. `root` is the app root. `deps` (all optional, defaults are the real host):
502
- * run(cmd, args, options) spawnSync-shaped; fakes in tests
516
+ * native REQUIRED: the production native runner ({ plan, run }, build-launch.mjs createNativeRunner). Every compiler-capable call goes through it; there is no spawn fallback
517
+ * run(cmd, args, options) spawnSync-shaped, for the NON-compiling direct steps only (xcodebuild -version, xcodegen --version, plutil, codesign, security, ditto, git)
503
518
  * fs node:fs-shaped
504
519
  * scanFiles(label, files, o) release.mjs's assertNoQaControlBinaries (QA rules plus the release-switch binary layer)
505
520
  * detectSession / route GUI signing session seams (ios-host.mjs)
506
521
  * gitHead() the source commit; it must not change during the build
507
- * storage() DerivedData and result bundle (rustright storage xcode-args)
508
522
  * scanForbidden(dir, strings) forbidden-string scan
509
523
  * counterDir where the per-app build number counter lives (the vault state root under right-release build)
510
524
  */
511
525
  export async function runIosPackageRecipe({
512
526
  root, app = "app", spec, dryRun = false, env = process.env, platform = process.platform,
513
527
  receiptDir = path.join(root, ".right-release", "receipts"), stateDir = path.join(root, ".right-release"), counterDir = stateDir,
514
- run = spawnSync, fs = nodeFs, scanFiles, detectSession, route, gitHead, storage, scanForbidden = scanForbiddenStrings,
528
+ native, run = spawnSync, fs = nodeFs, scanFiles, detectSession, route, gitHead, scanForbidden = scanForbiddenStrings,
515
529
  now = () => new Date(), tmpRoot = os.tmpdir(), log = console.log,
516
530
  }) {
517
531
  if (!path.isAbsolute(root)) throw invalid("root must be an absolute path");
518
532
  const ipa = path.resolve(root, spec.ipa);
519
- const archive = path.resolve(root, spec.archive);
520
533
  const receiptPath = path.join(receiptDir, IOS_RECEIPT_NAME);
521
534
 
522
535
  if (dryRun) {
@@ -527,6 +540,9 @@ export async function runIosPackageRecipe({
527
540
  }
528
541
  if (platform !== "darwin") throw new IosPackageError("IOS_PACKAGE_HOST_UNSUPPORTED", `the iOS package recipe needs macOS with Xcode; this host is ${platform} (only config validation and --dry-run run elsewhere)`);
529
542
 
543
+ // ---- the production native runner: there is no spawn route for a compiler-capable phase ----
544
+ if (!native || typeof native.run !== "function" || typeof native.plan !== "function") throw new IosPackageError("IOS_PACKAGE_NATIVE_REQUIRED", "the iOS package recipe needs the production native runner (build-launch.mjs createNativeRunner): xcodegen and xcodebuild archive/export run only as typed broker adapter jobs, never as a spawn of this recipe's own");
545
+
530
546
  // ---- preflight: before any effect ----
531
547
  let session;
532
548
  try { session = assertGuiSigningSession({ operation: "ios package", env, run, detect: detectSession, route }); }
@@ -541,18 +557,17 @@ export async function runIosPackageRecipe({
541
557
  if (gen.error || gen.status !== 0) throw new IosPackageError("IOS_PACKAGE_TOOL_MISSING", `xcodegen is required because iosPackage.projectSpec names ${spec.projectSpec}; install it (brew install xcodegen). A committed xcodeproj is never used instead`);
542
558
  }
543
559
  if (!fs.existsSync(path.resolve(root, spec.projectSpec ?? spec.project))) throw new IosPackageError("IOS_PACKAGE_CONFIG_INVALID", `${spec.projectSpec ?? spec.project} does not exist`);
544
- const resolvedStorage = (storage ?? (() => resolveXcodeStorage({ env, run, cwd: root, fallbackRoot: path.join(stateDir, "ios") })))();
545
560
  const head = gitHead ?? (() => { const r = run("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", windowsHide: true }); if (r.status !== 0) throw new IosPackageError("IOS_PACKAGE_SOURCE_CHANGED", "git rev-parse HEAD failed"); return String(r.stdout).trim(); });
546
561
  const sourceCommit = head();
547
562
 
548
563
  // ---- stale outputs go first, so an old IPA is never mistaken for this build's ----
549
- const exportDir = exportDirectory(spec, root);
550
- const stale = [archive, ipa, exportDir, receiptPath, path.join(receiptDir, "ios-export-options.plist"), ...(resolvedStorage.resultBundle ? [resolvedStorage.resultBundle] : []), ...(spec.cleanup.derivedData ? [resolvedStorage.derivedData] : [])];
564
+ const optionsFile = path.join(root, IOS_EXPORT_OPTIONS_RELATIVE);
565
+ const stale = [ipa, receiptPath, optionsFile];
551
566
  for (const file of stale) fs.rmSync(file, { recursive: true, force: true });
552
567
  fs.mkdirSync(receiptDir, { recursive: true });
553
568
  fs.mkdirSync(path.dirname(ipa), { recursive: true });
554
569
 
555
- const cleanupOutputs = () => { for (const file of [ipa, archive, exportDir, receiptPath]) { try { fs.rmSync(file, { recursive: true, force: true }); } catch { /* best effort */ } } };
570
+ const cleanupOutputs = () => { for (const file of [ipa, receiptPath]) { try { fs.rmSync(file, { recursive: true, force: true }); } catch { /* best effort */ } } };
556
571
  const step = (id, code, cmd, args, options = {}) => {
557
572
  log(`right-release: ios ${id}: ${cmd}`);
558
573
  const { logFile, ...runOptions } = options;
@@ -561,6 +576,34 @@ export async function runIosPackageRecipe({
561
576
  if (result.error || result.status !== 0) throw new IosPackageError(code, `${cmd} ${id} failed (${result.status ?? "unavailable"})${result.error ? `: ${result.error.message}` : ""}\n${outputTail(result)}`);
562
577
  return result;
563
578
  };
579
+ // A compiler-capable call: the production runner only. A refusal (no runner route, an unadvertised capability, an argv the broker's grammar refuses) happened BEFORE any launch and names the capability.
580
+ const refusedNative = (cmd, refusal) => new IosPackageError("IOS_PACKAGE_NATIVE_REFUSED", `${cmd} was refused before any launch (${refusal.code ?? "refused"}): ${refusal.message}`);
581
+ const planNative = (cmd, args, options = {}) => {
582
+ try { native.plan(cmd, args, { cwd: root, env, ...options }); } catch (error) {
583
+ if (error?.code === "RK_BUILD_LAUNCH_REFUSED") throw refusedNative(cmd, { code: error.reason, message: error.message });
584
+ throw error;
585
+ }
586
+ };
587
+ const nativeStep = (id, code, cmd, args, options = {}) => {
588
+ log(`right-release: ios ${id}: ${cmd} (broker adapter job)`);
589
+ const { logFile, cwd = root } = options;
590
+ const result = native.run(cmd, args, { cwd, env, timeout: spec.timeoutMs });
591
+ if (logFile) { try { fs.writeFileSync(logFile, `${outputTail(result, 4000)}\n`); } catch { /* diagnostics only */ } }
592
+ if (result.refusal) throw refusedNative(cmd, result.refusal);
593
+ if (!result.ok) throw new IosPackageError(code, `${cmd} ${id} failed (${result.status ?? "unavailable"})${result.error ? `: ${result.error.message}` : ""}\n${outputTail(result)}`);
594
+ return result;
595
+ };
596
+ // The managed location of a role, read back from the verified receipt's output mapping: never a path this recipe composed.
597
+ const located = (result, role, code) => {
598
+ const outputs = result.outputs;
599
+ const found = outputs?.ok === true ? outputs.byRole?.[role] : undefined;
600
+ if (typeof found !== "string" || !path.isAbsolute(found)) throw new IosPackageError(code, `the broker did not report the managed ${role} location of ${result.tool} (${outputs && outputs.ok === false ? outputs.problem : "no verified output directory"}); a broker-owned build's output is never guessed`);
601
+ return found;
602
+ };
603
+ const nativeEvidence = (result) => ({
604
+ tool: result.tool, adapter: result.adapter, capability: result.capability, carried: result.carried, relativeCwd: result.relativeCwd, requestId: result.requestId, outputDir: result.outputDir,
605
+ outputsSource: result.outputs?.source ?? null, argv: result.argv, outputs: result.outputs?.outputs ?? [],
606
+ });
564
607
  const plist = (file) => {
565
608
  const result = sh("plutil", ["-convert", "json", "-o", "-", "--", file], { timeout: 60_000 });
566
609
  try { if (result.status === 0) return JSON.parse(String(result.stdout)); } catch { /* reported below */ }
@@ -582,23 +625,35 @@ export async function runIosPackageRecipe({
582
625
 
583
626
  let tempDir;
584
627
  try {
628
+ // ---- plan every compiler-capable call against the broker's grammar BEFORE the first launch: a missing capability or a refused argv stops here, with nothing sent ----
629
+ // The export reads an options file of the step directory, so it is written first; the build number is a placeholder here (the real one is drawn from the counter just before the archive).
630
+ const optionsText = exportOptionsPlist(spec);
631
+ fs.mkdirSync(path.dirname(optionsFile), { recursive: true });
632
+ fs.writeFileSync(optionsFile, optionsText);
633
+ const plannedBuild = `${now().toISOString().slice(0, 10).replaceAll("-", "")}01`;
634
+ if (spec.projectSpec) planNative("xcodegen", xcodegenArgv(spec, root));
635
+ planNative("xcodebuild", archiveArgv(spec, { root, buildNumber: plannedBuild, trusted: plannedTrust(spec) }));
636
+ planNative("xcodebuild", exportArgv(spec, { optionsFile }));
585
637
  // ---- trust-check: reviewed Swift macro/plugin pins, before anything is generated or built ----
586
638
  const trust = verifySwiftTrustSource({ spec, root, sh, fs });
587
639
  // ---- generate ----
588
640
  if (spec.projectSpec) {
589
- step("generate", "IOS_PACKAGE_GENERATE_FAILED", "xcodegen", xcodegenArgv(spec, root));
641
+ nativeStep("generate", "IOS_PACKAGE_GENERATE_FAILED", "xcodegen", xcodegenArgv(spec, root));
590
642
  if (!fs.existsSync(path.resolve(root, spec.project))) throw new IosPackageError("IOS_PACKAGE_GENERATE_FAILED", `xcodegen did not produce ${spec.project}`);
591
643
  }
592
644
  // ---- prepare ----
593
645
  for (const [index, command] of spec.prepare.entries()) {
594
646
  const cwd = path.resolve(root, command.cwd ?? ".");
595
- step(`prepare ${index + 1}`, "IOS_PACKAGE_PREPARE_FAILED", command.cmd, command.args, { cwd });
647
+ // A prepare step that names a native build tool is a broker adapter job like any other; the app's own non-native commands stay direct steps.
648
+ if (nativeToolOf(command.cmd) !== null) nativeStep(`prepare ${index + 1}`, "IOS_PACKAGE_PREPARE_FAILED", command.cmd, command.args, { cwd });
649
+ else step(`prepare ${index + 1}`, "IOS_PACKAGE_PREPARE_FAILED", command.cmd, command.args, { cwd });
596
650
  }
597
651
  if (trust) installSwiftTrustSource({ trust, root, fs });
598
652
  // ---- archive ----
599
653
  const buildNumber = nextDateBuildNumber({ now: now(), stateFile: path.join(counterDir, `ios-build-number.${spec.bundleId}.json`), fs });
600
- step("archive", "IOS_PACKAGE_ARCHIVE_FAILED", "xcodebuild", archiveArgv(spec, { root, buildNumber, storage: resolvedStorage, trusted: trust?.evidence }), { logFile: path.join(receiptDir, "ios-archive.log") });
654
+ const archived = nativeStep("archive", "IOS_PACKAGE_ARCHIVE_FAILED", "xcodebuild", archiveArgv(spec, { root, buildNumber, trusted: trust?.evidence }), { logFile: path.join(receiptDir, "ios-archive.log") });
601
655
  if (trust) assertSwiftTrustUnchanged({ trust, root, fs });
656
+ const archive = located(archived, "archive", "IOS_PACKAGE_ARCHIVE_FAILED");
602
657
  const appsDir = path.join(archive, "Products", "Applications");
603
658
  const apps = fs.existsSync(appsDir) ? fs.readdirSync(appsDir).filter((name) => name.endsWith(".app")) : [];
604
659
  if (apps.length !== 1) throw new IosPackageError("IOS_PACKAGE_ARCHIVE_FAILED", `the archive must hold exactly one app under Products/Applications, found ${apps.length}`);
@@ -620,17 +675,20 @@ export async function runIosPackageRecipe({
620
675
  if (head() !== sourceCommit) throw new IosPackageError("IOS_PACKAGE_SOURCE_CHANGED", "the repository HEAD changed during the archive; refusing to export a mixed-source build");
621
676
 
622
677
  // ---- export ----
623
- const optionsFile = path.join(receiptDir, "ios-export-options.plist");
624
- const optionsText = exportOptionsPlist(spec);
625
- fs.writeFileSync(optionsFile, optionsText);
626
678
  // Taken just before the export: xcodebuild registers a build upload for this version and build during it (see ios-build-upload.mjs).
627
679
  const exportStartedAt = now().toISOString();
628
- step("export", "IOS_PACKAGE_EXPORT_FAILED", "xcodebuild", exportArgv(spec, { root, optionsFile }), { logFile: path.join(receiptDir, "ios-export.log") });
680
+ const exported = nativeStep("export", "IOS_PACKAGE_EXPORT_FAILED", "xcodebuild", exportArgv(spec, { optionsFile }), { logFile: path.join(receiptDir, "ios-export.log") });
629
681
  {
682
+ // The export must have read the very archive that was verified, and its directory is the broker's managed `export` role.
683
+ const exportedArchive = located(exported, "archive", "IOS_PACKAGE_EXPORT_FAILED");
684
+ if (path.normalize(exportedArchive) !== path.normalize(archive)) throw new IosPackageError("IOS_PACKAGE_EXPORT_FAILED", `the export read ${exportedArchive}, not the verified archive ${archive}`);
685
+ const exportDir = located(exported, "export", "IOS_PACKAGE_EXPORT_FAILED");
630
686
  const produced = fs.existsSync(exportDir) ? fs.readdirSync(exportDir).filter((name) => name.endsWith(".ipa")) : [];
631
- if (produced.length !== 1) throw new IosPackageError("IOS_PACKAGE_EXPORT_FAILED", `the export must produce exactly one .ipa in ${path.basename(exportDir)}, found ${produced.length}`);
687
+ if (produced.length !== 1) throw new IosPackageError("IOS_PACKAGE_EXPORT_FAILED", `the export must produce exactly one .ipa in the managed export directory, found ${produced.length}`);
688
+ const source = path.join(exportDir, produced[0]);
632
689
  fs.mkdirSync(path.dirname(ipa), { recursive: true });
633
- fs.renameSync(path.join(exportDir, produced[0]), ipa);
690
+ fs.copyFileSync(source, ipa);
691
+ if (sha256Bytes(fs.readFileSync(source)) !== sha256Bytes(fs.readFileSync(ipa))) throw new IosPackageError("IOS_PACKAGE_EXPORT_FAILED", "the IPA copied out of the managed export directory differs from the exported bytes");
634
692
  }
635
693
  if (!fs.statSync(ipa).size) throw new IosPackageError("IOS_PACKAGE_EXPORT_FAILED", "the exported IPA is empty");
636
694
 
@@ -690,6 +748,8 @@ export async function runIosPackageRecipe({
690
748
  profile,
691
749
  bundles: ipaInfos.map((bundle) => ({ role: bundle.role, name: bundle.label, bundleId: String(bundle.info.CFBundleIdentifier ?? "") })),
692
750
  archiveVersion: identity,
751
+ // RA-09: the broker's output readback. The archive and the export directory are the managed roles of the signed receipts of the archive and export calls (never paths this recipe chose).
752
+ native: { archivePath: archive, archive: nativeEvidence(archived), export: nativeEvidence(exported) },
693
753
  // Present only when the app trusts reviewed Swift macros/plugins: what was trusted and the exact pins file it was checked against.
694
754
  ...(trust ? { swiftTrust: { packageResolved: { path: trust.sourceRel, sha256: trust.sha256 }, macros: trust.evidence.macros, plugins: trust.evidence.plugins } } : {}),
695
755
  };
@@ -701,7 +761,6 @@ export async function runIosPackageRecipe({
701
761
  cleanupOutputs();
702
762
  throw error instanceof IosPackageError ? error : new IosPackageError("IOS_PACKAGE_VERIFY_FAILED", `iOS package failed: ${error.message}`, { cause: error });
703
763
  } finally {
704
- try { fs.rmSync(exportDir, { recursive: true, force: true }); } catch { /* best effort */ }
705
764
  if (tempDir) { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { /* best effort */ } }
706
765
  }
707
766
  }
@@ -6,6 +6,7 @@ import { createHash } from "node:crypto";
6
6
  import nodeFs from "node:fs";
7
7
  import path from "node:path";
8
8
  import { spawnSync } from "./background-process.mjs";
9
+ import { readSourceSplit } from "./source-split.mjs";
9
10
 
10
11
  export const LINUX_ARTIFACTS_SCHEMA = "rightkit.linux-artifacts/1";
11
12
  export const LINUX_ARCHES = Object.freeze(["x86_64", "aarch64"]);
@@ -57,11 +58,25 @@ function listFiles(fs, root, rel) {
57
58
  return fs.readdirSync(abs).flatMap((entry) => listFiles(fs, root, path.join(rel, entry)));
58
59
  }
59
60
 
61
+ /**
62
+ * Where the archive and its manifest go: `<app root>/.right-release/linux`. Inside the door's source split (docs/GATE-CONTRACT.md "Commit-only build") `root`
63
+ * is the SNAPSHOT of the commit, and `.right-release` is a reserved path segment of the stage-output ledger: a file there is never an admissible ledger
64
+ * entry, so the stage would end "result covers no known bytes" (exit 2) after a good build. Every output right-release owns stays with the CHECKOUT
65
+ * (source-split.mjs; build-release.mjs puts the vault, state and sealed there), so a split packs into the checkout's same app-relative
66
+ * `.right-release/linux`, which is also where an unsplit run writes it. The split is the live door run's (readSourceSplit: proven, or a refusal), never
67
+ * a path the caller names. `split` overrides the lookup (a caller that has already read it).
68
+ */
69
+ export function linuxArtifactsOutDir({ root, split, env = process.env, run = spawnSync }) {
70
+ const found = split !== undefined ? split : readSourceSplit({ env, cwd: root, run });
71
+ return path.join(found ? found.checkoutAppRoot : root, ".right-release", "linux");
72
+ }
73
+
60
74
  /**
61
75
  * Pack `archive.include` (relative to `root`) into `<outDir>/<name>-<version>-linux-<arch>.tar.gz`, then write or merge
62
- * `<outDir>/linux-artifacts.json`. A manifest of another version or commit is replaced, never mixed.
76
+ * `<outDir>/linux-artifacts.json`. A manifest of another version or commit is replaced, never mixed. `outDir` defaults to
77
+ * linuxArtifactsOutDir: under `root` itself, except inside a source split, where it is the checkout's (never the snapshot's).
63
78
  */
64
- export function packLinuxArtifacts({ root, archive, app, version, commit, outDir = path.join(root, ".right-release", "linux"), run = spawnSync, fs = nodeFs, log = console.log }) {
79
+ export function packLinuxArtifacts({ root, archive, app, version, commit, split, env = process.env, outDir = linuxArtifactsOutDir({ root, split, env }), run = spawnSync, fs = nodeFs, log = console.log }) {
65
80
  if (!/^\d+\.\d+\.\d+([-+][0-9A-Za-z.-]+)?$/.test(String(version ?? ""))) throw new Error(`linux artifacts: version ${JSON.stringify(version)} is not semver`);
66
81
  if (!/^[0-9a-f]{40}$/.test(String(commit ?? ""))) throw new Error("linux artifacts: commit must be the 40-hex source commit");
67
82
  const arch = linuxArch(run);
@@ -0,0 +1,83 @@
1
+ // GB-29 (GATE-CONTRACT GC-L02; P-L, A-L; E4): the private Linux qualifier, only where a manifest DECLARES a Linux target, and the pending candidate that waits for it.
2
+ //
3
+ // A private app qualifies on Mac or Dell, never on a mandatory GitHub CI path. When its manifest declares a Linux deliverable (`targets.linux`), the exact sealed Linux bytes the Linux origin
4
+ // produced are qualified in a COMPATIBLE Linux runtime (OS, architecture, libc) hosted by the Mac or the Dell: they are not rebuilt there. With no compatible runtime the candidate WAITS:
5
+ // a pending record keeps the candidate identity, the exact sealed digest and the pinned policy, nothing is discarded, and a later qualification resumes it only when it binds the same
6
+ // candidate, the same bytes, the same policy digest and every expected check passed. Nothing declared, nothing created: an app that declares no Linux target has no Linux artifact or qualification.
7
+ import { createHash } from "node:crypto";
8
+
9
+ export const PENDING_SCHEMA = "rightkit.pending-linux-candidate/1";
10
+ export const LINUX_QUALIFIER_REFUSED = "RK_LINUX_QUALIFIER_REFUSED";
11
+ const SHA256 = /^[0-9a-f]{64}$/;
12
+ const ARCHES = new Set(["x86_64", "aarch64"]);
13
+ const HOSTS = ["dell", "mac"];
14
+ const refuse = (message) => { throw Object.assign(new Error(`${LINUX_QUALIFIER_REFUSED}: ${message}`), { code: LINUX_QUALIFIER_REFUSED }); };
15
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
16
+
17
+ /** The Linux targets a product manifest declares (`targets.linux`: one object or a list, each `{ arch, libc? }`); none when it declares none. Never inferred from another target. */
18
+ export function declaredLinuxTargets(config) {
19
+ const declared = config?.targets?.linux;
20
+ if (declared === undefined || declared === null) return [];
21
+ const list = Array.isArray(declared) ? declared : [declared];
22
+ return list.map((target, i) => {
23
+ if (!isObject(target) || !ARCHES.has(target.arch) || (target.libc !== undefined && !["glibc", "musl"].includes(target.libc))) refuse(`targets.linux[${i}] must be { arch: x86_64|aarch64, libc?: glibc|musl }`);
24
+ return { os: "linux", arch: target.arch, ...(target.libc ? { libc: target.libc } : {}) };
25
+ });
26
+ }
27
+
28
+ /** Whether one hosted runtime can qualify one declared target: Linux, the same architecture, the same libc when the target names one, and available now. */
29
+ export function runtimeCompatible(runtime, target) {
30
+ return isObject(runtime) && runtime.os === "linux" && runtime.available === true && runtime.arch === target.arch && (target.libc === undefined || runtime.libc === target.libc);
31
+ }
32
+
33
+ /**
34
+ * The qualifier for each declared target, or the reason it waits. `hosts` is the Mac and Dell's hosted Linux runtimes `[{ host: "dell"|"mac", runtime: { kind, os, arch, libc?, available } }]`.
35
+ * `{ state: "NOT_APPLICABLE" }` when nothing is declared; `{ state: "QUALIFIER", assignments }` when every declared target has a compatible hosted runtime (the Dell first, then the Mac, so the
36
+ * choice is stable); `{ state: "WAITING", assignments, missing }` otherwise. Never CI, never a rebuild.
37
+ */
38
+ export function selectLinuxQualifier({ config, hosts }) {
39
+ const targets = declaredLinuxTargets(config);
40
+ if (targets.length === 0) return { state: "NOT_APPLICABLE", reason: "the manifest declares no Linux target: no Linux artifact or qualification is created" };
41
+ const ordered = [...(hosts ?? [])].filter((h) => HOSTS.includes(h?.host)).sort((a, b) => HOSTS.indexOf(a.host) - HOSTS.indexOf(b.host));
42
+ const assignments = []; const missing = [];
43
+ for (const target of targets) {
44
+ const hit = ordered.find((h) => runtimeCompatible(h.runtime, target));
45
+ if (hit) assignments.push({ target, host: hit.host, runtime: { kind: hit.runtime.kind, os: "linux", arch: hit.runtime.arch, ...(hit.runtime.libc ? { libc: hit.runtime.libc } : {}) } });
46
+ else missing.push({ target, reason: ordered.length ? `no hosted Linux runtime of ${ordered.map((h) => `${h.host}:${h.runtime?.arch ?? "?"}${h.runtime?.available === true ? "" : " (unavailable)"}`).join(", ")} is compatible with ${target.arch}${target.libc ? `/${target.libc}` : ""}` : "no Mac or Dell hosted Linux runtime is registered" });
47
+ }
48
+ return missing.length ? { state: "WAITING", assignments, missing } : { state: "QUALIFIER", assignments };
49
+ }
50
+
51
+ const digest = (text) => createHash("sha256").update(text).digest("hex");
52
+
53
+ /** The pending record of a Linux candidate that has no qualifier yet: it keeps everything a later qualification must bind to, and discards nothing. */
54
+ export function pendingCandidate({ candidate, artifactSha256, target, policyDigest, missing }) {
55
+ if (!SHA256.test(candidate ?? "") || !SHA256.test(artifactSha256 ?? "") || !SHA256.test(policyDigest ?? "")) refuse("a pending candidate names its candidate identity, sealed artifact digest and pinned policy digest");
56
+ return { schema: PENDING_SCHEMA, state: "WAITING", candidate, artifactSha256, target, policyDigest, missing: missing ?? [] };
57
+ }
58
+
59
+ /**
60
+ * Resume a pending candidate from a qualification run. Refuses (the pending record stays WAITING, unchanged) unless the evidence binds the same candidate and the exact sealed bytes, was
61
+ * produced by a runtime compatible with the declared target on a Mac or Dell host, ran under the policy digest pinned before the run, rebuilt nothing, and executed every expected check with a
62
+ * pass: a missing, skipped or failed check, an empty suite or a changed policy fails.
63
+ */
64
+ export function resumeCandidate(pending, evidence) {
65
+ if (!isObject(pending) || pending.schema !== PENDING_SCHEMA || pending.state !== "WAITING") refuse("only a pending (WAITING) Linux candidate can be resumed");
66
+ if (!isObject(evidence)) refuse("the qualification evidence is not an object");
67
+ if (evidence.candidate !== pending.candidate) refuse("the evidence is for another candidate");
68
+ if (evidence.artifactSha256 !== pending.artifactSha256) refuse(`the evidence qualified ${String(evidence.artifactSha256).slice(0, 12)}, not the sealed ${pending.artifactSha256.slice(0, 12)}: the exact sealed bytes are qualified, never a rebuild`);
69
+ if (evidence.rebuilt === true) refuse("the qualifier rebuilt the bytes: sealed Linux bytes are qualified unchanged");
70
+ if (!HOSTS.includes(evidence.host)) refuse(`the qualifier host ${JSON.stringify(evidence.host)} is not the Mac or the Dell`);
71
+ if (!runtimeCompatible({ ...evidence.runtime, available: true }, pending.target)) refuse("the qualifier's runtime is not compatible with the declared target");
72
+ if (evidence.policyDigest !== pending.policyDigest) refuse("the qualification ran under another policy than the one pinned before the run");
73
+ const expected = evidence.expectedChecks; const executed = evidence.checks;
74
+ if (!Array.isArray(expected) || expected.length === 0) refuse("an empty mandatory suite is never a qualification");
75
+ if (!Array.isArray(executed)) refuse("the evidence carries no executed checks");
76
+ for (const id of expected) {
77
+ const run = executed.filter((c) => c?.id === id);
78
+ if (run.length !== 1) refuse(`expected check ${id} was ${run.length === 0 ? "not executed" : "executed more than once"}`);
79
+ if (run[0].result !== "pass") refuse(`check ${id} is ${run[0].result}`);
80
+ }
81
+ if (executed.some((c) => !expected.includes(c?.id))) refuse("the evidence carries a check the pinned policy does not expect");
82
+ return { state: "QUALIFIED", candidate: pending.candidate, artifactSha256: pending.artifactSha256, host: evidence.host, evidenceDigest: digest(JSON.stringify(evidence)) };
83
+ }
package/local-publish.mjs CHANGED
@@ -29,6 +29,7 @@ import { refuseHostUpload } from "./host-upload-refusal.mjs";
29
29
  import { pathDependencyDirs, readCargoPackage } from "./crate-graph.mjs";
30
30
  import { recordPublished, stageSourceVersions, workspaceFeedOptions } from "./rightkit-sync.mjs";
31
31
  import { prepareSwapHelper, SWAP_HELPER_CARGO_ARGS, SWAP_HELPER_PACKAGE } from "./swap-helper-bundle.mjs";
32
+ import { exclusionsFor, npmContentDigest } from "./pack-digest.mjs";
32
33
  import { requireDoorRun, gateStateDir } from "./gate-stages.mjs";
33
34
 
34
35
  export const LOCAL_OPERATOR = "agent (local-build grant)";
@@ -546,6 +547,16 @@ async function publishUnit(parsed, deps, batch) {
546
547
  return result.value;
547
548
  }
548
549
 
550
+
551
+ /**
552
+ * The host's scratch root for an npm pack: the disk policy's root (`/Volumes/D/scratch`, `D:\scratch`) where it exists, the system temp directory where it does not
553
+ * (a hosted runner, a cloud container, any host the disk policy does not govern). The pack destination is always a fresh child of the root that is returned.
554
+ */
555
+ export function hostScratchRoot(platform, exists = fs.existsSync) {
556
+ const root = platform === "win32" ? "D:\\scratch" : "/Volumes/D/scratch";
557
+ return exists(root) ? root : os.tmpdir();
558
+ }
559
+
549
560
  async function* publishUnitStages(parsed, deps, batch) {
550
561
  const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
551
562
  const { kind, unit, allowIgnored, workspaceRoot } = parsed;
@@ -559,6 +570,10 @@ async function* publishUnitStages(parsed, deps, batch) {
559
570
  // Every pnpm step goes through the one door for build and test launches (build-launch.mjs): a rightkit-pnpm broker job on a
560
571
  // managed host (refused, never run directly, when the broker is unreachable), direct on a host without one. A run headed to release
561
572
  // then also has the signed receipt each stage owes (stage-commands.mjs). Other commands (cargo is already a broker job) are unchanged.
573
+ // A release-bound stage carries its nonce in RIGHTKIT_ATTEST_STAGE_NONCE (unit-gate.mjs stageEnv), which is no carrier variable. Every pnpm
574
+ // and Cargo step of a snapshot stage is a carried request of its own (build-launch.mjs routes pnpm to the carried adapter runner, carried-cargo.mjs
575
+ // runCarriedAdapter), and each derives its per-call nonce from the stage nonce (snapshot-broker.mjs carryBrokerAdapter and carryBrokerCargo,
576
+ // rightkit#1301 B8), so nothing is given the stage nonce itself and no step sets RUSTRIGHT_ATTEST_NONCE.
562
577
  const run = (command, args, options) => {
563
578
  const target = command === "pnpm" ? buildLaunch(command, args, { env: options?.env ?? process.env, platform: deps.platform ?? process.platform, exists: fs.existsSync }) : { command, args };
564
579
  const result = launch(target.command, target.args, options);
@@ -668,22 +683,20 @@ async function* publishUnitStages(parsed, deps, batch) {
668
683
  if (packageEnv) Object.assign(env, testEnv({ env: packageEnv }, isAssetGated));
669
684
 
670
685
  // 3. Pack the artifact and hash it.
671
- // An npm pack is a broker job whose --dir and --pack-destination the adapter accepts only under the host's scratch root, so it
672
- // packs there (the workspace rule for run output) rather than in the OS temp directory.
673
- const scratchRoot = deps.tmpdir ?? (kind === "npm" ? (platform === "win32" ? "D:\\scratch" : "/Volumes/D/scratch") : os.tmpdir());
686
+ // An npm pack is a broker job whose --pack-destination the adapter accepts only as an empty child of the host's scratch root, so the
687
+ // tarball goes there (the workspace rule for run output) rather than in the OS temp directory; the package itself is packed from the
688
+ // snapshot (relative --dir).
689
+ const scratchRoot = deps.tmpdir ?? (kind === "npm" ? hostScratchRoot(platform, deps.exists ?? fs.existsSync) : os.tmpdir());
674
690
  const scratch = fs.mkdtempSync(path.join(scratchRoot, "rk-local-publish-"));
675
691
  const cleanup = () => fs.rmSync(scratch, { recursive: true, force: true });
676
692
  try {
677
693
  let artifactFile; const packCommands = [];
678
- let packDir = dir;
679
694
  if (kind === "npm" && unit === SWAP_HELPER_PACKAGE) {
680
- if (parsed.qualifyOnly) {
681
- // Signing writes its binary and receipt. Keep both in the pack copy, never the source checkout.
682
- packDir = path.join(scratch, "package-source");
683
- fs.cpSync(unitPath, packDir, { recursive: true, filter: (source) => path.basename(source) !== "node_modules" });
684
- }
685
- // #258: the package ships a Developer-ID-signed bin/darwin/rightkit-swap; build, sign and verify it before packing (fails closed).
686
- try { (deps.prepareSwapHelper ?? prepareSwapHelper)({ workspaceRoot, packageDir: parsed.qualifyOnly ? packDir : unitPath, platform, run, env, log }); }
695
+ // #258: the package ships a Developer-ID-signed bin/darwin/rightkit-swap; build, sign and verify it before packing (fails closed). The
696
+ // helper and its signing receipt are written into the unit's own directory of the snapshot (packages/release/bin/darwin, both ignored by
697
+ // packages/release/.gitignore): declared stage outputs of this stage, recorded by the gate's stage-output ledger (stage-ledger.mjs) like
698
+ // any ignored generated file, never a copy of the package kept outside the snapshot, so the pack below runs from the snapshot.
699
+ try { (deps.prepareSwapHelper ?? prepareSwapHelper)({ workspaceRoot, packageDir: unitPath, platform, run, env, log }); }
687
700
  catch (error) { refuse(`${error.code ? `[${error.code}] ` : ""}${error.message}`); }
688
701
  packCommands.push(`rustright cargo ${SWAP_HELPER_CARGO_ARGS.join(" ")} && codesign --verify --strict bin/darwin/rightkit-swap`);
689
702
  }
@@ -695,7 +708,9 @@ async function* publishUnitStages(parsed, deps, batch) {
695
708
  // child of a scratch directory (never the scratch directory itself) and refuses one that already holds a tarball.
696
709
  const packOut = path.join(scratch, "pack");
697
710
  fs.mkdirSync(packOut);
698
- const args = npmPackArgs(packDir, packOut);
711
+ // `--dir` is the unit's directory RELATIVE to the repository root, where the step runs: a snapshot-bound adapter job refuses an absolute --dir; only
712
+ // --pack-destination is outside the snapshot (an empty child of the scratch directory).
713
+ const args = npmPackArgs(dir, packOut);
699
714
  packCommands.push(`pnpm ${args.join(" ")}`);
700
715
  const packed = await run("pnpm", args, { cwd: workspaceRoot, env });
701
716
  if (packed.error || packed.status !== 0) refuse(`pnpm pack failed for ${unit}\n${tail(`${packed.stdout ?? ""}\n${packed.stderr ?? ""}`)}`);
@@ -725,6 +740,7 @@ async function* publishUnitStages(parsed, deps, batch) {
725
740
  if (!fs.existsSync(artifactFile)) refuse(`cargo package did not produce ${artifactFile}`);
726
741
  }
727
742
  const artifactSha256 = sha256(fs.readFileSync(artifactFile));
743
+ let contentSha256 = null, contentExcluded = [];
728
744
  if (parsed.qualifyOnly && (git(["rev-parse", "HEAD"], workspaceRoot) !== head || git(["status", "--porcelain", "--", ...guardedPaths], workspaceRoot) !== "")) refuse("qualification inputs changed while preparing the artifact; no bundle was created");
729
745
  if (parsed.qualifyOnly) {
730
746
  fs.mkdirSync(parsed.bundleDir, { recursive: false });
@@ -732,7 +748,13 @@ async function* publishUnitStages(parsed, deps, batch) {
732
748
  fs.copyFileSync(artifactFile, bundledArtifact, fs.constants.COPYFILE_EXCL);
733
749
  if (sha256(fs.readFileSync(bundledArtifact)) !== artifactSha256) refuse("qualification archive changed while copying");
734
750
  artifactFile = bundledArtifact;
735
- yield { stage: "package", kind, name: unit, version, commit: head, artifactFile, artifactSha256, brokerRequests: brokerRequests.splice(0) };
751
+ // An npm archive is bound hosted by what it ships (pack-digest.mjs), not by its bytes; the swap helper and its signing receipt are
752
+ // the only entries left out, for @rightkit/release alone, and their presence is recorded.
753
+ if (kind === "npm") {
754
+ try { const digest = npmContentDigest(artifactFile, { exclude: exclusionsFor(unit) }); contentSha256 = digest.contentSha256; contentExcluded = Object.keys(digest.excluded).sort(); }
755
+ catch (error) { refuse(`${unit}: cannot read the packed archive for its content digest (${error.message})`); }
756
+ }
757
+ yield { stage: "package", kind, name: unit, version, commit: head, artifactFile, artifactSha256, ...(contentSha256 ? { contentSha256, contentExcluded } : {}), brokerRequests: brokerRequests.splice(0) };
736
758
  if (sha256(fs.readFileSync(artifactFile)) !== artifactSha256) refuse("qualification archive changed after packaging");
737
759
  }
738
760
 
@@ -767,7 +789,7 @@ async function* publishUnitStages(parsed, deps, batch) {
767
789
  // Bytes and evidence stay together outside the checkout.
768
790
  const bundledEvidence = evidencePath;
769
791
  log(`[right-release] qualified ${unit}@${version}; bundle ${parsed.bundleDir}${kind === "cargo" ? `; publish cargo --crate ${unit} --from-evidence ${bundledEvidence}` : ""}`);
770
- return { kind, name: unit, version, commit: head, artifactSha256, evidencePath: bundledEvidence, artifactFile, qualifiedOnly: true };
792
+ return { kind, name: unit, version, commit: head, artifactSha256, ...(contentSha256 ? { contentSha256, contentExcluded } : {}), evidencePath: bundledEvidence, artifactFile, qualifiedOnly: true };
771
793
  }
772
794
 
773
795
  // 5. The EXISTING publish path.
package/local-runtime.mjs CHANGED
@@ -33,7 +33,7 @@ const round3 = (value) => Math.round(value * 1000) / 1000;
33
33
  const tail = (text, lines = 30) => String(text ?? "").split(/\r?\n/).slice(-lines).join("\n");
34
34
  const loadFromRoot = (root, ...segments) => import(pathToFileURL(path.join(root, ...segments)).href);
35
35
  const CREDENTIAL_LIKE = /TOKEN|PASSWORD|SECRET|CREDENTIAL|^ACTIONS_ID_TOKEN/i;
36
- const BROKER_ENV = ["RIGHTKIT_BUILD_BROKER_SOCKET", "RIGHTKIT_CONTROL_ROOT", "RIGHTKIT_DATA_ROOT", "RIGHTKIT_MANAGED_ROOT"];
36
+ const BROKER_ENV = ["RUSTRIGHT_BUILD_BROKER_SOCKET", "RIGHTKIT_BUILD_BROKER_SOCKET", "RIGHTKIT_CONTROL_ROOT", "RIGHTKIT_DATA_ROOT", "RIGHTKIT_MANAGED_ROOT"];
37
37
  const BENCH_TIMEOUT_MS = 40 * 60 * 1000;
38
38
 
39
39
  // ---- host evidence -------------------------------------------------------------------------------
@@ -173,7 +173,7 @@ export async function measureRuntimeCrate(options, deps = {}) {
173
173
  const baseEnv = Object.fromEntries(Object.entries(deps.env ?? process.env).filter(([name]) => !CREDENTIAL_LIKE.test(name)));
174
174
  const brokerEnv = Object.fromEntries(BROKER_ENV.filter((name) => baseEnv[name]).map((name) => [name, baseEnv[name]]));
175
175
  const socket = discoverBrokerSocket(deps.env ?? process.env, { platform });
176
- if (socket) brokerEnv.RIGHTKIT_BUILD_BROKER_SOCKET = socket;
176
+ if (socket) brokerEnv.RUSTRIGHT_BUILD_BROKER_SOCKET = socket;
177
177
  const run = deps.run ?? ((command, args, settings) => spawnCollect(command, args, { maxBuffer: 64 * 1024 * 1024, ...settings }));
178
178
  const invoke = (args, settings) => run(cargoCommand[0], [...cargoCommand.slice(1), ...args], { cwd: crateDir, windowsHide: true, ...settings });
179
179
  const toolchainOf = async () => {