@rightkit/release 0.3.0 → 0.4.1

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 (83) hide show
  1. package/README.md +74 -10
  2. package/addon-command.mjs +3 -4
  3. package/adoption-evidence.mjs +650 -0
  4. package/android-play-upload.mjs +2 -1
  5. package/apple-profile-create.mjs +113 -10
  6. package/apple-profile-renew.mjs +11 -3
  7. package/artifact-storage.mjs +35 -2
  8. package/bin/darwin/rightkit-swap +0 -0
  9. package/binary-license-notices.mjs +161 -0
  10. package/build-release.mjs +138 -47
  11. package/cargo-license-notices.mjs +111 -0
  12. package/cli/right-release.mjs +21 -2
  13. package/codesign-environment.mjs +2 -2
  14. package/content-digest.mjs +209 -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/github-updater-release.mjs +70 -7
  21. package/in-place-install.mjs +17 -14
  22. package/install-app-windows.mjs +24 -7
  23. package/install-app.mjs +21 -40
  24. package/ios-asc-credentials.mjs +163 -0
  25. package/ios-build-context.mjs +129 -0
  26. package/ios-build-upload.mjs +282 -0
  27. package/ios-credentials-command.mjs +70 -0
  28. package/ios-dev.mjs +248 -0
  29. package/ios-host.mjs +76 -0
  30. package/ios-lock.mjs +75 -0
  31. package/ios-package-recipe.mjs +707 -0
  32. package/ios-testflight.mjs +266 -0
  33. package/ios-upload.mjs +313 -0
  34. package/local-publish.mjs +222 -53
  35. package/managed-cargo.mjs +8 -0
  36. package/mirror-root-artifact.mjs +5 -1
  37. package/native-cargo-layout.mjs +47 -4
  38. package/ownership-command.mjs +2 -1
  39. package/ownership-dependency-allowlist.json +3 -1
  40. package/ownership-evidence.mjs +30 -18
  41. package/ownership-key-command.mjs +69 -2
  42. package/ownership-mandatory-set.json +117 -67
  43. package/ownership-mandatory-set.mjs +141 -21
  44. package/ownership-repositories.json +1 -1
  45. package/ownership-scope.mjs +94 -0
  46. package/ownership.mjs +49 -7
  47. package/package-recipe-targets.mjs +41 -1
  48. package/package.json +1 -1
  49. package/pipeline-admission.mjs +375 -0
  50. package/pipeline-config.mjs +100 -0
  51. package/pipeline-generators.json +4 -0
  52. package/pipeline-generators.mjs +137 -0
  53. package/pipeline-scan.mjs +285 -0
  54. package/pipeline-tools.mjs +171 -0
  55. package/preflight.mjs +14 -7
  56. package/production-mode.mjs +42 -0
  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 +94 -32
  61. package/publish-cargo.mjs +117 -25
  62. package/publish-swift.mjs +12 -3
  63. package/publish-update.mjs +15 -2
  64. package/qa-build-features.mjs +16 -13
  65. package/qa-contract.mjs +30 -2
  66. package/qa-control-scan.mjs +8 -0
  67. package/release-config.mjs +330 -0
  68. package/release-invocation.mjs +118 -8
  69. package/release-switch-registry.json +126 -27
  70. package/release-switch-scan.mjs +88 -21
  71. package/release.mjs +82 -20
  72. package/rightkit-ownership.json +1 -1
  73. package/rightkit-sync.mjs +8 -0
  74. package/rightkit-versions.json +428 -194
  75. package/runtime-budget-crates.mjs +14 -0
  76. package/runtime-budgets.mjs +248 -47
  77. package/sign-updater.mjs +56 -59
  78. package/source-gate.mjs +8 -1
  79. package/stage-sidecar.mjs +207 -40
  80. package/swap-helper-bundle.mjs +7 -3
  81. package/target-distribution.mjs +5 -0
  82. package/upload-release.mjs +7 -2
  83. package/bin/darwin/rightkit-swap.sign.json +0 -6
@@ -18,9 +18,11 @@ import { previousPathOf } from "./in-place-install.mjs";
18
18
  * plus the `--exe` file; entries are relative regular files inside `--dest`, no directories, wildcards, `..` or drive paths;
19
19
  * - with NO declared payload the allowlist is just the `--exe` file (always backed up and restored; nothing else is);
20
20
  * - no path segment from `--dest` down to an allowlisted file may be a symlink or junction (reparse point); one is refused before backup and before restore or removal;
21
- * - the backup is `<dest>.previous`, holding only the allowlisted files that existed before (replaced on each install; nothing runs from it);
21
+ * - the TRANSIENT backup is `<dest>.previous`, holding only the allowlisted files that existed before; it is
22
+ * deleted after a successful install and after a failure restore, so no copy is ever retained (D01);
22
23
  * - a failed install restores only allowlisted files that existed before and removes only allowlisted files the failed run newly wrote;
23
- * it never touches any other file in `--dest`.
24
+ * it never touches any other file in `--dest`; a stale `<dest>.previous` is cleaned allowlist-scope only,
25
+ * never as a whole install directory.
24
26
  * Dev installs do not pass through this module: they keep no backup at all (see development.mjs, install-app.mjs --dev).
25
27
  */
26
28
  export class InstallAppWindowsError extends Error {
@@ -114,8 +116,20 @@ export function installAppWindows(opts, { platform = process.platform, fs = node
114
116
  fs.copyFileSync(inDest(relative), inPrevious(relative));
115
117
  }
116
118
  backupMade = true;
117
- event("previous-kept", { previous, files: backedUp.length, payloadOnly: true });
119
+ event("previous-parked", { previous, files: backedUp.length, payloadOnly: true, transient: true });
118
120
  }
121
+ // Delete the transient park. Ours (backupMade) is exactly the allowlisted scope we just created; a stale
122
+ // park from an older RightKit is cleaned allowlist-entry by allowlist-entry, never as a whole directory.
123
+ const cleanupPrevious = () => {
124
+ try {
125
+ if (backupMade) { fs.rmSync(previous, { recursive: true, force: true }); return; }
126
+ for (const relative of allowlist) {
127
+ const file = inPrevious(relative);
128
+ if (fs.existsSync(file) && isFile(file)) fs.rmSync(file, { force: true });
129
+ }
130
+ try { fs.rmdirSync(previous); } catch { /* non-empty or absent: leave it */ }
131
+ } catch { /* best effort; never mask the install result */ }
132
+ };
119
133
  // Roll back the payload and nothing else: restore allowlisted files that existed, remove allowlisted files this run newly wrote.
120
134
  // Each file is restored independently; failures are collected and reported together, never masking the installer error.
121
135
  const restoreFailures = [];
@@ -142,13 +156,16 @@ export function installAppWindows(opts, { platform = process.platform, fs = node
142
156
  const result = run(installer, windowsInstallArguments(dest), { stdio: "inherit", windowsHide: true, shell: false });
143
157
  if (result.status !== 0) {
144
158
  const restored = restore();
145
- throw new InstallAppWindowsError("installer-failed", `installer exited ${result.status ?? "abnormally"}${result.error ? `: ${result.error.message}` : ""}; ${restored ? `restored the ${backedUp.length} payload file(s) of ${dest} from ${previous}; no other file was touched` : noRollback}${failureNote()}`);
159
+ cleanupPrevious();
160
+ throw new InstallAppWindowsError("installer-failed", `installer exited ${result.status ?? "abnormally"}${result.error ? `: ${result.error.message}` : ""}; ${restored ? `restored the ${backedUp.length} payload file(s) of ${dest} from the transient backup (now deleted); no other file was touched` : noRollback}${failureNote()}; reinstall from the release installer if the app does not launch`);
146
161
  }
147
162
  // The installer must have updated the fixed folder: the executable is at the same path as before, not in a new folder.
148
163
  if (!fs.existsSync(installed) || fs.statSync(installed).size === 0) {
149
164
  const restored = restore();
150
- throw new InstallAppWindowsError("path-changed", `the installer did not place ${exe} at the fixed path ${installed}; ${restored ? `restored the ${backedUp.length} payload file(s) from ${previous}; no other file was touched` : noRollback}${failureNote()}`);
165
+ cleanupPrevious();
166
+ throw new InstallAppWindowsError("path-changed", `the installer did not place ${exe} at the fixed path ${installed}; ${restored ? `restored the ${backedUp.length} payload file(s) from the transient backup (now deleted); no other file was touched` : noRollback}${failureNote()}; reinstall from the release installer if the app does not launch`);
151
167
  }
152
- event("replaced", { installed, previous: backupMade ? previous : null, sha256: sha256(fs, installed) });
153
- return { ok: true, installed, previous: backupMade ? previous : null };
168
+ cleanupPrevious();
169
+ event("replaced", { installed, previous: null, sha256: sha256(fs, installed) });
170
+ return { ok: true, installed, previous: null };
154
171
  }
package/install-app.mjs CHANGED
@@ -271,15 +271,13 @@ 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));
283
281
  }
284
282
 
285
283
  // Exclusive per-destination install lock: a lock file beside the installed apps, created O_EXCL. A lock whose pid is dead is
@@ -340,9 +338,8 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
340
338
  let ok = j.phase === "verified";
341
339
  if (!ok) { try { await verifyBundle(run, j.installed, { assess: !j.dev, staple: false }); ok = true; } catch { ok = false; } }
342
340
  if (ok) {
343
- // A dev journal (from an older RightKit) never keeps the old bundle: the staged path is cleared below.
344
- try { if (!j.dev) await keepPreviousBundle(run, fsImpl, j.staged, j.installed); action = "rolled-forward"; }
345
- 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";
346
343
  } else {
347
344
  swapBack(swapPaths, fsImpl, j.staged, j.installed); // staged now holds the bad new bundle
348
345
  action = "rolled-back";
@@ -355,8 +352,8 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
355
352
  let ok = j.phase === "verified";
356
353
  if (!ok) { try { await verifyBundle(run, j.installed, { assess: !j.dev, staple: false }); ok = true; } catch { ok = false; } }
357
354
  if (ok) {
358
- try { if (j.dev) rm(j.backup); else await keepPreviousBundle(run, fsImpl, j.backup, j.installed); action = "rolled-forward"; }
359
- 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";
360
357
  } else {
361
358
  rm(j.installed);
362
359
  fsImpl.renameSync(j.backup, j.installed);
@@ -368,6 +365,7 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
368
365
  continue; // leave the journal for the next run
369
366
  }
370
367
  if (!keepStaged) rm(j.staged);
368
+ if (j.installed) rm(previousPathOf(j.installed)); // no retained `.previous` survives recovery
371
369
  if (j.mount && has(j.mount)) {
372
370
  const out = await detachWithRetry({ mount: j.mount, log: (e) => event(e.event, e), sleep, detach: (args) => run("/usr/bin/hdiutil", args) });
373
371
  if (out.ok) rm(j.mountRoot);
@@ -430,7 +428,7 @@ export async function installApp(opts, deps = {}) {
430
428
  if (s.mode === "atomic") {
431
429
  // installed was never absent. Before the exchange only the staged copy needs removing; after it,
432
430
  // exchange back (installed = old again, staged = the new copy) and drop the new copy. Once final,
433
- // 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.
434
432
  if (!s.final) {
435
433
  if (s.swapped) swapBack(swapPaths, fsImpl, s.staged, s.installed);
436
434
  removeQuiet(s.staged);
@@ -543,7 +541,7 @@ export async function installApp(opts, deps = {}) {
543
541
  failedStage = "stage";
544
542
  const base = appName.replace(/\.app$/, "");
545
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
546
- // journal beside it; the rollback copy is the fixed `<installed>.previous`.
544
+ // journal beside it; the old bundle's transient park is the fixed `<installed>.previous`, deleted once verified.
547
545
  const staged = path.join(opts.dest, `.${base}.rightkit-install.app`);
548
546
  // Dev installs keep NO old copy of any kind (Orthic-Labs/rightkit#348): the old bundle is removed, never moved to `.previous`.
549
547
  const backup = hadInstalled && !dev ? previousPathOf(installed) : null;
@@ -605,7 +603,7 @@ export async function installApp(opts, deps = {}) {
605
603
  swap.mode = "two-rename";
606
604
  jd = { ...journalData, swap: "two-rename" };
607
605
  writeJournal(fsImpl, journal, { ...jd, phase: "swapping" });
608
- 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
609
607
  fsImpl.renameSync(installed, backup); swap.backedUp = true;
610
608
  fsImpl.renameSync(staged, installed);
611
609
  swap.stagedIn = true;
@@ -626,20 +624,13 @@ export async function installApp(opts, deps = {}) {
626
624
  writeJournal(fsImpl, journal, { ...jd, phase: "verified", mount: null, mountRoot: null });
627
625
  swap.final = true;
628
626
 
629
- // Only now is the old bundle retired, to <installed>.previous (never the Trash).
630
- let kept = null;
631
- let keepFailed = false;
627
+ // Only now is the old bundle retired: deleted, never kept as `<installed>.previous` (D01).
632
628
  const oldBundle = swapMode === "atomic" ? staged : backup; // atomic: the exchange left the old bundle at the staged path
633
- if (hadInstalled && oldBundle) {
634
- try { kept = await keepPreviousBundle(run, fsImpl, oldBundle, installed); }
635
- catch (error) { event("previous-keep-failed", { backup: oldBundle, reason: errText(error) }); kept = oldBundle; }
636
- }
637
- if (!oldBundle || kept !== previousPathOf(installed)) { if (!oldBundle) removeQuiet(journal); }
638
- else removeQuiet(journal);
639
- // A stale `.previous` from an older RightKit must not survive a dev install either.
640
- 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);
641
632
  swap = null;
642
- event("replaced", { installed, previous: kept, swap: swapMode });
633
+ event("replaced", { installed, previous: null, swap: swapMode });
643
634
 
644
635
  // Relaunch hidden and in the background only.
645
636
  const relaunch = opts.relaunch === null ? wasRunning : opts.relaunch;
@@ -648,7 +639,7 @@ export async function installApp(opts, deps = {}) {
648
639
  await run("/usr/bin/open", ["-g", "-j", "-b", bundleId]);
649
640
  event("relaunched", { bundleId });
650
641
  }
651
- 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 } : {}) };
652
643
  event("done", result);
653
644
  return result;
654
645
  } catch (error) {
@@ -746,16 +737,6 @@ async function quitApp(run, kill, sleep, app) {
746
737
  return { pids, killed: remaining };
747
738
  }
748
739
 
749
- async function moveAside(run, fsImpl, from, to) {
750
- try {
751
- fsImpl.renameSync(from, to);
752
- } catch (error) {
753
- if (error.code !== "EXDEV") throw error;
754
- await run("/usr/bin/ditto", [from, to]);
755
- fsImpl.rmSync(from, { recursive: true, force: true });
756
- }
757
- }
758
-
759
740
  export async function main(argv = process.argv.slice(2), io = {}) {
760
741
  const stdout = io.stdout || ((s) => console.log(s));
761
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
+ }