@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.
- package/README.md +200 -2
- package/addon-command.mjs +3 -4
- package/android-play-upload.mjs +2 -1
- package/apple-profile-renew.mjs +11 -3
- package/artifact-storage.mjs +35 -2
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +1 -1
- package/build-release.mjs +138 -47
- package/cli/right-release.mjs +40 -5
- package/codesign-environment.mjs +2 -2
- package/command-spec.mjs +80 -0
- package/content-digest.mjs +209 -0
- package/deps-cargo.mjs +55 -0
- package/deps.mjs +7 -0
- package/development.mjs +17 -6
- package/direct-bootstrap.mjs +5 -6
- package/disk-command.mjs +1 -0
- package/disk-policy.json +6 -2
- package/disk-stable-root.mjs +13 -9
- package/dist-scan.mjs +102 -0
- package/dmg-build.mjs +53 -4
- package/github-updater-release.mjs +70 -7
- package/icon-pack.mjs +117 -0
- package/in-place-install.mjs +17 -14
- package/init-command.mjs +116 -0
- package/install-app-windows.mjs +24 -7
- package/install-app.mjs +56 -42
- package/ios-asc-credentials.mjs +163 -0
- package/ios-build-context.mjs +129 -0
- package/ios-build-upload.mjs +282 -0
- package/ios-credentials-command.mjs +70 -0
- package/ios-dev.mjs +248 -0
- package/ios-host.mjs +76 -0
- package/ios-lock.mjs +75 -0
- package/ios-package-recipe.mjs +707 -0
- package/ios-testflight.mjs +266 -0
- package/ios-upload.mjs +313 -0
- package/local-publish.mjs +390 -47
- package/macho-approval-hash.mjs +181 -0
- package/managed-cargo.mjs +57 -1
- package/media-sidecar.mjs +20 -4
- package/mirror-root-artifact.mjs +74 -22
- package/native-cargo-layout.mjs +47 -4
- package/npm-license-notices.mjs +159 -0
- package/ownership-command.mjs +2 -1
- package/ownership-dependency-allowlist.json +2 -1
- package/ownership-evidence.mjs +30 -18
- package/ownership-key-command.mjs +69 -2
- package/ownership-mandatory-set.mjs +66 -16
- package/ownership-repositories.json +1 -1
- package/ownership-scope.mjs +94 -0
- package/ownership.mjs +86 -11
- package/package-recipe-targets.mjs +56 -2
- package/package.json +1 -1
- package/pe-content.mjs +133 -0
- package/preflight.mjs +14 -7
- package/project-type.mjs +3 -1
- package/protected-credentials.mjs +28 -1
- package/publication-artifacts.mjs +30 -5
- package/publication-gate.mjs +83 -5
- package/publish-cargo.mjs +124 -18
- package/publish-update.mjs +15 -2
- package/qa-build-features.mjs +16 -13
- package/qa-contract.mjs +30 -2
- package/qa-control-scan.mjs +4 -0
- package/release-dependencies-command.mjs +45 -0
- package/release-dependencies.mjs +60 -0
- package/release-invocation.mjs +118 -8
- package/release-switch-registry.json +126 -27
- package/release-switch-scan.mjs +62 -24
- package/release.mjs +144 -21
- package/rightkit-ownership.json +46 -2
- package/rightkit-versions.json +380 -189
- package/runtime-budgets.mjs +134 -23
- package/sign-updater.mjs +56 -59
- package/source-gate.mjs +8 -1
- package/stage-sidecar.mjs +634 -69
- package/target-distribution.mjs +5 -0
- package/test-command.mjs +190 -0
- package/toolchain-swift.mjs +239 -0
- package/upload-release.mjs +7 -2
- 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
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
//
|
|
319
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
|
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
|
|
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.
|
|
515
|
-
|
|
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); //
|
|
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,
|
|
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
|
-
|
|
603
|
-
|
|
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:
|
|
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:
|
|
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
|
+
}
|