@rightkit/release 0.2.151 → 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 (65) hide show
  1. package/LICENSE-APACHE +201 -0
  2. package/LICENSE-MIT +21 -0
  3. package/README.md +117 -8
  4. package/build-release.mjs +7 -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/msix-appinstaller.mjs +4 -1
  25. package/msix-config.mjs +5 -1
  26. package/msix-repair-guard.mjs +24 -0
  27. package/msix-repair-guard.ps1 +356 -0
  28. package/ownership-catalog-config.json +6 -7
  29. package/ownership-command.mjs +69 -8
  30. package/ownership-evidence.mjs +1 -1
  31. package/ownership-key-authority.mjs +1 -1
  32. package/ownership-key-command.mjs +15 -3
  33. package/ownership-key-elevation.mjs +11 -2
  34. package/ownership-mandatory-set.json +420 -0
  35. package/ownership-mandatory-set.mjs +273 -0
  36. package/ownership.mjs +2 -2
  37. package/package.json +15 -3
  38. package/packaged-app-identity.mjs +88 -0
  39. package/preflight.mjs +25 -3
  40. package/prune-r2.mjs +28 -3
  41. package/publication-gate.mjs +49 -10
  42. package/publish-cargo.mjs +21 -1
  43. package/publish-swift.mjs +78 -141
  44. package/publish-update.mjs +24 -2
  45. package/qa-build-features.mjs +216 -0
  46. package/qa-control-scan.mjs +435 -0
  47. package/registry-parity.mjs +18 -1
  48. package/release-invocation.mjs +2 -1
  49. package/release-state.mjs +9 -0
  50. package/release-switch-registry.json +33 -0
  51. package/release-switch-scan.mjs +452 -0
  52. package/release.mjs +320 -26
  53. package/renamed-away.mjs +29 -0
  54. package/rightkit-ownership.json +682 -709
  55. package/rightkit-sync.mjs +8 -4
  56. package/rightkit-version-policy.mjs +18 -0
  57. package/rightkit-versions.json +211 -127
  58. package/runtime-budget-classes.json +4 -0
  59. package/runtime-budgets.mjs +870 -0
  60. package/runtime-bundle-scan.mjs +61 -0
  61. package/sign-macos.mjs +28 -30
  62. package/sign-updater.mjs +5 -2
  63. package/swift-mac-dmg.mjs +7 -2
  64. package/swift-package-command.mjs +1 -1
  65. 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,6 +197,7 @@ 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`);
163
203
  if (target.notarize.staple === false && (target.notarize.app || /\.(dmg|pkg)$/i.test(target.notarize.file))) {
@@ -165,6 +205,13 @@ if (opts.platform === "mac") {
165
205
  }
166
206
  const swiftDmgError = swiftMacDmgConfigError({ target, projectType: resolveProjectType({ appRoot: root, config, target }), appName: config.app ?? "app" });
167
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
+ }
168
215
  }
169
216
  if (opts.upload && target.publishBlocked) fail(`${config.app ?? "app"} ${opts.platform} publish blocked: ${target.publishBlocked}`);
170
217
  /**
@@ -254,6 +301,61 @@ if (opts.doctor) {
254
301
  process.exit(0);
255
302
  }
256
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
+
257
359
  const releaseLock = acquireReleaseLock(root, config.app ?? path.basename(root), opts.platform, opts.tier);
258
360
  const mode = "package";
259
361
  const command = target.package;
@@ -283,15 +385,21 @@ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT)
283
385
  // packs the exact signed bytes, signs the package through sign-windows, verifies signer == Publisher,
284
386
  // writes the App Installer route and the seal record. Never a second signer.
285
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 });
286
390
  const options = msixBuildOptions({ root, target, app: config.app, dryRun: opts.dryRun, receipts: {
287
391
  pe: windowsReceiptPath(root, "raw-exe"), msix: windowsReceiptPath(root, "installer"),
288
392
  dir: path.dirname(windowsReceiptPath(root, "raw-exe")) } });
289
393
  const built = buildMsix(options, { log: (event) => console.log(`right-release: ${JSON.stringify(event)}`) });
290
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 });
291
397
  } else if (opts.platform === "win") {
292
398
  const rawFiles = target.sign.prePackageFiles.map((p) => path.resolve(root, p));
293
399
  await runCommand(target.prePackage, root);
294
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 });
295
403
  // The marker must reach its FINAL value before signing. Tauri rewrites it during
296
404
  // bundling, and doing that to signed bytes invalidates Authenticode — the exact
297
405
  // defect that shipped invalid binaries. Idempotent, so an app that still patches
@@ -317,10 +425,17 @@ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT)
317
425
  } else if (opts.platform === "mac") {
318
426
  if (runMacPhase("prepackage")) await runCommand(target.prePackage, root);
319
427
  // Resolve after prePackage: with storage governance the artifact may live only in RIGHT_RELEASE_ARTIFACT_DIR.
320
- const signingFiles = target.sign.prePackageFiles.map((p) => resolveReleaseArtifactPath(root, p));
321
- 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
+ }
322
437
  if (runMacPhase("sign")) {
323
- await signMacos(signingFiles, root);
438
+ await signMacos(signingEntries, root, "signing", macSigning.nestedCode);
324
439
  // Opt-in (notarize.appBeforePackage): staple the .app itself before the package step copies it into the DMG.
325
440
  if (target.notarize.appBeforePackage) await notarizeAppBeforePackage(root);
326
441
  }
@@ -337,15 +452,23 @@ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT)
337
452
  }
338
453
  }
339
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
+
340
461
  if (opts.platform === "mac" && runMacPhase("package")) {
341
462
  // SwiftPM + appBeforePackage: the .app is stapled by now, so right-release packs it into the DMG itself.
342
463
  const swiftDmg = swiftMacDmgPlan({ target, projectType: resolveProjectType({ appRoot: root, config, target }), appName: config.app ?? path.basename(root), resolve: (p) => resolveReleaseArtifactPath(root, p) });
343
464
  if (swiftDmg) {
344
465
  if (opts.dryRun) console.log(`dry-run: build DMG ${swiftDmg.out} from stapled app ${swiftDmg.app}, then sign it`);
345
- 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") });
346
467
  }
347
468
  }
348
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 });
349
472
 
350
473
  for (const rel of target.artifacts ?? []) {
351
474
  await mustExist(resolveReleaseArtifactPath(root, rel), `missing release artifact: ${rel}`);
@@ -364,7 +487,7 @@ if (opts.platform === "win" && target.signingContract === WINDOWS_NSIS_SIGNING_C
364
487
  if (target.postSign) await runCommand(target.postSign, root);
365
488
  const updaterFiles = [...new Set((target.updater?.artifacts ?? []).map((artifact) => resolveReleaseArtifactPath(root, artifact.file)))];
366
489
  if (!updaterFiles.length) fail(`${config.app} win must declare updater.artifacts for post-code-signing minisign`);
367
- 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);
368
491
  }
369
492
 
370
493
  if (target.postSign && opts.platform !== "win" && (opts.platform !== "mac" || runMacPhase("post-sign"))) {
@@ -387,6 +510,10 @@ if (opts.platform !== "mac" || runMacPhase("harden")) {
387
510
  hardeningEvidence = path.join(receiptRoot, opts.platform + "-hardening-evidence.json");
388
511
  materializeHardeningEvidence({ root, allowances: target.hardeningAllowances, outputPath: hardeningEvidence });
389
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)));
390
517
  await run("node", [
391
518
  HARDENING_SCAN,
392
519
  ...(hardeningEvidence ? ["--allow-evidence", hardeningEvidence] : []),
@@ -425,6 +552,51 @@ function usage(code, message) {
425
552
  // Product package scripts may emit final installer bytes directly into the
426
553
  // managed artifact directory. Keep legacy target-relative paths working while
427
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
+
428
600
  function resolveReleaseArtifactPath(root, declared) {
429
601
  const local = path.resolve(root, declared);
430
602
  if (existsSync(local)) return local;
@@ -723,16 +895,105 @@ async function validateRightKitPackageContract(root, appName, hostedWorkflows) {
723
895
  fail(`${appName ?? pkg.name ?? "app"} ${name} must come from the published npm package, not ${specifier}`);
724
896
  }
725
897
  const allowed = RIGHTKIT_ALLOWED.get(name);
726
- if (allowed && !allowed.has(specifier)) {
898
+ if (allowed && !rightKitVersionAllowed(allowed, specifier)) {
727
899
  fail(`${appName ?? pkg.name ?? "app"} ${name} must be one of ${[...allowed].join(", ")}, got ${specifier}`);
728
900
  }
729
901
  }
730
902
  const releaseAllowed = RIGHTKIT_ALLOWED.get("@rightkit/release");
731
- if (!releaseAllowed?.has(pkg.devDependencies?.["@rightkit/release"])) {
903
+ if (!releaseAllowed || !rightKitVersionAllowed(releaseAllowed, pkg.devDependencies?.["@rightkit/release"])) {
732
904
  fail(`${appName ?? pkg.name ?? "app"} must depend on a supported @rightkit/release version in devDependencies`);
733
905
  }
734
906
  }
735
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
+
736
997
  async function validateHostedWorkflows(root, appName, workflows, policy) {
737
998
  if (policy !== "right-git-ci-only") {
738
999
  fail(`${appName} hosted workflow files are forbidden: ${workflows.join(", ")}`);
@@ -745,7 +1006,7 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
745
1006
  fail(`${appName} right-git-ci-only policy requires a valid .rightgit.json`);
746
1007
  }
747
1008
  const lanes = manifest?.lanes;
748
- // Mirrors the `lanes` enum in @rightkit/git manifest.schema.json.
1009
+ // Mirrors the `lanes` enum in @rightkit/ci manifest.schema.json.
749
1010
  const safeLanes = new Set(["ci", "qualification", "package-smoke", "release-candidate", "publish-npm", "publish-package-managers", "regenerate"]);
750
1011
  if (manifest?.schemaVersion !== 1
751
1012
  || manifest?.compile !== "github-actions-only"
@@ -775,7 +1036,7 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
775
1036
  };
776
1037
  const inside = (index, span) => span && index >= span.start && index < span.end;
777
1038
  const requestedSecrets = [...source.matchAll(/\bsecrets\.([A-Z0-9_]+)/gi)].map((match) => ({ name: match[1], index: match.index }));
778
- 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);
779
1040
  const windowsSpan = jobSpan("windows-sign");
780
1041
  const macosSpan = jobSpan("macos-sign");
781
1042
  const publishSpan = jobSpan("publish");
@@ -802,10 +1063,10 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
802
1063
  const protectedMacosSigning = workflow === "release-candidate.yml"
803
1064
  && manifest?.candidate?.signMacos === true
804
1065
  && (legacySigningGate("macos-sign").test(macosSource) || admittedSigningGate("macos-sign").test(macosSource));
805
- // @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
806
1067
  // the managed ci.yml: push to the default branch only, release environment,
807
1068
  // Developer ID certificate secrets only (no notarization keys).
808
- // @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
809
1070
  // right-git/regenerate-<run> branch and never writes to the default branch.
810
1071
  // `contents: write` and `pull-requests: write` are accepted only inside that
811
1072
  // job, only when the manifest declares `regenerate`, and only when the
@@ -831,6 +1092,8 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
831
1092
  const protectedDevMacSigning = workflow === "ci.yml"
832
1093
  && (manifest?.devArtifact?.mac === "sign" || manifest?.devArtifact?.mac === "notarize")
833
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.
834
1097
  const releaseChain = workflow === "release-candidate.yml" ? manifest?.candidate?.releaseChain : undefined;
835
1098
  const allowedReleaseSecrets = new Set(releaseChain?.publishSecrets ?? []);
836
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 }));
@@ -847,6 +1110,14 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
847
1110
  const protectedReleasePublish = releaseChain
848
1111
  && (legacyReleasePublishGate || admittedReleasePublishGate)
849
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);
850
1121
  if (/^\s*permissions:\s*write-all\s*$/mi.test(source)
851
1122
  || writePermissions.some(({ name, index }) => {
852
1123
  if (protectedRegenerate && inside(index, regenerateSpan) && allowedRegeneratePermissions.has(name)) return false;
@@ -856,20 +1127,21 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
856
1127
  })
857
1128
  || /pull_request_target\s*:/i.test(source)
858
1129
  || /\bsecrets\s*\[/i.test(source)
859
- || requestedSecrets.some(({ name, index }) => !(protectedMacosSigning && inside(index, macosSpan) && allowedMacosSecrets.has(name))
860
- && !(protectedDevMacSigning && inside(index, devMacSpan) && allowedDevMacSecrets.has(name))
861
- && !(protectedReleasePublish && inside(index, publishSpan) && allowedReleaseSecrets.has(name)))) {
862
- 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("; ")}` : ""}`);
863
1134
  }
864
1135
  }
865
1136
  }
866
1137
 
867
- function buildAllowedVersions(published = {}, staged = {}, legacy = {}) {
1138
+ function buildAllowedVersions(published = {}, staged = {}, legacy = {}, registry = {}) {
868
1139
  const allowed = new Map();
869
1140
  for (const [name, value] of [
870
1141
  ...Object.entries(published),
871
1142
  ...Object.entries(staged),
872
1143
  ...Object.entries(legacy),
1144
+ ...Object.entries(registry),
873
1145
  ]) {
874
1146
  const versions = allowed.get(name) ?? new Set();
875
1147
  for (const version of Array.isArray(value) ? value : [value]) versions.add(version);
@@ -908,24 +1180,37 @@ function windowsReceiptPath(root, phase) {
908
1180
  return signingReceiptPath({ appRoot: root, target, platform: "win", phase, stateRoot: process.env.RIGHT_RELEASE_STATE_ROOT });
909
1181
  }
910
1182
 
911
- 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);
912
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`);
913
- // 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.
914
1197
  let nested = [];
915
- 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
+ }
916
1202
  if (nested.length) {
917
1203
  const nestedReceipt = path.resolve(path.dirname(receipt), `macos-${phase}-nested.json`);
918
1204
  if (existsSync(nestedReceipt)) unlinkSync(nestedReceipt);
919
1205
  console.log(`right-release: signing ${nested.length} nested code item(s) inside-out`);
920
- await run("node", [SIGN_MACOS, "--receipt", nestedReceipt, ...nested], root);
1206
+ await run("node", macSignInvocation(nested, nestedReceipt), root);
1207
+ verifyMacosSigningSettings(nestedReceipt, nested);
921
1208
  }
922
- const args = [SIGN_MACOS];
923
- if (opts.dryRun) args.push("--dry-run");
924
- args.push("--receipt", receipt, ...files);
1209
+ const args = macSignInvocation(entries, receipt, opts.dryRun ? ["--dry-run"] : []);
925
1210
  if (!opts.dryRun && existsSync(receipt)) unlinkSync(receipt);
926
1211
  console.log("right-release: signing macOS Developer ID files");
927
1212
  await run("node", args, root);
928
- if (!opts.dryRun) verifyMacosSigningReceipt(receipt, files);
1213
+ if (!opts.dryRun) { verifyMacosSigningReceipt(receipt, files); verifyMacosSigningSettings(receipt, entries); }
929
1214
  }
930
1215
 
931
1216
  async function notarizeMacos(archive, root) {
@@ -977,6 +1262,15 @@ function verifyMacosSigningReceipt(receipt, files) {
977
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}`);
978
1263
  }
979
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
+
980
1274
  function verifyMacosNotarizationReceipt(receipt, archive) {
981
1275
  try { validateNotarizationReceipt({ receiptPath: receipt, archive }); }
982
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
+ ]);