@rightkit/release 0.2.174 → 0.4.0

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 (82) hide show
  1. package/README.md +200 -2
  2. package/addon-command.mjs +3 -4
  3. package/android-play-upload.mjs +2 -1
  4. package/apple-profile-renew.mjs +11 -3
  5. package/artifact-storage.mjs +35 -2
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/darwin/rightkit-swap.sign.json +1 -1
  8. package/build-release.mjs +138 -47
  9. package/cli/right-release.mjs +40 -5
  10. package/codesign-environment.mjs +2 -2
  11. package/command-spec.mjs +80 -0
  12. package/content-digest.mjs +209 -0
  13. package/deps-cargo.mjs +55 -0
  14. package/deps.mjs +7 -0
  15. package/development.mjs +17 -6
  16. package/direct-bootstrap.mjs +5 -6
  17. package/disk-command.mjs +1 -0
  18. package/disk-policy.json +6 -2
  19. package/disk-stable-root.mjs +13 -9
  20. package/dist-scan.mjs +102 -0
  21. package/dmg-build.mjs +53 -4
  22. package/github-updater-release.mjs +70 -7
  23. package/icon-pack.mjs +117 -0
  24. package/in-place-install.mjs +17 -14
  25. package/init-command.mjs +116 -0
  26. package/install-app-windows.mjs +24 -7
  27. package/install-app.mjs +56 -42
  28. package/ios-asc-credentials.mjs +163 -0
  29. package/ios-build-context.mjs +129 -0
  30. package/ios-build-upload.mjs +282 -0
  31. package/ios-credentials-command.mjs +70 -0
  32. package/ios-dev.mjs +248 -0
  33. package/ios-host.mjs +76 -0
  34. package/ios-lock.mjs +75 -0
  35. package/ios-package-recipe.mjs +707 -0
  36. package/ios-testflight.mjs +266 -0
  37. package/ios-upload.mjs +313 -0
  38. package/local-publish.mjs +390 -47
  39. package/macho-approval-hash.mjs +181 -0
  40. package/managed-cargo.mjs +57 -1
  41. package/media-sidecar.mjs +20 -4
  42. package/mirror-root-artifact.mjs +74 -22
  43. package/native-cargo-layout.mjs +47 -4
  44. package/npm-license-notices.mjs +159 -0
  45. package/ownership-command.mjs +2 -1
  46. package/ownership-dependency-allowlist.json +2 -1
  47. package/ownership-evidence.mjs +30 -18
  48. package/ownership-key-command.mjs +69 -2
  49. package/ownership-mandatory-set.mjs +66 -16
  50. package/ownership-repositories.json +1 -1
  51. package/ownership-scope.mjs +94 -0
  52. package/ownership.mjs +86 -11
  53. package/package-recipe-targets.mjs +56 -2
  54. package/package.json +1 -1
  55. package/pe-content.mjs +133 -0
  56. package/preflight.mjs +14 -7
  57. package/project-type.mjs +3 -1
  58. package/protected-credentials.mjs +28 -1
  59. package/publication-artifacts.mjs +30 -5
  60. package/publication-gate.mjs +83 -5
  61. package/publish-cargo.mjs +124 -18
  62. package/publish-update.mjs +15 -2
  63. package/qa-build-features.mjs +16 -13
  64. package/qa-contract.mjs +30 -2
  65. package/qa-control-scan.mjs +4 -0
  66. package/release-dependencies-command.mjs +45 -0
  67. package/release-dependencies.mjs +60 -0
  68. package/release-invocation.mjs +118 -8
  69. package/release-switch-registry.json +126 -27
  70. package/release-switch-scan.mjs +62 -24
  71. package/release.mjs +144 -21
  72. package/rightkit-ownership.json +46 -2
  73. package/rightkit-versions.json +380 -189
  74. package/runtime-budgets.mjs +134 -23
  75. package/sign-updater.mjs +56 -59
  76. package/source-gate.mjs +8 -1
  77. package/stage-sidecar.mjs +634 -69
  78. package/target-distribution.mjs +5 -0
  79. package/test-command.mjs +190 -0
  80. package/toolchain-swift.mjs +239 -0
  81. package/upload-release.mjs +7 -2
  82. package/web-build.mjs +67 -0
package/install-app.mjs CHANGED
@@ -271,15 +271,38 @@ const defaultIsAlive = (pid) => {
271
271
  try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
272
272
  };
273
273
 
274
- // Keep the bundle it replaced as `<installed>.previous` beside the fixed path (replacing any older one), by rename.
275
- // Rollback is renaming it back; nothing ever runs from it. It is never sent to the Trash: a Trash copy is a second
276
- // path macOS can resolve for the same bundle id.
277
- async function keepPreviousBundle(run, fsImpl, bundle, installed) {
278
- const previous = previousPathOf(installed);
279
- if (bundle === previous) return previous;
280
- fsImpl.rmSync(previous, { recursive: true, force: true });
281
- await moveAside(run, fsImpl, bundle, previous);
282
- return previous;
274
+ // Retire the replaced bundle after the new one verifies: NO `<installed>.previous` is retained (D01/#771).
275
+ // Only the exact journaled park scope is cleaned: the old bundle at its staged/backup path and the stale
276
+ // `<installed>.previous` an older RightKit left beside this fixed path. Nothing else in dest is touched.
277
+ function retireOldBundle(fsImpl, bundle, installed) {
278
+ const rm = (p) => { if (p) { try { fsImpl.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } } };
279
+ rm(bundle);
280
+ rm(previousPathOf(installed));
281
+ }
282
+
283
+ // Exclusive per-destination install lock: a lock file beside the installed apps, created O_EXCL. A lock whose pid is dead is
284
+ // stale and taken over once. Held across recovery, staging, journal mutation and cleanup so two installers never share the
285
+ // fixed staging/journal paths.
286
+ const LOCK_NAME = ".rightkit-install.lock";
287
+ // Locks this process currently holds. A lock naming our own pid that is not in this set was leaked (a simulated or
288
+ // aborted install in this process), so it is stale exactly like a dead pid's.
289
+ const heldLocks = new Set();
290
+ function acquireInstallLock({ dest, fsImpl, pid, isAlive }) {
291
+ const lock = path.join(dest, LOCK_NAME);
292
+ for (let attempt = 0; attempt < 2; attempt += 1) {
293
+ try {
294
+ fsImpl.writeFileSync(lock, JSON.stringify({ pid }), { flag: "wx" });
295
+ heldLocks.add(lock);
296
+ return () => { heldLocks.delete(lock); try { fsImpl.rmSync(lock, { force: true }); } catch { /* best effort */ } };
297
+ } catch (error) {
298
+ if (error?.code !== "EEXIST") throw new InstallAppError("lock-failed", `cannot create install lock ${lock}: ${errText(error)}`);
299
+ let holder = null;
300
+ try { holder = JSON.parse(fsImpl.readFileSync(lock, "utf8")).pid; } catch { holder = null; }
301
+ if (Number.isInteger(holder) && isAlive(holder) && !(holder === pid && !heldLocks.has(lock))) throw new InstallAppError("install-locked", `another install holds ${lock} (pid ${holder})`);
302
+ try { fsImpl.rmSync(lock, { force: true }); } catch { /* retry decides */ }
303
+ }
304
+ }
305
+ throw new InstallAppError("install-locked", `could not take install lock ${lock}`);
283
306
  }
284
307
 
285
308
  // Deterministic roll-forward/back of any interrupted install journal in `dest`. Idempotent.
@@ -315,9 +338,8 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
315
338
  let ok = j.phase === "verified";
316
339
  if (!ok) { try { await verifyBundle(run, j.installed, { assess: !j.dev, staple: false }); ok = true; } catch { ok = false; } }
317
340
  if (ok) {
318
- // A dev journal (from an older RightKit) never keeps the old bundle: the staged path is cleared below.
319
- try { if (!j.dev) await keepPreviousBundle(run, fsImpl, j.staged, j.installed); action = "rolled-forward"; }
320
- catch { action = "rolled-forward-previous-pending"; keepJournal = true; keepStaged = true; }
341
+ // Roll forward: the old bundle at the staged path is retired below, never kept as `.previous`.
342
+ action = "rolled-forward";
321
343
  } else {
322
344
  swapBack(swapPaths, fsImpl, j.staged, j.installed); // staged now holds the bad new bundle
323
345
  action = "rolled-back";
@@ -330,8 +352,8 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
330
352
  let ok = j.phase === "verified";
331
353
  if (!ok) { try { await verifyBundle(run, j.installed, { assess: !j.dev, staple: false }); ok = true; } catch { ok = false; } }
332
354
  if (ok) {
333
- try { if (j.dev) rm(j.backup); else await keepPreviousBundle(run, fsImpl, j.backup, j.installed); action = "rolled-forward"; }
334
- catch { action = "rolled-forward-previous-pending"; keepJournal = true; }
355
+ rm(j.backup); // validated journaled backup scope only; never retained as `.previous`
356
+ action = "rolled-forward";
335
357
  } else {
336
358
  rm(j.installed);
337
359
  fsImpl.renameSync(j.backup, j.installed);
@@ -343,6 +365,7 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
343
365
  continue; // leave the journal for the next run
344
366
  }
345
367
  if (!keepStaged) rm(j.staged);
368
+ if (j.installed) rm(previousPathOf(j.installed)); // no retained `.previous` survives recovery
346
369
  if (j.mount && has(j.mount)) {
347
370
  const out = await detachWithRetry({ mount: j.mount, log: (e) => event(e.event, e), sleep, detach: (args) => run("/usr/bin/hdiutil", args) });
348
371
  if (out.ok) rm(j.mountRoot);
@@ -357,7 +380,10 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
357
380
  export async function recoverApp(opts, deps = {}) {
358
381
  const { execFile = realExecFile, fsImpl = fs, home = os.homedir(), now = () => new Date(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)), emit = () => {}, isAlive, platform = process.platform, env = process.env, swapPaths = createSwapPaths({ env }) } = deps;
359
382
  if (platform !== "darwin") throw new InstallAppError("unsupported", `install-app is not implemented on ${platform}`);
360
- const results = await recoverInstalls({ dest: opts.dest, appName: opts.appName, run: (c, a) => execFile(c, a), fsImpl, home, now, event: (name, data = {}) => emit({ event: name, ...data }), sleep, isAlive, swapPaths });
383
+ const release = acquireInstallLock({ dest: opts.dest, fsImpl, pid: process.pid, isAlive: isAlive ?? defaultIsAlive });
384
+ let results;
385
+ try { results = await recoverInstalls({ dest: opts.dest, appName: opts.appName, run: (c, a) => execFile(c, a), fsImpl, home, now, event: (name, data = {}) => emit({ event: name, ...data }), sleep, isAlive, swapPaths }); }
386
+ finally { release(); }
361
387
  emit({ event: "recover-done", dest: opts.dest, recovered: results.length });
362
388
  return { ok: true, recovered: results };
363
389
  }
@@ -389,6 +415,7 @@ export async function installApp(opts, deps = {}) {
389
415
  let mountPoint = null;
390
416
  let failedStage = "start";
391
417
  let swap = null; // set once the journal exists; tracks which renames happened
418
+ let releaseLock = () => {};
392
419
  let devOldRemoved = false; // a dev install removed the old bundle; nothing can put it back
393
420
 
394
421
  const removeQuiet = (p) => { if (p) { try { fsImpl.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } } };
@@ -401,7 +428,7 @@ export async function installApp(opts, deps = {}) {
401
428
  if (s.mode === "atomic") {
402
429
  // installed was never absent. Before the exchange only the staged copy needs removing; after it,
403
430
  // exchange back (installed = old again, staged = the new copy) and drop the new copy. Once final,
404
- // staged holds the verified-old bundle awaiting retirement to .previous, so it must not be touched.
431
+ // staged holds the verified-old bundle awaiting retirement (deletion), so it must not be touched.
405
432
  if (!s.final) {
406
433
  if (s.swapped) swapBack(swapPaths, fsImpl, s.staged, s.installed);
407
434
  removeQuiet(s.staged);
@@ -428,6 +455,7 @@ export async function installApp(opts, deps = {}) {
428
455
  await detach();
429
456
  cleanupExtract();
430
457
  } catch { /* exiting anyway */ }
458
+ releaseLock();
431
459
  exit(128 + (os.constants.signals[sig] || 0));
432
460
  })();
433
461
  };
@@ -455,6 +483,7 @@ export async function installApp(opts, deps = {}) {
455
483
  if (!fsImpl.existsSync(inputPath)) throw new InstallAppError(`${inputKind}-missing`, `${inputKind.toUpperCase()} not found: ${inputPath}`);
456
484
  if (!fsImpl.existsSync(opts.dest)) throw new InstallAppError("dest-missing", `destination not found: ${opts.dest}`);
457
485
 
486
+ releaseLock = acquireInstallLock({ dest: opts.dest, fsImpl, pid, isAlive });
458
487
  await recoverInstalls({ dest: opts.dest, appName: opts.appName, run, fsImpl, home, now, event, sleep, isAlive, selfPid: pid, swapPaths });
459
488
 
460
489
  let sourceApp = null;
@@ -511,8 +540,9 @@ export async function installApp(opts, deps = {}) {
511
540
  // Stage a verified copy on the destination volume so the swap is rename(2) only.
512
541
  failedStage = "stage";
513
542
  const base = appName.replace(/\.app$/, "");
514
- // Staging is never run from: it is verified, then renamed into place. The rollback copy is the fixed `<installed>.previous`.
515
- const staged = path.join(opts.dest, `.${base}.rightkit-install-${pid}.app`);
543
+ // Staging is never run from: it is verified, then renamed into place. Its name is fixed per app (no per-pid sibling), like the
544
+ // journal beside it; the old bundle's transient park is the fixed `<installed>.previous`, deleted once verified.
545
+ const staged = path.join(opts.dest, `.${base}.rightkit-install.app`);
516
546
  // Dev installs keep NO old copy of any kind (Orthic-Labs/rightkit#348): the old bundle is removed, never moved to `.previous`.
517
547
  const backup = hadInstalled && !dev ? previousPathOf(installed) : null;
518
548
  const journal = journalPathFor(opts.dest, appName);
@@ -573,7 +603,7 @@ export async function installApp(opts, deps = {}) {
573
603
  swap.mode = "two-rename";
574
604
  jd = { ...journalData, swap: "two-rename" };
575
605
  writeJournal(fsImpl, journal, { ...jd, phase: "swapping" });
576
- removeQuiet(backup); // an older .previous; the one kept copy is the bundle moving aside now
606
+ removeQuiet(backup); // clear the transient park (or a stale .previous) before parking the old bundle
577
607
  fsImpl.renameSync(installed, backup); swap.backedUp = true;
578
608
  fsImpl.renameSync(staged, installed);
579
609
  swap.stagedIn = true;
@@ -594,20 +624,13 @@ export async function installApp(opts, deps = {}) {
594
624
  writeJournal(fsImpl, journal, { ...jd, phase: "verified", mount: null, mountRoot: null });
595
625
  swap.final = true;
596
626
 
597
- // Only now is the old bundle retired, to <installed>.previous (never the Trash).
598
- let kept = null;
599
- let keepFailed = false;
627
+ // Only now is the old bundle retired: deleted, never kept as `<installed>.previous` (D01).
600
628
  const oldBundle = swapMode === "atomic" ? staged : backup; // atomic: the exchange left the old bundle at the staged path
601
- if (hadInstalled && oldBundle) {
602
- try { kept = await keepPreviousBundle(run, fsImpl, oldBundle, installed); }
603
- catch (error) { event("previous-keep-failed", { backup: oldBundle, reason: errText(error) }); kept = oldBundle; }
604
- }
605
- if (!oldBundle || kept !== previousPathOf(installed)) { if (!oldBundle) removeQuiet(journal); }
606
- else removeQuiet(journal);
607
- // A stale `.previous` from an older RightKit must not survive a dev install either.
608
- if (dev) removeQuiet(previousPathOf(installed));
629
+ if (hadInstalled && oldBundle) retireOldBundle(fsImpl, oldBundle, installed);
630
+ else removeQuiet(previousPathOf(installed)); // a stale `.previous` from an older RightKit survives no install
631
+ removeQuiet(journal);
609
632
  swap = null;
610
- event("replaced", { installed, previous: kept, swap: swapMode });
633
+ event("replaced", { installed, previous: null, swap: swapMode });
611
634
 
612
635
  // Relaunch hidden and in the background only.
613
636
  const relaunch = opts.relaunch === null ? wasRunning : opts.relaunch;
@@ -616,7 +639,7 @@ export async function installApp(opts, deps = {}) {
616
639
  await run("/usr/bin/open", ["-g", "-j", "-b", bundleId]);
617
640
  event("relaunched", { bundleId });
618
641
  }
619
- const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous: kept, swap: swapMode, relaunched: relaunch, ...(dev ? { dev: true } : {}) };
642
+ const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous: null, swap: swapMode, relaunched: relaunch, ...(dev ? { dev: true } : {}) };
620
643
  event("done", result);
621
644
  return result;
622
645
  } catch (error) {
@@ -634,6 +657,7 @@ export async function installApp(opts, deps = {}) {
634
657
  rollbackSwap();
635
658
  await detach();
636
659
  cleanupExtract();
660
+ releaseLock();
637
661
  }
638
662
  }
639
663
 
@@ -713,16 +737,6 @@ async function quitApp(run, kill, sleep, app) {
713
737
  return { pids, killed: remaining };
714
738
  }
715
739
 
716
- async function moveAside(run, fsImpl, from, to) {
717
- try {
718
- fsImpl.renameSync(from, to);
719
- } catch (error) {
720
- if (error.code !== "EXDEV") throw error;
721
- await run("/usr/bin/ditto", [from, to]);
722
- fsImpl.rmSync(from, { recursive: true, force: true });
723
- }
724
- }
725
-
726
740
  export async function main(argv = process.argv.slice(2), io = {}) {
727
741
  const stdout = io.stdout || ((s) => console.log(s));
728
742
  const stderr = io.stderr || ((s) => console.error(s));
@@ -0,0 +1,163 @@
1
+ // App Store Connect API credentials for the iOS lane (bogusyogi/rightkit#349, settled decision D4: no credential sits in a file).
2
+ //
3
+ // The .p8 key, the key id and the issuer id are read at run time from the macOS login Keychain:
4
+ // security find-generic-password -s <service> -a <account> -w
5
+ // Service names are configurable (`distribution.credentials`), with documented defaults. They are never read from config
6
+ // values, environment variables or file paths:
7
+ // - a config key that names a path, a key id, an issuer or a secret is refused (IOS_ASC_CREDENTIAL_SOURCE_REFUSED);
8
+ // - key material in ANY environment variable is refused on every host; the usual ASC variable names are refused on managed
9
+ // hosts, exactly as macOS notarization does (managed-host-secrets.mjs);
10
+ // - key material never reaches a log line, an error message or a command argument.
11
+ // `right-release ios-credentials store` writes the three items once (ios-credentials-command.mjs); the key is stored as the
12
+ // single-line base64 of its PKCS#8 DER so no newline or quoting problem can occur, and it is sent to `security -i` on stdin so
13
+ // it never appears in any process argument list.
14
+ import { createPrivateKey } from "node:crypto";
15
+ import os from "node:os";
16
+ import { spawnSync } from "./background-process.mjs";
17
+ import { refuseManagedEnvSecrets } from "./managed-host-secrets.mjs";
18
+
19
+ export const DEFAULT_ASC_SERVICES = Object.freeze({
20
+ keyService: "rightkit-asc-api-key",
21
+ keyIdService: "rightkit-asc-key-id",
22
+ issuerService: "rightkit-asc-issuer-id",
23
+ });
24
+
25
+ export class IosCredentialsError extends Error {
26
+ constructor(code, message) { super(message); this.name = "IosCredentialsError"; this.code = code; }
27
+ }
28
+ const refuse = (code, message) => { throw new IosCredentialsError(code, message); };
29
+
30
+ const SERVICE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
31
+ const ACCOUNT = /^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$/;
32
+ const KEY_ID = /^[A-Z0-9]{10}$/;
33
+ const ISSUER = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
34
+ const PEM_MARKER = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
35
+ const SOURCE_KEYS = ["keyService", "keyIdService", "issuerService", "account"];
36
+ // Config keys that would carry a path, an identifier or a secret instead of a Keychain service name.
37
+ const FORBIDDEN_SOURCE_KEYS = /^(?:key|keyPath|keyFile|file|path|p8|pem|keyId|keyID|issuer|issuerId|issuerID|env|envKey|password|secret|token|value)$/i;
38
+
39
+ /** Environment variable names that carry ASC key material or paths; refused on managed hosts. */
40
+ export const ASC_ENV_NAMES = Object.freeze([
41
+ "APPLE_API_KEY_PATH", "APPLE_API_KEY", "APPLE_API_ISSUER",
42
+ "ASC_KEY_PATH", "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_API_KEY", "ASC_API_KEY_PATH",
43
+ "APP_STORE_CONNECT_API_KEY", "APP_STORE_CONNECT_API_KEY_PATH", "APP_STORE_CONNECT_KEY_ID", "APP_STORE_CONNECT_ISSUER_ID",
44
+ "HR_IOS_ASC_KEY_PATH", "HR_IOS_ASC_KEY_ID", "HR_IOS_ASC_ISSUER_ID",
45
+ ]);
46
+
47
+ /** Validate an optional credentials block and return the service names and account (defaults applied). */
48
+ export function resolveAscCredentialSource(credentials, { env = process.env, label = "ASC credentials" } = {}) {
49
+ if (credentials !== undefined && (credentials === null || typeof credentials !== "object" || Array.isArray(credentials))) {
50
+ refuse("IOS_ASC_CONFIG_INVALID", `${label} must be an object of Keychain service names`);
51
+ }
52
+ const given = credentials ?? {};
53
+ for (const key of Object.keys(given)) {
54
+ if (FORBIDDEN_SOURCE_KEYS.test(key)) {
55
+ refuse("IOS_ASC_CREDENTIAL_SOURCE_REFUSED", `${label}.${key} is refused: App Store Connect credentials are read only from the macOS login Keychain (service names ${SOURCE_KEYS.join(", ")}), never from a file path, an inline value or the environment`);
56
+ }
57
+ if (!SOURCE_KEYS.includes(key)) refuse("IOS_ASC_CONFIG_INVALID", `${label} has unknown key ${key} (allowed: ${SOURCE_KEYS.join(", ")})`);
58
+ }
59
+ const source = {};
60
+ for (const key of ["keyService", "keyIdService", "issuerService"]) {
61
+ const value = given[key] ?? DEFAULT_ASC_SERVICES[key];
62
+ if (typeof value !== "string" || !SERVICE.test(value)) refuse("IOS_ASC_CONFIG_INVALID", `${label}.${key} must be a Keychain service name (letters, digits, dot, underscore, hyphen)`);
63
+ source[key] = value;
64
+ }
65
+ const names = new Set([source.keyService, source.keyIdService, source.issuerService]);
66
+ if (names.size !== 3) refuse("IOS_ASC_CONFIG_INVALID", `${label} must use three different Keychain service names`);
67
+ const account = given.account ?? (env.USER || os.userInfo().username);
68
+ if (typeof account !== "string" || !ACCOUNT.test(account)) refuse("IOS_ASC_CONFIG_INVALID", `${label}.account must be a Keychain account name`);
69
+ return { ...source, account };
70
+ }
71
+
72
+ /** Key material in any variable is refused on every host (names only); the ASC variable names are refused on managed hosts. */
73
+ export function refuseIosAscEnv(env = process.env) {
74
+ for (const [name, value] of Object.entries(env)) {
75
+ if (PEM_MARKER.test(String(value ?? ""))) {
76
+ refuse("IOS_ASC_ENV_SECRET_REFUSED", `${name} is refused: App Store Connect key material must never be passed in the environment; keep it in the macOS login Keychain (right-release ios-credentials store)`);
77
+ }
78
+ }
79
+ refuseManagedEnvSecrets(env, ASC_ENV_NAMES);
80
+ }
81
+
82
+ /** Parse a .p8 (PEM text) or the stored single-line base64 PKCS#8 DER into a P-256 private KeyObject. */
83
+ export function parseAscPrivateKey(text) {
84
+ const value = String(text ?? "").trim();
85
+ let key;
86
+ try {
87
+ key = PEM_MARKER.test(value) ? createPrivateKey(value) : createPrivateKey({ key: Buffer.from(value, "base64"), format: "der", type: "pkcs8" });
88
+ } catch { refuse("IOS_ASC_KEY_INVALID", "the App Store Connect API key is not a valid .p8 private key"); }
89
+ if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") refuse("IOS_ASC_KEY_INVALID", "the App Store Connect API key must be an ES256 (P-256) key");
90
+ return key;
91
+ }
92
+
93
+ export function validateAscKeyId(value) {
94
+ if (!KEY_ID.test(String(value ?? ""))) refuse("IOS_ASC_KEY_ID_INVALID", "the App Store Connect key id must be 10 upper-case letters or digits");
95
+ return String(value);
96
+ }
97
+ export function validateAscIssuer(value) {
98
+ if (!ISSUER.test(String(value ?? ""))) refuse("IOS_ASC_ISSUER_INVALID", "the App Store Connect issuer id must be a UUID");
99
+ return String(value);
100
+ }
101
+
102
+ export function iosCredentialsStoreHint() {
103
+ return "pipe the .p8 into: right-release ios-credentials store --key-id <KEYID> --issuer <issuer-uuid> (then delete the .p8 file)";
104
+ }
105
+
106
+ function keychainRead({ service, account, run }) {
107
+ const result = run("security", ["find-generic-password", "-s", service, "-a", account, "-w"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], shell: false, windowsHide: true, timeout: 30_000 });
108
+ if (result.error || result.status !== 0) return undefined;
109
+ const value = String(result.stdout ?? "").trim();
110
+ return value || undefined;
111
+ }
112
+
113
+ /**
114
+ * Read the key, key id and issuer from the login Keychain. macOS only. Returns the parsed KeyObject (never the text).
115
+ * A missing item names the service, never a value.
116
+ */
117
+ export function readAscCredentials({ credentials, env = process.env, platform = process.platform, run = spawnSync } = {}) {
118
+ if (platform !== "darwin") refuse("IOS_ASC_HOST_UNSUPPORTED", "App Store Connect credentials are read from the macOS login Keychain; this host is not macOS");
119
+ refuseIosAscEnv(env);
120
+ const source = resolveAscCredentialSource(credentials, { env });
121
+ const read = (service, what) => {
122
+ const value = keychainRead({ service, account: source.account, run });
123
+ if (!value) refuse("IOS_ASC_CREDENTIAL_MISSING", `the ${what} is not in the login Keychain (service ${service}, account ${source.account}); ${iosCredentialsStoreHint()}`);
124
+ return value;
125
+ };
126
+ const keyText = read(source.keyService, "App Store Connect API key");
127
+ const keyId = validateAscKeyId(read(source.keyIdService, "App Store Connect key id"));
128
+ const issuer = validateAscIssuer(read(source.issuerService, "App Store Connect issuer id"));
129
+ return { keyId, issuer, privateKey: parseAscPrivateKey(keyText), source };
130
+ }
131
+
132
+ /**
133
+ * Write the three items into the login Keychain through `security -i` (commands on stdin, so the key is never an argument).
134
+ * The items are read back and compared; a missing or different item fails the store. Never writes the key to disk.
135
+ */
136
+ export function storeAscCredentials({ keyText, keyId, issuer, credentials, env = process.env, platform = process.platform, run = spawnSync } = {}) {
137
+ if (platform !== "darwin") refuse("IOS_ASC_HOST_UNSUPPORTED", "App Store Connect credentials are stored in the macOS login Keychain; this host is not macOS");
138
+ refuseIosAscEnv(env);
139
+ const source = resolveAscCredentialSource(credentials, { env });
140
+ const key = parseAscPrivateKey(keyText);
141
+ const encodedKey = key.export({ type: "pkcs8", format: "der" }).toString("base64");
142
+ const items = [
143
+ { service: source.keyService, value: encodedKey },
144
+ { service: source.keyIdService, value: validateAscKeyId(keyId) },
145
+ { service: source.issuerService, value: validateAscIssuer(issuer) },
146
+ ];
147
+ // Base64, a key id and a UUID need no escaping inside double quotes; service and account were validated above.
148
+ const script = `${items.map((item) => `add-generic-password -U -a "${source.account}" -s "${item.service}" -l "${item.service}" -w "${item.value}"`).join("\n")}\nexit\n`;
149
+ const result = run("security", ["-i"], { input: script, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], shell: false, windowsHide: true, timeout: 60_000 });
150
+ if (result.error) refuse("IOS_ASC_STORE_FAILED", "could not run security to store the App Store Connect credentials");
151
+ for (const item of items) {
152
+ if (keychainRead({ service: item.service, account: source.account, run }) !== item.value) {
153
+ refuse("IOS_ASC_STORE_FAILED", `the Keychain item ${item.service} was not stored correctly (is the login keychain unlocked in this session?)`);
154
+ }
155
+ }
156
+ return { services: [source.keyService, source.keyIdService, source.issuerService], account: source.account, keyId: items[1].value, issuer: items[2].value };
157
+ }
158
+
159
+ /** Report which items exist, without returning or printing any key material. */
160
+ export function checkAscCredentials(options) {
161
+ const credentials = readAscCredentials(options);
162
+ return { keyId: credentials.keyId, issuer: credentials.issuer, services: [credentials.source.keyService, credentials.source.keyIdService, credentials.source.issuerService], account: credentials.source.account };
163
+ }
@@ -0,0 +1,129 @@
1
+ // What the sealed `build` path does differently for a native iOS app with no Cargo host (no src-tauri, no
2
+ // nativeAssembly manifest; iosPackage.prepare commands are refused there, see release-invocation.mjs), so the
3
+ // release owns no Cargo manifest, lock, target directory, broker target or Rust cache identity. Pure and
4
+ // stub-friendly, the same shape as swift-build-context.mjs, so build-release.mjs branches once.
5
+
6
+ import { createHash } from "node:crypto";
7
+ import { existsSync } from "node:fs";
8
+ import path from "node:path";
9
+ import { declaredCargoManifest } from "./native-cargo-layout.mjs";
10
+ import { isNativeIosTarget, nativeIosPrepareProblems } from "./release-invocation.mjs";
11
+
12
+ // Everything the release lane exports for Rust builds is removed from a native iOS build: releaseEnvironment() sets
13
+ // CARGO_TARGET_DIR, CARGO_HOME, SCCACHE_DIR, RUSTC_WRAPPER (and in shared mode SCCACHE_CACHE_SIZE, SCCACHE_BASEDIRS,
14
+ // SCCACHE_IGNORE_SERVER_IO_ERROR), and the host shell may carry RUSTFLAGS, RUSTC_* and other Cargo settings or registry secrets. Matching by
15
+ // prefix keeps any later addition covered.
16
+ const RUST_ENV_PREFIXES = ["CARGO_", "RUST", "SCCACHE_", "RIGHT_RELEASE_CACHE_OWNER"];
17
+ // Kept as the host has them, so a hand `cargo build` in iosPackage.prepare behaves as it does in the user's shell: harmless tuning
18
+ // knobs, the rustup toolchain locator, CARGO_HOME (the release lane's value is replaced by the host's own, or removed when the host
19
+ // has none) and the proxy / network settings.
20
+ const RUST_ENV_KEEP = new Set(["CARGO_BUILD_JOBS", "CARGO_JOBS", "RUST_TEST_THREADS", "CARGO_HOME"]);
21
+ const RUST_ENV_KEEP_PREFIXES = ["RUSTUP_", "CARGO_NET_", "CARGO_HTTP_"];
22
+ const isRustEnv = (upper) => RUST_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix));
23
+ const isKeptRustEnv = (upper) => RUST_ENV_KEEP.has(upper) || RUST_ENV_KEEP_PREFIXES.some((prefix) => upper.startsWith(prefix));
24
+
25
+ /**
26
+ * env is the release lane's environment (host variables plus what releaseEnvironment exported); host is the process
27
+ * environment the release was started from. Release-lane Rust variables are dropped; the kept ones come from host, never from the lane.
28
+ */
29
+ export function stripRustEnvironment(env, host = env) {
30
+ const next = {};
31
+ for (const [name, value] of Object.entries(env)) if (!isRustEnv(name.toUpperCase())) next[name] = value;
32
+ for (const [name, value] of Object.entries(host)) {
33
+ const upper = name.toUpperCase();
34
+ if (isRustEnv(upper) && isKeptRustEnv(upper)) next[name] = value;
35
+ }
36
+ return next;
37
+ }
38
+
39
+ // ---- release-switch inputs: the Rust the prepare commands build must be checked, never skipped --------------------------
40
+
41
+ /**
42
+ * What the release-switch gates need for a build that runs Rust: the manifest to resolve the feature closure from
43
+ * (targets.<os>.cargoManifest, else the declared nativeAssembly.cargoManifest), the iosPackage.prepare commands to scan, the
44
+ * prepare cargo commands whose --features / --no-default-features / --all-features define the closure, and the reasons the
45
+ * features cannot be determined (which refuse the build). A prepare command that is a shell or wrapper, or a cargo command
46
+ * whose feature list comes from an unexpanded variable, hides what it builds, so it is refused when a Rust manifest is declared.
47
+ */
48
+ export function releaseSwitchInputs({ appRoot, config, target, prepare = [], nativeIos = false }) {
49
+ // Every other platform, and iOS with src-tauri, keeps the pre-existing resolver order: targets.<os>.cargoManifest only.
50
+ if (!nativeIos) return { cargoManifest: target?.cargoManifest, commands: [], cargoCommands: [], problems: [] };
51
+ const cargoManifest = target?.cargoManifest ?? declaredCargoManifest(config);
52
+ const commands = prepare.map((command, index) => ({ label: `iosPackage.prepare[${index}]`, command }));
53
+ const cargoCommands = prepare.filter((command) => command?.cmd === "cargo");
54
+ return { cargoManifest, commands, cargoCommands, problems: nativeIosPrepareProblems({ appRoot, config, prepare }) };
55
+ }
56
+
57
+ /** Bare executable names the iosPackage.prepare commands invoke (a path such as ./script.sh is the app's own file, not a tool). */
58
+ export function iosPrepareExecutables(iosSpec) {
59
+ return [...new Set((iosSpec?.prepare ?? []).map((command) => command?.cmd).filter((cmd) => typeof cmd === "string" && cmd && !/[\\/]/.test(cmd)))];
60
+ }
61
+
62
+ /**
63
+ * The iOS side of the sealed build, with every host effect injected: whether the app owns a Cargo layout (null when not), the
64
+ * tool versions to record, and nothing probed before its presence is asserted. exec(cmd, args) -> stdout;
65
+ * assertExecutables(names) throws the normal "required executable not found" failure.
66
+ */
67
+ export function resolveIosBuildContext({ appRoot, config, target, iosSpec, exec, assertExecutables, exists = existsSync }) {
68
+ // An iOS app with src-tauri (Pulse) returns null: its build is exactly the Cargo-backed one, with nothing added or probed here.
69
+ if (!isNativeIosTarget({ appRoot, target })) return null;
70
+ const hasPackageJson = exists(path.join(appRoot, "package.json"));
71
+ const packageManager = hasPackageJson ? config.packageManager ?? "pnpm" : null;
72
+ const rust = Boolean(declaredCargoManifest(config));
73
+ assertExecutables(["xcodebuild", ...(iosSpec.projectSpec ? ["xcodegen"] : []), ...(packageManager ? [packageManager] : []), ...(rust ? ["cargo", "rustc"] : [])]);
74
+ const toolVersions = collectIosNativeToolVersions({ packageManager, exec, projectSpec: iosSpec.projectSpec, rust });
75
+ return { nativeIos: true, nativeLayout: null, hasPackageJson, toolVersions, prepareExecutables: iosPrepareExecutables(iosSpec) };
76
+ }
77
+
78
+ const oneLine = (text) => String(text ?? "").trim().split(/\r?\n/).map((line) => line.trim()).filter(Boolean).join(" ");
79
+
80
+ /**
81
+ * The Xcode toolchain an iOS release is built with, in the exact form the IPA receipt records it
82
+ * (`xcodebuild -version`, one line). `xcodegen --version` only when iosPackage names a project.yml.
83
+ * exec(cmd, args) -> stdout string; throws when the tool is missing, which is correct: the iOS lane cannot build without it.
84
+ */
85
+ export function collectIosXcodeToolVersions({ exec, projectSpec }) {
86
+ const xcodebuild = oneLine(exec("xcodebuild", ["-version"]));
87
+ if (!xcodebuild) throw new Error("xcodebuild -version printed nothing");
88
+ const xcodegen = projectSpec ? oneLine(exec("xcodegen", ["--version"])) : null;
89
+ if (projectSpec && !xcodegen) throw new Error("xcodegen --version printed nothing");
90
+ return { xcodebuild, xcodegen };
91
+ }
92
+
93
+ /** Tool versions for a native iOS build: Rust fields are explicit nulls unless a Rust manifest is declared (then cargo -V and rustc -V). */
94
+ export function collectIosNativeToolVersions({ packageManager, exec, projectSpec, rust = false, nodeVersion = process.version }) {
95
+ return {
96
+ node: nodeVersion,
97
+ packageManager: packageManager ? `${packageManager} ${exec(packageManager, ["--version"]).trim()}` : null,
98
+ ...collectIosXcodeToolVersions({ exec, projectSpec }),
99
+ cargo: rust ? oneLine(exec("cargo", ["-V"])) : null,
100
+ rustc: rust ? oneLine(exec("rustc", ["-V"])) : null,
101
+ rustHost: null,
102
+ sccache: null,
103
+ };
104
+ }
105
+
106
+ export function iosArchitecture({ hostArch = process.arch } = {}) {
107
+ return hostArch === "arm64" ? "arm64" : "x64";
108
+ }
109
+
110
+ /**
111
+ * Plan for a Cargo-less iOS sealed build: identity from the Xcode toolchain, forced non-shared cache mode, no broker
112
+ * cargo target, no target bridge, a plain cache dir under the vault, and an environment with every Rust variable removed.
113
+ */
114
+ export function planIosNativeBuild({ vaultRoot, appRoot, platform = "ios", toolVersions, profile = "release", hostArch, hostEnv = process.env }) {
115
+ if (!toolVersions?.xcodebuild) throw new Error("iOS build identity requires the xcodebuild version");
116
+ const architecture = iosArchitecture({ hostArch });
117
+ const payload = { projectType: "ios-native", xcodebuild: toolVersions.xcodebuild, xcodegen: toolVersions.xcodegen ?? null, architecture, profile };
118
+ const identity = { ...payload, target: architecture, features: [], fingerprint: createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 16) };
119
+ const buildDir = path.resolve(vaultRoot, "cache", "ios-native", platform, identity.fingerprint);
120
+ return {
121
+ identity, architecture, buildDir,
122
+ cacheMode: "legacy",
123
+ managedCargoTarget: null,
124
+ targetBridge: { run: (fn) => fn(), ensure() {} },
125
+ // The kept Rust variables (CARGO_HOME, proxy settings, rustup) come from the environment the release was started from.
126
+ sanitizeEnv: (env) => stripRustEnvironment(env, hostEnv),
127
+ watchDirs: [buildDir, path.join(appRoot, ".cache")],
128
+ };
129
+ }