@rightkit/release 0.2.165 → 0.2.173

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 (86) hide show
  1. package/README.md +121 -4
  2. package/addon-command.mjs +6 -13
  3. package/android-aab-scan.mjs +265 -0
  4. package/android-dev.mjs +111 -0
  5. package/android-ndk.mjs +77 -0
  6. package/android-play-upload.mjs +262 -0
  7. package/android-release.mjs +344 -0
  8. package/android-test-fixtures.mjs +79 -0
  9. package/apple-profile-create.mjs +363 -0
  10. package/bin/darwin/rightkit-swap +0 -0
  11. package/bin/darwin/rightkit-swap.sign.json +6 -0
  12. package/build-release.mjs +73 -13
  13. package/cargo-guard.mjs +2 -0
  14. package/cargo-profile-contract.mjs +183 -0
  15. package/cargo-target.mjs +8 -0
  16. package/cli/right-release.mjs +16 -4
  17. package/committed-source.mjs +1 -1
  18. package/dev-artifact-refresh.mjs +176 -0
  19. package/dev-artifact-support.mjs +61 -0
  20. package/development-signing.mjs +71 -4
  21. package/development.mjs +93 -47
  22. package/direct-bootstrap.mjs +26 -41
  23. package/disk-command.mjs +298 -5
  24. package/disk-policy.json +7 -5
  25. package/disk-policy.mjs +105 -16
  26. package/disk-stable-root.mjs +19 -16
  27. package/engine-locator.mjs +116 -0
  28. package/external-bin.mjs +133 -0
  29. package/github-release.mjs +2 -2
  30. package/github-updater-release.mjs +154 -0
  31. package/hardeningscan.mjs +1 -1
  32. package/in-place-install.mjs +56 -0
  33. package/install-app-windows.mjs +63 -0
  34. package/install-app.mjs +53 -23
  35. package/install-tauri-devid.mjs +172 -0
  36. package/inventory-check.mjs +1 -1
  37. package/local-publish.mjs +11 -1
  38. package/mac-package-recipe.mjs +304 -0
  39. package/mac-signing-matrix.mjs +23 -11
  40. package/managed-cargo.mjs +6 -0
  41. package/media-sidecar-catalog.mjs +45 -0
  42. package/media-sidecar.mjs +306 -0
  43. package/ownership-approval-command.mjs +10 -44
  44. package/ownership-approval.mjs +3 -42
  45. package/ownership-catalog-config.json +2 -1
  46. package/ownership-catalog.mjs +4 -2
  47. package/ownership-command.mjs +7 -9
  48. package/ownership-dependency-allowlist.json +25 -1
  49. package/ownership-evidence.mjs +8 -5
  50. package/ownership-git-inventory.mjs +41 -7
  51. package/ownership-lockfiles.mjs +91 -8
  52. package/ownership-mandatory-set.json +36 -16
  53. package/ownership-report.mjs +5 -6
  54. package/ownership-scan-worker.mjs +2 -2
  55. package/ownership-verdict.mjs +10 -7
  56. package/ownership.mjs +47 -29
  57. package/package-recipe-targets.mjs +95 -0
  58. package/package.json +7 -4
  59. package/portable-development.mjs +32 -115
  60. package/preflight.mjs +4 -6
  61. package/project-type.mjs +1 -1
  62. package/publication-artifacts.mjs +34 -0
  63. package/publish-update.mjs +72 -11
  64. package/qa-adoption-exemptions.json +5 -0
  65. package/qa-build-features.mjs +7 -0
  66. package/qa-contract.mjs +182 -0
  67. package/qa-control-scan.mjs +3 -3
  68. package/release-qa-command.mjs +38 -12
  69. package/release-qa-installed.mjs +136 -0
  70. package/release-qa-receipt.mjs +23 -2
  71. package/release.mjs +285 -14
  72. package/rightkit-ownership.json +537 -5
  73. package/rightkit-sync.mjs +27 -1
  74. package/rightkit-versions.json +111 -63
  75. package/runtime-budget-classes.json +28 -0
  76. package/runtime-budgets.mjs +4 -1
  77. package/sign-candidate.mjs +161 -0
  78. package/sign-dev-artifact.mjs +198 -0
  79. package/stage-binary.mjs +60 -0
  80. package/stage-sidecar-command.mjs +41 -0
  81. package/stage-sidecar.mjs +424 -0
  82. package/swap-helper-bundle.mjs +105 -0
  83. package/swift-mac-dmg.mjs +2 -1
  84. package/templates/cargo-profiles.toml +28 -0
  85. package/upload-release.mjs +11 -1
  86. package/win-package-recipe.mjs +321 -0
@@ -10,7 +10,7 @@
10
10
  // key held in the protected credential store (Keychain / protected file / CI secret) — not app-writable JSON.
11
11
  // The floor, this verifier, and the receipt schema are pinned in the protected policy bundle.
12
12
  import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
13
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
14
14
  import path from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { spawnSync } from "./background-process.mjs";
@@ -50,6 +50,22 @@ export function qaReceiptPath({ repoRoot, releaseId, platform, stage }) {
50
50
  return path.join(repoRoot, ".right-release", "qa", releaseId, platformDirOf(platform), `${stage}.receipt.json`);
51
51
  }
52
52
 
53
+ export const QA_CARRIED_RECEIPT_ENV = "RIGHT_RELEASE_QA_RECEIPT";
54
+
55
+ /**
56
+ * Receipt carried from the job that ran source QA to the signing job (Orthic-Labs/rightkit#183).
57
+ * `RIGHT_RELEASE_QA_RECEIPT` names the receipt file, or a directory holding `source.receipt.json`.
58
+ * Returns the file path when it exists, else null. The carried file is verified exactly like a local one
59
+ * (MAC, commit, tree, lockfile closure, config, floor, bundle), so carrying adds no trust.
60
+ */
61
+ export function carriedSourceReceiptPath(env = process.env) {
62
+ const value = String(env?.[QA_CARRIED_RECEIPT_ENV] ?? "").trim();
63
+ if (!value) return null;
64
+ const resolved = path.resolve(value);
65
+ const file = existsSync(resolved) && statSync(resolved).isDirectory() ? path.join(resolved, "source.receipt.json") : resolved;
66
+ return existsSync(file) ? file : null;
67
+ }
68
+
53
69
  export function loadQaFloor(dir = here) {
54
70
  const file = path.join(dir, QA_FLOOR_FILE);
55
71
  let floor;
@@ -247,10 +263,15 @@ function loadKeyOrRefuse(opts, command) {
247
263
  */
248
264
  export function assertSourceQaAdmission({ repoRoot, releaseId, platform, configName, releaseConfigSha256, ...opts }) {
249
265
  const command = qaCommandFor({ stage: "source", platform, releaseId, configName });
250
- const receipt = readQaReceipt(qaReceiptPath({ repoRoot, releaseId, platform, stage: "source" }));
266
+ // A receipt carried from the QA job (RIGHT_RELEASE_QA_RECEIPT) wins over the local path; the signing job has no local one.
267
+ const carried = carriedSourceReceiptPath(opts.env ?? process.env);
268
+ const receipt = readQaReceipt(carried ?? qaReceiptPath({ repoRoot, releaseId, platform, stage: "source" }));
251
269
  if (!receipt) throw new QaAdmissionError(`no source QA receipt for ${releaseId} (${platform}); a candidate cannot be built or signed without passing source QA`, { command });
252
270
  const verdict = verifyQaReceipt(receipt, { key: loadKeyOrRefuse(opts, command), stage: "source", repoRoot, releaseConfigSha256, floorDir: opts.floorDir, gitRunner: opts.gitRunner, policyBundleDigest: opts.policyBundleDigest, source: opts.source, closure: opts.closure });
253
271
  if (!verdict.ok) throw new QaAdmissionError(`source QA receipt rejected: ${verdict.reason}`, { command });
272
+ if (carried && (receipt.releaseId !== releaseId || receipt.platform !== platform)) {
273
+ throw new QaAdmissionError(`source QA receipt rejected: carried receipt is for ${receipt.releaseId} (${receipt.platform}), not ${releaseId} (${platform})`, { command });
274
+ }
254
275
  return { digest: receipt.digest, floorDigest: receipt.floor.digest };
255
276
  }
256
277
 
package/release.mjs CHANGED
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env node
2
+ import { expandPackageRecipeTarget } from "./package-recipe-targets.mjs";
2
3
  import { access, readFile, readdir } from "node:fs/promises";
3
4
  import { createHash } from "node:crypto";
4
- import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { createRequire } from "node:module";
5
7
  import path from "node:path";
6
8
  import { fileURLToPath, pathToFileURL } from "node:url";
7
9
  import { spawn, spawnSync } from "./background-process.mjs";
8
10
  import { validateRightKitCargoContract } from "./cargo-contract.mjs";
9
- import { assertQaBackdoorContract } from "./qa-contract.mjs";
11
+ import { assertCargoProfileContract } from "./cargo-profile-contract.mjs";
12
+ import { assertQaAdoption, assertQaBackdoorContract } from "./qa-contract.mjs";
10
13
  import { assertLegalReleaseContract } from "./legal-contract.mjs";
11
14
  import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
12
15
  import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
@@ -28,6 +31,9 @@ import { verifyNsisEmbeddedBinary } from "./nsis-payload.mjs";
28
31
  import { terminateProcessTree } from "./heavy-command.mjs";
29
32
  import { materializeHardeningEvidence } from "./hardening-evidence.mjs";
30
33
  import { signingReceiptPath } from "./signing-receipts.mjs";
34
+ import { MEDIA_SIDECAR_OFFER_NAME, MEDIA_SIDECAR_RECEIPT_NAME, buildMediaSidecarReceipt, macSidecarSigningProblems, mediaSidecarNestedCode, planMediaSidecar, sidecarFileEvidence, validateMediaSidecarConfig, verifyStagedSidecars, writeMediaSidecarNotices } from "./media-sidecar.mjs";
35
+ import { loadSidecarCatalog } from "./media-sidecar-catalog.mjs";
36
+ import { EXTERNAL_BIN_RECEIPT_NAME, buildExternalBinReceipt, externalBinNestedCode, planExternalBin, validateExternalBinConfig, verifyStagedExternalBins } from "./external-bin.mjs";
31
37
  import { notarizeAppBundle, validateNotarizationReceipt } from "./macos-notarization.mjs";
32
38
  import { MACOS_PORTABLE_SIGNING_CONTRACT } from "./macos-signing-contract.mjs";
33
39
  import { fileEvidence } from "./macos-file-evidence.mjs";
@@ -42,6 +48,7 @@ import { assertNoQaControl, assertNoWebBundleMocks } from "./qa-control-scan.mjs
42
48
  import { assertNoForbiddenBuildFeatures } from "./qa-build-features.mjs";
43
49
  import { assertNoReleaseSwitches, commandTokens, resolveFeatureClosure } from "./release-switch-scan.mjs";
44
50
  import { devtoolsEvidence, resolveDevtoolsAllowance } from "./devtools-policy.mjs";
51
+ import { runAndroidRelease, validateAndroidTarget } from "./android-release.mjs";
45
52
 
46
53
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
47
54
  const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
@@ -87,6 +94,7 @@ const SWIFT_PRIVATE_PARALLEL_DEV_MAC = {
87
94
  };
88
95
  const SWIFT_PRIVATE_JOBS = new Map([
89
96
  ["ci.yml", new Map([["ci", SWIFT_PRIVATE_ANY_PROFILE], ["dev-artifact-mac", SWIFT_PRIVATE_PARALLEL_DEV_MAC], ["test", SWIFT_PRIVATE_NODE_PROFILE]])],
97
+ ["swift-check.yml", new Map([["swift-check", SWIFT_PRIVATE_ANY_PROFILE]])],
90
98
  ["qualification.yml", new Map([["qualification", SWIFT_PRIVATE_ANY_PROFILE]])],
91
99
  ["release-candidate.yml", new Map([["candidate", SWIFT_PRIVATE_ANY_PROFILE]])],
92
100
  ["regenerate.yml", new Map([["regenerate", SWIFT_PRIVATE_ANY_PROFILE]])],
@@ -158,6 +166,16 @@ const config = (await import(pathToFileURL(configPath))).default;
158
166
  if (!config) usage(2, `config did not export default: ${configPath}`);
159
167
  if (config.schema !== 1) fail(`unsupported config schema: ${config.schema ?? "<missing>"} (expected 1)`);
160
168
 
169
+ // Hosted-workflow policy patterns: defined before the top-level await below uses them.
170
+ const SIGN_EXISTING_SECRETS = new Set(["APPLE_CERTIFICATE_BASE64", "APPLE_CERTIFICATE_PASSWORD", "APPLE_KEYCHAIN_PASSWORD"]);
171
+ const SIGN_EXISTING_ACTIONS = new Set(["actions/checkout", "actions/setup-node", "pnpm/action-setup", "actions/download-artifact", "actions/upload-artifact"]);
172
+ const SIGN_EXISTING_RIGHT_RELEASE_COMMANDS = new Set(["ownership", "sign-dev-artifact"]);
173
+ const SIGN_EXISTING_BUILD_RE = /\b(?:cargo|rustc|xcodebuild|tauri|cmake|msbuild|gradle|notarytool|stapler)\b|swift build|right-release build|--release\b|\b(?:npm|pnpm|yarn) (?:run|build)\b/;
174
+ const SIGN_EXISTING_GUARD = " private-repo-guard:\n runs-on: ubuntu-24.04\n steps:\n - name: Refuse private repository\n if: ${{ github.event.repository.private }}\n run: exit 1\n";
175
+ const SIGN_EXISTING_JOB_HEAD = /^(?: #[^\n]*\n)* needs: \[private-repo-guard\]\n if: \$\{\{ github\.event_name == 'workflow_dispatch' && github\.ref_name == github\.event\.repository\.default_branch \}\}\n environment: [A-Za-z0-9][A-Za-z0-9._-]*\n runs-on: (?:macos|xcode)-[A-Za-z0-9._-]+\n(?: timeout-minutes: \d+\n)? permissions:\n contents: read\n actions: read\n steps:\n/;
176
+ // Head of the managed `supersede` job (the span starts at the preceding newline, before the job key line).
177
+ const SUPERSEDE_JOB_HEAD = /^\n supersede:\n needs: \[private-repo-guard\]\n if: \$\{\{ github\.event_name == 'push' && github\.ref_name == github\.event\.repository\.default_branch \}\}\n runs-on: ubuntu-[0-9.]+\n timeout-minutes: \d+\n permissions:\n contents: read\n actions: write\n steps:\n/;
178
+
161
179
  const root = path.dirname(configPath);
162
180
  const workdir = path.resolve(root, config.workdir ?? ".");
163
181
  const nativeLayout = resolveNativeCargoLayout({ appRoot: root, config });
@@ -168,7 +186,13 @@ if (!(resolveProjectType({ appRoot: root, config, target: config.targets?.[opts.
168
186
  await validateRightKitPackageContract(root, config.app, config.hostedWorkflows);
169
187
  }
170
188
  validateRightKitCargoContract(root, RIGHTKIT_CARGO_ALLOWED, config.app ?? path.basename(root));
171
- const target = config.targets?.[opts.platform];
189
+ // One standard dev profile and panic = "unwind" in every app (templates/cargo-profiles.toml).
190
+ assertCargoProfileContract(root, config.app ?? path.basename(root));
191
+ // RightKit QA is mandatory for every app (also SwiftPM apps without a package.json): no QA lane refuses the release.
192
+ assertQaAdoption({ appRoot: root, app: config.app });
193
+ let target = config.targets?.[opts.platform];
194
+ // Declarative winPackage / macPackage (#345) expand to the same hook shape every gate below already checks.
195
+ try { target = expandPackageRecipeTarget(target, { platform: opts.platform, appRoot: root, config }); } catch (error) { fail(`${config.app ?? "app"} ${opts.platform} ${error.message}`); }
172
196
  if (!target) fail(`${config.app ?? "app"} has no ${opts.platform} release target`);
173
197
  const legalContract = config.legal
174
198
  ? assertLegalReleaseContract(root, config.legal, config.app ?? path.basename(root), opts.platform)
@@ -177,6 +201,16 @@ if (target.signed !== true) {
177
201
  fail(`${config.app ?? "app"} ${opts.platform} must declare signed: true; unsigned release targets are forbidden`);
178
202
  }
179
203
  let macSigning; // validated macOS sign.prePackageFiles / sign.nestedCode (mac only)
204
+ let mediaSidecarConfig = null; // validated targets.<mac|win>.mediaSidecar (rightkit-ffmpeg decode sidecar)
205
+ if (target.mediaSidecar !== undefined) {
206
+ if (opts.platform !== "mac" && opts.platform !== "win") fail(`${config.app ?? "app"} ${opts.platform} does not support mediaSidecar (mac and win only)`);
207
+ try { mediaSidecarConfig = validateMediaSidecarConfig(target.mediaSidecar, { platform: opts.platform }); } catch (error) { fail(`${config.app ?? "app"} ${opts.platform} ${error.message}`); }
208
+ }
209
+ let externalBinConfig = null; // validated targets.<mac|win>.externalBin (generic Tauri externalBin sidecars, #270)
210
+ if (target.externalBin !== undefined) {
211
+ if (opts.platform !== "mac" && opts.platform !== "win") fail(`${config.app ?? "app"} ${opts.platform} does not support externalBin (mac and win only)`);
212
+ try { externalBinConfig = validateExternalBinConfig(target.externalBin, { platform: opts.platform, reservedNames: mediaSidecarConfig?.tools ?? [] }); } catch (error) { fail(`${config.app ?? "app"} ${opts.platform} ${error.message}`); }
213
+ }
180
214
  if (opts.platform === "win") {
181
215
  const isNsis = target.signingContract === WINDOWS_NSIS_SIGNING_CONTRACT;
182
216
  const isPortable = target.signingContract === WINDOWS_PORTABLE_SIGNING_CONTRACT;
@@ -198,7 +232,10 @@ if (opts.platform === "mac") {
198
232
  if (target.signingContract !== MACOS_PORTABLE_SIGNING_CONTRACT) fail(`${config.app ?? "app"} mac must declare signingContract: ${MACOS_PORTABLE_SIGNING_CONTRACT}`);
199
233
  if (!target.prePackage?.cmd) fail(`${config.app ?? "app"} mac must declare prePackage to materialize Developer ID signing files`);
200
234
  if (!target.sign?.prePackageFiles?.length) fail(`${config.app ?? "app"} mac must declare at least one sign.prePackageFiles`);
201
- try { macSigning = validateMacSigningConfig(target.sign, { root }); } catch (error) { fail(`${config.app ?? "app"} mac ${error.message}`); }
235
+ const macSignConfig = mediaSidecarConfig || externalBinConfig
236
+ ? { ...target.sign, nestedCode: { ...(mediaSidecarConfig ? mediaSidecarNestedCode(mediaSidecarConfig) : {}), ...(externalBinConfig ? externalBinNestedCode(externalBinConfig) : {}), ...(target.sign.nestedCode ?? {}) } }
237
+ : target.sign;
238
+ try { macSigning = validateMacSigningConfig(macSignConfig, { root }); } catch (error) { fail(`${config.app ?? "app"} mac ${error.message}`); }
202
239
  if (!target.notarize?.file) fail(`${config.app ?? "app"} mac must declare notarize.file`);
203
240
  if (target.notarize.appBeforePackage && !target.notarize.app) fail(`${config.app ?? "app"} mac notarize.appBeforePackage requires notarize.app`);
204
241
  if (target.notarize.staple === false && (target.notarize.app || /\.(dmg|pkg)$/i.test(target.notarize.file))) {
@@ -214,6 +251,10 @@ if (opts.platform === "mac") {
214
251
  } catch (error) { fail(`${config.app ?? "app"} mac targets.mac.dmg.spec: ${error.message}`); }
215
252
  }
216
253
  }
254
+ let androidTarget; // validated Android AAB target (android only; see android-release.mjs)
255
+ if (opts.platform === "android") {
256
+ try { androidTarget = validateAndroidTarget(target, { root }); } catch (error) { fail(`${config.app ?? "app"} android ${error.message}`); }
257
+ }
217
258
  if (opts.upload && target.publishBlocked) fail(`${config.app ?? "app"} ${opts.platform} publish blocked: ${target.publishBlocked}`);
218
259
  /**
219
260
  * Assemble preflight inputs from the app's own files. Kept here (not in
@@ -316,6 +357,7 @@ try {
316
357
  { label: "nativeAssembly.packageHook", command: nativeAssembly?.packageHook },
317
358
  { label: "nativeAssembly.finalizer", command: nativeFinalizer },
318
359
  { label: "target.postSign", command: target.postSign },
360
+ { label: "target.gradle", command: target.gradle },
319
361
  ] });
320
362
  } catch (error) { fail(error.message); }
321
363
 
@@ -337,7 +379,7 @@ try {
337
379
  rust: config.releaseSwitches?.rust,
338
380
  features: config.releaseSwitches?.features ?? [],
339
381
  triple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget,
340
- commandTexts: [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, target.postSign],
382
+ commandTexts: [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, target.postSign, target.gradle],
341
383
  });
342
384
  if (closure.skipped) console.log(`right-release: feature closure skipped (${closure.skipped})`);
343
385
  else treeText = closure.treeText;
@@ -346,7 +388,7 @@ try {
346
388
  console.log(`right-release: dry run: ${error.message}`);
347
389
  }
348
390
  // remoteHost: rightkit-daemon `remote` is refused in the closure unless the app declares releaseSwitches.remoteHost: true.
349
- const closureInput = { treeText, remoteHost: config.releaseSwitches?.remoteHost, commands: [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, target.postSign].filter(Boolean).map((c) => commandTokens(c)) };
391
+ const closureInput = { treeText, remoteHost: config.releaseSwitches?.remoteHost, commands: [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, target.postSign, target.gradle].filter(Boolean).map((c) => commandTokens(c)) };
350
392
  if (releaseSwitchInventoryText != null) {
351
393
  assertNoReleaseSwitches({ ...closureInput, sourceRoot: root, inventoryText: releaseSwitchInventoryText, appForbid: config.releaseSwitches?.forbid ?? [] }, "source switch inventory");
352
394
  } else if (treeText != null || closureInput.commands.length) {
@@ -360,7 +402,7 @@ try {
360
402
  const releaseLock = acquireReleaseLock(root, config.app ?? path.basename(root), opts.platform, opts.tier);
361
403
  const mode = "package";
362
404
  const command = target.package;
363
- if (!command && !packageCommandOptional(target, resolveProjectType({ appRoot: root, config, target }))) fail(`${config.app ?? "app"} has no ${opts.platform} ${mode} command`);
405
+ if (!command && opts.platform !== "android" && !packageCommandOptional(target, resolveProjectType({ appRoot: root, config, target }))) fail(`${config.app ?? "app"} has no ${opts.platform} ${mode} command`);
364
406
  if (opts.upload && !target.publish && !target.upload) {
365
407
  fail(`${config.app ?? "app"} has no ${opts.platform} upload config`);
366
408
  }
@@ -397,8 +439,14 @@ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT)
397
439
  if (!built.dryRun) assertNoQaControlBinaries("msix package", [options.out], { requireBinary: true });
398
440
  } else if (opts.platform === "win") {
399
441
  const rawFiles = target.sign.prePackageFiles.map((p) => path.resolve(root, p));
442
+ const sidecarPlan = await prepareMediaSidecar(root);
443
+ const externalPlan = await prepareExternalBin(root);
400
444
  await runCommand(target.prePackage, root);
401
445
  for (const file of rawFiles) await mustExist(file, `missing raw EXE signing artifact: ${file}`);
446
+ const sidecarPre = sidecarPlan && !opts.dryRun ? verifyStagedSidecars(sidecarPlan) : [];
447
+ const externalPre = externalPlan && !opts.dryRun ? verifyExternalBins(externalPlan) : [];
448
+ const sidecarFiles = [...sidecarPre, ...externalPre].map((file) => file.stagedPath);
449
+ assertNoQaControlBinaries("windows media sidecar and externalBin", sidecarFiles, { prePack: true });
402
450
  // Before signing and packaging: the raw EXE is uncompressed, so a QA control server is visible here.
403
451
  assertNoQaControlBinaries("windows raw EXE", rawFiles, { requireBinary: true, prePack: true });
404
452
  // The marker must reach its FINAL value before signing. Tauri rewrites it during
@@ -410,7 +458,15 @@ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT)
410
458
  for (const receipt of bundleMarkerReceipts) {
411
459
  console.log(`right-release: bundle marker ${receipt.bundle} ${receipt.alreadyPatched ? "already applied" : "applied"} at offset ${receipt.offset} in ${path.basename(receipt.file)}`);
412
460
  }
413
- await signWindows(rawFiles, "raw-exe", root);
461
+ await signWindows([...rawFiles, ...sidecarFiles], "raw-exe", root);
462
+ if (sidecarPlan && !opts.dryRun) {
463
+ const signed = Object.fromEntries(sidecarPre.map((file) => [file.tool, sidecarFileEvidence(file.stagedPath)]));
464
+ writeMediaSidecarReceipt(root, buildMediaSidecarReceipt(sidecarPlan, sidecarPre, signed));
465
+ }
466
+ if (externalPlan && !opts.dryRun) {
467
+ const signed = Object.fromEntries(externalPre.map((file) => [file.tool, sidecarFileEvidence(file.stagedPath)]));
468
+ writeExternalBinReceipt(root, buildExternalBinReceipt(externalPlan, externalPre, signed));
469
+ }
414
470
  if (nativeAssembly?.packageHook && !hasNativeTargetSpecificPrePackage(target, nativeAssembly.packageHook)) {
415
471
  await runCommand(nativeAssembly.packageHook, root);
416
472
  }
@@ -424,6 +480,8 @@ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT)
424
480
  });
425
481
  }
426
482
  } else if (opts.platform === "mac") {
483
+ const sidecarPlan = await prepareMediaSidecar(root, { stage: runMacPhase("prepackage") });
484
+ const externalPlan = await prepareExternalBin(root, { stage: runMacPhase("prepackage") });
427
485
  if (runMacPhase("prepackage")) await runCommand(target.prePackage, root);
428
486
  // Resolve after prePackage: with storage governance the artifact may live only in RIGHT_RELEASE_ARTIFACT_DIR.
429
487
  const signingEntries = macSigning.entries.map((entry) => ({ ...entry, file: resolveReleaseArtifactPath(root, entry.file) }));
@@ -437,9 +495,29 @@ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT)
437
495
  }
438
496
  if (runMacPhase("sign")) {
439
497
  await signMacos(signingEntries, root, "signing", macSigning.nestedCode);
498
+ if (sidecarPlan && !opts.dryRun) {
499
+ const app = signingEntries.find((entry) => /\.app\/?$/.test(entry.file));
500
+ if (!app) fail("mediaSidecar on macOS needs an .app entry in sign.prePackageFiles");
501
+ writeMediaSidecarReceipt(root, finishMacSidecar(sidecarPlan, app.file, root));
502
+ }
503
+ if (externalPlan && !opts.dryRun) {
504
+ const app = signingEntries.find((entry) => /\.app\/?$/.test(entry.file));
505
+ if (!app) fail("externalBin on macOS needs an .app entry in sign.prePackageFiles");
506
+ writeExternalBinReceipt(root, finishMacExternalBin(externalPlan, app.file, root));
507
+ }
440
508
  // Opt-in (notarize.appBeforePackage): staple the .app itself before the package step copies it into the DMG.
441
509
  if (target.notarize.appBeforePackage) await notarizeAppBeforePackage(root);
442
510
  }
511
+ } else if (opts.platform === "android") {
512
+ // Gradle bundleRelease with upload-key signing (secrets by file path only), then the in-process AAB scan, signer pin and receipt.
513
+ await runAndroidRelease({
514
+ root, app: config.app ?? path.basename(root), target: androidTarget, dryRun: opts.dryRun,
515
+ runGradle: (gradle, env) => runCommand(gradle, root, { env }),
516
+ resolveArtifact: (declared) => resolveReleaseArtifactPath(root, declared),
517
+ scanFiles: (label, files, scanOptions) => assertNoQaControlBinaries(label, files, scanOptions),
518
+ receiptDir: path.resolve(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(root, ".right-release", "receipts")),
519
+ allowContentOnlyDevtools: devtoolsAllowance.mode === "content-only",
520
+ });
443
521
  } else if (nativeAssembly) {
444
522
  if (nativeAssembly.packageHook) await runCommand(nativeAssembly.packageHook, root);
445
523
  if (nativeFinalizer) {
@@ -995,6 +1073,83 @@ function swiftPrivateSecretRefusal({ source, workflow, manifest, lanes, name, in
995
1073
  return null;
996
1074
  }
997
1075
 
1076
+ // @rightkit/ci `sign-existing` lane (0.2.49, Orthic-Labs/rightkit#257): signs an already-qualified artifact of another run.
1077
+ // It is accepted only in its exact rendered shape: workflow_dispatch only; jobs private-repo-guard and sign-existing only;
1078
+ // the default-branch gate behind the private-repo guard; the protected environment; contents: read and actions: read
1079
+ // (workflow and job); exactly the three Apple Developer ID secrets, referenced inside the sign-existing job only; pinned
1080
+ // allowlisted actions; and no build, notarization or release step. Returns null when the shape matches, else the reason.
1081
+
1082
+ function signExistingRefusal(source) {
1083
+ const jobsAt = source.indexOf("\njobs:\n");
1084
+ if (jobsAt < 0) return "it declares no jobs";
1085
+ const head = source.slice(0, jobsAt + 1);
1086
+ const jobs = source.slice(jobsAt + 7);
1087
+ const onBlock = /^on:\n((?:[ \t]+[^\n]*\n|\n)*)/m.exec(head)?.[1] ?? "";
1088
+ const triggers = [...onBlock.matchAll(/^ ([A-Za-z_]+):/gm)].map((match) => match[1]);
1089
+ if (triggers.length !== 1 || triggers[0] !== "workflow_dispatch") return "it must be triggered by workflow_dispatch only";
1090
+ if (!/^permissions:\n contents: read\n actions: read\n(?! )/m.test(head)) return "workflow permissions must be exactly contents: read and actions: read";
1091
+ const names = [...jobs.matchAll(/^ ([A-Za-z0-9_-]+):[ \t]*$/gm)].map((match) => match[1]);
1092
+ if (names.join(",") !== "private-repo-guard,sign-existing") return `jobs must be exactly private-repo-guard and sign-existing, got ${names.join(", ") || "none"}`;
1093
+ const marker = "\n sign-existing:\n";
1094
+ const guard = jobs.slice(0, jobs.indexOf(marker) + 1);
1095
+ if (!guard.startsWith(SIGN_EXISTING_GUARD) || guard.length !== SIGN_EXISTING_GUARD.length + (guard.endsWith("\n\n") ? 1 : 0)) return "the private-repo-guard job is not the managed guard";
1096
+ const sign = jobs.slice(jobs.indexOf(marker) + marker.length);
1097
+ if (!SIGN_EXISTING_JOB_HEAD.test(sign)) return "the sign-existing job must need only private-repo-guard, run only for workflow_dispatch on the default branch in an environment on a macOS runner, with job permissions exactly contents: read and actions: read";
1098
+ const secrets = [...source.matchAll(/\bsecrets\.([A-Z0-9_]+)/gi)].map((match) => match[1]);
1099
+ const signSecrets = [...sign.matchAll(/\bsecrets\.([A-Z0-9_]+)/gi)].map((match) => match[1]);
1100
+ if (secrets.length !== signSecrets.length || new Set(secrets).size !== SIGN_EXISTING_SECRETS.size || secrets.some((name) => !SIGN_EXISTING_SECRETS.has(name))) return "it must reference exactly the three Apple Developer ID secrets, inside the sign-existing job";
1101
+ for (const [, action] of sign.matchAll(/^\s*(?:- )?uses: (\S+)/gm)) {
1102
+ const [name, pin] = action.split("@");
1103
+ if (!SIGN_EXISTING_ACTIONS.has(name) || !/^[0-9a-f]{40}$/.test(pin ?? "")) return `action ${action} is not an allowed commit-pinned action`;
1104
+ }
1105
+ for (const [, command] of source.matchAll(/right-release\s+([A-Za-z][A-Za-z-]*)/g)) {
1106
+ if (!SIGN_EXISTING_RIGHT_RELEASE_COMMANDS.has(command)) return `right-release ${command} is not allowed; the lane only signs an existing artifact`;
1107
+ }
1108
+ if (!/right-release sign-dev-artifact /.test(sign)) return "it must run right-release sign-dev-artifact";
1109
+ const build = SIGN_EXISTING_BUILD_RE.exec(sign);
1110
+ if (build) return `it must not build, notarize or release (found ${JSON.stringify(build[0])})`;
1111
+ return null;
1112
+ }
1113
+
1114
+ // The checks above are a denylist backstop. The authority is byte equality with what @rightkit/ci renders for this repository's
1115
+ // manifest: an extra step (for example one that copies a secret into `env:` and posts it somewhere, or one that interpolates
1116
+ // `${{ inputs.* }}` straight into `run:`) passes every pattern above but can never equal the managed render.
1117
+ // The renderer comes from this monorepo's own packages/ci when present, else from the repository's installed @rightkit/ci; with
1118
+ // neither the file is refused, because an unverifiable sign-existing.yml must not be accepted.
1119
+ async function loadRightKitCiRenderer(root) {
1120
+ const candidates = [pathToFileURL(fileURLToPath(new URL("../ci/sync.mjs", import.meta.url))).href];
1121
+ try {
1122
+ candidates.push(pathToFileURL(createRequire(path.join(root, "package.json")).resolve("@rightkit/ci/sync.mjs")).href);
1123
+ } catch {
1124
+ // not installed in the repository; the sibling candidate may still apply
1125
+ }
1126
+ for (const href of candidates) {
1127
+ try {
1128
+ if (href.startsWith("file:") && !existsSync(fileURLToPath(href))) continue;
1129
+ const loaded = await import(href);
1130
+ if (typeof loaded.renderLanes === "function") return loaded.renderLanes;
1131
+ } catch {
1132
+ // try the next candidate
1133
+ }
1134
+ }
1135
+ return null;
1136
+ }
1137
+
1138
+ async function signExistingRenderRefusal(root, manifest, source) {
1139
+ const renderLanes = await loadRightKitCiRenderer(root);
1140
+ if (!renderLanes) return "the @rightkit/ci renderer is not available (install @rightkit/ci as a devDependency), so the file cannot be compared with its managed render";
1141
+ let expected;
1142
+ try {
1143
+ expected = renderLanes(manifest, root).find((lane) => lane.filename === "sign-existing.yml")?.content;
1144
+ } catch (error) {
1145
+ return `the manifest does not render a sign-existing lane (${error.message})`;
1146
+ }
1147
+ if (typeof expected !== "string") return "the manifest does not render a sign-existing lane";
1148
+ if (source.replace(/\r\n?/g, "\n") !== expected.replace(/\r\n?/g, "\n")) return "the file is not byte-for-byte the @rightkit/ci render of this repository's .rightgit.json (run right-git sync); extra, changed or reordered steps are refused";
1149
+ return null;
1150
+ }
1151
+
1152
+
998
1153
  async function validateHostedWorkflows(root, appName, workflows, policy) {
999
1154
  if (policy !== "right-git-ci-only") {
1000
1155
  fail(`${appName} hosted workflow files are forbidden: ${workflows.join(", ")}`);
@@ -1008,15 +1163,15 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
1008
1163
  }
1009
1164
  const lanes = manifest?.lanes;
1010
1165
  // Mirrors the `lanes` enum in @rightkit/ci manifest.schema.json.
1011
- const safeLanes = new Set(["ci", "qualification", "package-smoke", "release-candidate", "publish-npm", "publish-package-managers", "regenerate"]);
1166
+ const safeLanes = new Set(["ci", "swift-check", "qualification", "package-smoke", "release-candidate", "publish-npm", "publish-package-managers", "regenerate", "sign-existing"]);
1012
1167
  if (manifest?.schemaVersion !== 1
1013
1168
  || manifest?.compile !== "github-actions-only"
1014
1169
  || !Array.isArray(lanes)
1015
1170
  || lanes.length === 0
1016
- || !lanes.includes("ci")
1171
+ || !(lanes.includes("ci") || lanes.includes("swift-check"))
1017
1172
  || new Set(lanes).size !== lanes.length
1018
1173
  || lanes.some((lane) => !safeLanes.has(lane))) {
1019
- fail(`${appName} right-git-ci-only policy requires compile=github-actions-only plus unique .rightgit.json lanes drawn from [${[...safeLanes].map((lane) => `"${lane}"`).join(", ")}] and including "ci"`);
1174
+ fail(`${appName} right-git-ci-only policy requires compile=github-actions-only plus unique .rightgit.json lanes drawn from [${[...safeLanes].map((lane) => `"${lane}"`).join(", ")}] and including "ci" or "swift-check"`);
1020
1175
  }
1021
1176
  const expectedWorkflows = lanes.map((lane) => `${lane}.yml`).sort();
1022
1177
  if (JSON.stringify(workflows) !== JSON.stringify(expectedWorkflows)) {
@@ -1056,7 +1211,8 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
1056
1211
  `${job}:\\n needs: \\[candidate\\]\\n if: \\$\\{\\{ startsWith\\(github\\.ref, 'refs\\/tags\\/v'\\)(?: \\|\\| \\(github\\.event_name == 'workflow_dispatch' && inputs\\.signed_qualification == true\\))? \\}\\}`,
1057
1212
  );
1058
1213
  const admittedSigningGate = (job) => new RegExp(
1059
- `${job}:\\n needs: \\[admission, candidate\\]\\n if: \\$\\{\\{ needs\\.admission\\.outputs\\.signed_qualification == 'true' \\}\\}`,
1214
+ // `, dev-qualification` (@rightkit/ci candidate.devQualification, #107) only adds a gate.
1215
+ `${job}:\\n needs: \\[admission, candidate(?:, dev-qualification)?\\]\\n if: \\$\\{\\{ needs\\.admission\\.outputs\\.signed_qualification == 'true' \\}\\}`,
1060
1216
  );
1061
1217
  const protectedWindowsSigning = workflow === "release-candidate.yml"
1062
1218
  && manifest?.candidate?.signWindows === true
@@ -1083,6 +1239,16 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
1083
1239
  && /git push origin "HEAD:refs\/heads\/\$branch"\n/.test(regenerateSource)
1084
1240
  && !/git push(?![^\n]*"HEAD:refs\/heads\/\$branch")/.test(regenerateSource);
1085
1241
  const allowedRegeneratePermissions = new Set(["contents", "pull-requests"]);
1242
+ // @rightkit/ci `supersede` job (one CI run per repo, #338): `actions: write` is accepted only for the
1243
+ // job whose head matches the managed shape exactly (push to the default branch only, needs only
1244
+ // private-repo-guard, contents: read plus actions: write, no action `uses:`), and only in ci.yml when
1245
+ // the manifest has not opted into `concurrency.mainPushes: "queue"` (which renders no such job).
1246
+ const supersedeSpan = workflow === "ci.yml" ? jobSpan("supersede") : null;
1247
+ const supersedeSource = supersedeSpan ? source.slice(supersedeSpan.start, supersedeSpan.end) : "";
1248
+ const protectedSupersede = workflow === "ci.yml"
1249
+ && manifest?.concurrency?.mainPushes !== "queue"
1250
+ && SUPERSEDE_JOB_HEAD.test(supersedeSource)
1251
+ && !/^\s*-?\s*uses:/m.test(supersedeSource);
1086
1252
  const devMacSpan = workflow === "ci.yml" ? jobSpan("dev-mac-sign") : null;
1087
1253
  const devMacSource = devMacSpan ? source.slice(devMacSpan.start, devMacSpan.end) : "";
1088
1254
  // `mac: "notarize"` additionally allows the notary API key secrets; the
@@ -1093,6 +1259,16 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
1093
1259
  const protectedDevMacSigning = workflow === "ci.yml"
1094
1260
  && (manifest?.devArtifact?.mac === "sign" || manifest?.devArtifact?.mac === "notarize")
1095
1261
  && /dev-mac-sign:\n(?: #[^\n]*\n)* needs: \[ci\]\n if: \$\{\{ github\.event_name == 'push' && github\.ref_name == github\.event\.repository\.default_branch \}\}\n environment: /.test(devMacSource);
1262
+ // @rightkit/ci `sign-existing` lane: any deviation from the exact managed shape is refused outright.
1263
+ const signExistingSpan = workflow === "sign-existing.yml" ? jobSpan("sign-existing") : null;
1264
+ let protectedSignExisting = false;
1265
+ if (workflow === "sign-existing.yml") {
1266
+ const reason = manifest?.signExisting && typeof manifest.signExisting === "object"
1267
+ ? (signExistingRefusal(source) ?? await signExistingRenderRefusal(root, manifest, source))
1268
+ : "the manifest declares no signExisting";
1269
+ if (reason !== null) fail(`${appName} right-git managed sign-existing.yml is not the protected sign-existing shape: ${reason}`);
1270
+ protectedSignExisting = true;
1271
+ }
1096
1272
  // @rightkit/ci `swift.privatePackages` credential: allowed only in the workflow/job pairs and on the
1097
1273
  // env lines listed with SWIFT_PRIVATE_JOBS; swiftPrivateSecretRefusal explains every other reference.
1098
1274
  const releaseChain = workflow === "release-candidate.yml" ? manifest?.candidate?.releaseChain : undefined;
@@ -1116,12 +1292,14 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
1116
1292
  const refusedSecrets = requestedSecrets
1117
1293
  .filter(({ name, index }) => !(protectedMacosSigning && inside(index, macosSpan) && allowedMacosSecrets.has(name))
1118
1294
  && !(protectedDevMacSigning && inside(index, devMacSpan) && allowedDevMacSecrets.has(name))
1295
+ && !(protectedSignExisting && inside(index, signExistingSpan) && SIGN_EXISTING_SECRETS.has(name))
1119
1296
  && !(protectedReleasePublish && inside(index, publishSpan) && allowedReleaseSecrets.has(name)))
1120
1297
  .map(({ name, index }) => swiftPrivateSecretRefusal({ source, workflow, manifest, lanes, name, index }))
1121
1298
  .filter((reason) => reason !== null);
1122
1299
  if (/^\s*permissions:\s*write-all\s*$/mi.test(source)
1123
1300
  || writePermissions.some(({ name, index }) => {
1124
1301
  if (protectedRegenerate && inside(index, regenerateSpan) && allowedRegeneratePermissions.has(name)) return false;
1302
+ if (protectedSupersede && inside(index, supersedeSpan) && name === "actions") return false;
1125
1303
  return name === "id-token"
1126
1304
  ? !(protectedWindowsSigning && inside(index, windowsSpan))
1127
1305
  : name !== "contents" || !protectedReleasePublish || !inside(index, publishSpan);
@@ -1164,6 +1342,99 @@ function expandEnv(value) {
1164
1342
  return String(value).replace(/%([^%]+)%/g, (_, name) => process.env[name] ?? "");
1165
1343
  }
1166
1344
 
1345
+ // rightkit-ffmpeg decode sidecar (media-sidecar.mjs): plan from the catalog the app compiled against, write the LGPL
1346
+ // notices and the source offer, optionally run the app's stage command, and (before signing) check the staged files.
1347
+ async function prepareMediaSidecar(root, { stage = true } = {}) {
1348
+ if (!mediaSidecarConfig) return null;
1349
+ let plan;
1350
+ try {
1351
+ const loaded = loadSidecarCatalog({ config: mediaSidecarConfig, root, manifestPath: nativeLayout.manifestPath });
1352
+ plan = planMediaSidecar({ config: mediaSidecarConfig, ...loaded, root });
1353
+ } catch (error) { fail(`${config.app ?? "app"} ${opts.platform} mediaSidecar: [${error.code ?? "ERROR"}] ${error.message}`); }
1354
+ console.log(`right-release: media sidecar ${plan.rows.map((row) => row.id).join(", ")}`);
1355
+ if (opts.dryRun) {
1356
+ console.log(`dry-run: would write ${plan.notices.length + 1} notice file(s) to ${mediaSidecarConfig.notices.dir} and expect ${plan.files.map((file) => path.basename(file.stagedPath)).join(", ")} in ${mediaSidecarConfig.stageDir}`);
1357
+ return plan;
1358
+ }
1359
+ if (stage) {
1360
+ try { writeMediaSidecarNotices(plan); } catch (error) { fail(`mediaSidecar: [${error.code ?? "ERROR"}] ${error.message}`); }
1361
+ if (mediaSidecarConfig.stage) await runCommand(mediaSidecarConfig.stage, root);
1362
+ }
1363
+ return plan;
1364
+ }
1365
+
1366
+ function finishMacSidecar(plan, appPath, root) {
1367
+ let preSign;
1368
+ const signed = {};
1369
+ const checked = [];
1370
+ try { preSign = verifyStagedSidecars(plan); } catch (error) { fail(`mediaSidecar: [${error.code ?? "ERROR"}] ${error.message}`); }
1371
+ for (const file of plan.files) {
1372
+ const inBundle = path.join(appPath, ...file.bundlePath.split("/"));
1373
+ if (!existsSync(inBundle)) fail(`mediaSidecar: ${file.tool} is not inside the built app at ${file.bundlePath}; list it under Tauri externalBin`);
1374
+ signed[file.tool] = sidecarFileEvidence(inBundle);
1375
+ checked.push({ file: inBundle, sha256: signed[file.tool].sha256 });
1376
+ }
1377
+ const nestedReceipt = path.resolve(path.dirname(macosReceiptPath(root, "signing")), "macos-signing-nested.json");
1378
+ let nested;
1379
+ try { nested = JSON.parse(readFileSync(nestedReceipt, "utf8")); } catch { fail(`mediaSidecar: missing nested macOS signing receipt ${nestedReceipt}`); }
1380
+ const problems = macSidecarSigningProblems(nested, checked);
1381
+ if (problems.length) fail(`mediaSidecar: sidecar signing evidence rejected: ${problems.join("; ")}`);
1382
+ const bundled = readdirSync(appPath, { recursive: true }).map(String);
1383
+ if (!bundled.some((entry) => path.basename(entry) === MEDIA_SIDECAR_OFFER_NAME)) {
1384
+ fail(`mediaSidecar: ${MEDIA_SIDECAR_OFFER_NAME} is not inside the built app; add ${mediaSidecarConfig.notices.dir} to the Tauri bundle resources`);
1385
+ }
1386
+ return buildMediaSidecarReceipt(plan, preSign, signed);
1387
+ }
1388
+
1389
+ // Generic Tauri externalBin sidecars (external-bin.mjs, #270): the app's own stage command writes <name>-<triple>[.exe].
1390
+ async function prepareExternalBin(root, { stage = true } = {}) {
1391
+ if (!externalBinConfig) return null;
1392
+ const plan = planExternalBin({ config: externalBinConfig, root });
1393
+ console.log(`right-release: externalBin ${plan.files.map((file) => path.basename(file.stagedPath)).join(", ")}`);
1394
+ if (opts.dryRun) {
1395
+ console.log(`dry-run: would expect ${plan.files.map((file) => path.basename(file.stagedPath)).join(", ")} in ${externalBinConfig.stageDir}`);
1396
+ return plan;
1397
+ }
1398
+ if (stage && externalBinConfig.stage) await runCommand(externalBinConfig.stage, root);
1399
+ return plan;
1400
+ }
1401
+
1402
+ function verifyExternalBins(plan) {
1403
+ try { return verifyStagedExternalBins(plan); } catch (error) { fail(`externalBin: [${error.code ?? "ERROR"}] ${error.message}`); }
1404
+ }
1405
+
1406
+ function finishMacExternalBin(plan, appPath, root) {
1407
+ const preSign = verifyExternalBins(plan);
1408
+ const signed = {};
1409
+ const checked = [];
1410
+ for (const file of plan.files) {
1411
+ const inBundle = path.join(appPath, ...file.bundlePath.split("/"));
1412
+ if (!existsSync(inBundle)) fail(`externalBin: ${file.tool} is not inside the built app at ${file.bundlePath}; list it under Tauri externalBin`);
1413
+ signed[file.tool] = sidecarFileEvidence(inBundle);
1414
+ checked.push({ file: inBundle, sha256: signed[file.tool].sha256 });
1415
+ }
1416
+ const nestedReceipt = path.resolve(path.dirname(macosReceiptPath(root, "signing")), "macos-signing-nested.json");
1417
+ let nested;
1418
+ try { nested = JSON.parse(readFileSync(nestedReceipt, "utf8")); } catch { fail(`externalBin: missing nested macOS signing receipt ${nestedReceipt}`); }
1419
+ const problems = macSidecarSigningProblems(nested, checked);
1420
+ if (problems.length) fail(`externalBin: signing evidence rejected: ${problems.join("; ")}`);
1421
+ return buildExternalBinReceipt(plan, preSign, signed);
1422
+ }
1423
+
1424
+ function writeExternalBinReceipt(root, receipt) {
1425
+ const file = path.resolve(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(root, ".right-release", "receipts"), EXTERNAL_BIN_RECEIPT_NAME);
1426
+ mkdirSync(path.dirname(file), { recursive: true });
1427
+ writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
1428
+ console.log(`right-release: externalBin receipt ${file}`);
1429
+ }
1430
+
1431
+ function writeMediaSidecarReceipt(root, receipt) {
1432
+ const file = path.resolve(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(root, ".right-release", "receipts"), MEDIA_SIDECAR_RECEIPT_NAME);
1433
+ mkdirSync(path.dirname(file), { recursive: true });
1434
+ writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
1435
+ console.log(`right-release: media sidecar receipt ${file}`);
1436
+ }
1437
+
1167
1438
  async function signWindows(files, phase, root) {
1168
1439
  const args = [SIGN_WINDOWS];
1169
1440
  const receipt = windowsReceiptPath(root, phase);
@@ -1181,13 +1452,13 @@ function windowsReceiptPath(root, phase) {
1181
1452
  return signingReceiptPath({ appRoot: root, target, platform: "win", phase, stateRoot: process.env.RIGHT_RELEASE_STATE_ROOT });
1182
1453
  }
1183
1454
 
1184
- // A signing step with per-file settings (entitlements, codesign options) goes to sign-macos.mjs as a manifest;
1455
+ // A signing step with per-file settings (entitlements, codesign options, a stable identifier) goes to sign-macos.mjs as a manifest;
1185
1456
  // a step with only defaults keeps the historical positional invocation.
1186
1457
  function macSignInvocation(items, receiptPath, extra = []) {
1187
1458
  if (opts.dryRun || !items.some(hasNonDefaultSettings)) return [SIGN_MACOS, ...extra, "--receipt", receiptPath, ...items.map((item) => item.file)];
1188
1459
  const manifest = path.resolve(path.dirname(receiptPath), `${path.basename(receiptPath, ".json")}.manifest.json`);
1189
1460
  mkdirSync(path.dirname(manifest), { recursive: true });
1190
- writeFileSync(manifest, `${JSON.stringify({ schema: 1, entries: items.map((item) => ({ file: item.file, ...(item.entitlements ? { entitlements: item.entitlements } : {}), options: item.options ?? [] })) }, null, 2)}\n`);
1461
+ writeFileSync(manifest, `${JSON.stringify({ schema: 1, entries: items.map((item) => ({ file: item.file, ...(item.identifier ? { identifier: item.identifier } : {}), ...(item.entitlements ? { entitlements: item.entitlements } : {}), options: item.options ?? [] })) }, null, 2)}\n`);
1191
1462
  return [SIGN_MACOS, ...extra, "--receipt", receiptPath, "--manifest", manifest];
1192
1463
  }
1193
1464