@rightkit/release 0.2.132 → 0.2.134

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.
package/build-release.mjs CHANGED
@@ -27,6 +27,8 @@ import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
27
27
  import { commandOutputPortable, releaseEnvironment, resolveReleaseLayout, runBuildStateMachine, verifySealedRelease, watchProgress } from "./release-state.mjs";
28
28
  import { acquireCacheLease, acquireSuiteBuildSlot, applyCachePrune, assertWriteVolumeFloors, inspectCache, inspectWriteVolumes, markCacheEntrySuccessful, planCachePrune, readCachePolicy, resolveCacheLayout, resolveSharedCacheIdentity, resolveSharedCacheRoot } from "./cache-policy.mjs";
29
29
  import { createTargetBridge } from "./target-bridge.mjs";
30
+ import { signingReceiptPath } from "./signing-receipts.mjs";
31
+ import { validateNotarizationReceipt } from "./macos-notarization.mjs";
30
32
  import { brokerManagedHost } from "./cargo-contract.mjs";
31
33
  import { resolveReleaseCacheMode } from "./release-cache-mode.mjs";
32
34
  import { resolveTargetRoot } from "./cargo-target.mjs";
@@ -36,14 +38,14 @@ import { assertCleanSource } from "./source-gate.mjs";
36
38
  import { acquireHeavyWorkSlot, heavyCommandEnvironment, summarizePreBrokerWaits, terminateProcessTree } from "./heavy-command.mjs";
37
39
  import { processGroupCpuSeconds } from "./process-liveness.mjs";
38
40
  import { cancelAndReapManagedRequest, firstManagedRequestId, releaseProgressLimits } from "./progress-control.mjs";
41
+ import { beginArtifactLease, completeArtifactLease, materializeArtifacts, retainArtifacts, resolveArtifactDir, resolveManagedRoot } from "./artifact-storage.mjs";
39
42
 
40
43
  // Fingerprint of the pipeline code that can change the bytes we ship or the way
41
44
  // they are signed — deliberately NOT the package version, which moves for docs
42
45
  // and test-only edits and would force a cold Rust rebuild of every app each time.
43
- // These four files are the ones whose behaviour the cached target directory can
44
- // outlive.
46
+ // Cached target directories must not outlive changes to these signing helpers.
45
47
 
46
- const PIPELINE_FINGERPRINT_SOURCES = ["release.mjs", "sign-windows.mjs", "sign-macos.mjs", "notarize-macos.mjs", "tauri-bundle-marker.mjs", "nsis-payload.mjs"];
48
+ const PIPELINE_FINGERPRINT_SOURCES = ["release.mjs", "sign-windows.mjs", "sign-macos.mjs", "macos-signing-session.mjs", "notarize-macos.mjs", "macos-notarization.mjs", "signing-receipts.mjs", "tauri-bundle-marker.mjs", "nsis-payload.mjs"];
47
49
  const PIPELINE_FINGERPRINT = createHash("sha256")
48
50
  .update(PIPELINE_FINGERPRINT_SOURCES.map((file) => `${file}:${createHash("sha256").update(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), file))).digest("hex")}`).join("\n"))
49
51
  .digest("hex")
@@ -170,7 +172,9 @@ try {
170
172
  if (dirtyInputs.length > 0) fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
171
173
  const toolVersions = collectToolVersions(config.packageManager ?? "pnpm");
172
174
  const inputHashes = hashInputs(requiredInputs);
173
- const receiptRoot = path.resolve(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(appRoot, ".right-release", "receipts"));
175
+ const releaseId = `${config.app}-${config.version}-${shortCommit}`;
176
+ const platformDir = platform === "win" ? "windows" : "mac";
177
+ const stateRoot = path.join(vaultRoot, "state", releaseId, platformDir);
174
178
  const signingIdentity = platform === "win" ? {
175
179
  contract: target.signingContract ?? "<missing>",
176
180
  // Pipeline code participates in cache identity. Without it, a fix to the
@@ -184,10 +188,7 @@ try {
184
188
  // windows-<phase>.json name is only the fallback. Hardcoding the default
185
189
  // here made the seal read a path the signer never wrote whenever a config
186
190
  // (e.g. an architecture-qualified receipt) overrode it.
187
- receiptInputs: ["raw-exe", "installer", "embedding"].map((phase) => {
188
- const configured = phase === "raw-exe" ? target.sign?.receipt : phase === "installer" ? target.sign?.installerReceipt : undefined;
189
- return configured ? path.resolve(appRoot, configured) : path.join(receiptRoot, `windows-${phase}.json`);
190
- }),
191
+ receiptInputs: ["raw-exe", "installer", "embedding"].map((phase) => signingReceiptPath({ appRoot, target, platform, phase, stateRoot })),
191
192
  } : platform === "mac" ? {
192
193
  contract: target.signingContract ?? "<missing>",
193
194
  pipeline: PIPELINE_FINGERPRINT,
@@ -195,10 +196,7 @@ try {
195
196
  // Mac portable releases produce one Developer ID receipt before packaging
196
197
  // and one Accepted notarization receipt after. Both must be part of cache
197
198
  // identity and seal evidence, including configured receipt overrides.
198
- receiptInputs: [
199
- target.sign?.receipt ? path.resolve(appRoot, target.sign.receipt) : path.join(receiptRoot, "macos-signing.json"),
200
- target.notarize?.receipt ? path.resolve(appRoot, target.notarize.receipt) : path.join(receiptRoot, "macos-notarization.json"),
201
- ],
199
+ receiptInputs: ["signing", "notarization"].map((phase) => signingReceiptPath({ appRoot, target, platform, phase, stateRoot })),
202
200
  } : null;
203
201
  if (signingIdentity) inputHashes[".right-release/signing-identity.json"] = hashFileText(JSON.stringify(signingIdentity));
204
202
  const cargoLock = nativeLayout.lockPath;
@@ -259,6 +257,8 @@ try {
259
257
  ...(pathPrefix ? { PATH: `${pathPrefix}${path.delimiter}${process.env.PATH ?? ""}` } : {}),
260
258
  RIGHT_RELEASE_REPO_ROOT: layout.repoRoot,
261
259
  RIGHT_RELEASE_APP_ROOT: layout.appRoot,
260
+ RIGHTKIT_MANAGED_ROOT: resolveManagedRoot({ env: process.env }),
261
+ RIGHT_RELEASE_ARTIFACT_DIR: resolveArtifactDir({ app: config.app, version: config.version, managedRoot: resolveManagedRoot({ env: process.env }) }),
262
262
  };
263
263
  if (managedCargoTarget) {
264
264
  for (const name of ["CARGO_HOME", "CARGO_TARGET_DIR", "RUSTC_WRAPPER", "SCCACHE_DIR", "SCCACHE_BASEDIRS", "SCCACHE_CACHE_SIZE", "RUSTFLAGS"]) {
@@ -277,10 +277,12 @@ try {
277
277
  const preBrokerWaits = summarizePreBrokerWaits({ heavySlot, suiteSlot, heavyOwnerInherited: process.env.RIGHTSUITE_HEAVY_WORK_OWNER === "1" });
278
278
  console.error(`[right-release] pre-broker waits: ${preBrokerWaits.summary}`);
279
279
  throwIfInterrupted();
280
- const releaseId = `${config.app}-${config.version}-${shortCommit}`;
281
- const platformDir = platform === "win" ? "windows" : "mac";
280
+ const managedRoot = resolveManagedRoot({ env: process.env });
281
+ const managedArtifactDir = resolveArtifactDir({ app: config.app, version: config.version, managedRoot });
282
+ beginArtifactLease({ app: config.app, version: config.version, managedRoot });
283
+ env.RIGHTKIT_MANAGED_ROOT = managedRoot;
284
+ env.RIGHT_RELEASE_ARTIFACT_DIR = managedArtifactDir;
282
285
  const buildRoot = managedCargoTarget ? null : path.join(vaultRoot, "build", config.app, config.version, shortCommit, platformDir);
283
- const stateRoot = path.join(vaultRoot, "state", releaseId, platformDir);
284
286
  env.RIGHT_RELEASE_STATE_ROOT = stateRoot;
285
287
  const lockPayload = {
286
288
  schema: 1,
@@ -384,7 +386,7 @@ try {
384
386
  });
385
387
  },
386
388
  seal: async ({ sealedDir }) => {
387
- sealRelease({ configRoot: appRoot, managedCargoTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence });
389
+ sealRelease({ configRoot: appRoot, managedCargoTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, managedRoot });
388
390
  verifySealedRelease(sealedDir);
389
391
  if (cacheMode === "shared") markCacheEntrySuccessful({ layout: sharedLayout });
390
392
  checkpoint(stateRoot, "sealed");
@@ -424,7 +426,7 @@ function sqlCipherFeatures(text) {
424
426
  .filter((value) => /sqlcipher|openssl/i.test(value));
425
427
  }
426
428
 
427
- function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence }) {
429
+ function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, managedRoot }) {
428
430
  // Every app's right-release.config.mjs names installer/updater artifacts
429
431
  // relative to its resolved native target. On a broker-managed host that
430
432
  // directory is never populated (the broker owns CARGO_TARGET_DIR and
@@ -442,7 +444,12 @@ function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir,
442
444
  if (managedCargoTarget && effectiveTargetPrefix !== targetPrefix && normalized.startsWith(effectiveTargetPrefix)) {
443
445
  return path.join(managedCargoTarget, normalized.slice(effectiveTargetPrefix.length));
444
446
  }
445
- return path.resolve(configRoot, file);
447
+ const local = path.resolve(configRoot, file);
448
+ if (existsSync(local)) return local;
449
+ const managed = resolveArtifactDir({ app: config.app, version: config.version, managedRoot });
450
+ const managedCandidate = path.join(managed, path.basename(file));
451
+ if (existsSync(managedCandidate)) return managedCandidate;
452
+ return local;
446
453
  };
447
454
  const sources = new Map();
448
455
  const addSource = (source, role) => {
@@ -460,6 +467,7 @@ function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir,
460
467
  if (existsSync(temp)) rmSync(temp, { recursive: true, force: true });
461
468
  mkdirSync(temp, { recursive: true });
462
469
  const files = [];
470
+ const artifactInputs = [];
463
471
  const sourceNames = new Map();
464
472
  for (const [source, roles] of sources) {
465
473
  let name = path.basename(source);
@@ -467,6 +475,7 @@ function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir,
467
475
  sourceNames.set(source, name);
468
476
  copyFileSync(source, path.join(temp, name));
469
477
  files.push({ role: [...roles].sort().join("+"), name, sha256: hashFile(source), sizeBytes: statSync(source).size });
478
+ artifactInputs.push({ source, name });
470
479
  }
471
480
  const routes = { patch: [], update: [] };
472
481
  for (const artifact of target.installer?.artifacts ?? []) {
@@ -497,6 +506,10 @@ function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir,
497
506
  signing: signingIdentity ? { ...signingIdentity, receipts: (() => {
498
507
  const present = signingIdentity.receiptInputs.filter((file) => existsSync(file));
499
508
  if (present.length === 0) throw new Error(`no signing receipts found to seal; expected at least ${signingIdentity.receiptInputs[0]}`);
509
+ if (platform === "mac") {
510
+ if (present.length !== signingIdentity.receiptInputs.length) throw new Error("missing current-build macOS signing or notarization receipt");
511
+ validateNotarizationReceipt({ receiptPath: signingIdentity.receiptInputs[1], archive: resolveArtifactPath(target.notarize.file) });
512
+ }
500
513
  return present.map((file) => ({ file, sha256: hashFile(file) }));
501
514
  })() } : null,
502
515
  files,
@@ -514,6 +527,14 @@ function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir,
514
527
  const file = path.join(sealedDir, item.name);
515
528
  try { chmodSync(file, 0o444); } catch { /* best effort; hashes are authoritative */ }
516
529
  }
530
+ // Keep candidate installer/updater bytes outside app repositories. Sealed
531
+ // copies remain immutable release authority; managed copies support local
532
+ // retention and recovery without reintroducing repo-root artifacts.
533
+ materializeArtifacts({ app: config.app, version: config.version, managedRoot, files: artifactInputs });
534
+ completeArtifactLease({ app: config.app, version: config.version, managedRoot });
535
+ const retention = retainArtifacts({ app: config.app, managedRoot });
536
+ if (retention.state === "unknown") console.error(`[right-release] artifact retention skipped: ${retention.reason}`);
537
+ else if (retention.removed.length) console.log(`[right-release] artifact retention removed ${retention.removed.join(", ")}`);
517
538
  }
518
539
 
519
540
  function collectToolVersions(packageManager) {
@@ -18,6 +18,7 @@ const commands = new Map([
18
18
  ["hardeningscan", "hardeningscan.mjs"],
19
19
  ["sign-windows", "sign-windows.mjs"],
20
20
  ["sign-updater", "sign-updater.mjs"],
21
+ ["signing-agent", "signing-agent-command.mjs"],
21
22
  ["create-mac-updater", "create-mac-updater.mjs"],
22
23
  ["mirror-root-artifact", "mirror-root-artifact.mjs"],
23
24
  ["github", "github-release.mjs"],
@@ -31,7 +32,7 @@ const args = process.argv.slice(2);
31
32
  const first = args[0];
32
33
  if (first === "dev") {
33
34
  import("../development.mjs").then(({ main }) => main(args.slice(1))).catch((error) => {
34
- console.error(`right-release dev: ${error.message}`);
35
+ console.error(`right-release dev: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
35
36
  process.exitCode = 1;
36
37
  });
37
38
  } else {
@@ -69,6 +70,8 @@ if (first === "--version" || first === "-v") {
69
70
  );
70
71
  } else if (first === "cache") {
71
72
  run("cache-command.mjs", args.slice(1));
73
+ } else if (first === "apple") {
74
+ run("apple-profile-renew.mjs", args.slice(1));
72
75
  } else if (first === "publish") {
73
76
  const rest = args.slice(1);
74
77
  if (rest[0] === "cargo") {
@@ -166,9 +169,12 @@ Commands:
166
169
  (npm-rightkit-publish) and verify registry bytes
167
170
  publish cargo --crate <name> [--dry-run] Test, inspect, scan, and publish one crates.io package
168
171
  publish swift [--repo <git-url>] [--dry-run] Test, tag, resolve a fresh exact-pin consumer, and publish RightKitSwift to its git repo
169
- model promote --authority heardright --config <file> [--dry-run]
172
+ model promote --authority <app-key> --config <file> [--dry-run]
170
173
  Sign and promote one runtime/model artifact
171
174
  addon doctor|build|upload|adopt <options> Manage a signed headless add-on
175
+ apple profile renew [--config <file>] [--profile <file>] [--dry-run]
176
+ Renew macOS profile for current Keychain signer
177
+ signing-agent install|uninstall|status Manage invisible Aqua-session signing service
172
178
  doctor [--platform mac|win] Inspect one app's release config
173
179
  doctor --all Verify all Right Suite app release contracts
174
180
  suite-doctor Verify all local Right Suite repositories
@@ -192,7 +198,7 @@ Commands:
192
198
  lsclean <AppName.app> Clear macOS LaunchServices duplicates
193
199
  generate-dmg-background <options> Generate the branded multi-resolution DMG background
194
200
  mirror-root-artifact --file <path> --package-root <dir>
195
- Persist a worktree artifact in the primary repo root
201
+ Persist artifact in RIGHT_RELEASE_ARTIFACT_DIR
196
202
  github --release <id> --platform mac|win [--repo owner/repo] [--dry-run]
197
203
  Attach sealed signed artifacts to a verified GitHub Release
198
204
 
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { execFileSync, spawnSync } from "./background-process.mjs";
2
+ import { spawnSync } from "./background-process.mjs";
3
3
  import { existsSync, rmSync } from "node:fs";
4
- import os from "node:os";
5
4
  import path from "node:path";
5
+ import { signMacUpdaterFiles } from "./macos-signing-session.mjs";
6
6
 
7
7
  const args = process.argv.slice(2);
8
8
  let app;
@@ -27,18 +27,6 @@ const env = { ...process.env };
27
27
  // entries. Tauri's Rust extractor writes those entries as real bundle files,
28
28
  // invalidating the Developer ID seal and causing syspolicyd to trash the app.
29
29
  env.COPYFILE_DISABLE = "1";
30
- if (!dryRun && !env.TAURI_SIGNING_PRIVATE_KEY) {
31
- try {
32
- env.TAURI_SIGNING_PRIVATE_KEY = execFileSync(
33
- "security",
34
- ["find-generic-password", "-a", os.userInfo().username, "-s", "rightsuite-updater-key", "-w"],
35
- { encoding: "utf8" },
36
- ).trim();
37
- } catch {
38
- fail("shared updater key is missing from Keychain service rightsuite-updater-key");
39
- }
40
- }
41
- env.TAURI_SIGNING_PRIVATE_KEY_PASSWORD ??= "";
42
30
 
43
31
  rmSync(outputPath, { force: true });
44
32
  rmSync(`${outputPath}.sig`, { force: true });
@@ -51,7 +39,12 @@ run("tar", [
51
39
  path.dirname(appPath),
52
40
  path.join(path.basename(appPath), "Contents"),
53
41
  ]);
54
- run("pnpm", ["exec", "tauri", "signer", "sign", outputPath]);
42
+ if (dryRun) run("pnpm", ["exec", "tauri", "signer", "sign", outputPath]);
43
+ else {
44
+ console.log(`pnpm exec tauri signer sign ${outputPath}`);
45
+ try { signMacUpdaterFiles([outputPath], { cwd, env }); }
46
+ catch (error) { fail(`${error.code ? `[${error.code}] ` : ""}${error.message}`); }
47
+ }
55
48
 
56
49
  function run(command, commandArgs) {
57
50
  const envPrefix = command === "tar" ? "COPYFILE_DISABLE=1 " : "";
@@ -0,0 +1,193 @@
1
+ import { createHash, X509Certificate } from "node:crypto";
2
+ import { closeSync, copyFileSync, existsSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawnSync } from "./background-process.mjs";
6
+ import { runMacCodesignCommands } from "./macos-signing-session.mjs";
7
+
8
+ export class MacosDevelopmentSigningError extends Error {
9
+ constructor(code, message, details = {}) {
10
+ super(message, details.cause ? { cause: details.cause } : undefined);
11
+ this.name = "MacosDevelopmentSigningError";
12
+ this.code = code;
13
+ Object.assign(this, details);
14
+ }
15
+ }
16
+
17
+ const DEFAULT_TEAM = "6KLGD3LLKF";
18
+ const output = (result) => `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
19
+ const captureOptions = (root, env) => ({ cwd: root, env, encoding: "utf8", stdio: ["inherit", "pipe", "pipe"], windowsHide: true, shell: false });
20
+
21
+ function checked(command, args, context, code, identity) {
22
+ const result = context.run(command, args, captureOptions(context.root, context.env));
23
+ if (result.error || result.status !== 0) {
24
+ const diagnostic = result.error?.message || output(result) || `exit ${result.status ?? "unavailable"}${result.signal ? ` (${result.signal})` : ""}`;
25
+ const interaction = result.status === 36 || /errSecInteractionNotAllowed|User interaction is not allowed/i.test(diagnostic);
26
+ throw new MacosDevelopmentSigningError(interaction ? "DEV_MAC_SIGNING_KEY_UNAVAILABLE" : code,
27
+ `${identity ? `Signing identity ${JSON.stringify(identity)}: ` : ""}${command} failed (${result.status ?? "unavailable"}): ${diagnostic}. ${interaction
28
+ ? "Run right-release dev from Terminal in a logged-in macOS GUI session where this identity's private key is accessible; SSH cannot authorize Keychain interaction."
29
+ : "Check the configured identity, profile and entitlements; confirm this identity and its private key are available in Keychain Access in a logged-in GUI session."}`,
30
+ { identity, status: result.status, diagnostic, cause: result.error });
31
+ }
32
+ return result;
33
+ }
34
+
35
+ function readPlist(file, context) {
36
+ const result = checked("plutil", ["-convert", "json", "-o", "-", file], context, "DEV_MAC_SIGNING_CONFIG_INVALID");
37
+ try { return JSON.parse(result.stdout); }
38
+ catch { throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", `Invalid plist JSON: ${file}`); }
39
+ }
40
+
41
+ function decodeProfile(file, context) {
42
+ const cms = checked("security", ["cms", "-D", "-i", file], context, "DEV_MAC_SIGNING_PROFILE_INVALID");
43
+ // plutil JSON conversion rejects the date/data values in provisioning profiles.
44
+ const parser = "import base64,datetime,json,plistlib,sys\ndef encode(v):\n if isinstance(v,bytes): return base64.b64encode(v).decode('ascii')\n if isinstance(v,(datetime.date,datetime.datetime)): return v.isoformat()\n raise TypeError(type(v).__name__)\nprint(json.dumps(plistlib.loads(sys.stdin.buffer.read()),default=encode))";
45
+ const parsed = context.run("python3", ["-c", parser], { ...captureOptions(context.root, context.env), stdio: "pipe", input: cms.stdout });
46
+ try {
47
+ if (parsed.error || parsed.status !== 0) throw new Error(parsed.error?.message || output(parsed));
48
+ const profile = JSON.parse(parsed.stdout);
49
+ if (!Array.isArray(profile.TeamIdentifier) || profile.TeamIdentifier.length !== 1 || !profile.TeamIdentifier[0]) throw new Error("profile must name one team");
50
+ if (!profile.Platform?.includes("OSX")) throw new Error("profile must target OSX");
51
+ if (!(Date.parse(profile.ExpirationDate) > Date.now())) throw new Error("profile is expired or has no expiration date");
52
+ const certificates = (profile.DeveloperCertificates ?? []).map((data) => {
53
+ const cert = new X509Certificate(Buffer.from(data, "base64"));
54
+ return { name: /^CN=(.+)$/m.exec(cert.subject)?.[1], sha1: createHash("sha1").update(cert.raw).digest("hex").toUpperCase() };
55
+ });
56
+ if (!certificates.length) throw new Error("profile has no signing certificates");
57
+ return { profile, certificates, team: profile.TeamIdentifier[0] };
58
+ } catch (cause) {
59
+ throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_PROFILE_INVALID", `Cannot use provisioning profile ${file}: ${cause.message}. Configure a valid macOS profile authorizing this app and signing certificate.`, { cause });
60
+ }
61
+ }
62
+
63
+ export function resolveMacosDevelopmentSigning({ root, bundle, descriptor = {}, config = {}, env = process.env, run = spawnSync }) {
64
+ const context = { root, env, run };
65
+ const tauriRoot = path.dirname(path.resolve(root, descriptor.manifestPath ?? "src-tauri/Cargo.toml"));
66
+ let mac = {};
67
+ for (const name of ["tauri.conf.json", "tauri.macos.conf.json"]) {
68
+ const file = path.join(tauriRoot, name);
69
+ if (existsSync(file)) mac = { ...mac, ...JSON.parse(readFileSync(file, "utf8")).bundle?.macOS };
70
+ }
71
+ const sign = config.targets?.mac?.sign ?? {};
72
+ const entitlements = sign.entitlements ? path.resolve(root, sign.entitlements) : mac.entitlements ? path.resolve(tauriRoot, mac.entitlements) : null;
73
+ if (entitlements && !existsSync(entitlements)) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", `Configured entitlements file is missing: ${entitlements}`);
74
+ const tauriProfile = mac.provisioningProfile ?? mac.files?.["embedded.provisionprofile"] ?? mac.files?.["/embedded.provisionprofile"];
75
+ const configuredProfile = sign.provisioningProfile ? path.resolve(root, sign.provisioningProfile) : tauriProfile ? path.resolve(tauriRoot, tauriProfile) : null;
76
+ const embedded = path.join(bundle, "Contents", "embedded.provisionprofile");
77
+ // App preparation may already embed an app-specific profile (e.g. HeardRight).
78
+ let provisioningProfile = configuredProfile ?? (existsSync(embedded) ? embedded : null);
79
+ if (provisioningProfile && !existsSync(provisioningProfile)) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_PROFILE_INVALID", `Configured provisioning profile is missing: ${provisioningProfile}`);
80
+ let profileInfo = provisioningProfile ? decodeProfile(provisioningProfile, context) : null;
81
+ const override = env.APPLE_SIGNING_IDENTITY?.trim() || env.APPLE_DEVELOPER_ID?.trim();
82
+ const releaseTeam = env.APPLE_TEAM_ID?.trim() || DEFAULT_TEAM;
83
+ const configuredIdentity = sign.identity?.trim() || mac.signingIdentity?.trim();
84
+ if (override === "-" || (!profileInfo && configuredIdentity === "-")) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_IDENTITY_INVALID", 'Signing identity "-" is ad hoc; configure APPLE_SIGNING_IDENTITY or APPLE_DEVELOPER_ID with a real Team identity.', { identity: "-" });
85
+ const requestedIdentity = override || (!profileInfo ? configuredIdentity : undefined);
86
+ const expected = requestedIdentity || profileInfo?.certificates.map((cert) => cert.name).join(" or ") || `Developer ID Application (team ${releaseTeam})`;
87
+ const found = checked("security", ["find-identity", "-v", "-p", "codesigning"], context, "DEV_MAC_SIGNING_IDENTITY_MISSING", expected);
88
+ const identities = [...String(found.stdout ?? "").matchAll(/([A-Fa-f0-9]{40})\s+"([^"\n]+)"/g)].map((match) => ({ sha1: match[1].toUpperCase(), name: match[2] }));
89
+ const matchesRequest = (item, requested) => item.name === requested || item.sha1 === requested?.toUpperCase();
90
+ const profiled = (info) => identities.filter((item) => info.certificates.some((cert) => cert.sha1 === item.sha1) && /^(Apple Development|Mac Developer|Developer ID Application):/.test(item.name));
91
+ // A configured profile can go stale when its certificate is renewed: prefer a sibling profile for the
92
+ // same app identifier that authorizes a certificate whose private key is in this Keychain.
93
+ if (profileInfo && !profiled(profileInfo).length && configuredProfile) {
94
+ const appId = (info) => info.profile.Entitlements?.["com.apple.application-identifier"] ?? info.profile.Entitlements?.["application-identifier"];
95
+ const siblings = readdirSync(path.dirname(configuredProfile)).filter((name) => name.endsWith(".provisionprofile")).map((name) => path.join(path.dirname(configuredProfile), name)).filter((file) => path.resolve(file) !== path.resolve(configuredProfile));
96
+ const usable = siblings.flatMap((file) => { try { const info = decodeProfile(file, context); return appId(info) === appId(profileInfo) && profiled(info).length ? [{ file, info }] : []; } catch { return []; } })
97
+ .sort((a, b) => Date.parse(b.info.profile.ExpirationDate) - Date.parse(a.info.profile.ExpirationDate));
98
+ if (usable.length) { provisioningProfile = usable[0].file; profileInfo = usable[0].info; }
99
+ }
100
+ let candidates = profileInfo
101
+ ? profiled(profileInfo)
102
+ : identities.filter((item) => item.name.startsWith("Developer ID Application:") && item.name.endsWith(`(${releaseTeam})`));
103
+ if (requestedIdentity) candidates = candidates.filter((item) => matchesRequest(item, requestedIdentity));
104
+ else if (profileInfo && configuredIdentity && candidates.some((item) => matchesRequest(item, configuredIdentity))) candidates = candidates.filter((item) => matchesRequest(item, configuredIdentity));
105
+ if (candidates.length !== 1) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_IDENTITY_MISSING", `Signing identity ${JSON.stringify(expected)}: ${candidates.length ? "multiple matching identities" : "no valid matching identity/private key"} in Keychain${provisioningProfile ? ` for ${provisioningProfile}` : ""}. ${candidates.length ? "Set APPLE_SIGNING_IDENTITY or APPLE_DEVELOPER_ID to one matching identity." : "Confirm the profile-authorized certificate and its private key are available in Keychain Access in a logged-in GUI session."}`, { identity: expected });
106
+ return { identity: candidates[0].name, certificateSha1: candidates[0].sha1, teamIdentifier: profileInfo?.team ?? releaseTeam, entitlements, provisioningProfile };
107
+ }
108
+
109
+ function bundleInfo(directory, context) {
110
+ for (const location of ["Contents/Info.plist", "Resources/Info.plist", "Info.plist"]) {
111
+ const file = path.join(directory, location);
112
+ if (existsSync(file)) return readPlist(file, context);
113
+ }
114
+ return {};
115
+ }
116
+
117
+ function macho(file) {
118
+ const fd = openSync(file, "r");
119
+ try {
120
+ const magic = Buffer.alloc(4);
121
+ return readSync(fd, magic, 0, 4, 0) === 4 && new Set(["feedface", "cefaedfe", "feedfacf", "cffaedfe", "cafebabe", "bebafeca", "cafebabf", "bfbafeca"]).has(magic.toString("hex"));
122
+ } finally { closeSync(fd); }
123
+ }
124
+
125
+ export function signMacosDevelopmentBundle(options) {
126
+ const { root, bundle, env = process.env, run = spawnSync } = options;
127
+ const context = { root, env, run };
128
+ const signing = resolveMacosDevelopmentSigning({ ...options, env, run });
129
+ const { identity, certificateSha1, teamIdentifier, entitlements, provisioningProfile } = signing;
130
+ const embedded = path.join(bundle, "Contents", "embedded.provisionprofile");
131
+ if (provisioningProfile && path.resolve(provisioningProfile) !== path.resolve(embedded)) copyFileSync(provisioningProfile, embedded);
132
+ const scratch = mkdtempSync(path.join(os.tmpdir(), "rightkit-dev-signing-"));
133
+ try {
134
+ const appInfo = bundleInfo(bundle, context);
135
+ const appEntitlements = entitlements ? readPlist(entitlements, context) : {};
136
+ // Loose tools cannot claim the parent app's profile-restricted entitlements.
137
+ // Retain their existing entitlements; give unsigned tools only the app's runtime relaxations.
138
+ const runtimeEntitlements = Object.fromEntries(Object.entries(appEntitlements).filter(([key]) => key.startsWith("com.apple.security.cs.") || key === "com.apple.security.device.audio-input"));
139
+ const runtimeFile = path.join(scratch, "runtime.plist");
140
+ writeFileSync(runtimeFile, JSON.stringify(runtimeEntitlements));
141
+ checked("plutil", ["-convert", "xml1", runtimeFile], context, "DEV_MAC_SIGNING_CONFIG_INVALID", identity);
142
+ // Apps keep sidecar/helper variants beside their main entitlements file:
143
+ // entitlements-engine.plist, entitlements-cloudsync.plist, entitlements-credentials.plist.
144
+ const entitlementVariants = entitlements ? readdirSync(path.dirname(entitlements))
145
+ .filter((name) => name.startsWith(`${path.basename(entitlements, path.extname(entitlements))}-`) && name.endsWith(".plist"))
146
+ .map((name) => ({ file: path.join(path.dirname(entitlements), name), suffix: path.basename(name, ".plist").slice(path.basename(entitlements, path.extname(entitlements)).length + 1).toLowerCase() }))
147
+ .sort((a, b) => b.suffix.length - a.suffix.length) : [];
148
+ const targets = [];
149
+ const bundleExecutables = new Set();
150
+ const visit = (directory, isBundle = false) => {
151
+ const info = isBundle ? bundleInfo(directory, context) : {};
152
+ if (info.CFBundleExecutable) {
153
+ for (const file of [path.join(directory, "Contents/MacOS", info.CFBundleExecutable), path.join(directory, info.CFBundleExecutable)]) {
154
+ if (existsSync(file)) bundleExecutables.add(realpathSync(file));
155
+ }
156
+ }
157
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
158
+ const file = path.join(directory, entry.name);
159
+ if (entry.isSymbolicLink()) continue;
160
+ if (entry.isDirectory()) visit(file, /\.(app|framework|xpc|appex|bundle)$/.test(entry.name));
161
+ else if (entry.isFile() && macho(file)) targets.push({ file, info: null });
162
+ }
163
+ if (isBundle) targets.push({ file: directory, info });
164
+ };
165
+ visit(bundle, true);
166
+ const ordered = targets.filter((target) => target.info || !bundleExecutables.has(realpathSync(target.file)))
167
+ .sort((a, b) => b.file.split(path.sep).length - a.file.split(path.sep).length || a.file.localeCompare(b.file));
168
+ const commands = [];
169
+ for (const target of ordered) {
170
+ // Bind the exact profile-authorized certificate even when names collide in Keychain.
171
+ const args = ["--force", "--options", "runtime", "--sign", certificateSha1];
172
+ const parent = target.file === bundle;
173
+ const profiledHelper = target.info && existsSync(path.join(target.file, "Contents/embedded.provisionprofile")) && target.info.CFBundleIdentifier === appInfo.CFBundleIdentifier;
174
+ if (profiledHelper && !parent && provisioningProfile) copyFileSync(embedded, path.join(target.file, "Contents/embedded.provisionprofile"));
175
+ const variant = !parent && entitlementVariants.find((item) => path.basename(target.file).replace(/\.(app|xpc|appex|bundle)$/, "").toLowerCase().endsWith(item.suffix));
176
+ if (variant) args.push("--entitlements", variant.file);
177
+ else if (entitlements && (parent || profiledHelper)) args.push("--entitlements", entitlements);
178
+ else {
179
+ const existing = run("codesign", ["--display", "--entitlements", ":-", target.file], captureOptions(root, env));
180
+ if (existing.status === 0 && /<plist|<dict/.test(String(existing.stdout ?? ""))) args.push("--preserve-metadata=entitlements");
181
+ else if (!target.info && (statSync(target.file).mode & 0o111) && !/\.(dylib|so)$/.test(target.file) && Object.keys(runtimeEntitlements).length) args.push("--entitlements", runtimeFile);
182
+ }
183
+ commands.push([...args, target.file]);
184
+ }
185
+ const signed = runMacCodesignCommands(commands, { ...options.sessionOptions, cwd: root, env, run });
186
+ for (const [index, args] of commands.entries()) checked("codesign", args, { ...context, run: () => signed[index] }, "DEV_MAC_SIGNING_FAILED", identity);
187
+ checked("codesign", ["--verify", "--strict", "--deep", bundle], context, "DEV_MAC_SIGNATURE_INVALID", identity);
188
+ const details = output(checked("codesign", ["--display", "--verbose=4", bundle], context, "DEV_MAC_SIGNATURE_INVALID", identity));
189
+ const actualTeam = /^TeamIdentifier=(.+)$/m.exec(details)?.[1]?.trim();
190
+ if (actualTeam !== teamIdentifier || /^Signature=adhoc$/m.test(details)) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_TEAM_MISMATCH", `Signing identity ${JSON.stringify(identity)}: expected TeamIdentifier=${teamIdentifier}, found ${actualTeam ?? "missing"}. Refusing dev installation.`, { identity, expectedTeam: teamIdentifier, actualTeam });
191
+ return { ...signing, provisioningProfile: provisioningProfile ? embedded : null, verified: "strict-deep", signedTargets: ordered.length };
192
+ } finally { rmSync(scratch, { recursive: true, force: true }); }
193
+ }
package/development.mjs CHANGED
@@ -8,8 +8,10 @@ import { assertReleaseExecutionAllowed } from "./execution-policy.mjs";
8
8
  import { acquireManagedRunLease, managedRuntimeClient, runCargoSync } from "./managed-cargo.mjs";
9
9
  import { BUNDLE_MARKERS, bundleMarkerBuffer, readTauriBundleMarkers } from "./tauri-bundle-marker.mjs";
10
10
  import { installPortableDevelopment } from "./portable-development.mjs";
11
+ import { signMacosDevelopmentBundle } from "./development-signing.mjs";
11
12
 
12
13
  export const DEVELOPMENT_PROFILE = "release-iterate";
14
+ const RELEASE_ONLY_APPLE_ENV = ["APPLE_ID", "APPLE_PASSWORD", "APPLE_API_KEY", "APPLE_API_ISSUER", "APPLE_API_KEY_PATH", "APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD"];
13
15
 
14
16
  export function developmentEnvironment(env = process.env) {
15
17
  const result = {
@@ -18,12 +20,9 @@ export function developmentEnvironment(env = process.env) {
18
20
  RIGHTKIT_CARGO_PROFILE: DEVELOPMENT_PROFILE,
19
21
  TAURI_SIGNING_PRIVATE_KEY: "",
20
22
  TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "",
21
- APPLE_SIGNING_IDENTITY: "-",
22
- APPLE_ID: "",
23
- APPLE_PASSWORD: "",
24
- APPLE_TEAM_ID: "",
25
23
  CSC_IDENTITY_AUTO_DISCOVERY: "false",
26
24
  };
25
+ for (const key of RELEASE_ONLY_APPLE_ENV) delete result[key];
27
26
  delete result.CARGO_INCREMENTAL;
28
27
  const client = managedRuntimeClient(result);
29
28
  if (client) {
@@ -76,8 +75,8 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
76
75
  const startedMs = Date.now();
77
76
  for (const command of target.build) execute(command, { root, env: childEnv, dryRun, run, label: "build" });
78
77
  if (buildOnly || !target.install) return { built: true, installed: false };
79
- install(target.install, { root, platform, env: childEnv, run, sinceMs: startedMs });
80
- return { built: true, installed: true };
78
+ const signing = install(target.install, { root, platform, config, env: childEnv, run, sinceMs: startedMs });
79
+ return { built: true, installed: true, ...(platform === "mac" && target.install.kind === "tauri" ? { signing } : {}) };
81
80
  } finally { lease.release(); }
82
81
  }
83
82
 
@@ -85,7 +84,7 @@ export function assertNativeDevelopmentHost(platform, host = process.platform) {
85
84
  if ((platform === "win" && host !== "win32") || (platform === "mac" && host !== "darwin")) throw new Error(`development target ${platform} requires its native host`);
86
85
  }
87
86
 
88
- function install(descriptor, { root, platform, env, run, sinceMs = null }) {
87
+ function install(descriptor, { root, platform, config, env, run, sinceMs = null }) {
89
88
  if (descriptor.kind === "command") return execute(descriptor, { root, env, run, label: "install" });
90
89
  if (descriptor.kind === "portable") return installPortableDevelopment(descriptor, { root, platform: platform === "win" ? "win32" : "darwin", env });
91
90
  if (platform === "win") {
@@ -111,11 +110,10 @@ function install(descriptor, { root, platform, env, run, sinceMs = null }) {
111
110
  const backup = `${destination}.rightkit-backup-${process.pid}`;
112
111
  rmSync(staging, { recursive: true, force: true });
113
112
  rmSync(backup, { recursive: true, force: true });
114
- cpSync(app, staging, { recursive: true });
115
- const signature = run("codesign", ["--force", "--deep", "--preserve-metadata=entitlements", "--sign", "-", staging], { cwd: root, env, stdio: "inherit", windowsHide: true, shell: false });
116
- if (signature.status !== 0) { rmSync(staging, { recursive: true, force: true }); throw new Error(`ad-hoc signing failed (${signature.status}): ${staging}`); }
117
- const verified = run("codesign", ["--verify", "--deep", "--strict", staging], { cwd: root, env, stdio: "inherit", windowsHide: true, shell: false });
118
- if (verified.status !== 0) { rmSync(staging, { recursive: true, force: true }); throw new Error(`ad-hoc signature verification failed (${verified.status}): ${staging}`); }
113
+ cpSync(app, staging, { recursive: true, verbatimSymlinks: true });
114
+ let signing;
115
+ try { signing = signMacosDevelopmentBundle({ root, bundle: staging, descriptor, config, env, run }); }
116
+ catch (error) { rmSync(staging, { recursive: true, force: true }); throw error; }
119
117
  let backedUp = false;
120
118
  try {
121
119
  if (existsSync(destination)) { renameSync(destination, backup); backedUp = true; }
@@ -128,6 +126,9 @@ function install(descriptor, { root, platform, env, run, sinceMs = null }) {
128
126
  rmSync(staging, { recursive: true, force: true });
129
127
  throw error;
130
128
  }
129
+ if (signing.provisioningProfile) signing.provisioningProfile = path.join(destination, "Contents", "embedded.provisionprofile");
130
+ console.log(`right-release dev: installed ${destination}; identity=${signing.identity}; TeamIdentifier=${signing.teamIdentifier}; profile=${signing.provisioningProfile ?? "none"}`);
131
+ return signing;
131
132
  }
132
133
 
133
134
  export function resolveTauriArtifact(descriptor, { root, platform, env = process.env, runCargo = runCargoSync }) {
@@ -268,6 +269,7 @@ export async function main(args = process.argv.slice(2)) {
268
269
  const absoluteConfig = path.resolve(configPath);
269
270
  const safeEnv = developmentEnvironment(process.env);
270
271
  for (const [key, value] of Object.entries(safeEnv)) process.env[key] = value;
272
+ for (const key of RELEASE_ONLY_APPLE_ENV) delete process.env[key];
271
273
  delete process.env.CARGO_INCREMENTAL;
272
274
  const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path");
273
275
  if (pathKey) process.env[pathKey] = safeEnv[pathKey] ?? process.env[pathKey];
@@ -279,5 +281,5 @@ export async function main(args = process.argv.slice(2)) {
279
281
  }
280
282
 
281
283
  if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
282
- main().catch((error) => { console.error(`right-release dev: ${error.message}`); process.exitCode = 1; });
284
+ main().catch((error) => { console.error(`right-release dev: ${error.code ? `[${error.code}] ` : ""}${error.message}`); process.exitCode = 1; });
283
285
  }