@rightkit/release 0.3.0 → 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 +25 -4
- 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 +11 -2
- package/codesign-environment.mjs +2 -2
- package/content-digest.mjs +209 -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/github-updater-release.mjs +70 -7
- package/in-place-install.mjs +17 -14
- package/install-app-windows.mjs +24 -7
- package/install-app.mjs +21 -40
- 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 +92 -37
- package/managed-cargo.mjs +8 -0
- package/mirror-root-artifact.mjs +5 -1
- package/native-cargo-layout.mjs +47 -4
- package/ownership-command.mjs +2 -1
- package/ownership-dependency-allowlist.json +1 -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 +49 -7
- package/package-recipe-targets.mjs +41 -1
- package/package.json +1 -1
- 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 +15 -4
- package/publish-cargo.mjs +109 -22
- 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-invocation.mjs +118 -8
- package/release-switch-registry.json +126 -27
- package/release-switch-scan.mjs +45 -17
- package/release.mjs +69 -17
- package/rightkit-ownership.json +1 -1
- package/rightkit-versions.json +379 -189
- package/runtime-budgets.mjs +120 -16
- package/sign-updater.mjs +56 -59
- package/source-gate.mjs +8 -1
- package/stage-sidecar.mjs +160 -32
- package/target-distribution.mjs +5 -0
- package/upload-release.mjs +7 -2
package/install-app-windows.mjs
CHANGED
|
@@ -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
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
//
|
|
344
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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
|
|
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
|
|
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); //
|
|
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,
|
|
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
|
-
|
|
635
|
-
|
|
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:
|
|
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:
|
|
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
|
+
}
|