@rightkit/release 0.2.150 → 0.2.153

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 (67) hide show
  1. package/LICENSE-APACHE +201 -0
  2. package/LICENSE-MIT +21 -0
  3. package/README.md +119 -8
  4. package/build-release.mjs +16 -2
  5. package/cli/right-release.mjs +18 -5
  6. package/committed-source.mjs +19 -1
  7. package/crate-graph.mjs +121 -0
  8. package/development-signing.mjs +8 -0
  9. package/development.mjs +46 -13
  10. package/devtools-policy.json +5 -0
  11. package/devtools-policy.mjs +119 -0
  12. package/direct-bootstrap.mjs +9 -3
  13. package/dmg-build.mjs +19 -5
  14. package/dmg-layout.mjs +2 -2
  15. package/dmg-spec.mjs +97 -0
  16. package/execution-policy.mjs +17 -1
  17. package/github-release.mjs +25 -7
  18. package/github-upload-gate.mjs +84 -0
  19. package/hardeningscan.mjs +3 -0
  20. package/inventory-check.mjs +4 -4
  21. package/mac-bundle-signing.mjs +1 -1
  22. package/mac-sign-core.mjs +97 -0
  23. package/mac-signing-matrix.mjs +186 -0
  24. package/macos-notarization.mjs +18 -2
  25. package/msix-appinstaller.mjs +4 -1
  26. package/msix-config.mjs +5 -1
  27. package/msix-repair-guard.mjs +24 -0
  28. package/msix-repair-guard.ps1 +356 -0
  29. package/notarize-macos.mjs +4 -3
  30. package/ownership-catalog-config.json +6 -7
  31. package/ownership-command.mjs +69 -8
  32. package/ownership-evidence.mjs +1 -1
  33. package/ownership-key-authority.mjs +1 -1
  34. package/ownership-key-command.mjs +15 -3
  35. package/ownership-key-elevation.mjs +11 -2
  36. package/ownership-mandatory-set.json +420 -0
  37. package/ownership-mandatory-set.mjs +273 -0
  38. package/ownership.mjs +2 -2
  39. package/package.json +15 -3
  40. package/packaged-app-identity.mjs +88 -0
  41. package/preflight.mjs +25 -3
  42. package/prune-r2.mjs +28 -3
  43. package/publication-gate.mjs +49 -10
  44. package/publish-cargo.mjs +21 -1
  45. package/publish-swift.mjs +78 -141
  46. package/publish-update.mjs +24 -2
  47. package/qa-build-features.mjs +216 -0
  48. package/qa-control-scan.mjs +435 -0
  49. package/registry-parity.mjs +18 -1
  50. package/release-invocation.mjs +2 -1
  51. package/release-state.mjs +9 -0
  52. package/release-switch-registry.json +33 -0
  53. package/release-switch-scan.mjs +452 -0
  54. package/release.mjs +324 -26
  55. package/renamed-away.mjs +29 -0
  56. package/rightkit-ownership.json +682 -709
  57. package/rightkit-sync.mjs +8 -4
  58. package/rightkit-version-policy.mjs +18 -0
  59. package/rightkit-versions.json +212 -127
  60. package/runtime-budget-classes.json +4 -0
  61. package/runtime-budgets.mjs +870 -0
  62. package/runtime-bundle-scan.mjs +61 -0
  63. package/sign-macos.mjs +28 -30
  64. package/sign-updater.mjs +5 -2
  65. package/swift-mac-dmg.mjs +7 -2
  66. package/swift-package-command.mjs +1 -1
  67. package/upload-release.mjs +10 -37
package/release.mjs CHANGED
@@ -21,6 +21,7 @@ import { packageCommandOptional, resolveProjectType, targetUsesMsix } from "./pr
21
21
  import { MSIX_SIGNING_CONTRACT, assertValidMsixTarget } from "./msix-config.mjs";
22
22
  import { buildMsix } from "./msix-build.mjs";
23
23
  import { msixBuildOptions } from "./msix-target.mjs";
24
+ import { rightKitVersionAllowed } from "./rightkit-version-policy.mjs";
24
25
  import { collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
25
26
  import { patchTauriBundleType } from "./tauri-bundle-marker.mjs";
26
27
  import { verifyNsisEmbeddedBinary } from "./nsis-payload.mjs";
@@ -30,12 +31,17 @@ import { signingReceiptPath } from "./signing-receipts.mjs";
30
31
  import { notarizeAppBundle, validateNotarizationReceipt } from "./macos-notarization.mjs";
31
32
  import { MACOS_PORTABLE_SIGNING_CONTRACT } from "./macos-signing-contract.mjs";
32
33
  import { fileEvidence } from "./macos-file-evidence.mjs";
33
- import { expandMacSigningEntries } from "./mac-bundle-signing.mjs";
34
+ import { readDmgSpec } from "./dmg-spec.mjs";
35
+ import { hasNonDefaultSettings, matrixReceiptProblems, planMacSigning, validateMacSigningConfig } from "./mac-signing-matrix.mjs";
34
36
  import { buildStapledSwiftMacDmg, swiftMacDmgConfigError, swiftMacDmgPlan } from "./swift-mac-dmg.mjs";
35
37
  import { runConfiguredChecks } from "./release-checks.mjs";
36
38
  import { resolveMacosDeveloperIdIdentity } from "./macos-signing-identity.mjs";
37
39
  import { DEFAULT_STORAGE_THRESHOLD_BYTES, findInstallerCopyScripts, findLargeRepositorySubdirectories } from "./installer-storage-doctor.mjs";
38
40
  import { macosCodesignEnvironment } from "./codesign-environment.mjs";
41
+ import { assertNoQaControl, assertNoWebBundleMocks } from "./qa-control-scan.mjs";
42
+ import { assertNoForbiddenBuildFeatures } from "./qa-build-features.mjs";
43
+ import { assertNoReleaseSwitches, commandTokens, resolveFeatureClosure } from "./release-switch-scan.mjs";
44
+ import { devtoolsEvidence, resolveDevtoolsAllowance } from "./devtools-policy.mjs";
39
45
 
40
46
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
41
47
  const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
@@ -51,6 +57,7 @@ const RIGHTKIT_ALLOWED = buildAllowedVersions(
51
57
  RIGHTKIT_VERSIONS.npm,
52
58
  RIGHTKIT_VERSIONS.stagedNpm,
53
59
  RIGHTKIT_VERSIONS.legacyNpm,
60
+ RIGHTKIT_VERSIONS.registry?.npm,
54
61
  );
55
62
  const RIGHTKIT_CARGO_ALLOWED = buildAllowedVersions(RIGHTKIT_VERSIONS.cargo, RIGHTKIT_VERSIONS.stagedCargo, RIGHTKIT_VERSIONS.legacyCargo);
56
63
  const FORBIDDEN_RIGHTKIT_SPEC = /^(?:git|file|link|workspace):|github|github\.com/i;
@@ -58,6 +65,35 @@ const WINDOWS_NSIS_SIGNING_CONTRACT = "windows-raw-exe-authenticode-before-nsis-
58
65
  const WINDOWS_PORTABLE_SIGNING_CONTRACT = "windows-raw-exe-authenticode-before-portable-v1";
59
66
  const DEFAULT_NOTARIZATION_TIMEOUT_MS = 30 * 60 * 1000;
60
67
 
68
+ // Apple Developer ID and notary secrets: the only secrets the signing jobs of the managed release chain
69
+ // (macos-sign, and dev-mac-sign when devArtifact.mac is "notarize") may reference.
70
+ const APPLE_RELEASE_SECRETS = ["APPLE_CERTIFICATE_BASE64", "APPLE_CERTIFICATE_PASSWORD", "APPLE_KEYCHAIN_PASSWORD", "APPLE_API_KEY_BASE64", "APPLE_API_KEY", "APPLE_API_ISSUER"];
71
+ // Package-manager publication tokens of the managed publish-package-managers lane.
72
+ const PACKAGE_MANAGER_PUBLISH_SECRETS = ["HOMEBREW_TAP_TOKEN", "WINGET_CREATE_GITHUB_TOKEN"];
73
+
74
+ // @rightkit/ci `swift.privatePackages` (0.2.41) renders one configure step and one cleanup step, with the
75
+ // token passed through step `env` as `RIGHT_GIT_PRIVATE_TOKEN_<n>: ${{ secrets.<NAME> }}`, into exactly these
76
+ // workflow/job pairs. Nothing else may reference the declared secret: not another job of these workflows, not
77
+ // package-smoke, publish-npm or publish-package-managers, and not a signing, qualification or publish job.
78
+ const SWIFT_PRIVATE_STEP_NAMES = new Set(["Configure private Swift package access", "Remove private Swift package access"]);
79
+ const SWIFT_PRIVATE_ANY_PROFILE = { only: "any profile", allows: () => true };
80
+ const SWIFT_PRIVATE_NODE_PROFILE = { only: "the node profile", allows: (manifest) => (manifest?.profile ?? "node") === "node" };
81
+ const SWIFT_PRIVATE_PARALLEL_DEV_MAC = {
82
+ only: "profile rust-hybrid with devArtifact.parallel and a devArtifact.mac of sign, notarize or unsigned",
83
+ allows: (manifest) => manifest?.profile === "rust-hybrid"
84
+ && manifest?.devArtifact?.parallel === true
85
+ && ["sign", "notarize", "unsigned"].includes(manifest.devArtifact.mac),
86
+ };
87
+ const SWIFT_PRIVATE_JOBS = new Map([
88
+ ["ci.yml", new Map([["ci", SWIFT_PRIVATE_ANY_PROFILE], ["dev-artifact-mac", SWIFT_PRIVATE_PARALLEL_DEV_MAC], ["test", SWIFT_PRIVATE_NODE_PROFILE]])],
89
+ ["qualification.yml", new Map([["qualification", SWIFT_PRIVATE_ANY_PROFILE]])],
90
+ ["release-candidate.yml", new Map([["candidate", SWIFT_PRIVATE_ANY_PROFILE]])],
91
+ ["regenerate.yml", new Map([["regenerate", SWIFT_PRIVATE_ANY_PROFILE]])],
92
+ ]);
93
+ // release-candidate.yml and regenerate.yml are dispatch lanes by design. In ci.yml and qualification.yml a
94
+ // workflow_dispatch trigger would let a manual run of any ref reach the credential.
95
+ const SWIFT_PRIVATE_DISPATCH_WORKFLOWS = new Set(["release-candidate.yml", "regenerate.yml"]);
96
+
61
97
  const args = process.argv.slice(2);
62
98
  const opts = {
63
99
  config: "right-release.config.mjs",
@@ -103,7 +139,8 @@ if (opts.resumeFrom && !MAC_RESUME_PHASES.includes(opts.resumeFrom)) usage(2, `i
103
139
  const sccacheVersion = assertSharedSccachePrerequisite();
104
140
 
105
141
  const requestedConfigPath = path.resolve(opts.config);
106
- const configPath = opts.doctor ? realpathSync(requestedConfigPath) : requestedConfigPath;
142
+ // Native: expands a Windows short name (RUNNER~1), so the path compares with Git's long root below.
143
+ const configPath = opts.doctor ? realpathSync.native(requestedConfigPath) : requestedConfigPath;
107
144
  let doctorInvocation;
108
145
  if (opts.doctor) {
109
146
  const invocationRoot = path.dirname(configPath);
@@ -138,6 +175,7 @@ const legalContract = config.legal
138
175
  if (target.signed !== true) {
139
176
  fail(`${config.app ?? "app"} ${opts.platform} must declare signed: true; unsigned release targets are forbidden`);
140
177
  }
178
+ let macSigning; // validated macOS sign.prePackageFiles / sign.nestedCode (mac only)
141
179
  if (opts.platform === "win") {
142
180
  const isNsis = target.signingContract === WINDOWS_NSIS_SIGNING_CONTRACT;
143
181
  const isPortable = target.signingContract === WINDOWS_PORTABLE_SIGNING_CONTRACT;
@@ -148,6 +186,7 @@ if (opts.platform === "win") {
148
186
  } else {
149
187
  if (!target.prePackage?.cmd) fail(`${config.app ?? "app"} win must declare prePackage for the raw EXE build`);
150
188
  if (!target.sign?.prePackageFiles?.length) fail(`${config.app ?? "app"} win must declare at least one sign.prePackageFiles raw EXE`);
189
+ if (target.sign.prePackageFiles.some((file) => typeof file !== "string") || target.sign.nestedCode !== undefined) fail(`${config.app ?? "app"} win sign.prePackageFiles entries must be path strings; object entries and sign.nestedCode are macOS-only`);
151
190
  if (isNsis && target.sign.prePackageFiles.length !== 1) fail(`${config.app ?? "app"} win NSIS must declare exactly one sign.prePackageFiles raw EXE`);
152
191
  if (isNsis && !target.sign?.files?.length) fail(`${config.app ?? "app"} win NSIS must declare sign.files for installer signing`);
153
192
  if (isNsis && target.sign.prePackageFiles.some((file) => target.sign.files.includes(file))) fail(`${config.app ?? "app"} win raw EXE and installer signing files must be distinct`);
@@ -158,10 +197,21 @@ if (opts.platform === "mac") {
158
197
  if (target.signingContract !== MACOS_PORTABLE_SIGNING_CONTRACT) fail(`${config.app ?? "app"} mac must declare signingContract: ${MACOS_PORTABLE_SIGNING_CONTRACT}`);
159
198
  if (!target.prePackage?.cmd) fail(`${config.app ?? "app"} mac must declare prePackage to materialize Developer ID signing files`);
160
199
  if (!target.sign?.prePackageFiles?.length) fail(`${config.app ?? "app"} mac must declare at least one sign.prePackageFiles`);
200
+ try { macSigning = validateMacSigningConfig(target.sign, { root }); } catch (error) { fail(`${config.app ?? "app"} mac ${error.message}`); }
161
201
  if (!target.notarize?.file) fail(`${config.app ?? "app"} mac must declare notarize.file`);
162
202
  if (target.notarize.appBeforePackage && !target.notarize.app) fail(`${config.app ?? "app"} mac notarize.appBeforePackage requires notarize.app`);
203
+ if (target.notarize.staple === false && (target.notarize.app || /\.(dmg|pkg)$/i.test(target.notarize.file))) {
204
+ fail(`${config.app ?? "app"} mac notarize.staple: false applies only to an archive of bare executables (no notarize.app, not a .dmg/.pkg)`);
205
+ }
163
206
  const swiftDmgError = swiftMacDmgConfigError({ target, projectType: resolveProjectType({ appRoot: root, config, target }), appName: config.app ?? "app" });
164
207
  if (swiftDmgError) fail(swiftDmgError);
208
+ if (target.dmg && typeof target.dmg === "object" && target.dmg.spec !== undefined) {
209
+ try {
210
+ if (typeof target.dmg.spec !== "string" || !target.dmg.spec) throw new Error("dmg.spec must be a path string");
211
+ const specOptions = readDmgSpec(path.resolve(root, target.dmg.spec), { app: target.notarize.app });
212
+ for (const asset of [specOptions.background, specOptions.volumeIcon]) if (asset && !existsSync(asset)) throw new Error(`referenced file not found: ${asset}`);
213
+ } catch (error) { fail(`${config.app ?? "app"} mac targets.mac.dmg.spec: ${error.message}`); }
214
+ }
165
215
  }
166
216
  if (opts.upload && target.publishBlocked) fail(`${config.app ?? "app"} ${opts.platform} publish blocked: ${target.publishBlocked}`);
167
217
  /**
@@ -251,6 +301,61 @@ if (opts.doctor) {
251
301
  process.exit(0);
252
302
  }
253
303
 
304
+ let prePackScanned = false; // set once a pre-pack payload scan has seen a native binary
305
+ // Source-level layer of "no test-automation surface ships": refuse a release or update build whose build command
306
+ // or app Cargo manifest enables devtools, qa-native, qa-control or dev-native (also in --dry-run).
307
+ // DevTools: refused for every app except an allow-listed one that declares `devtools: "content-only"` (devtools-policy.mjs).
308
+ let devtoolsAllowance = { mode: "denied" };
309
+ try {
310
+ devtoolsAllowance = resolveDevtoolsAllowance({ config, root });
311
+ if (devtoolsAllowance.mode === "content-only") console.log(`right-release: devtools allowance recorded: ${JSON.stringify(devtoolsEvidence(devtoolsAllowance))}`);
312
+ assertNoForbiddenBuildFeatures({ root, tier: "release/update", devtools: devtoolsAllowance, cargoManifest: target.cargoManifest, manifest: config.releaseSwitches?.manifest, commands: [
313
+ { label: "target.prePackage", command: target.prePackage },
314
+ { label: "target.package", command: target.package },
315
+ { label: "nativeAssembly.packageHook", command: nativeAssembly?.packageHook },
316
+ { label: "nativeAssembly.finalizer", command: nativeFinalizer },
317
+ { label: "target.postSign", command: target.postSign },
318
+ ] });
319
+ } catch (error) { fail(error.message); }
320
+
321
+ // D4 (M21/M23) source layer: every environment/argument read in app source is registered in the app's switches.json and
322
+ // every S1-S4 selector is compiled out of release. The inventory is `config.releaseSwitches.inventory` (relative to the
323
+ // app root) or `switches.json`; a release without one fails (a dry run only checks one that exists).
324
+ // The resolved feature graph (`cargo tree -e features`) is the AUTHORITATIVE test-mode check: it runs here, in the app
325
+ // repository, for the release target, and a release that cannot run it fails (a dry run reports and continues).
326
+ // The binary marker/name scan below is secondary evidence.
327
+ const releaseSwitchInventoryPath = path.resolve(root, config.releaseSwitches?.inventory ?? "switches.json");
328
+ const releaseSwitchInventoryText = existsSync(releaseSwitchInventoryPath) ? readFileSync(releaseSwitchInventoryPath, "utf8") : null;
329
+ try {
330
+ let treeText;
331
+ try {
332
+ const closure = resolveFeatureClosure({
333
+ root,
334
+ manifest: config.releaseSwitches?.manifest,
335
+ cargoManifest: target.cargoManifest,
336
+ rust: config.releaseSwitches?.rust,
337
+ features: config.releaseSwitches?.features ?? [],
338
+ triple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget,
339
+ commandTexts: [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, target.postSign],
340
+ });
341
+ if (closure.skipped) console.log(`right-release: feature closure skipped (${closure.skipped})`);
342
+ else treeText = closure.treeText;
343
+ } catch (error) {
344
+ if (!opts.dryRun) throw error;
345
+ console.log(`right-release: dry run: ${error.message}`);
346
+ }
347
+ // remoteHost: rightkit-daemon `remote` is refused in the closure unless the app declares releaseSwitches.remoteHost: true.
348
+ const closureInput = { treeText, remoteHost: config.releaseSwitches?.remoteHost, commands: [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, target.postSign].filter(Boolean).map((c) => commandTokens(c)) };
349
+ if (releaseSwitchInventoryText != null) {
350
+ assertNoReleaseSwitches({ ...closureInput, sourceRoot: root, inventoryText: releaseSwitchInventoryText, appForbid: config.releaseSwitches?.forbid ?? [] }, "source switch inventory");
351
+ } else if (treeText != null || closureInput.commands.length) {
352
+ assertNoReleaseSwitches({ ...closureInput, appForbid: config.releaseSwitches?.forbid ?? [] }, "feature closure");
353
+ }
354
+ if (releaseSwitchInventoryText == null && !opts.dryRun) {
355
+ throw new Error(`source switch inventory: ${path.relative(root, releaseSwitchInventoryPath) || releaseSwitchInventoryPath} is missing; a release build needs the app's switches.json (see packages/release/README.md, "switches.json")`);
356
+ }
357
+ } catch (error) { fail(error.message); }
358
+
254
359
  const releaseLock = acquireReleaseLock(root, config.app ?? path.basename(root), opts.platform, opts.tier);
255
360
  const mode = "package";
256
361
  const command = target.package;
@@ -280,15 +385,21 @@ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT)
280
385
  // packs the exact signed bytes, signs the package through sign-windows, verifies signer == Publisher,
281
386
  // writes the App Installer route and the seal record. Never a second signer.
282
387
  await runCommand(target.prePackage, root);
388
+ // Raw payload scan BEFORE the package is packed (a packed MSIX is only readable through its ZIP directory).
389
+ assertNoQaControlBinaries("msix staged payload", [path.resolve(root, target.msix.stageDir)], { requireBinary: true, prePack: true });
283
390
  const options = msixBuildOptions({ root, target, app: config.app, dryRun: opts.dryRun, receipts: {
284
391
  pe: windowsReceiptPath(root, "raw-exe"), msix: windowsReceiptPath(root, "installer"),
285
392
  dir: path.dirname(windowsReceiptPath(root, "raw-exe")) } });
286
393
  const built = buildMsix(options, { log: (event) => console.log(`right-release: ${JSON.stringify(event)}`) });
287
394
  console.log(`right-release: msix ${built.dryRun ? "dry-run" : "built"} ${options.out}`);
395
+ // Second layer: read the finished package through its ZIP central directory (stored and deflate entries).
396
+ if (!built.dryRun) assertNoQaControlBinaries("msix package", [options.out], { requireBinary: true });
288
397
  } else if (opts.platform === "win") {
289
398
  const rawFiles = target.sign.prePackageFiles.map((p) => path.resolve(root, p));
290
399
  await runCommand(target.prePackage, root);
291
400
  for (const file of rawFiles) await mustExist(file, `missing raw EXE signing artifact: ${file}`);
401
+ // Before signing and packaging: the raw EXE is uncompressed, so a QA control server is visible here.
402
+ assertNoQaControlBinaries("windows raw EXE", rawFiles, { requireBinary: true, prePack: true });
292
403
  // The marker must reach its FINAL value before signing. Tauri rewrites it during
293
404
  // bundling, and doing that to signed bytes invalidates Authenticode — the exact
294
405
  // defect that shipped invalid binaries. Idempotent, so an app that still patches
@@ -314,10 +425,17 @@ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT)
314
425
  } else if (opts.platform === "mac") {
315
426
  if (runMacPhase("prepackage")) await runCommand(target.prePackage, root);
316
427
  // Resolve after prePackage: with storage governance the artifact may live only in RIGHT_RELEASE_ARTIFACT_DIR.
317
- const signingFiles = target.sign.prePackageFiles.map((p) => resolveReleaseArtifactPath(root, p));
318
- for (const file of signingFiles) await mustExist(file, `missing macOS Developer ID signing artifact: ${file}`);
428
+ const signingEntries = macSigning.entries.map((entry) => ({ ...entry, file: resolveReleaseArtifactPath(root, entry.file) }));
429
+ for (const { file } of signingEntries) await mustExist(file, `missing macOS Developer ID signing artifact: ${file}`);
430
+ assertNoQaControlBinaries("macOS signing set", signingEntries.map(({ file }) => file), { requireBinary: true, prePack: true });
431
+ // The DMG is built from the .app (notarize.app / the signing set); a compressed DMG cannot be scanned afterwards.
432
+ if (target.notarize.app) {
433
+ const stagedApp = resolveReleaseArtifactPath(root, target.notarize.app);
434
+ await mustExist(stagedApp, `missing macOS app to be packed into the DMG: ${stagedApp}`);
435
+ assertNoQaControlBinaries("macOS app staged for the DMG", [stagedApp], { requireBinary: true, prePack: true });
436
+ }
319
437
  if (runMacPhase("sign")) {
320
- await signMacos(signingFiles, root);
438
+ await signMacos(signingEntries, root, "signing", macSigning.nestedCode);
321
439
  // Opt-in (notarize.appBeforePackage): staple the .app itself before the package step copies it into the DMG.
322
440
  if (target.notarize.appBeforePackage) await notarizeAppBeforePackage(root);
323
441
  }
@@ -334,15 +452,23 @@ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT)
334
452
  }
335
453
  }
336
454
 
455
+ // Extra files or directories the app declares (for example the unpacked bundle or a staged payload).
456
+ assertNoQaControlBinaries("target.qaControlScan", (target.qaControlScan ?? []).map((p) => resolveReleaseArtifactPath(root, p)), { requireBinary: (target.qaControlScan ?? []).length > 0, prePack: true });
457
+
458
+ // Frontend bundle: no browser-QA mock may ship. Read-only, so it also runs under --dry-run (against a dist that already exists).
459
+ assertNoWebBundleMocksInFrontend("pre-package", { required: false });
460
+
337
461
  if (opts.platform === "mac" && runMacPhase("package")) {
338
462
  // SwiftPM + appBeforePackage: the .app is stapled by now, so right-release packs it into the DMG itself.
339
463
  const swiftDmg = swiftMacDmgPlan({ target, projectType: resolveProjectType({ appRoot: root, config, target }), appName: config.app ?? path.basename(root), resolve: (p) => resolveReleaseArtifactPath(root, p) });
340
464
  if (swiftDmg) {
341
465
  if (opts.dryRun) console.log(`dry-run: build DMG ${swiftDmg.out} from stapled app ${swiftDmg.app}, then sign it`);
342
- else await buildStapledSwiftMacDmg(swiftDmg, { remove: (file) => rmSync(file, { force: true }), signDmg: (dmg) => signMacos([dmg], root, "dmg-signing") });
466
+ else await buildStapledSwiftMacDmg(swiftDmg, { remove: (file) => rmSync(file, { force: true }), signDmg: (dmg) => signMacos([{ file: dmg, options: [] }], root, "dmg-signing") });
343
467
  }
344
468
  }
345
469
  if (command && (opts.platform !== "mac" || runMacPhase("package"))) await runCommand(command, root);
470
+ // The package command builds the frontend; a real run must now find and clean it.
471
+ assertNoWebBundleMocksInFrontend("post-package", { required: !opts.dryRun });
346
472
 
347
473
  for (const rel of target.artifacts ?? []) {
348
474
  await mustExist(resolveReleaseArtifactPath(root, rel), `missing release artifact: ${rel}`);
@@ -361,7 +487,7 @@ if (opts.platform === "win" && target.signingContract === WINDOWS_NSIS_SIGNING_C
361
487
  if (target.postSign) await runCommand(target.postSign, root);
362
488
  const updaterFiles = [...new Set((target.updater?.artifacts ?? []).map((artifact) => resolveReleaseArtifactPath(root, artifact.file)))];
363
489
  if (!updaterFiles.length) fail(`${config.app} win must declare updater.artifacts for post-code-signing minisign`);
364
- await run("node", [SIGN_UPDATER, ...(opts.dryRun ? ["--dry-run"] : []), ...updaterFiles.flatMap((file) => ["--file", file])], root);
490
+ await run("node", [SIGN_UPDATER, ...(opts.dryRun ? ["--dry-run"] : []), ...(target.updater?.keyFile ? ["--key-file", String(target.updater.keyFile)] : []), ...updaterFiles.flatMap((file) => ["--file", file])], root);
365
491
  }
366
492
 
367
493
  if (target.postSign && opts.platform !== "win" && (opts.platform !== "mac" || runMacPhase("post-sign"))) {
@@ -384,6 +510,10 @@ if (opts.platform !== "mac" || runMacPhase("harden")) {
384
510
  hardeningEvidence = path.join(receiptRoot, opts.platform + "-hardening-evidence.json");
385
511
  materializeHardeningEvidence({ root, allowances: target.hardeningAllowances, outputPath: hardeningEvidence });
386
512
  }
513
+ // Fail closed: the installer itself (NSIS LZMA, compressed DMG) cannot be read, so the raw/staged payload must
514
+ // have been scanned before packing. Inputs that are only ZIP containers (MSIX) are scanned in place.
515
+ if (!opts.dryRun && !prePackScanned) fail("no pre-pack payload scan ran for this release (raw EXE, staged payload or .app); an installer cannot be scanned after packing");
516
+ assertNoQaControlBinaries("hardening targets", scanTargets.map((p) => resolveReleaseArtifactPath(root, p)));
387
517
  await run("node", [
388
518
  HARDENING_SCAN,
389
519
  ...(hardeningEvidence ? ["--allow-evidence", hardeningEvidence] : []),
@@ -422,6 +552,51 @@ function usage(code, message) {
422
552
  // Product package scripts may emit final installer bytes directly into the
423
553
  // managed artifact directory. Keep legacy target-relative paths working while
424
554
  // preferring managed output when the local path does not exist.
555
+ // Release and update builds never contain the QA control server (rightkit-qa-driver `qa-control`).
556
+ // The development lane (development.mjs) does not run this file and is allowed to contain it.
557
+ // `requireBinary`: an input holding no native binary (missing or empty staging dir) is a failure, not a pass.
558
+ // `prePack`: marks that the raw payload was scanned before packing. What stays unscannable after packing
559
+ // (NSIS LZMA payload, compressed DMG) is logged so the release record says so.
560
+ function assertNoQaControlBinaries(label, files, { requireBinary = false, prePack = false } = {}) {
561
+ if (opts.dryRun || files.length === 0) return;
562
+ try {
563
+ const stats = assertNoQaControl(files, label, { requireBinary, allowContentOnlyDevtools: devtoolsAllowance.mode === "content-only" });
564
+ // D4 binary layer, same inputs: no test-mode marker and no S1-S4 selector name in any native binary.
565
+ assertNoReleaseSwitches({ binaryPaths: files, inventoryText: releaseSwitchInventoryText ?? undefined, appForbid: config.releaseSwitches?.forbid ?? [], requireBinary: false }, `${label} (release switches)`);
566
+ if (prePack && stats.binaries > 0) prePackScanned = true;
567
+ for (const opaque of stats.opaque) {
568
+ console.log(`right-release: ${label}: ${opaque.kind.toUpperCase()} payload is compressed and cannot be scanned (${opaque.file}); the raw payload was scanned before packing`);
569
+ }
570
+ } catch (error) {
571
+ fail(error.message);
572
+ }
573
+ }
574
+
575
+ // Frontend dist directories to scan: `target.webBundleScan` (explicit, relative to the app root) or, when absent, the
576
+ // Tauri `build.frontendDist` / `build.distDir` of src-tauri/tauri.conf.json. A URL (dev server) is not a bundle.
577
+ function frontendBundleDirs() {
578
+ if (Array.isArray(target.webBundleScan)) return target.webBundleScan.map((p) => path.resolve(root, p));
579
+ const confPath = path.join(root, "src-tauri", "tauri.conf.json");
580
+ if (!existsSync(confPath)) return [];
581
+ try {
582
+ const build = JSON.parse(readFileSync(confPath, "utf8")).build ?? {};
583
+ const dist = build.frontendDist ?? build.distDir;
584
+ if (typeof dist !== "string" || /^[a-z][a-z0-9+.-]*:\/\//i.test(dist)) return [];
585
+ return [path.resolve(root, "src-tauri", dist)];
586
+ } catch (error) { fail(`src-tauri/tauri.conf.json is unreadable, so the frontend bundle cannot be located for the mock scan: ${error.message}`); }
587
+ }
588
+
589
+ function assertNoWebBundleMocksInFrontend(phase, { required }) {
590
+ const dirs = frontendBundleDirs() ?? [];
591
+ const present = dirs.filter((dir) => existsSync(dir));
592
+ if (!required && present.length === 0) return;
593
+ if (dirs.length === 0) return;
594
+ try {
595
+ const { files } = assertNoWebBundleMocks(required ? dirs : present, `${phase} frontend bundle`, { requireFiles: true });
596
+ console.log(`right-release: ${phase} frontend bundle: ${files} web file(s) scanned, no browser-QA mock marker`);
597
+ } catch (error) { fail(error.message); }
598
+ }
599
+
425
600
  function resolveReleaseArtifactPath(root, declared) {
426
601
  const local = path.resolve(root, declared);
427
602
  if (existsSync(local)) return local;
@@ -720,16 +895,105 @@ async function validateRightKitPackageContract(root, appName, hostedWorkflows) {
720
895
  fail(`${appName ?? pkg.name ?? "app"} ${name} must come from the published npm package, not ${specifier}`);
721
896
  }
722
897
  const allowed = RIGHTKIT_ALLOWED.get(name);
723
- if (allowed && !allowed.has(specifier)) {
898
+ if (allowed && !rightKitVersionAllowed(allowed, specifier)) {
724
899
  fail(`${appName ?? pkg.name ?? "app"} ${name} must be one of ${[...allowed].join(", ")}, got ${specifier}`);
725
900
  }
726
901
  }
727
902
  const releaseAllowed = RIGHTKIT_ALLOWED.get("@rightkit/release");
728
- if (!releaseAllowed?.has(pkg.devDependencies?.["@rightkit/release"])) {
903
+ if (!releaseAllowed || !rightKitVersionAllowed(releaseAllowed, pkg.devDependencies?.["@rightkit/release"])) {
729
904
  fail(`${appName ?? pkg.name ?? "app"} must depend on a supported @rightkit/release version in devDependencies`);
730
905
  }
731
906
  }
732
907
 
908
+ // The top-level `on:` block(s) of a workflow: the `on` line and every indented, blank or comment line after it.
909
+ function workflowTriggerBlocks(source) {
910
+ const lines = source.split("\n");
911
+ const blocks = [];
912
+ for (let index = 0; index < lines.length; index += 1) {
913
+ if (!/^(?:on|"on"|'on')[ \t]*:/.test(lines[index])) continue;
914
+ let end = index + 1;
915
+ while (end < lines.length && (lines[end] === "" || /^[\s#]/.test(lines[end]))) end += 1;
916
+ blocks.push(lines.slice(index, end).join("\n"));
917
+ }
918
+ return blocks;
919
+ }
920
+
921
+ // The job of a managed workflow that holds `index` ({ name, start }), or null when the offset sits outside every job.
922
+ function workflowJobAt(source, index) {
923
+ const marker = "\njobs:\n";
924
+ const jobsAt = source.indexOf(marker);
925
+ if (jobsAt < 0) return null;
926
+ const bodyStart = jobsAt + marker.length;
927
+ const unindented = /^[^\s#]/m.exec(source.slice(bodyStart));
928
+ const bodyEnd = unindented ? bodyStart + unindented.index : source.length;
929
+ if (index < bodyStart || index >= bodyEnd) return null;
930
+ let job = null;
931
+ for (const header of source.slice(bodyStart, bodyEnd).matchAll(/^ {2}(?![\s#])([^\n]*)$/gm)) {
932
+ const start = bodyStart + header.index;
933
+ if (start > index) break;
934
+ const key = /^(?:"([^"\n]*)"|'([^'\n]*)'|([^\s:"'][^\s:]*))[ \t]*:/.exec(header[1]);
935
+ job = { name: key ? (key[1] ?? key[2] ?? key[3]) : header[1], start };
936
+ }
937
+ return job;
938
+ }
939
+
940
+ // Why a `secrets.<name>` reference at `index` is not the declared swift.privatePackages credential in its
941
+ // exact rendered place, or null when it is. Callers handle the signing and publish allowances first.
942
+ function swiftPrivateSecretRefusal({ source, workflow, manifest, lanes, name, index }) {
943
+ const refuse = (reason) => `secrets.${name}: ${reason}`;
944
+ const entries = manifest?.swift?.privatePackages;
945
+ const declared = Array.isArray(entries)
946
+ && entries.length > 0
947
+ && entries.every((entry) => typeof entry?.secret === "string" && /^[A-Za-z_][A-Za-z0-9_]{0,99}$/.test(entry.secret) && !/^github_/i.test(entry.secret));
948
+ if (!declared || !entries.some((entry) => entry.secret === name)) {
949
+ return refuse(Array.isArray(entries) && entries.length > 0
950
+ ? "not an allowed release secret and not declared in .rightgit.json swift.privatePackages"
951
+ : "not an allowed release secret for this workflow");
952
+ }
953
+ const publishSecrets = manifest?.candidate?.releaseChain?.publishSecrets;
954
+ const reserved = new Set([...APPLE_RELEASE_SECRETS, ...PACKAGE_MANAGER_PUBLISH_SECRETS, ...(Array.isArray(publishSecrets) ? publishSecrets : [])]);
955
+ const collisions = entries.map((entry) => entry.secret).filter((secret) => reserved.has(secret));
956
+ if (collisions.length > 0) {
957
+ return refuse(`.rightgit.json swift.privatePackages declares a signing or publishing secret name (${collisions.join(", ")})`);
958
+ }
959
+ const jobs = SWIFT_PRIVATE_JOBS.get(workflow);
960
+ if (!jobs) return refuse(`swift.privatePackages credentials are never rendered into ${workflow}`);
961
+ const lane = workflow.replace(/\.yml$/, "");
962
+ if (!lanes.includes(lane)) return refuse(`lane ${lane} is not listed in .rightgit.json`);
963
+ if (manifest?.devArtifact?.dispatch === true) return refuse("devArtifact.dispatch is true");
964
+ const triggerBlocks = workflowTriggerBlocks(source);
965
+ if (triggerBlocks.length === 0) return refuse(`${workflow} has no on: block to check its triggers`);
966
+ const untrusted = /\b(pull_request_target|workflow_run)\b/.exec(triggerBlocks.join("\n"));
967
+ if (untrusted) return refuse(`${workflow} is triggered by ${untrusted[1]}`);
968
+ if (!SWIFT_PRIVATE_DISPATCH_WORKFLOWS.has(workflow) && /workflow_dispatch/.test(source)) {
969
+ return refuse(`${workflow} has a workflow_dispatch trigger`);
970
+ }
971
+ const job = workflowJobAt(source, index);
972
+ if (!job) return refuse(`reference sits outside every job of ${workflow}`);
973
+ const rule = jobs.get(job.name);
974
+ if (!rule) return refuse(`job '${job.name}' of ${workflow} may not hold the swift.privatePackages credential (allowed: ${[...jobs.keys()].join(", ")})`);
975
+ if (!rule.allows(manifest)) return refuse(`job '${job.name}' of ${workflow} is rendered only for ${rule.only}`);
976
+ const lineStart = source.lastIndexOf("\n", index) + 1;
977
+ const lineEnd = source.indexOf("\n", index);
978
+ const line = source.slice(lineStart, lineEnd < 0 ? source.length : lineEnd);
979
+ const envLine = /^ {10}RIGHT_GIT_PRIVATE_TOKEN_(0|[1-9]\d*): \$\{\{ secrets\.([A-Za-z0-9_]+) \}\}$/.exec(line);
980
+ if (!envLine || envLine[2] !== name || entries[Number(envLine[1])]?.secret !== name) {
981
+ return refuse("not a RIGHT_GIT_PRIVATE_TOKEN_<n> env line naming entry <n> of swift.privatePackages");
982
+ }
983
+ // The owning step is the nearest preceding step line (` - ...`) of the same job, and the line must
984
+ // sit in that step's own `env:` block.
985
+ const beforeLine = source.slice(job.start, lineStart);
986
+ const owner = [...beforeLine.matchAll(/^ {6}- [^\n]*$/gm)].at(-1);
987
+ const ownerName = /^ {6}- name: (.+)$/.exec(owner?.[0] ?? "")?.[1];
988
+ const sinceOwner = owner ? beforeLine.slice(owner.index).split("\n") : [];
989
+ let header = sinceOwner.length - 1;
990
+ while (header >= 0 && (sinceOwner[header] === "" || /^ {10}/.test(sinceOwner[header]))) header -= 1;
991
+ if (!SWIFT_PRIVATE_STEP_NAMES.has(ownerName) || sinceOwner[header] !== " env:") {
992
+ return refuse('not in the env of the "Configure private Swift package access" or "Remove private Swift package access" step');
993
+ }
994
+ return null;
995
+ }
996
+
733
997
  async function validateHostedWorkflows(root, appName, workflows, policy) {
734
998
  if (policy !== "right-git-ci-only") {
735
999
  fail(`${appName} hosted workflow files are forbidden: ${workflows.join(", ")}`);
@@ -742,7 +1006,7 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
742
1006
  fail(`${appName} right-git-ci-only policy requires a valid .rightgit.json`);
743
1007
  }
744
1008
  const lanes = manifest?.lanes;
745
- // Mirrors the `lanes` enum in @rightkit/git manifest.schema.json.
1009
+ // Mirrors the `lanes` enum in @rightkit/ci manifest.schema.json.
746
1010
  const safeLanes = new Set(["ci", "qualification", "package-smoke", "release-candidate", "publish-npm", "publish-package-managers", "regenerate"]);
747
1011
  if (manifest?.schemaVersion !== 1
748
1012
  || manifest?.compile !== "github-actions-only"
@@ -772,7 +1036,7 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
772
1036
  };
773
1037
  const inside = (index, span) => span && index >= span.start && index < span.end;
774
1038
  const requestedSecrets = [...source.matchAll(/\bsecrets\.([A-Z0-9_]+)/gi)].map((match) => ({ name: match[1], index: match.index }));
775
- const allowedMacosSecrets = new Set(["APPLE_CERTIFICATE_BASE64", "APPLE_CERTIFICATE_PASSWORD", "APPLE_KEYCHAIN_PASSWORD", "APPLE_API_KEY_BASE64", "APPLE_API_KEY", "APPLE_API_ISSUER"]);
1039
+ const allowedMacosSecrets = new Set(APPLE_RELEASE_SECRETS);
776
1040
  const windowsSpan = jobSpan("windows-sign");
777
1041
  const macosSpan = jobSpan("macos-sign");
778
1042
  const publishSpan = jobSpan("publish");
@@ -799,10 +1063,10 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
799
1063
  const protectedMacosSigning = workflow === "release-candidate.yml"
800
1064
  && manifest?.candidate?.signMacos === true
801
1065
  && (legacySigningGate("macos-sign").test(macosSource) || admittedSigningGate("macos-sign").test(macosSource));
802
- // @rightkit/git `devArtifact.mac: "sign"` renders a `dev-mac-sign` job into
1066
+ // @rightkit/ci `devArtifact.mac: "sign"` renders a `dev-mac-sign` job into
803
1067
  // the managed ci.yml: push to the default branch only, release environment,
804
1068
  // Developer ID certificate secrets only (no notarization keys).
805
- // @rightkit/git `regenerate` lane: manual-only, opens a pull request from a
1069
+ // @rightkit/ci `regenerate` lane: manual-only, opens a pull request from a
806
1070
  // right-git/regenerate-<run> branch and never writes to the default branch.
807
1071
  // `contents: write` and `pull-requests: write` are accepted only inside that
808
1072
  // job, only when the manifest declares `regenerate`, and only when the
@@ -828,6 +1092,8 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
828
1092
  const protectedDevMacSigning = workflow === "ci.yml"
829
1093
  && (manifest?.devArtifact?.mac === "sign" || manifest?.devArtifact?.mac === "notarize")
830
1094
  && /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);
1095
+ // @rightkit/ci `swift.privatePackages` credential: allowed only in the workflow/job pairs and on the
1096
+ // env lines listed with SWIFT_PRIVATE_JOBS; swiftPrivateSecretRefusal explains every other reference.
831
1097
  const releaseChain = workflow === "release-candidate.yml" ? manifest?.candidate?.releaseChain : undefined;
832
1098
  const allowedReleaseSecrets = new Set(releaseChain?.publishSecrets ?? []);
833
1099
  const writePermissions = [...source.matchAll(/^\s*(actions|attestations|checks|contents|deployments|discussions|id-token|issues|models|packages|pages|pull-requests|security-events|statuses):\s*write\s*$/gmi)].map((match) => ({ name: match[1].toLowerCase(), index: match.index }));
@@ -844,6 +1110,14 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
844
1110
  const protectedReleasePublish = releaseChain
845
1111
  && (legacyReleasePublishGate || admittedReleasePublishGate)
846
1112
  && /\n permissions:\n contents: write\n/.test(publishSource);
1113
+ // A secret reference is refused unless a signing or publish allowance above covers it or it is the
1114
+ // declared swift.privatePackages credential in its exact rendered place.
1115
+ const refusedSecrets = requestedSecrets
1116
+ .filter(({ name, index }) => !(protectedMacosSigning && inside(index, macosSpan) && allowedMacosSecrets.has(name))
1117
+ && !(protectedDevMacSigning && inside(index, devMacSpan) && allowedDevMacSecrets.has(name))
1118
+ && !(protectedReleasePublish && inside(index, publishSpan) && allowedReleaseSecrets.has(name)))
1119
+ .map(({ name, index }) => swiftPrivateSecretRefusal({ source, workflow, manifest, lanes, name, index }))
1120
+ .filter((reason) => reason !== null);
847
1121
  if (/^\s*permissions:\s*write-all\s*$/mi.test(source)
848
1122
  || writePermissions.some(({ name, index }) => {
849
1123
  if (protectedRegenerate && inside(index, regenerateSpan) && allowedRegeneratePermissions.has(name)) return false;
@@ -853,20 +1127,21 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
853
1127
  })
854
1128
  || /pull_request_target\s*:/i.test(source)
855
1129
  || /\bsecrets\s*\[/i.test(source)
856
- || requestedSecrets.some(({ name, index }) => !(protectedMacosSigning && inside(index, macosSpan) && allowedMacosSecrets.has(name))
857
- && !(protectedDevMacSigning && inside(index, devMacSpan) && allowedDevMacSecrets.has(name))
858
- && !(protectedReleasePublish && inside(index, publishSpan) && allowedReleaseSecrets.has(name)))) {
859
- fail(`${appName} right-git managed ${workflow} requests release-capable permissions or secrets`);
1130
+ || refusedSecrets.length > 0) {
1131
+ const reasons = [...new Set(refusedSecrets)];
1132
+ const detail = reasons.length > 10 ? [...reasons.slice(0, 10), `${reasons.length - 10} more`] : reasons;
1133
+ fail(`${appName} right-git managed ${workflow} requests release-capable permissions or secrets${detail.length > 0 ? `: ${detail.join("; ")}` : ""}`);
860
1134
  }
861
1135
  }
862
1136
  }
863
1137
 
864
- function buildAllowedVersions(published = {}, staged = {}, legacy = {}) {
1138
+ function buildAllowedVersions(published = {}, staged = {}, legacy = {}, registry = {}) {
865
1139
  const allowed = new Map();
866
1140
  for (const [name, value] of [
867
1141
  ...Object.entries(published),
868
1142
  ...Object.entries(staged),
869
1143
  ...Object.entries(legacy),
1144
+ ...Object.entries(registry),
870
1145
  ]) {
871
1146
  const versions = allowed.get(name) ?? new Set();
872
1147
  for (const version of Array.isArray(value) ? value : [value]) versions.add(version);
@@ -905,24 +1180,37 @@ function windowsReceiptPath(root, phase) {
905
1180
  return signingReceiptPath({ appRoot: root, target, platform: "win", phase, stateRoot: process.env.RIGHT_RELEASE_STATE_ROOT });
906
1181
  }
907
1182
 
908
- async function signMacos(files, root, phase = "signing") {
1183
+ // A signing step with per-file settings (entitlements, codesign options) goes to sign-macos.mjs as a manifest;
1184
+ // a step with only defaults keeps the historical positional invocation.
1185
+ function macSignInvocation(items, receiptPath, extra = []) {
1186
+ if (opts.dryRun || !items.some(hasNonDefaultSettings)) return [SIGN_MACOS, ...extra, "--receipt", receiptPath, ...items.map((item) => item.file)];
1187
+ const manifest = path.resolve(path.dirname(receiptPath), `${path.basename(receiptPath, ".json")}.manifest.json`);
1188
+ mkdirSync(path.dirname(manifest), { recursive: true });
1189
+ writeFileSync(manifest, `${JSON.stringify({ schema: 1, entries: items.map((item) => ({ file: item.file, ...(item.entitlements ? { entitlements: item.entitlements } : {}), options: item.options ?? [] })) }, null, 2)}\n`);
1190
+ return [SIGN_MACOS, ...extra, "--receipt", receiptPath, "--manifest", manifest];
1191
+ }
1192
+
1193
+ async function signMacos(entries, root, phase = "signing", nestedCode = []) {
1194
+ const files = entries.map((entry) => entry.file);
909
1195
  const receipt = phase === "signing" ? macosReceiptPath(root, "signing") : path.resolve(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(root, ".right-release", "receipts"), `macos-${phase}.json`);
910
- // Directory entries (.app bundles): nested code first, no entitlements, then the bundle itself.
1196
+ // Directory entries (.app bundles): nested code first (default settings unless sign.nestedCode names it), then the bundle itself.
911
1197
  let nested = [];
912
- try { ({ nested } = opts.dryRun ? { nested: [] } : expandMacSigningEntries(files)); } catch (error) { fail(error.message); }
1198
+ try { ({ nested } = opts.dryRun ? { nested: [] } : planMacSigning({ entries, nestedCode })); } catch (error) { fail(error.message); }
1199
+ if (opts.dryRun && (nestedCode.length || entries.some(hasNonDefaultSettings))) {
1200
+ console.log(`right-release: dry-run: per-file signing matrix (${nestedCode.length} nestedCode rule(s), ${entries.filter(hasNonDefaultSettings).length} entry setting(s)); nested code is resolved against the built bundle at sign time`);
1201
+ }
913
1202
  if (nested.length) {
914
1203
  const nestedReceipt = path.resolve(path.dirname(receipt), `macos-${phase}-nested.json`);
915
1204
  if (existsSync(nestedReceipt)) unlinkSync(nestedReceipt);
916
1205
  console.log(`right-release: signing ${nested.length} nested code item(s) inside-out`);
917
- await run("node", [SIGN_MACOS, "--receipt", nestedReceipt, ...nested], root);
1206
+ await run("node", macSignInvocation(nested, nestedReceipt), root);
1207
+ verifyMacosSigningSettings(nestedReceipt, nested);
918
1208
  }
919
- const args = [SIGN_MACOS];
920
- if (opts.dryRun) args.push("--dry-run");
921
- args.push("--receipt", receipt, ...files);
1209
+ const args = macSignInvocation(entries, receipt, opts.dryRun ? ["--dry-run"] : []);
922
1210
  if (!opts.dryRun && existsSync(receipt)) unlinkSync(receipt);
923
1211
  console.log("right-release: signing macOS Developer ID files");
924
1212
  await run("node", args, root);
925
- if (!opts.dryRun) verifyMacosSigningReceipt(receipt, files);
1213
+ if (!opts.dryRun) { verifyMacosSigningReceipt(receipt, files); verifyMacosSigningSettings(receipt, entries); }
926
1214
  }
927
1215
 
928
1216
  async function notarizeMacos(archive, root) {
@@ -931,6 +1219,7 @@ async function notarizeMacos(archive, root) {
931
1219
  if (opts.dryRun) args.push("--dry-run");
932
1220
  args.push("--receipt", receipt);
933
1221
  if (target.notarize.profile) args.push("--profile", target.notarize.profile);
1222
+ if (target.notarize.staple === false) args.push("--no-staple");
934
1223
  // With appBeforePackage the app already carries its own stapled ticket inside the packaged DMG.
935
1224
  if (target.notarize.app && !target.notarize.appBeforePackage) args.push("--app", resolveReleaseArtifactPath(root, target.notarize.app));
936
1225
  args.push(archive);
@@ -973,6 +1262,15 @@ function verifyMacosSigningReceipt(receipt, files) {
973
1262
  || evidence.files.some((item) => item.codesign !== "strict-valid" || item.identity !== expectedIdentity || item.timestampPresent !== true || item.after?.sha256 !== hashes.get(path.resolve(item.file)))) fail(`invalid macOS Developer ID signing receipt evidence: ${receipt}`);
974
1263
  }
975
1264
 
1265
+ // The receipt must record the entitlements digest and codesign options the plan asked for.
1266
+ function verifyMacosSigningSettings(receipt, items) {
1267
+ if (!items.some(hasNonDefaultSettings)) return;
1268
+ let evidence;
1269
+ try { evidence = JSON.parse(readFileSync(receipt, "utf8")); } catch { fail(`invalid macOS Developer ID signing receipt: ${receipt}`); }
1270
+ const problems = matrixReceiptProblems(evidence.files, items, (file) => fileEvidence(file).sha256);
1271
+ if (problems.length) fail(`macOS signing receipt does not match the signing matrix (${receipt}):\n${problems.map((line) => `- ${line}`).join("\n")}`);
1272
+ }
1273
+
976
1274
  function verifyMacosNotarizationReceipt(receipt, archive) {
977
1275
  try { validateNotarizationReceipt({ receiptPath: receipt, archive }); }
978
1276
  catch (error) { fail(error.message); }
@@ -0,0 +1,29 @@
1
+ // Crates renamed in the owner naming batch (2026-10-14). The old names stay in `cargo` as published history that apps
2
+ // still pin until they migrate, but the tree has only the new directory and the old name is never staged again.
3
+ // Remove an entry here when the last app pin moves and `cargo` drops the old name.
4
+ export const RENAMED_AWAY = new Map([
5
+ ["rightkit-shell", "rightkit-tauri-shell"],
6
+ ["rightkit-tauri", "rightkit-device"],
7
+ ["rightkit-control", "rightkit-qa-driver"],
8
+ ["rightkit-jobs", "rightkit-job-queue"],
9
+ ["rightkit-process", "rightkit-child-process"],
10
+ ["rightkit-framed-sidecar", "rightkit-sidecar"],
11
+ ["rightkit-secrets", "rightkit-credentials"],
12
+ ["rightkit-settings", "rightkit-app-settings"],
13
+ ["rightkit-ort", "rightkit-onnx"],
14
+ ["rightkit-media", "rightkit-ffmpeg"],
15
+ ["rightkit-watch", "rightkit-file-watch"],
16
+ ["rightkit-scenario", "rightkit-workflow"],
17
+ ["rightkit-qts", "rightkit-tts-qwen"],
18
+ ["rightkit-qts-ggml-sys", "rightkit-tts-qwen-sys"],
19
+ ["rightkit-qts-ggml", null],
20
+ ]);
21
+
22
+ // npm packages renamed in the same batch. Old names stay in rightkit-versions.json (npm/stagedNpm/legacyNpm) as
23
+ // published history that apps still pin; the tree has only the successor directory.
24
+ export const RENAMED_AWAY_NPM = new Map([
25
+ ["@rightkit/git", "@rightkit/ci"],
26
+ ["@rightkit/updates", "@rightkit/app-update"],
27
+ ["@rightkit/shell", "@rightkit/tauri-shell"],
28
+ ["@rightkit/app-shell", "@rightkit/react-shell"],
29
+ ]);