@hot-updater/apple-helper 1.1.3 → 1.2.0-rc.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/dist/index.cjs +1771 -463
- package/dist/index.d.cts +192 -22
- package/dist/index.d.mts +193 -22
- package/dist/index.mjs +1770 -478
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -21,18 +21,452 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
21
21
|
enumerable: true
|
|
22
22
|
}) : target, mod));
|
|
23
23
|
//#endregion
|
|
24
|
-
let
|
|
25
|
-
|
|
26
|
-
let
|
|
27
|
-
path = __toESM(path, 1);
|
|
28
|
-
let _hot_updater_cli_tools = require("@hot-updater/cli-tools");
|
|
29
|
-
let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
|
|
30
|
-
let execa = require("execa");
|
|
24
|
+
let node_crypto = require("node:crypto");
|
|
25
|
+
node_crypto = __toESM(node_crypto, 1);
|
|
26
|
+
let node_fs = require("node:fs");
|
|
31
27
|
let node_fs_promises = require("node:fs/promises");
|
|
28
|
+
node_fs_promises = __toESM(node_fs_promises, 1);
|
|
29
|
+
let node_net = require("node:net");
|
|
30
|
+
node_net = __toESM(node_net, 1);
|
|
32
31
|
let node_os = require("node:os");
|
|
33
32
|
node_os = __toESM(node_os, 1);
|
|
34
33
|
let node_path = require("node:path");
|
|
35
|
-
|
|
34
|
+
node_path = __toESM(node_path, 1);
|
|
35
|
+
let _hot_updater_cli_tools = require("@hot-updater/cli-tools");
|
|
36
|
+
let execa = require("execa");
|
|
37
|
+
let fs = require("fs");
|
|
38
|
+
fs = __toESM(fs, 1);
|
|
39
|
+
let path = require("path");
|
|
40
|
+
path = __toESM(path, 1);
|
|
41
|
+
let child_process = require("child_process");
|
|
42
|
+
let crypto = require("crypto");
|
|
43
|
+
crypto = __toESM(crypto, 1);
|
|
44
|
+
let os = require("os");
|
|
45
|
+
os = __toESM(os, 1);
|
|
46
|
+
//#region src/asc/ascCli.ts
|
|
47
|
+
/**
|
|
48
|
+
* The release of asc, the open-source App Store Connect CLI
|
|
49
|
+
* (https://github.com/rorkai/App-Store-Connect-CLI, MIT), that signs in with
|
|
50
|
+
* an Apple Account and issues App Store Connect API keys for Hot Updater. An
|
|
51
|
+
* `asc` on PATH is used when it is at least this version.
|
|
52
|
+
*/
|
|
53
|
+
const ASC_CLI_VERSION = "5.16.1";
|
|
54
|
+
/**
|
|
55
|
+
* The SHA-256 of each macOS binary of the release, from its
|
|
56
|
+
* asc_5.16.1_checksums.txt. Pinned here, so a download is trusted only when
|
|
57
|
+
* it is the exact binary this version of Hot Updater was written against.
|
|
58
|
+
*/
|
|
59
|
+
const ASC_CLI_SHA256 = {
|
|
60
|
+
arm64: "85a848ef33fe3490adc4f0348af30b7bbf95209a258dac06c735318f62495707",
|
|
61
|
+
x64: "5a978732e0c2f2b673da601568c489dfaa232ae32c93dcfa63b95f0b892a2580"
|
|
62
|
+
};
|
|
63
|
+
const ASC_RELEASE_ASSET = {
|
|
64
|
+
arm64: `asc_${ASC_CLI_VERSION}_macOS_arm64`,
|
|
65
|
+
x64: `asc_${ASC_CLI_VERSION}_macOS_amd64`
|
|
66
|
+
};
|
|
67
|
+
const ASC_RELEASE_URL = `https://github.com/rorkai/App-Store-Connect-CLI/releases/download/${ASC_CLI_VERSION}`;
|
|
68
|
+
const INSTALL_HINT = `Install asc ${ASC_CLI_VERSION} or later with \`brew install asc\` (https://github.com/rorkai/App-Store-Connect-CLI), then run the command again.`;
|
|
69
|
+
/** asc could not be found, downloaded, or verified. */
|
|
70
|
+
var AscCliUnavailableError = class extends Error {
|
|
71
|
+
constructor(message, options) {
|
|
72
|
+
super(message, options);
|
|
73
|
+
this.name = "AscCliUnavailableError";
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const parseVersion = (output) => {
|
|
77
|
+
const match = /(\d+)\.(\d+)\.(\d+)/u.exec(output);
|
|
78
|
+
return match ? match.slice(1, 4).map(Number) : void 0;
|
|
79
|
+
};
|
|
80
|
+
const isAtLeast = (version, minimum) => {
|
|
81
|
+
for (let index = 0; index < minimum.length; index++) {
|
|
82
|
+
const part = version[index] ?? 0;
|
|
83
|
+
if (part !== minimum[index]) return part > minimum[index];
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
};
|
|
87
|
+
const sha256Of = (file) => new Promise((resolve, reject) => {
|
|
88
|
+
const hash = node_crypto.default.createHash("sha256");
|
|
89
|
+
(0, node_fs.createReadStream)(file).on("data", (chunk) => hash.update(chunk)).on("error", reject).on("end", () => resolve(hash.digest("hex")));
|
|
90
|
+
});
|
|
91
|
+
/** Where a downloaded asc is kept, per user, so every project shares it. */
|
|
92
|
+
const defaultAscCacheDir = () => node_path.default.join(node_os.default.homedir(), "Library", "Caches", "hot-updater");
|
|
93
|
+
/**
|
|
94
|
+
* Finds asc: the `asc` on PATH when it is at least `ASC_CLI_VERSION`, else
|
|
95
|
+
* the pinned release in `cacheDir`, downloaded from GitHub on first use and
|
|
96
|
+
* verified against its pinned SHA-256 each time it is used. Only macOS has
|
|
97
|
+
* one: TestFlight builds need Xcode.
|
|
98
|
+
*/
|
|
99
|
+
const findAscCli = async ({ cacheDir = defaultAscCacheDir(), fetch = globalThis.fetch, platform = process.platform, arch = process.arch, pathCommand = "asc" } = {}) => {
|
|
100
|
+
if (platform !== "darwin") throw new AscCliUnavailableError("Signing in with an Apple Account to issue an App Store Connect API key runs on macOS, where iOS builds run.");
|
|
101
|
+
const minimum = parseVersion(ASC_CLI_VERSION);
|
|
102
|
+
const onPath = await (0, execa.execa)(pathCommand, ["--version"], {
|
|
103
|
+
reject: false,
|
|
104
|
+
env: { ASC_TELEMETRY_DISABLED: "1" },
|
|
105
|
+
timeout: 15e3
|
|
106
|
+
}).catch(() => void 0);
|
|
107
|
+
const pathVersion = onPath?.exitCode === 0 ? parseVersion(onPath.stdout) : void 0;
|
|
108
|
+
if (pathVersion && isAtLeast(pathVersion, minimum)) return {
|
|
109
|
+
path: pathCommand,
|
|
110
|
+
version: pathVersion.join("."),
|
|
111
|
+
source: "path"
|
|
112
|
+
};
|
|
113
|
+
const asset = ASC_RELEASE_ASSET[arch];
|
|
114
|
+
const expected = ASC_CLI_SHA256[arch];
|
|
115
|
+
if (!asset || !expected) throw new AscCliUnavailableError(`asc publishes no macOS binary for ${arch}. ${INSTALL_HINT}`);
|
|
116
|
+
const binary = node_path.default.join(cacheDir, "asc", ASC_CLI_VERSION, asset);
|
|
117
|
+
if (await sha256Of(binary).catch(() => void 0) === expected) return {
|
|
118
|
+
path: binary,
|
|
119
|
+
version: ASC_CLI_VERSION,
|
|
120
|
+
source: "cache"
|
|
121
|
+
};
|
|
122
|
+
await node_fs_promises.default.mkdir(node_path.default.dirname(binary), { recursive: true });
|
|
123
|
+
const partial = `${binary}.${process.pid}.download`;
|
|
124
|
+
try {
|
|
125
|
+
let response;
|
|
126
|
+
try {
|
|
127
|
+
response = await fetch(`${ASC_RELEASE_URL}/${asset}`, { redirect: "follow" });
|
|
128
|
+
} catch (error) {
|
|
129
|
+
throw new AscCliUnavailableError(`Could not download asc ${ASC_CLI_VERSION} from GitHub (${error instanceof Error ? error.message : String(error)}). ${INSTALL_HINT}`, { cause: error });
|
|
130
|
+
}
|
|
131
|
+
if (!response.ok || !response.body) throw new AscCliUnavailableError(`Could not download asc ${ASC_CLI_VERSION} from GitHub (HTTP ${response.status}). ${INSTALL_HINT}`);
|
|
132
|
+
await node_fs_promises.default.writeFile(partial, Buffer.from(await response.arrayBuffer()), { mode: 448 });
|
|
133
|
+
const actual = await sha256Of(partial);
|
|
134
|
+
if (actual !== expected) throw new AscCliUnavailableError(`The downloaded asc ${ASC_CLI_VERSION} does not match its published SHA-256 (expected ${expected}, got ${actual}), so it was deleted and not run. ${INSTALL_HINT}`);
|
|
135
|
+
await node_fs_promises.default.chmod(partial, 493);
|
|
136
|
+
await node_fs_promises.default.rename(partial, binary);
|
|
137
|
+
} finally {
|
|
138
|
+
await node_fs_promises.default.rm(partial, { force: true });
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
path: binary,
|
|
142
|
+
version: ASC_CLI_VERSION,
|
|
143
|
+
source: "download"
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region src/asc/ascCliKeyIssuer.ts
|
|
148
|
+
/** An asc command failed; its message is asc's own, which it printed too. */
|
|
149
|
+
var AscCommandError = class extends Error {
|
|
150
|
+
exitCode;
|
|
151
|
+
constructor(message, exitCode, options) {
|
|
152
|
+
super(message, options);
|
|
153
|
+
this.name = "AscCommandError";
|
|
154
|
+
this.exitCode = exitCode;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const TERMINATION_SIGNALS$2 = [
|
|
158
|
+
"SIGHUP",
|
|
159
|
+
"SIGINT",
|
|
160
|
+
"SIGTERM"
|
|
161
|
+
];
|
|
162
|
+
/**
|
|
163
|
+
* A 0700 directory holding asc's whole state for one sign-in (its web
|
|
164
|
+
* session, config, and the downloaded `.p8`), removed by `dispose`, on
|
|
165
|
+
* `exit`, or on a termination signal, so nothing outlives the sign-in.
|
|
166
|
+
*/
|
|
167
|
+
const createWorkDirectory = async () => {
|
|
168
|
+
const root = await node_fs_promises.default.mkdtemp(node_path.default.join(node_os.default.tmpdir(), "hot-updater-asc-"));
|
|
169
|
+
await node_fs_promises.default.chmod(root, 448);
|
|
170
|
+
const remove = () => (0, node_fs.rmSync)(root, {
|
|
171
|
+
force: true,
|
|
172
|
+
recursive: true
|
|
173
|
+
});
|
|
174
|
+
const onSignal = (signal) => {
|
|
175
|
+
stop();
|
|
176
|
+
remove();
|
|
177
|
+
if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
|
|
178
|
+
};
|
|
179
|
+
const stop = () => {
|
|
180
|
+
process.off("exit", remove);
|
|
181
|
+
for (const signal of TERMINATION_SIGNALS$2) process.off(signal, onSignal);
|
|
182
|
+
};
|
|
183
|
+
process.on("exit", remove);
|
|
184
|
+
for (const signal of TERMINATION_SIGNALS$2) process.on(signal, onSignal);
|
|
185
|
+
for (const directory of [
|
|
186
|
+
"home",
|
|
187
|
+
"session",
|
|
188
|
+
"keys"
|
|
189
|
+
]) await node_fs_promises.default.mkdir(node_path.default.join(root, directory), { mode: 448 });
|
|
190
|
+
let disposal;
|
|
191
|
+
return {
|
|
192
|
+
root,
|
|
193
|
+
dispose: () => {
|
|
194
|
+
disposal ??= (async () => {
|
|
195
|
+
stop();
|
|
196
|
+
await node_fs_promises.default.rm(root, {
|
|
197
|
+
force: true,
|
|
198
|
+
recursive: true
|
|
199
|
+
});
|
|
200
|
+
})();
|
|
201
|
+
return disposal;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
/**
|
|
206
|
+
* asc's environment: the caller's, without any `ASC_*` variable (such as a
|
|
207
|
+
* saved password, a 2FA code command, or another API key), and with every
|
|
208
|
+
* file asc keeps (home, config, web session) in `root`. Passwords are never
|
|
209
|
+
* stored, the keychain is bypassed, and telemetry is off.
|
|
210
|
+
*/
|
|
211
|
+
const ascEnvironment = (root, base = process.env) => {
|
|
212
|
+
const env = {};
|
|
213
|
+
for (const [name, value] of Object.entries(base)) if (!name.startsWith("ASC_") && name !== "DO_NOT_TRACK") env[name] = value;
|
|
214
|
+
return {
|
|
215
|
+
...env,
|
|
216
|
+
HOME: node_path.default.join(root, "home"),
|
|
217
|
+
XDG_CONFIG_HOME: node_path.default.join(root, "home", ".config"),
|
|
218
|
+
XDG_CACHE_HOME: node_path.default.join(root, "home", ".cache"),
|
|
219
|
+
ASC_CONFIG_PATH: node_path.default.join(root, "home", "config.json"),
|
|
220
|
+
ASC_WEB_SESSION_CACHE_DIR: node_path.default.join(root, "session"),
|
|
221
|
+
ASC_WEB_SESSION_CACHE_BACKEND: "file",
|
|
222
|
+
ASC_WEB_DONT_STORE_PASSWORD: "1",
|
|
223
|
+
ASC_BYPASS_KEYCHAIN: "1",
|
|
224
|
+
ASC_TELEMETRY_DISABLED: "1",
|
|
225
|
+
ASC_SPINNER_DISABLED: "1"
|
|
226
|
+
};
|
|
227
|
+
};
|
|
228
|
+
const answer = (value) => _hot_updater_cli_tools.p.isCancel(value) ? void 0 : value;
|
|
229
|
+
/** Hot Updater's own prompts, so the sign-in looks like the rest of the CLI. */
|
|
230
|
+
const cliPrompts = {
|
|
231
|
+
password: async (appleId) => answer(await _hot_updater_cli_tools.p.password({
|
|
232
|
+
message: appleId ? `Password of ${appleId}` : "Apple Account password",
|
|
233
|
+
mask: "•",
|
|
234
|
+
validate: (value) => value ? void 0 : "Enter the password."
|
|
235
|
+
})),
|
|
236
|
+
twoFactorCode: async (attempt) => answer(await _hot_updater_cli_tools.p.text({
|
|
237
|
+
message: attempt === 1 ? "Two-factor code from your trusted device (no device at hand? enter any wrong code once, and Apple texts one to your phone)" : "Verification code Apple sent to your phone",
|
|
238
|
+
validate: (value) => /^\d{6}$/u.test(value?.trim() ?? "") ? void 0 : "Enter the 6-digit code."
|
|
239
|
+
}))?.trim()
|
|
240
|
+
};
|
|
241
|
+
/** `value` as one word for `sh`. */
|
|
242
|
+
const shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
243
|
+
/**
|
|
244
|
+
* A two-factor code command for asc that asks `prompts` in this process:
|
|
245
|
+
* asc runs it when Apple wants a code, and it prints what the developer
|
|
246
|
+
* typed, which it gets over a socket in the 0700 work directory. A
|
|
247
|
+
* cancelled prompt makes it print nothing and fail, which stops the sign-in.
|
|
248
|
+
*/
|
|
249
|
+
const listenForTwoFactorCodes = async (root, prompts) => {
|
|
250
|
+
const socketPath = node_path.default.join(root, "2fa.sock");
|
|
251
|
+
const scriptPath = node_path.default.join(root, "two-factor-code.cjs");
|
|
252
|
+
await node_fs_promises.default.writeFile(scriptPath, [
|
|
253
|
+
"const socket = require(\"node:net\").connect(" + JSON.stringify(socketPath) + ");",
|
|
254
|
+
"let code = \"\";",
|
|
255
|
+
"socket.on(\"data\", (chunk) => { code += chunk; });",
|
|
256
|
+
"socket.on(\"end\", () => {",
|
|
257
|
+
" code = code.trim();",
|
|
258
|
+
" if (!code) process.exit(1);",
|
|
259
|
+
" process.stdout.write(code + \"\\n\");",
|
|
260
|
+
"});",
|
|
261
|
+
"socket.on(\"error\", () => process.exit(1));",
|
|
262
|
+
""
|
|
263
|
+
].join("\n"), { mode: 384 });
|
|
264
|
+
let attempts = 0;
|
|
265
|
+
let cancelled = false;
|
|
266
|
+
const server = node_net.default.createServer((socket) => {
|
|
267
|
+
attempts += 1;
|
|
268
|
+
prompts.twoFactorCode(attempts).then((code) => {
|
|
269
|
+
if (!code) cancelled = true;
|
|
270
|
+
socket.end(code ? `${code}\n` : "");
|
|
271
|
+
}, () => {
|
|
272
|
+
cancelled = true;
|
|
273
|
+
socket.destroy();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
await new Promise((resolve, reject) => {
|
|
277
|
+
server.once("error", reject);
|
|
278
|
+
server.listen(socketPath, resolve);
|
|
279
|
+
});
|
|
280
|
+
return {
|
|
281
|
+
command: `${shellQuote(process.execPath)} ${shellQuote(scriptPath)}`,
|
|
282
|
+
cancelled: () => cancelled,
|
|
283
|
+
close: () => new Promise((resolve) => server.close(() => resolve()))
|
|
284
|
+
};
|
|
285
|
+
};
|
|
286
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
287
|
+
const text = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
|
|
288
|
+
/** The last line of asc's error output, which names what failed. */
|
|
289
|
+
const lastLine = (stderr) => stderr.split("\n").map((line) => line.trim()).filter(Boolean).at(-1);
|
|
290
|
+
/** What to do about a key asc could not create, by what App Store Connect said. */
|
|
291
|
+
const createFailureHint = (stderr) => {
|
|
292
|
+
if (/agreement/iu.test(stderr)) return "The Apple Developer Program License Agreement has changed: the Account Holder accepts it at https://developer.apple.com/account, then run this again.";
|
|
293
|
+
if (/limit|maximum/iu.test(stderr)) return `The team has as many active API keys as App Store Connect allows: revoke one you no longer use at ${ASC_INTEGRATIONS_URL}, then run this again.`;
|
|
294
|
+
if (/account holder|admin|permission|forbidden|not allowed|403/iu.test(stderr)) return "Creating an App Store Connect API key needs the Account Holder or Admin role in the team: sign in with such an Apple Account, or ask one to run this.";
|
|
295
|
+
};
|
|
296
|
+
/** A P-256 private key, as App Store Connect issues one, or a thrown error. */
|
|
297
|
+
const assertP8 = (p8) => {
|
|
298
|
+
let key;
|
|
299
|
+
try {
|
|
300
|
+
key = node_crypto.default.createPrivateKey(p8);
|
|
301
|
+
} catch {
|
|
302
|
+
throw new Error("asc saved a .p8 that is not a private key.");
|
|
303
|
+
}
|
|
304
|
+
if (key.asymmetricKeyType !== "ec") throw new Error("asc saved a .p8 that is not an EC private key.");
|
|
305
|
+
};
|
|
306
|
+
/**
|
|
307
|
+
* `IssueAscApiKey` with asc, the open-source App Store Connect CLI
|
|
308
|
+
* (https://github.com/rorkai/App-Store-Connect-CLI, MIT):
|
|
309
|
+
*
|
|
310
|
+
* 1. `asc web auth login` signs in with the password and the two-factor
|
|
311
|
+
* codes typed into Hot Updater's prompts. The password reaches only that
|
|
312
|
+
* asc process, in its environment (never an argument), each code only
|
|
313
|
+
* asc's two-factor code command, and neither is kept or printed.
|
|
314
|
+
* 2. `chooseTeam` sees the team asc signed in to, unless `teamId` is set.
|
|
315
|
+
* 3. `asc web api-keys create` issues the key and saves its one-time `.p8`,
|
|
316
|
+
* which is checked before it is handed out; a key that fails the check is
|
|
317
|
+
* revoked at once.
|
|
318
|
+
* 4. `revoke` runs `asc web api-keys revoke` in the same session.
|
|
319
|
+
*
|
|
320
|
+
* asc's whole state (its web session, config, and the `.p8`) lives in a
|
|
321
|
+
* temporary 0700 directory, which `dispose` deletes, as does the process
|
|
322
|
+
* exiting first.
|
|
323
|
+
*
|
|
324
|
+
* @param asc The asc binary; found or downloaded with `findAscCli` unless set.
|
|
325
|
+
* @param env The environment asc's own is derived from.
|
|
326
|
+
* @param prompts Where the password and two-factor codes are asked.
|
|
327
|
+
*/
|
|
328
|
+
const createAscCliKeyIssuer = ({ asc, env = process.env, prompts = cliPrompts } = {}) => async ({ name, role, appleId, teamId, chooseTeam }) => {
|
|
329
|
+
const cli = asc ?? await findAscCli();
|
|
330
|
+
_hot_updater_cli_tools.p.log.info("Hot Updater signs in with asc, the open-source App Store Connect CLI. It keeps only the API key it issues: neither the password nor the session.");
|
|
331
|
+
const password = await prompts.password(appleId);
|
|
332
|
+
if (password === void 0) throw new AscCommandError("Signing in was cancelled.", void 0);
|
|
333
|
+
(0, _hot_updater_cli_tools.registerSecretValue)(password);
|
|
334
|
+
const work = await createWorkDirectory();
|
|
335
|
+
const ascEnv = ascEnvironment(work.root, env);
|
|
336
|
+
const run = async (step, args, extraEnv = {}) => {
|
|
337
|
+
const result = await (0, execa.execa)(cli.path, args, {
|
|
338
|
+
env: {
|
|
339
|
+
...ascEnv,
|
|
340
|
+
...extraEnv
|
|
341
|
+
},
|
|
342
|
+
extendEnv: false,
|
|
343
|
+
stdin: "ignore",
|
|
344
|
+
stdout: "pipe",
|
|
345
|
+
stderr: "pipe",
|
|
346
|
+
reject: false
|
|
347
|
+
});
|
|
348
|
+
const stderr = String(result.stderr ?? "");
|
|
349
|
+
if (result.exitCode !== 0 || result.failed) throw new AscCommandError(`${step} failed: ${lastLine(stderr) ?? (result.signal ? `stopped by ${result.signal}` : void 0) ?? `exit code ${result.exitCode}`}`, result.exitCode, { cause: { stderr } });
|
|
350
|
+
let parsed;
|
|
351
|
+
try {
|
|
352
|
+
parsed = JSON.parse(String(result.stdout));
|
|
353
|
+
} catch {
|
|
354
|
+
parsed = void 0;
|
|
355
|
+
}
|
|
356
|
+
if (!isRecord(parsed)) throw new AscCommandError(`${step} printed no JSON this version of Hot Updater reads.`, result.exitCode);
|
|
357
|
+
return parsed;
|
|
358
|
+
};
|
|
359
|
+
try {
|
|
360
|
+
const twoFactor = await listenForTwoFactorCodes(work.root, prompts);
|
|
361
|
+
let login;
|
|
362
|
+
try {
|
|
363
|
+
login = await run("Signing in with asc web auth login", [
|
|
364
|
+
"web",
|
|
365
|
+
"auth",
|
|
366
|
+
"login",
|
|
367
|
+
...appleId ? ["--apple-id", appleId] : [],
|
|
368
|
+
"--two-factor-code-command",
|
|
369
|
+
twoFactor.command,
|
|
370
|
+
"--output",
|
|
371
|
+
"json"
|
|
372
|
+
], { ASC_WEB_PASSWORD: password });
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if (twoFactor.cancelled()) throw new AscCommandError("Signing in was cancelled.", void 0, { cause: error });
|
|
375
|
+
throw error;
|
|
376
|
+
} finally {
|
|
377
|
+
await twoFactor.close();
|
|
378
|
+
}
|
|
379
|
+
if (login.authenticated !== true) throw new AscCommandError("asc web auth login finished without a signed-in session.", 0);
|
|
380
|
+
const signedIn = {
|
|
381
|
+
...text(login.appleId) ? { appleId: text(login.appleId) } : {},
|
|
382
|
+
...text(login.publicProviderId) ? { teamId: text(login.publicProviderId) } : {},
|
|
383
|
+
...text(login.developerTeamId) ? { developerTeamId: text(login.developerTeamId) } : {}
|
|
384
|
+
};
|
|
385
|
+
const chosen = teamId ?? await chooseTeam?.(signedIn);
|
|
386
|
+
const team = chosen === void 0 || chosen === signedIn.teamId ? signedIn : {
|
|
387
|
+
...signedIn.appleId ? { appleId: signedIn.appleId } : {},
|
|
388
|
+
teamId: chosen
|
|
389
|
+
};
|
|
390
|
+
const sessionArgs = [...signedIn.appleId ? ["--apple-id", signedIn.appleId] : [], ...chosen === void 0 ? [] : ["--public-provider-id", chosen]];
|
|
391
|
+
let created;
|
|
392
|
+
try {
|
|
393
|
+
created = await run("Creating the App Store Connect API key", [
|
|
394
|
+
"web",
|
|
395
|
+
"api-keys",
|
|
396
|
+
"create",
|
|
397
|
+
"--name",
|
|
398
|
+
name,
|
|
399
|
+
"--role",
|
|
400
|
+
role,
|
|
401
|
+
"--output-dir",
|
|
402
|
+
node_path.default.join(work.root, "keys"),
|
|
403
|
+
...sessionArgs,
|
|
404
|
+
"--output",
|
|
405
|
+
"json"
|
|
406
|
+
]);
|
|
407
|
+
} catch (error) {
|
|
408
|
+
const stderr = error instanceof AscCommandError && isRecord(error.cause) ? String(error.cause.stderr ?? "") : "";
|
|
409
|
+
const hint = createFailureHint(stderr);
|
|
410
|
+
if (hint && error instanceof AscCommandError) throw new AscCommandError(`${error.message}\n${hint}`, error.exitCode, { cause: error });
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
const keyId = text(created.keyId);
|
|
414
|
+
if (!keyId) throw new AscCommandError("asc web api-keys create printed no key ID.", 0);
|
|
415
|
+
const revoke = () => run("Revoking the key", [
|
|
416
|
+
"web",
|
|
417
|
+
"api-keys",
|
|
418
|
+
"revoke",
|
|
419
|
+
"--key-id",
|
|
420
|
+
keyId,
|
|
421
|
+
"--type",
|
|
422
|
+
"team",
|
|
423
|
+
"--confirm",
|
|
424
|
+
...sessionArgs,
|
|
425
|
+
"--output",
|
|
426
|
+
"json"
|
|
427
|
+
]).then((json) => json.active === false || json.status === "revoked", () => false);
|
|
428
|
+
let p8;
|
|
429
|
+
let issuerId;
|
|
430
|
+
try {
|
|
431
|
+
const issuer = text(created.issuerId);
|
|
432
|
+
const p8Path = text(created.p8Path);
|
|
433
|
+
const keys = node_path.default.join(work.root, "keys");
|
|
434
|
+
if (!issuer) throw new Error("asc printed no issuer ID for the key.");
|
|
435
|
+
if (!p8Path || await node_fs_promises.default.realpath(node_path.default.dirname(node_path.default.resolve(p8Path))) !== await node_fs_promises.default.realpath(keys)) throw new Error("asc saved the key's .p8 outside its output directory.");
|
|
436
|
+
p8 = (await node_fs_promises.default.readFile(p8Path, "utf-8")).trim();
|
|
437
|
+
(0, _hot_updater_cli_tools.registerSecretValue)(p8);
|
|
438
|
+
(0, _hot_updater_cli_tools.registerSecretValue)(Buffer.from(p8).toString("base64"));
|
|
439
|
+
assertP8(p8);
|
|
440
|
+
issuerId = issuer;
|
|
441
|
+
} catch (error) {
|
|
442
|
+
const revoked = await revoke();
|
|
443
|
+
throw new Error(revoked ? `${error.message} The App Store Connect API key ${keyId} was revoked again.` : `${error.message} Revoking the App Store Connect API key ${keyId} failed too: revoke it at ${ASC_INTEGRATIONS_URL}.`, { cause: error });
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
p8,
|
|
447
|
+
keyId,
|
|
448
|
+
issuerId,
|
|
449
|
+
name: text(created.name) ?? name,
|
|
450
|
+
team,
|
|
451
|
+
revoke,
|
|
452
|
+
dispose: work.dispose
|
|
453
|
+
};
|
|
454
|
+
} catch (error) {
|
|
455
|
+
await work.dispose();
|
|
456
|
+
throw error;
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
//#endregion
|
|
460
|
+
//#region src/ascApiKey.ts
|
|
461
|
+
/** Where team API keys are listed and revoked in App Store Connect. */
|
|
462
|
+
const ASC_INTEGRATIONS_URL = "https://appstoreconnect.apple.com/access/integrations/api";
|
|
463
|
+
/**
|
|
464
|
+
* Hot Updater's sign-in, the one place that picks how it is done: asc, the
|
|
465
|
+
* open-source App Store Connect CLI. Another implementation of
|
|
466
|
+
* `IssueAscApiKey` replaces it here without touching its callers.
|
|
467
|
+
*/
|
|
468
|
+
const issueAscApiKey = (request) => createAscCliKeyIssuer()(request);
|
|
469
|
+
//#endregion
|
|
36
470
|
//#region src/utils/cocoapods.ts
|
|
37
471
|
const installPodsIfNeeded = async (sourceDir) => {
|
|
38
472
|
const podfilePath = path.default.join(sourceDir, "Podfile");
|
|
@@ -42,6 +476,16 @@ const installPodsIfNeeded = async (sourceDir) => {
|
|
|
42
476
|
}
|
|
43
477
|
const gemfilePaths = [path.default.join(sourceDir, "Gemfile"), path.default.join((0, _hot_updater_cli_tools.getCwd)(), "Gemfile")];
|
|
44
478
|
const shouldUseBundler = (await Promise.all(gemfilePaths.map(checkShouldUseBundler))).some(Boolean);
|
|
479
|
+
const reactNative = (0, _hot_updater_cli_tools.getReactNativeMetadatas)();
|
|
480
|
+
const plan = resolvePodInstallPlan({
|
|
481
|
+
version: reactNative.version,
|
|
482
|
+
env: process.env
|
|
483
|
+
});
|
|
484
|
+
if (plan.runCodegen) await runReactNativeCodegen({
|
|
485
|
+
projectRoot: (0, _hot_updater_cli_tools.getCwd)(),
|
|
486
|
+
reactNativePath: reactNative.packagePath,
|
|
487
|
+
sourceDir
|
|
488
|
+
});
|
|
45
489
|
try {
|
|
46
490
|
if (shouldUseBundler) {
|
|
47
491
|
_hot_updater_cli_tools.p.log.info("Using bundler for CocoaPods installation");
|
|
@@ -57,7 +501,7 @@ const installPodsIfNeeded = async (sourceDir) => {
|
|
|
57
501
|
"install"
|
|
58
502
|
], {
|
|
59
503
|
cwd: sourceDir,
|
|
60
|
-
env:
|
|
504
|
+
env: plan.env
|
|
61
505
|
});
|
|
62
506
|
podSpinner.stop("CocoaPods dependencies installed");
|
|
63
507
|
} else {
|
|
@@ -65,7 +509,7 @@ const installPodsIfNeeded = async (sourceDir) => {
|
|
|
65
509
|
spinner.start("Installing CocoaPods dependencies");
|
|
66
510
|
await (0, execa.execa)("pod", ["install"], {
|
|
67
511
|
cwd: sourceDir,
|
|
68
|
-
env:
|
|
512
|
+
env: plan.env
|
|
69
513
|
});
|
|
70
514
|
spinner.stop("CocoaPods dependencies installed");
|
|
71
515
|
}
|
|
@@ -81,13 +525,55 @@ const checkShouldUseBundler = async (gemfilePath) => {
|
|
|
81
525
|
return false;
|
|
82
526
|
}
|
|
83
527
|
};
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
528
|
+
/**
|
|
529
|
+
* Runs React Native codegen into `<sourceDir>/build/generated/ios`, as the
|
|
530
|
+
* React Native CLI does before `pod install`. Clearing the old output first
|
|
531
|
+
* drops the modules of removed libraries.
|
|
532
|
+
*/
|
|
533
|
+
const runReactNativeCodegen = async ({ projectRoot, reactNativePath, sourceDir }) => {
|
|
534
|
+
await fs.default.promises.rm(path.default.join(sourceDir, "build", "generated", "ios"), {
|
|
535
|
+
recursive: true,
|
|
536
|
+
force: true
|
|
537
|
+
});
|
|
538
|
+
const spinner = _hot_updater_cli_tools.p.spinner();
|
|
539
|
+
spinner.start("Running React Native codegen");
|
|
540
|
+
try {
|
|
541
|
+
await (0, execa.execa)("node", [
|
|
542
|
+
path.default.join(reactNativePath, "scripts", "generate-codegen-artifacts.js"),
|
|
543
|
+
"-p",
|
|
544
|
+
projectRoot,
|
|
545
|
+
"-o",
|
|
546
|
+
sourceDir,
|
|
547
|
+
"-t",
|
|
548
|
+
"ios"
|
|
549
|
+
], { cwd: projectRoot });
|
|
550
|
+
} catch (error) {
|
|
551
|
+
spinner.stop("React Native codegen failed");
|
|
552
|
+
throw new Error(`React Native codegen failed: ${error}`);
|
|
553
|
+
}
|
|
554
|
+
spinner.stop("React Native codegen done");
|
|
555
|
+
};
|
|
556
|
+
/**
|
|
557
|
+
* The environment `pod install` runs with, and whether codegen runs first.
|
|
558
|
+
*
|
|
559
|
+
* From React Native 0.79, `RCT_IGNORE_PODS_DEPRECATION=1` silences the
|
|
560
|
+
* deprecation notice for calling `pod install` directly and also sets
|
|
561
|
+
* `RCT_SKIP_CODEGEN=1`, because the React Native CLI runs codegen itself
|
|
562
|
+
* first. Hot Updater sets it the same way and runs codegen too, unless the
|
|
563
|
+
* user's `RCT_SKIP_CODEGEN=1` takes codegen over. Before 0.79, and when the
|
|
564
|
+
* version is unknown, CocoaPods runs codegen. A user's value of a flag wins.
|
|
565
|
+
*/
|
|
566
|
+
const resolvePodInstallPlan = ({ version, env }) => {
|
|
567
|
+
const podsSkipCodegen = version.major > 0 || version.minor >= 79;
|
|
568
|
+
const usePrebuiltReactNative = version.major > 0 || version.minor >= 81;
|
|
569
|
+
const ignorePodsDeprecation = env["RCT_IGNORE_PODS_DEPRECATION"] ?? (podsSkipCodegen ? "1" : void 0);
|
|
87
570
|
return {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
571
|
+
runCodegen: podsSkipCodegen && ignorePodsDeprecation === "1" && env["RCT_SKIP_CODEGEN"] !== "1",
|
|
572
|
+
env: {
|
|
573
|
+
...ignorePodsDeprecation !== void 0 && { RCT_IGNORE_PODS_DEPRECATION: ignorePodsDeprecation },
|
|
574
|
+
RCT_USE_RN_DEP: env["RCT_USE_RN_DEP"] ?? (usePrebuiltReactNative ? "1" : "0"),
|
|
575
|
+
RCT_USE_PREBUILT_RNCORE: env["RCT_USE_PREBUILT_RNCORE"] ?? (usePrebuiltReactNative ? "1" : "0")
|
|
576
|
+
}
|
|
91
577
|
};
|
|
92
578
|
};
|
|
93
579
|
//#endregion
|
|
@@ -166,9 +652,15 @@ const parseXcodeProjectInfo = async (sourceDir) => {
|
|
|
166
652
|
//#endregion
|
|
167
653
|
//#region src/utils/prettifyXcodebuildError.ts
|
|
168
654
|
const prettifyXcodebuildError = (error) => {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
655
|
+
if (error instanceof _hot_updater_cli_tools.BuildFailedError) {
|
|
656
|
+
const errorLines = error.summary.join("\n");
|
|
657
|
+
const message = errorLines.includes("error: No profiles for") || errorLines.includes("error: Provisioning profile") ? "Not valid provisioning profile is set in the project. Check out the Signing & Capabilities tab in Xcode." : error.message;
|
|
658
|
+
return new _hot_updater_cli_tools.BuildFailedError(`Xcodebuild failed: ${message}`, {
|
|
659
|
+
logPath: error.logPath,
|
|
660
|
+
summary: error.summary,
|
|
661
|
+
cause: error.cause
|
|
662
|
+
});
|
|
663
|
+
}
|
|
172
664
|
return /* @__PURE__ */ new Error(`Xcodebuild failed: ${error}`);
|
|
173
665
|
};
|
|
174
666
|
//#endregion
|
|
@@ -211,13 +703,20 @@ const createXcodebuildLogger = ({ logPrefix }) => new _hot_updater_cli_tools.Bui
|
|
|
211
703
|
"BUILD SUCCEEDED",
|
|
212
704
|
"ARCHIVE SUCCEEDED"
|
|
213
705
|
]
|
|
214
|
-
]
|
|
706
|
+
],
|
|
707
|
+
failureSummary: {
|
|
708
|
+
lines: [/(^|:\s)error: /, /^\*\* [A-Z ]+ FAILED \*\*$/],
|
|
709
|
+
blocks: [{
|
|
710
|
+
start: "The following build commands failed:",
|
|
711
|
+
end: /^\(\d+ failures?\)$/,
|
|
712
|
+
includeEnd: true
|
|
713
|
+
}]
|
|
714
|
+
}
|
|
215
715
|
});
|
|
216
716
|
//#endregion
|
|
217
717
|
//#region src/utils/runXcodebuildWithLogging.ts
|
|
218
718
|
let cachedXcbeautifyAvailability = null;
|
|
219
|
-
const TERMINATION_SIGNALS = ["SIGINT", "SIGTERM"];
|
|
220
|
-
const IGNORED_PIPELINE_ERROR_CODES = ["EPIPE", "ERR_STREAM_PREMATURE_CLOSE"];
|
|
719
|
+
const TERMINATION_SIGNALS$1 = ["SIGINT", "SIGTERM"];
|
|
221
720
|
const isXcbeautifyAvailable = async () => {
|
|
222
721
|
if (cachedXcbeautifyAvailability !== null) return cachedXcbeautifyAvailability;
|
|
223
722
|
try {
|
|
@@ -233,6 +732,7 @@ const createRawXcodebuildProcess = ({ args, env, sourceDir }) => {
|
|
|
233
732
|
return (0, execa.execa)("xcodebuild", args, {
|
|
234
733
|
cwd: sourceDir,
|
|
235
734
|
all: true,
|
|
735
|
+
buffer: false,
|
|
236
736
|
env: {
|
|
237
737
|
...process.env,
|
|
238
738
|
...env,
|
|
@@ -243,29 +743,17 @@ const createRawXcodebuildProcess = ({ args, env, sourceDir }) => {
|
|
|
243
743
|
const createXcbeautifyProcess = ({ cwd }) => {
|
|
244
744
|
return (0, execa.execa)("xcbeautify", [], {
|
|
245
745
|
cwd,
|
|
246
|
-
all: true
|
|
746
|
+
all: true,
|
|
747
|
+
buffer: false
|
|
247
748
|
});
|
|
248
749
|
};
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
const shouldIgnorePipelineError = (error) => {
|
|
255
|
-
const errorCode = getErrorCode(error);
|
|
256
|
-
return IGNORED_PIPELINE_ERROR_CODES.includes(errorCode);
|
|
257
|
-
};
|
|
258
|
-
const createXcodebuildOutputPipingPromise = async ({ xcodebuildOutput, xcbeautifyInput }) => {
|
|
259
|
-
try {
|
|
260
|
-
return await (0, stream_promises.pipeline)(xcodebuildOutput, xcbeautifyInput);
|
|
261
|
-
} catch (error) {
|
|
262
|
-
if (shouldIgnorePipelineError(error)) return;
|
|
263
|
-
throw error;
|
|
750
|
+
/** Why xcodebuild failed, without its command line. */
|
|
751
|
+
const describeFailure = (error) => {
|
|
752
|
+
if (error instanceof execa.ExecaError) {
|
|
753
|
+
if (error.signal) return `xcodebuild was stopped by ${error.signal}.`;
|
|
754
|
+
if (error.exitCode !== void 0) return `xcodebuild exited with code ${error.exitCode}.`;
|
|
264
755
|
}
|
|
265
|
-
|
|
266
|
-
const processOutputStream = async ({ outputStream, logger }) => {
|
|
267
|
-
if (!outputStream) return;
|
|
268
|
-
await logger.processStream(outputStream);
|
|
756
|
+
return error instanceof Error ? error.message : String(error);
|
|
269
757
|
};
|
|
270
758
|
const runXcodebuildWithLogging = async ({ args, env, sourceDir, successMessage, failureMessage, logPrefix }) => {
|
|
271
759
|
const useXcbeautify = await isXcbeautifyAvailable();
|
|
@@ -275,74 +763,153 @@ const runXcodebuildWithLogging = async ({ args, env, sourceDir, successMessage,
|
|
|
275
763
|
const terminateActiveProcesses = () => {
|
|
276
764
|
for (const activeProcess of activeProcesses) activeProcess.kill();
|
|
277
765
|
};
|
|
278
|
-
for (const signal of TERMINATION_SIGNALS) process.once(signal, terminateActiveProcesses);
|
|
766
|
+
for (const signal of TERMINATION_SIGNALS$1) process.once(signal, terminateActiveProcesses);
|
|
279
767
|
try {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
outputStream: xcbeautifyProcess.all ?? xcbeautifyProcess.stdout
|
|
298
|
-
});
|
|
299
|
-
await Promise.all([
|
|
300
|
-
xcbeautifyProcess,
|
|
301
|
-
xcodebuildProcess,
|
|
302
|
-
pipingPromise
|
|
303
|
-
]);
|
|
304
|
-
} else {
|
|
305
|
-
const xcodebuildProcess = createRawXcodebuildProcess({
|
|
306
|
-
args,
|
|
307
|
-
env,
|
|
308
|
-
sourceDir
|
|
309
|
-
});
|
|
310
|
-
activeProcesses.push(xcodebuildProcess);
|
|
311
|
-
await processOutputStream({
|
|
312
|
-
logger,
|
|
313
|
-
outputStream: xcodebuildProcess.all ?? xcodebuildProcess.stdout
|
|
768
|
+
const xcodebuildProcess = createRawXcodebuildProcess({
|
|
769
|
+
args,
|
|
770
|
+
env,
|
|
771
|
+
sourceDir
|
|
772
|
+
});
|
|
773
|
+
activeProcesses.push(xcodebuildProcess);
|
|
774
|
+
const xcodebuildOutput = xcodebuildProcess.all ?? xcodebuildProcess.stdout;
|
|
775
|
+
let xcbeautifyProcess = null;
|
|
776
|
+
if (useXcbeautify && xcodebuildOutput) {
|
|
777
|
+
xcbeautifyProcess = createXcbeautifyProcess({ cwd: sourceDir });
|
|
778
|
+
activeProcesses.push(xcbeautifyProcess);
|
|
779
|
+
}
|
|
780
|
+
const logging = xcodebuildOutput ? logger.processStream(xcodebuildOutput, { display: xcbeautifyProcess ? xcbeautifyProcess.all ?? xcbeautifyProcess.stdout ?? void 0 : void 0 }) : Promise.resolve();
|
|
781
|
+
const xcbeautifyInput = xcbeautifyProcess?.stdin;
|
|
782
|
+
if (xcodebuildOutput && xcbeautifyInput) {
|
|
783
|
+
xcbeautifyInput.on("error", () => {
|
|
784
|
+
xcodebuildOutput.unpipe(xcbeautifyInput);
|
|
314
785
|
});
|
|
315
|
-
|
|
786
|
+
xcodebuildOutput.pipe(xcbeautifyInput);
|
|
316
787
|
}
|
|
788
|
+
const [xcodebuildResult, loggingResult] = await Promise.allSettled([
|
|
789
|
+
xcodebuildProcess,
|
|
790
|
+
logging,
|
|
791
|
+
xcbeautifyProcess
|
|
792
|
+
]);
|
|
793
|
+
if (xcodebuildResult.status === "rejected") throw xcodebuildResult.reason;
|
|
794
|
+
if (loggingResult.status === "rejected") throw loggingResult.reason;
|
|
317
795
|
logger.stop(successMessage);
|
|
318
796
|
} catch (error) {
|
|
319
797
|
logger.writeError(error);
|
|
320
798
|
logger.stop(failureMessage, false);
|
|
321
|
-
throw error
|
|
799
|
+
throw new _hot_updater_cli_tools.BuildFailedError(`${failureMessage}: ${describeFailure(error)}`, {
|
|
800
|
+
logPath: logger.logPath,
|
|
801
|
+
summary: logger.failureSummary,
|
|
802
|
+
cause: error
|
|
803
|
+
});
|
|
322
804
|
} finally {
|
|
323
|
-
for (const signal of TERMINATION_SIGNALS) process.off(signal, terminateActiveProcesses);
|
|
805
|
+
for (const signal of TERMINATION_SIGNALS$1) process.off(signal, terminateActiveProcesses);
|
|
324
806
|
await logger.close();
|
|
325
807
|
}
|
|
326
808
|
};
|
|
327
809
|
//#endregion
|
|
810
|
+
//#region src/utils/writeBuildXcconfig.ts
|
|
811
|
+
const SDK_CONDITIONS = [
|
|
812
|
+
"",
|
|
813
|
+
"[sdk=iphoneos*]",
|
|
814
|
+
"[sdk=iphonesimulator*]"
|
|
815
|
+
];
|
|
816
|
+
/**
|
|
817
|
+
* Finds a setting of `keys` that an included xcconfig sets with a condition
|
|
818
|
+
* the build xcconfig does not, such as `[config=Release]` or `[arch=arm64]`,
|
|
819
|
+
* which would outrank the build's own. It follows `#include`s, which are
|
|
820
|
+
* relative to the including file.
|
|
821
|
+
*/
|
|
822
|
+
const findConditionalSetting = async (files, keys, visited = /* @__PURE__ */ new Set()) => {
|
|
823
|
+
for (const file of files) {
|
|
824
|
+
if (visited.has(file) || !fs.default.existsSync(file)) continue;
|
|
825
|
+
visited.add(file);
|
|
826
|
+
const lines = (await fs.default.promises.readFile(file, "utf-8")).split("\n");
|
|
827
|
+
for (const [index, line] of lines.entries()) {
|
|
828
|
+
const include = line.match(/^\s*#include\??\s*"([^"]+)"/)?.[1];
|
|
829
|
+
if (include) {
|
|
830
|
+
const found = await findConditionalSetting([path.default.resolve(path.default.dirname(file), include)], keys, visited);
|
|
831
|
+
if (found) return found;
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
const [, key, conditions] = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)((?:\[[^\]]*\])+)\s*=/) ?? [];
|
|
835
|
+
if (key && keys.includes(key) && !SDK_CONDITIONS.includes(conditions)) return `${file}:${index + 1} sets ${key}${conditions}`;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
/**
|
|
840
|
+
* Writes the xcconfig a build loads, which sets the CLI's build settings
|
|
841
|
+
* over every other. xcodebuild ranks an `-xcconfig`'s settings above the
|
|
842
|
+
* command line's, so the scheme's `xcconfig` and each `-xcconfig` in
|
|
843
|
+
* `extraParams` are included, in order, before `settings`, and the build
|
|
844
|
+
* loads only this one.
|
|
845
|
+
*/
|
|
846
|
+
const writeBuildXcconfig = async ({ directory, sourceDir, xcconfig, extraParams = [], settings }) => {
|
|
847
|
+
const included = xcconfig ? [xcconfig] : [];
|
|
848
|
+
const otherParams = [];
|
|
849
|
+
for (let i = 0; i < extraParams.length; i++) if (extraParams[i] === "-xcconfig" && i + 1 < extraParams.length) included.push(extraParams[++i]);
|
|
850
|
+
else otherParams.push(extraParams[i]);
|
|
851
|
+
const includedFiles = included.map((file) => path.default.resolve(sourceDir, file));
|
|
852
|
+
const conditional = await findConditionalSetting(includedFiles, Object.keys(settings));
|
|
853
|
+
if (conditional) throw new Error(`${conditional}, which would outrank the value the build sets. Set it without that condition, or remove it.`);
|
|
854
|
+
const buildXcconfig = path.default.join(directory, "HotUpdater.xcconfig");
|
|
855
|
+
await fs.default.promises.writeFile(buildXcconfig, [
|
|
856
|
+
...includedFiles.map((file) => `#include "${file}"`),
|
|
857
|
+
...Object.entries(settings).flatMap(([key, value]) => SDK_CONDITIONS.map((condition) => `${key}${condition} = ${value}`)),
|
|
858
|
+
""
|
|
859
|
+
].join("\n"));
|
|
860
|
+
return {
|
|
861
|
+
xcconfig: buildXcconfig,
|
|
862
|
+
extraParams: otherParams
|
|
863
|
+
};
|
|
864
|
+
};
|
|
865
|
+
/**
|
|
866
|
+
* Automatic signing over a target set to manual signing, whose provisioning
|
|
867
|
+
* profile and identity Xcode would otherwise report as conflicting.
|
|
868
|
+
*/
|
|
869
|
+
const AUTOMATIC_SIGNING = {
|
|
870
|
+
CODE_SIGN_STYLE: "Automatic",
|
|
871
|
+
PROVISIONING_PROFILE: "",
|
|
872
|
+
PROVISIONING_PROFILE_SPECIFIER: "",
|
|
873
|
+
CODE_SIGN_IDENTITY: "Apple Development"
|
|
874
|
+
};
|
|
875
|
+
/**
|
|
876
|
+
* The settings a build sets over the scheme's: the MIN_BUNDLE_ID the CLI
|
|
877
|
+
* issues and reports, and automatic signing when an App Store Connect API
|
|
878
|
+
* key signs, which a target set to manual signing would not use.
|
|
879
|
+
*/
|
|
880
|
+
const buildSettingsOverScheme = ({ minBundleId, authenticationArgs }) => ({
|
|
881
|
+
HOT_UPDATER_MIN_BUNDLE_ID: minBundleId,
|
|
882
|
+
...authenticationArgs ? AUTOMATIC_SIGNING : {}
|
|
883
|
+
});
|
|
884
|
+
//#endregion
|
|
328
885
|
//#region src/builder/archiveXcodeProject.ts
|
|
329
|
-
const archiveXcodeProject = async ({ sourceDir, platform, installPods, destination, extraParams, configuration, xcodeScheme, xcconfig, logPrefix }) => {
|
|
330
|
-
const xcodeProject = await parseXcodeProjectInfo(sourceDir);
|
|
886
|
+
const archiveXcodeProject = async ({ sourceDir, platform, installPods, destination, extraParams, configuration, xcodeScheme, xcconfig, logPrefix, minBundleId, authenticationArgs }) => {
|
|
331
887
|
if (installPods) await installPodsIfNeeded(sourceDir);
|
|
888
|
+
const xcodeProject = await parseXcodeProjectInfo(sourceDir);
|
|
332
889
|
const tmpDir = await createRandomTmpDir();
|
|
333
890
|
const archiveName = `${xcodeProject.name.replace(".xcworkspace", "").replace(".xcodeproj", "")}.xcarchive`;
|
|
334
891
|
const archivePath = path.default.join(tmpDir, archiveName);
|
|
335
|
-
const
|
|
892
|
+
const build = await writeBuildXcconfig({
|
|
893
|
+
directory: tmpDir,
|
|
894
|
+
sourceDir,
|
|
895
|
+
xcconfig,
|
|
896
|
+
extraParams,
|
|
897
|
+
settings: buildSettingsOverScheme({
|
|
898
|
+
minBundleId,
|
|
899
|
+
authenticationArgs
|
|
900
|
+
})
|
|
901
|
+
});
|
|
336
902
|
const archiveArgs = prepareArchiveArgs({
|
|
903
|
+
authenticationArgs,
|
|
337
904
|
minBundleId,
|
|
338
905
|
archivePath,
|
|
339
906
|
platform,
|
|
340
907
|
sourceDir,
|
|
341
908
|
xcodeProject,
|
|
342
909
|
configuration,
|
|
343
|
-
extraParams,
|
|
910
|
+
extraParams: build.extraParams,
|
|
344
911
|
destination,
|
|
345
|
-
xcconfig,
|
|
912
|
+
xcconfig: build.xcconfig,
|
|
346
913
|
xcodeScheme
|
|
347
914
|
});
|
|
348
915
|
_hot_updater_cli_tools.p.log.info(`Xcode Archive Settings:
|
|
@@ -365,7 +932,7 @@ Command xcodebuild ${archiveArgs.join(" ")}
|
|
|
365
932
|
throw prettifyXcodebuildError(error);
|
|
366
933
|
}
|
|
367
934
|
};
|
|
368
|
-
const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xcodeProject, configuration = "Release", xcconfig, extraParams, destination = [], minBundleId }) => {
|
|
935
|
+
const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xcodeProject, configuration = "Release", xcconfig, extraParams, destination = [], minBundleId, authenticationArgs }) => {
|
|
369
936
|
const args = [
|
|
370
937
|
xcodeProject.isWorkspace ? "-workspace" : "-project",
|
|
371
938
|
path.default.join(sourceDir, xcodeProject.name),
|
|
@@ -378,6 +945,7 @@ const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xco
|
|
|
378
945
|
archivePath,
|
|
379
946
|
`HOT_UPDATER_MIN_BUNDLE_ID=${minBundleId}`
|
|
380
947
|
];
|
|
948
|
+
if (authenticationArgs) args.push(...authenticationArgs);
|
|
381
949
|
if (xcconfig) args.push("-xcconfig", xcconfig);
|
|
382
950
|
if (extraParams) args.push(...extraParams);
|
|
383
951
|
const resolvedDestinations = resolveDestinations({
|
|
@@ -394,9 +962,9 @@ const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xco
|
|
|
394
962
|
};
|
|
395
963
|
//#endregion
|
|
396
964
|
//#region src/builder/buildXcodeProject.ts
|
|
397
|
-
const buildXcodeProject = async ({ sourceDir, platform, xcodeScheme, configuration, deviceType, logPrefix, destination = [], useGenericDestination = false, installPods, extraParams }) => {
|
|
398
|
-
const xcodeProject = await parseXcodeProjectInfo(sourceDir);
|
|
965
|
+
const buildXcodeProject = async ({ sourceDir, platform, xcodeScheme, configuration, deviceType, logPrefix, destination = [], useGenericDestination = false, installPods, extraParams, minBundleId, authenticationArgs }) => {
|
|
399
966
|
if (installPods) await installPodsIfNeeded(sourceDir);
|
|
967
|
+
const xcodeProject = await parseXcodeProjectInfo(sourceDir);
|
|
400
968
|
const derivedDataPath = await createRandomTmpDir();
|
|
401
969
|
const resolvedDestinations = resolveDestinations({
|
|
402
970
|
destinations: destination,
|
|
@@ -407,14 +975,24 @@ const buildXcodeProject = async ({ sourceDir, platform, xcodeScheme, configurati
|
|
|
407
975
|
platform,
|
|
408
976
|
useGeneric: useGenericDestination
|
|
409
977
|
}));
|
|
410
|
-
const
|
|
978
|
+
const build = await writeBuildXcconfig({
|
|
979
|
+
directory: await createRandomTmpDir(),
|
|
980
|
+
sourceDir,
|
|
981
|
+
extraParams,
|
|
982
|
+
settings: buildSettingsOverScheme({
|
|
983
|
+
minBundleId,
|
|
984
|
+
authenticationArgs
|
|
985
|
+
})
|
|
986
|
+
});
|
|
411
987
|
const buildArgs = prepareBuildArgs({
|
|
988
|
+
authenticationArgs,
|
|
989
|
+
xcconfig: build.xcconfig,
|
|
412
990
|
minBundleId,
|
|
413
991
|
configuration,
|
|
414
992
|
derivedDataPath,
|
|
415
993
|
deviceType,
|
|
416
994
|
resolvedDestinations,
|
|
417
|
-
extraParams,
|
|
995
|
+
extraParams: build.extraParams,
|
|
418
996
|
platform,
|
|
419
997
|
xcodeScheme,
|
|
420
998
|
sourceDir,
|
|
@@ -449,7 +1027,7 @@ Command xcodebuild ${buildArgs.join(" ")}
|
|
|
449
1027
|
throw prettifyXcodebuildError(error);
|
|
450
1028
|
}
|
|
451
1029
|
};
|
|
452
|
-
const prepareBuildArgs = ({ xcodeProject, sourceDir, platform, xcodeScheme, configuration, deviceType, resolvedDestinations, derivedDataPath, extraParams, minBundleId }) => {
|
|
1030
|
+
const prepareBuildArgs = ({ xcodeProject, sourceDir, platform, xcodeScheme, configuration, deviceType, resolvedDestinations, derivedDataPath, extraParams, minBundleId, authenticationArgs, xcconfig }) => {
|
|
453
1031
|
const sdk = deviceType === "simulator" ? platformConfigs[platform].simulatorSdk : platformConfigs[platform].deviceSdk;
|
|
454
1032
|
const args = [
|
|
455
1033
|
xcodeProject.isWorkspace ? "-workspace" : "-project",
|
|
@@ -465,6 +1043,8 @@ const prepareBuildArgs = ({ xcodeProject, sourceDir, platform, xcodeScheme, conf
|
|
|
465
1043
|
`HOT_UPDATER_MIN_BUNDLE_ID=${minBundleId}`,
|
|
466
1044
|
"build"
|
|
467
1045
|
];
|
|
1046
|
+
if (authenticationArgs) args.push(...authenticationArgs);
|
|
1047
|
+
args.push("-xcconfig", xcconfig);
|
|
468
1048
|
if (extraParams) args.push(...extraParams);
|
|
469
1049
|
for (const dest of resolvedDestinations) args.push("-destination", dest);
|
|
470
1050
|
return args;
|
|
@@ -501,9 +1081,10 @@ const getBuildSettings = async ({ xcodeProject, sourceDir, xcodeScheme, configur
|
|
|
501
1081
|
};
|
|
502
1082
|
//#endregion
|
|
503
1083
|
//#region src/builder/exportXcodeArchive.ts
|
|
504
|
-
const exportXcodeArchive = async ({ archivePath, sourceDir, exportExtraParams, exportOptionsPlist, logPrefix }) => {
|
|
1084
|
+
const exportXcodeArchive = async ({ archivePath, sourceDir, exportExtraParams, exportOptionsPlist, logPrefix, authenticationArgs }) => {
|
|
505
1085
|
const exportPath = await createRandomTmpDir();
|
|
506
1086
|
const exportArgs = prepareExportArgs({
|
|
1087
|
+
authenticationArgs,
|
|
507
1088
|
archivePath,
|
|
508
1089
|
exportPath,
|
|
509
1090
|
exportOptionsPlist,
|
|
@@ -522,7 +1103,7 @@ const exportXcodeArchive = async ({ archivePath, sourceDir, exportExtraParams, e
|
|
|
522
1103
|
throw prettifyXcodebuildError(error);
|
|
523
1104
|
}
|
|
524
1105
|
};
|
|
525
|
-
const prepareExportArgs = ({ exportPath, archivePath, exportExtraParams, exportOptionsPlist }) => {
|
|
1106
|
+
const prepareExportArgs = ({ exportPath, archivePath, exportExtraParams, exportOptionsPlist, authenticationArgs }) => {
|
|
526
1107
|
const args = [
|
|
527
1108
|
"-exportArchive",
|
|
528
1109
|
"-archivePath",
|
|
@@ -530,6 +1111,7 @@ const prepareExportArgs = ({ exportPath, archivePath, exportExtraParams, exportO
|
|
|
530
1111
|
"-exportPath",
|
|
531
1112
|
exportPath
|
|
532
1113
|
];
|
|
1114
|
+
if (authenticationArgs) args.push(...authenticationArgs);
|
|
533
1115
|
if (exportExtraParams) args.push(...exportExtraParams);
|
|
534
1116
|
args.push("-exportOptionsPlist", exportOptionsPlist);
|
|
535
1117
|
return args;
|
|
@@ -545,446 +1127,1107 @@ const assertXcodebuildExist = async () => {
|
|
|
545
1127
|
}
|
|
546
1128
|
};
|
|
547
1129
|
//#endregion
|
|
548
|
-
//#region src/
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
1130
|
+
//#region src/utils/escapeUnzipPattern.ts
|
|
1131
|
+
/**
|
|
1132
|
+
* A zip entry name as `unzip` reads a literal member. It takes `*`, `?` and
|
|
1133
|
+
* `[...]` in a member name as wildcards, which a backslash escapes, so an
|
|
1134
|
+
* app named `My[Dev].app` would otherwise match nothing.
|
|
1135
|
+
*/
|
|
1136
|
+
const escapeUnzipPattern = (name) => name.replace(/[[\]*?\\]/gu, "\\$&");
|
|
1137
|
+
//#endregion
|
|
1138
|
+
//#region src/utils/readIpaInfo.ts
|
|
1139
|
+
const INFO_PLIST_KEYS = {
|
|
1140
|
+
bundleId: "CFBundleIdentifier",
|
|
1141
|
+
shortVersion: "CFBundleShortVersionString",
|
|
1142
|
+
bundleVersion: "CFBundleVersion"
|
|
1143
|
+
};
|
|
1144
|
+
/**
|
|
1145
|
+
* Reads the app's `Payload/<App>.app/Info.plist` out of an `.ipa`, never a
|
|
1146
|
+
* framework's or an extension's, which nest deeper.
|
|
1147
|
+
*/
|
|
1148
|
+
const readIpaInfo = async (ipaPath) => {
|
|
1149
|
+
const { stdout } = await (0, execa.execa)("unzip", ["-Z1", ipaPath]);
|
|
1150
|
+
const entry = stdout.split("\n").map((name) => name.trim()).find((name) => /^Payload\/[^/]+\.app\/Info\.plist$/u.test(name));
|
|
1151
|
+
if (entry === void 0) throw new Error(`${path.default.basename(ipaPath)} has no Payload/<App>.app/Info.plist.`);
|
|
1152
|
+
const directory = await createRandomTmpDir();
|
|
1153
|
+
try {
|
|
1154
|
+
const infoPlist = path.default.join(directory, "Info.plist");
|
|
1155
|
+
await (0, execa.execa)("unzip", [
|
|
1156
|
+
"-p",
|
|
1157
|
+
ipaPath,
|
|
1158
|
+
escapeUnzipPattern(entry)
|
|
1159
|
+
], { stdout: { file: infoPlist } });
|
|
1160
|
+
const info = {};
|
|
1161
|
+
for (const [field, key] of Object.entries(INFO_PLIST_KEYS)) {
|
|
1162
|
+
const { stdout: value, exitCode } = await (0, execa.execa)("plutil", [
|
|
1163
|
+
"-extract",
|
|
1164
|
+
key,
|
|
1165
|
+
"raw",
|
|
1166
|
+
"-o",
|
|
1167
|
+
"-",
|
|
1168
|
+
infoPlist
|
|
1169
|
+
], { reject: false });
|
|
1170
|
+
if (exitCode !== 0 || !value.trim()) throw new Error(`${path.default.basename(ipaPath)}'s Info.plist has no ${key}.`);
|
|
1171
|
+
info[field] = value.trim();
|
|
560
1172
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
platform: schemeConfig.platform,
|
|
567
|
-
xcodeScheme: schemeConfig.scheme,
|
|
568
|
-
configuration: schemeConfig.configuration,
|
|
569
|
-
deviceType: "simulator",
|
|
570
|
-
installPods: schemeConfig.installPods,
|
|
571
|
-
extraParams: schemeConfig.extraParams,
|
|
572
|
-
useGenericDestination: true
|
|
573
|
-
});
|
|
574
|
-
const finalAppPath = path.default.join(appDir, path.default.basename(appPath));
|
|
575
|
-
await fs.default.promises.cp(appPath, finalAppPath, { recursive: true });
|
|
576
|
-
_hot_updater_cli_tools.p.log.success(`.app file created: ${path.default.basename(appPath)}`);
|
|
577
|
-
return {
|
|
578
|
-
buildDirectory,
|
|
579
|
-
buildArtifactPath: finalAppPath
|
|
580
|
-
};
|
|
581
|
-
} else {
|
|
582
|
-
const { archivePath } = await archiveXcodeProject({
|
|
583
|
-
platform: schemeConfig.platform,
|
|
584
|
-
sourceDir: iosProjectRoot,
|
|
585
|
-
xcconfig: schemeConfig.xcconfig,
|
|
586
|
-
xcodeScheme: schemeConfig.scheme,
|
|
587
|
-
destination: schemeConfig.destination,
|
|
588
|
-
extraParams: schemeConfig.extraParams,
|
|
589
|
-
configuration: schemeConfig.configuration,
|
|
590
|
-
installPods: schemeConfig.installPods,
|
|
591
|
-
logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build-archive`
|
|
1173
|
+
return info;
|
|
1174
|
+
} finally {
|
|
1175
|
+
await fs.default.promises.rm(directory, {
|
|
1176
|
+
recursive: true,
|
|
1177
|
+
force: true
|
|
592
1178
|
});
|
|
593
|
-
const finalArchivePath = path.default.join(archiveDir, path.default.basename(archivePath));
|
|
594
|
-
await fs.default.promises.cp(archivePath, finalArchivePath, { recursive: true });
|
|
595
|
-
const appFromArchive = extractAppFromXcarchive(finalArchivePath);
|
|
596
|
-
if (appFromArchive) {
|
|
597
|
-
await fs.default.promises.cp(appFromArchive, path.default.join(archiveDir, path.default.basename(appFromArchive)), { recursive: true });
|
|
598
|
-
_hot_updater_cli_tools.p.log.success(".app extracted from .xcarchive");
|
|
599
|
-
}
|
|
600
|
-
if (schemeConfig.exportOptionsPlist) {
|
|
601
|
-
await fs.default.promises.mkdir(exportDir, { recursive: true });
|
|
602
|
-
const { exportPath } = await exportXcodeArchive({
|
|
603
|
-
archivePath,
|
|
604
|
-
exportExtraParams: schemeConfig.exportExtraParams,
|
|
605
|
-
exportOptionsPlist: schemeConfig.exportOptionsPlist,
|
|
606
|
-
sourceDir: iosProjectRoot,
|
|
607
|
-
logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build-export`
|
|
608
|
-
});
|
|
609
|
-
await fs.default.promises.cp(exportPath, exportDir, { recursive: true });
|
|
610
|
-
const ipaFile = fs.default.readdirSync(exportDir).find((file) => file.endsWith(".ipa"));
|
|
611
|
-
if (ipaFile) return {
|
|
612
|
-
buildDirectory,
|
|
613
|
-
buildArtifactPath: path.default.join(exportDir, ipaFile)
|
|
614
|
-
};
|
|
615
|
-
throw new Error("IPA file not found after export");
|
|
616
|
-
}
|
|
617
|
-
return {
|
|
618
|
-
buildDirectory,
|
|
619
|
-
buildArtifactPath: finalArchivePath
|
|
620
|
-
};
|
|
621
1179
|
}
|
|
622
1180
|
};
|
|
623
|
-
const extractAppFromXcarchive = (archivePath) => {
|
|
624
|
-
const productsPath = path.default.join(archivePath, "Products", "Applications");
|
|
625
|
-
if (!fs.default.existsSync(productsPath)) return;
|
|
626
|
-
const appFile = fs.default.readdirSync(productsPath).find((file) => file.endsWith(".app"));
|
|
627
|
-
if (appFile) return path.default.join(productsPath, appFile);
|
|
628
|
-
_hot_updater_cli_tools.p.log.warn("Failed to extract .app from xcarchive");
|
|
629
|
-
};
|
|
630
1181
|
//#endregion
|
|
631
|
-
//#region src/
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
1182
|
+
//#region src/utils/xcodeSigning.ts
|
|
1183
|
+
/**
|
|
1184
|
+
* How a device build signs with automatic signing, authenticated by the
|
|
1185
|
+
* scheme's App Store Connect API key: the xcodebuild arguments, and the key.
|
|
1186
|
+
* The key is resolved only here, for a device build of a scheme with
|
|
1187
|
+
* `signing`. Without `credentials`, it resolves from the environment or the
|
|
1188
|
+
* config's `secrets` adapter, and the temporary files are deleted when the
|
|
1189
|
+
* process exits.
|
|
1190
|
+
*/
|
|
1191
|
+
const resolveXcodeSigning = async ({ schemeConfig, credentials }) => {
|
|
1192
|
+
const reference = schemeConfig.signing?.ascApiKey;
|
|
1193
|
+
if (!reference) return;
|
|
1194
|
+
const apiKey = await (credentials ?? (0, _hot_updater_cli_tools.createCredentialResolver)()).resolve({
|
|
1195
|
+
kind: "asc-api-key",
|
|
1196
|
+
name: (0, _hot_updater_cli_tools.parseSecretReference)(reference, "signing.ascApiKey")
|
|
641
1197
|
});
|
|
1198
|
+
return {
|
|
1199
|
+
authenticationArgs: [
|
|
1200
|
+
"-allowProvisioningUpdates",
|
|
1201
|
+
"-authenticationKeyPath",
|
|
1202
|
+
apiKey.keyPath,
|
|
1203
|
+
"-authenticationKeyID",
|
|
1204
|
+
apiKey.keyId,
|
|
1205
|
+
"-authenticationKeyIssuerID",
|
|
1206
|
+
apiKey.issuerId
|
|
1207
|
+
],
|
|
1208
|
+
apiKey
|
|
1209
|
+
};
|
|
642
1210
|
};
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
const launchAppOnDevice = async ({ device, options }) => {
|
|
664
|
-
const deviceCtlArgs = [
|
|
665
|
-
"devicectl",
|
|
666
|
-
"device",
|
|
667
|
-
"process",
|
|
668
|
-
"launch",
|
|
669
|
-
"--device",
|
|
670
|
-
device.udid,
|
|
671
|
-
options.bundleIdentifier
|
|
672
|
-
];
|
|
673
|
-
const spinner = _hot_updater_cli_tools.p.spinner();
|
|
674
|
-
spinner.start(`Launching app on ${device.name}`);
|
|
675
|
-
try {
|
|
676
|
-
await (0, execa.execa)("xcrun", deviceCtlArgs, { cwd: options.sourceDir });
|
|
677
|
-
spinner.stop(`Successfully launched app on ${device.name}`);
|
|
678
|
-
} catch (error) {
|
|
679
|
-
spinner.stop(`Failed to launch app on ${device.name}`);
|
|
680
|
-
throw new Error(`Failed to launch the app on ${device.name}: ${error}`);
|
|
681
|
-
}
|
|
1211
|
+
const APP_STORE_CONNECT_EXPORT_OPTIONS = `<?xml version="1.0" encoding="UTF-8"?>
|
|
1212
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1213
|
+
<plist version="1.0">
|
|
1214
|
+
<dict>
|
|
1215
|
+
<key>method</key>
|
|
1216
|
+
<string>app-store-connect</string>
|
|
1217
|
+
<key>signingStyle</key>
|
|
1218
|
+
<string>automatic</string>
|
|
1219
|
+
</dict>
|
|
1220
|
+
</plist>
|
|
1221
|
+
`;
|
|
1222
|
+
/**
|
|
1223
|
+
* Writes the export options of a scheme that signs with an App Store Connect
|
|
1224
|
+
* API key and sets no `exportOptionsPlist`: an App Store Connect export with
|
|
1225
|
+
* automatic signing.
|
|
1226
|
+
*/
|
|
1227
|
+
const writeAppStoreConnectExportOptions = async (directory) => {
|
|
1228
|
+
const exportOptionsPlist = path.default.join(directory, "ExportOptions.plist");
|
|
1229
|
+
await fs.default.promises.writeFile(exportOptionsPlist, APP_STORE_CONNECT_EXPORT_OPTIONS);
|
|
1230
|
+
return exportOptionsPlist;
|
|
682
1231
|
};
|
|
1232
|
+
/** The export methods whose `.ipa` App Store Connect takes. */
|
|
1233
|
+
const APP_STORE_EXPORT_METHODS = /* @__PURE__ */ new Set(["app-store", "app-store-connect"]);
|
|
1234
|
+
/** The `method` of export options, or "" when it has none or can't be read. */
|
|
1235
|
+
const readExportMethod = async (exportOptionsPlist) => {
|
|
1236
|
+
const xml = (await fs.default.promises.readFile(exportOptionsPlist, "utf-8").catch(() => void 0))?.replace(/<!--[\s\S]*?-->/gu, "").match(/<key>\s*method\s*<\/key>\s*<string>\s*([^<]*?)\s*<\/string>/u);
|
|
1237
|
+
if (xml) return xml[1];
|
|
1238
|
+
const { stdout, exitCode } = await (0, execa.execa)("plutil", [
|
|
1239
|
+
"-extract",
|
|
1240
|
+
"method",
|
|
1241
|
+
"raw",
|
|
1242
|
+
"-o",
|
|
1243
|
+
"-",
|
|
1244
|
+
exportOptionsPlist
|
|
1245
|
+
], { reject: false });
|
|
1246
|
+
return exitCode === 0 ? stdout.trim() : "";
|
|
1247
|
+
};
|
|
1248
|
+
/**
|
|
1249
|
+
* Whether a device build exports for App Store Connect: with its
|
|
1250
|
+
* `exportOptionsPlist`'s method, or, without one, as a scheme signing with
|
|
1251
|
+
* an App Store Connect API key does. Such an `.ipa` is signed with Apple's
|
|
1252
|
+
* cloud-managed distribution certificate, never with a development
|
|
1253
|
+
* certificate the archive was signed with.
|
|
1254
|
+
*/
|
|
1255
|
+
const exportsForAppStoreConnect = async (schemeConfig) => !schemeConfig.exportOptionsPlist || APP_STORE_EXPORT_METHODS.has(await readExportMethod(schemeConfig.exportOptionsPlist));
|
|
683
1256
|
//#endregion
|
|
684
|
-
//#region src/
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
1257
|
+
//#region src/uploadToTestFlight.ts
|
|
1258
|
+
const API_URL$1 = "https://api.appstoreconnect.apple.com";
|
|
1259
|
+
/** Apple accepts tokens that expire within 20 minutes. */
|
|
1260
|
+
const TOKEN_LIFETIME_SECONDS = 1200;
|
|
1261
|
+
/** A token is replaced well before it expires, so no request carries a stale one. */
|
|
1262
|
+
const TOKEN_REUSE_SECONDS = 600;
|
|
1263
|
+
/** The parts of an `.ipa` sent at once. */
|
|
1264
|
+
const UPLOAD_CONCURRENCY = 4;
|
|
1265
|
+
/** Attempts to create the build upload and to commit the uploaded `.ipa`. */
|
|
1266
|
+
const COMMIT_ATTEMPTS = 3;
|
|
1267
|
+
/** Attempts per part: a part is a plain PUT, safe to repeat. */
|
|
1268
|
+
const UPLOAD_ATTEMPTS = 3;
|
|
1269
|
+
const IN_PROGRESS_STATES = /* @__PURE__ */ new Set(["AWAITING_UPLOAD", "PROCESSING"]);
|
|
1270
|
+
/** The error App Store Connect answers with when a build number is taken. */
|
|
1271
|
+
const DUPLICATE_ERROR_CODE = "ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE";
|
|
1272
|
+
/**
|
|
1273
|
+
* App Store Connect refused the upload: it already has a build with the
|
|
1274
|
+
* `.ipa`'s version and build number, from this or another upload, and takes
|
|
1275
|
+
* each build number once per version.
|
|
1276
|
+
*/
|
|
1277
|
+
var TestFlightBuildExistsError = class extends Error {
|
|
1278
|
+
shortVersion;
|
|
1279
|
+
bundleVersion;
|
|
1280
|
+
name = "TestFlightBuildExistsError";
|
|
1281
|
+
constructor(shortVersion, bundleVersion, options) {
|
|
1282
|
+
super(`App Store Connect already has build ${bundleVersion} of version ${shortVersion}. Each build of a version needs a new build number (CFBundleVersion).`, options);
|
|
1283
|
+
this.shortVersion = shortVersion;
|
|
1284
|
+
this.bundleVersion = bundleVersion;
|
|
1285
|
+
}
|
|
696
1286
|
};
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1287
|
+
/** An App Store Connect API request that failed, with the errors it answered. */
|
|
1288
|
+
var AppStoreConnectError = class extends Error {
|
|
1289
|
+
status;
|
|
1290
|
+
errors;
|
|
1291
|
+
/** The answer's `Retry-After` header, for a rate limit. */
|
|
1292
|
+
retryAfter;
|
|
1293
|
+
constructor(action, status, errors, retryAfter = null) {
|
|
1294
|
+
const details = errors.map((error) => error.detail ?? error.title ?? error.code).filter(Boolean);
|
|
1295
|
+
super(`${action} failed (HTTP ${status})${details.length > 0 ? `: ${details.join(" ")}` : "."}`);
|
|
1296
|
+
this.name = "AppStoreConnectError";
|
|
1297
|
+
this.status = status;
|
|
1298
|
+
this.errors = errors;
|
|
1299
|
+
this.retryAfter = retryAfter;
|
|
1300
|
+
}
|
|
707
1301
|
};
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
1302
|
+
/**
|
|
1303
|
+
* An App Store Connect API token: an ES256 JWT, whose signature JWS encodes
|
|
1304
|
+
* as the raw 64-byte r || s, not the DER Node signs by default.
|
|
1305
|
+
*/
|
|
1306
|
+
const createAppStoreConnectToken = ({ privateKey, keyId, issuerId, issuedAtSeconds = Math.floor(Date.now() / 1e3) }) => {
|
|
1307
|
+
const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
1308
|
+
const signingInput = `${encode({
|
|
1309
|
+
alg: "ES256",
|
|
1310
|
+
kid: keyId,
|
|
1311
|
+
typ: "JWT"
|
|
1312
|
+
})}.${encode({
|
|
1313
|
+
iss: issuerId,
|
|
1314
|
+
iat: issuedAtSeconds,
|
|
1315
|
+
exp: issuedAtSeconds + TOKEN_LIFETIME_SECONDS,
|
|
1316
|
+
aud: "appstoreconnect-v1"
|
|
1317
|
+
})}`;
|
|
1318
|
+
return `${signingInput}.${crypto.default.sign("sha256", Buffer.from(signingInput), {
|
|
1319
|
+
key: privateKey,
|
|
1320
|
+
dsaEncoding: "ieee-p1363"
|
|
1321
|
+
}).toString("base64url")}`;
|
|
1322
|
+
};
|
|
1323
|
+
const readPrivateKey = async (keyPath) => {
|
|
711
1324
|
try {
|
|
712
|
-
await (
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
device.udid,
|
|
716
|
-
appPath
|
|
717
|
-
], { cwd: options.sourceDir });
|
|
718
|
-
spinner.stop(`Successfully installed app on "${device.name}"`);
|
|
719
|
-
} catch (error) {
|
|
720
|
-
spinner.stop(`Failed to install app on "${device.name}"`);
|
|
721
|
-
throw new Error(`Failed to install the app on simulator: ${error}`);
|
|
1325
|
+
return crypto.default.createPrivateKey(await fs.default.promises.readFile(keyPath, "utf-8"));
|
|
1326
|
+
} catch {
|
|
1327
|
+
throw new Error("The App Store Connect API key is not a .p8 private key.");
|
|
722
1328
|
}
|
|
723
1329
|
};
|
|
724
|
-
const
|
|
725
|
-
const
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
1330
|
+
const readApiErrors = async (response) => {
|
|
1331
|
+
const body = await response.json().catch(() => void 0);
|
|
1332
|
+
return Array.isArray(body?.errors) ? body.errors : [];
|
|
1333
|
+
};
|
|
1334
|
+
const describeDetails = (details) => details.map((detail) => `${detail.description ?? "(no description)"}${detail.code ? ` (${detail.code})` : ""}`);
|
|
1335
|
+
/** Answers a request may outlive: a timeout, a rate limit, a server error. */
|
|
1336
|
+
const isRetryableStatus = (status) => status === 408 || status === 429 || status >= 500;
|
|
1337
|
+
/**
|
|
1338
|
+
* Whether a request may succeed when sent again: a retryable answer, or a
|
|
1339
|
+
* network failure, which fetch rejects with a TypeError. A refused key or
|
|
1340
|
+
* request is not.
|
|
1341
|
+
*/
|
|
1342
|
+
const isRetryable = (error) => error instanceof AppStoreConnectError ? isRetryableStatus(error.status) : error instanceof TypeError;
|
|
1343
|
+
/** Whether App Store Connect refused a request for its rate limit only. */
|
|
1344
|
+
const isRateLimited = (error) => error instanceof AppStoreConnectError && error.status === 429;
|
|
1345
|
+
/**
|
|
1346
|
+
* Runs `request`, again after a failure `retryable` accepts, up to the
|
|
1347
|
+
* attempts.
|
|
1348
|
+
*/
|
|
1349
|
+
const retrying = async (request, retryable = isRetryable) => {
|
|
1350
|
+
for (let attempt = 1;; attempt++) try {
|
|
1351
|
+
return await request();
|
|
735
1352
|
} catch (error) {
|
|
736
|
-
|
|
737
|
-
|
|
1353
|
+
if (!retryable(error) || attempt === COMMIT_ATTEMPTS) throw error;
|
|
1354
|
+
await sleep(retryDelayMs(error, attempt));
|
|
738
1355
|
}
|
|
739
1356
|
};
|
|
740
|
-
const
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
1357
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1358
|
+
/** The longest wait before a retry, so a run stays bounded. */
|
|
1359
|
+
const MAX_RETRY_DELAY_MS = 6e4;
|
|
1360
|
+
/**
|
|
1361
|
+
* The first wait after a rate limit without `Retry-After`: App Store Connect
|
|
1362
|
+
* limits a key per rolling hour, so a retry within seconds meets it again.
|
|
1363
|
+
*/
|
|
1364
|
+
const RATE_LIMIT_DELAY_MS = 15e3;
|
|
1365
|
+
/**
|
|
1366
|
+
* How long to wait before sending a request again after `failure`, an
|
|
1367
|
+
* answer or the error it raised: the `Retry-After` seconds it asks for, a
|
|
1368
|
+
* longer backoff for a rate limit without one, and else a growing backoff,
|
|
1369
|
+
* all within a bound.
|
|
1370
|
+
*/
|
|
1371
|
+
const retryDelayMs = (failure, attempt) => {
|
|
1372
|
+
const { status, retryAfter } = failure instanceof Response ? {
|
|
1373
|
+
status: failure.status,
|
|
1374
|
+
retryAfter: failure.headers.get("retry-after")
|
|
1375
|
+
} : failure instanceof AppStoreConnectError ? failure : {
|
|
1376
|
+
status: 0,
|
|
1377
|
+
retryAfter: null
|
|
1378
|
+
};
|
|
1379
|
+
const seconds = retryAfter === null || retryAfter.trim() === "" ? NaN : Number(retryAfter);
|
|
1380
|
+
const delay = Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : status === 429 ? RATE_LIMIT_DELAY_MS * attempt : 1e3 * attempt;
|
|
1381
|
+
return Math.min(delay, MAX_RETRY_DELAY_MS);
|
|
1382
|
+
};
|
|
1383
|
+
/**
|
|
1384
|
+
* Runs `run` over `items`, at most `concurrency` at a time. After the first
|
|
1385
|
+
* failure no item starts, and it throws that failure once every running item
|
|
1386
|
+
* has settled, so nothing is still sending when the caller cleans up.
|
|
1387
|
+
*/
|
|
1388
|
+
const runConcurrently = async (items, concurrency, run) => {
|
|
1389
|
+
let next = 0;
|
|
1390
|
+
let failure;
|
|
1391
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
1392
|
+
while (!failure && next < items.length) {
|
|
1393
|
+
const index = next++;
|
|
1394
|
+
try {
|
|
1395
|
+
await run(items[index], index);
|
|
1396
|
+
} catch (error) {
|
|
1397
|
+
failure ??= { error };
|
|
1398
|
+
}
|
|
751
1399
|
}
|
|
752
|
-
|
|
1400
|
+
}));
|
|
1401
|
+
if (failure) throw failure.error;
|
|
1402
|
+
};
|
|
1403
|
+
/**
|
|
1404
|
+
* A signing build setting's assignment: the key, any conditions such as
|
|
1405
|
+
* `[sdk=iphoneos*]`, and the value, without a trailing comment.
|
|
1406
|
+
*/
|
|
1407
|
+
const SIGNING_SETTING = /^\s*(CODE_SIGNING_ALLOWED|CODE_SIGNING_REQUIRED)((?:\[[^\]]*\])*)\s*=\s*([^\s/]*)\s*(?:\/\/.*)?$/u;
|
|
1408
|
+
/** A scheme's `exportOptionsPlist`, which resolves from the project's root. */
|
|
1409
|
+
const schemeFile = (file) => path.default.isAbsolute(file) ? file : path.default.resolve((0, _hot_updater_cli_tools.getCwd)(), file);
|
|
1410
|
+
/** Applies one assignment, as Xcode does: the last unconditional one wins. */
|
|
1411
|
+
const applySetting = (settings, line, label) => {
|
|
1412
|
+
const [, key, conditions, value] = line.match(SIGNING_SETTING) ?? [];
|
|
1413
|
+
if (!key) return;
|
|
1414
|
+
if (conditions) settings.conditional.add(key);
|
|
1415
|
+
else settings.values.set(key, {
|
|
1416
|
+
value,
|
|
1417
|
+
where: `${line.trim()} in ${label}`
|
|
1418
|
+
});
|
|
1419
|
+
};
|
|
1420
|
+
/**
|
|
1421
|
+
* Applies an xcconfig's signing settings over `settings`, in order,
|
|
1422
|
+
* following its `#include`s, which resolve from the including file, as
|
|
1423
|
+
* Xcode reads it. A file included again applies again; only an include
|
|
1424
|
+
* cycle (`including`) stops.
|
|
1425
|
+
*/
|
|
1426
|
+
const applyXcconfig = async (file, label, settings, including) => {
|
|
1427
|
+
if (including.has(file)) return;
|
|
1428
|
+
including.add(file);
|
|
1429
|
+
const uncommented = (await fs.default.promises.readFile(file, "utf-8").catch(() => void 0))?.replace(/\/\*[\s\S]*?\*\//gu, (comment) => comment.replace(/[^\n]/gu, ""));
|
|
1430
|
+
for (const line of uncommented?.split("\n") ?? []) {
|
|
1431
|
+
const include = line.match(/^\s*#include\??\s*"([^"]+)"/u)?.[1];
|
|
1432
|
+
if (include) await applyXcconfig(path.default.resolve(path.default.dirname(file), include), include, settings, including);
|
|
1433
|
+
else applySetting(settings, line, label);
|
|
753
1434
|
}
|
|
1435
|
+
including.delete(file);
|
|
754
1436
|
};
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
*
|
|
759
|
-
*
|
|
760
|
-
*
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
1437
|
+
/**
|
|
1438
|
+
* Where the scheme turns code signing off, after every override the build
|
|
1439
|
+
* applies: the `extraParams` settings first, then the xcconfigs the build
|
|
1440
|
+
* xcconfig includes over them, in order, its `xcconfig` and each
|
|
1441
|
+
* `-xcconfig` in `extraParams`, which resolve from `ios/`, where xcodebuild
|
|
1442
|
+
* runs (`writeBuildXcconfig`). Undefined when it signs, or when a
|
|
1443
|
+
* conditional setting, such as `[sdk=iphoneos*]`, may decide: the build
|
|
1444
|
+
* does.
|
|
1445
|
+
*/
|
|
1446
|
+
const codeSigningOff = async (iosScheme) => {
|
|
1447
|
+
const extraParams = iosScheme.extraParams ?? [];
|
|
1448
|
+
const settings = {
|
|
1449
|
+
values: /* @__PURE__ */ new Map(),
|
|
1450
|
+
conditional: /* @__PURE__ */ new Set()
|
|
1451
|
+
};
|
|
1452
|
+
for (const entry of extraParams) applySetting(settings, entry, "extraParams");
|
|
1453
|
+
const xcconfigs = [...iosScheme.xcconfig ? [iosScheme.xcconfig] : [], ...extraParams.flatMap((entry, index) => entry === "-xcconfig" && index + 1 < extraParams.length ? [extraParams[index + 1]] : [])];
|
|
1454
|
+
for (const xcconfig of xcconfigs) await applyXcconfig(path.default.resolve((0, _hot_updater_cli_tools.getCwd)(), "ios", xcconfig), xcconfig, settings, /* @__PURE__ */ new Set());
|
|
1455
|
+
for (const [key, { value, where }] of settings.values) if (/^no$/iu.test(value) && !settings.conditional.has(key)) return where;
|
|
771
1456
|
};
|
|
772
1457
|
/**
|
|
773
|
-
*
|
|
1458
|
+
* The App Store Connect API key a scheme's `.ipa` uploads with: the
|
|
1459
|
+
* `signing.ascApiKey` it signs with, or else the `asc-api-key` item. It reads
|
|
1460
|
+
* the config, and the scheme's files that exist before the native projects
|
|
1461
|
+
* are written, and throws, saying why, for a scheme whose build App Store
|
|
1462
|
+
* Connect would not take: a simulator `.app`, a Debug configuration, which
|
|
1463
|
+
* loads its JavaScript from Metro, code signing turned off, an
|
|
1464
|
+
* `.xcarchive` it cannot export, or export options of another method. An
|
|
1465
|
+
* `exportOptionsPlist` the adapter's prebuild writes later is checked after
|
|
1466
|
+
* it (`assertTestFlightExportOptions`).
|
|
774
1467
|
*/
|
|
775
|
-
const
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
const output = JSON.parse(fs.default.readFileSync(tmpPath, "utf8"));
|
|
786
|
-
return parseDevicectlList(output.result.devices);
|
|
787
|
-
} catch (error) {
|
|
788
|
-
throw new Error(`Failed to get devices: ${error}`);
|
|
1468
|
+
const resolveTestFlightUploadKey = async ({ scheme, iosScheme }) => {
|
|
1469
|
+
if (iosScheme.simulator) throw new Error(`TestFlight takes an .ipa, and the scheme ${scheme} builds a simulator .app.`);
|
|
1470
|
+
const configuration = iosScheme.configuration ?? "Release";
|
|
1471
|
+
if (/debug$/i.test(configuration)) throw new Error(`TestFlight takes a release build, and the scheme ${scheme} builds the ${configuration} configuration, which loads its JavaScript from Metro.`);
|
|
1472
|
+
const unsigned = await codeSigningOff(iosScheme);
|
|
1473
|
+
if (unsigned) throw new Error(`TestFlight takes a signed .ipa, and the scheme ${scheme} turns code signing off (${unsigned}).`);
|
|
1474
|
+
if (!iosScheme.exportOptionsPlist && !iosScheme.signing) throw new Error(`The scheme ${scheme} archives without exporting an .ipa. Set signing.ascApiKey, or an exportOptionsPlist with the method app-store-connect.`);
|
|
1475
|
+
if (iosScheme.exportOptionsPlist && fs.default.existsSync(schemeFile(iosScheme.exportOptionsPlist))) {
|
|
1476
|
+
const method = await readExportMethod(schemeFile(iosScheme.exportOptionsPlist));
|
|
1477
|
+
if (!APP_STORE_EXPORT_METHODS.has(method)) throw new Error(`TestFlight takes an .ipa exported for App Store Connect, and the scheme ${scheme}'s exportOptionsPlist exports with the method ${method || "(none)"}. Set its method to app-store-connect.`);
|
|
789
1478
|
}
|
|
1479
|
+
return {
|
|
1480
|
+
kind: "asc-api-key",
|
|
1481
|
+
name: iosScheme.signing ? (0, _hot_updater_cli_tools.parseSecretReference)(iosScheme.signing.ascApiKey, "signing.ascApiKey") : "asc-api-key"
|
|
1482
|
+
};
|
|
790
1483
|
};
|
|
791
1484
|
/**
|
|
792
|
-
*
|
|
1485
|
+
* Throws when the scheme's `exportOptionsPlist` exports with another method
|
|
1486
|
+
* than App Store Connect's, whose `.ipa` App Store Connect refuses. It reads
|
|
1487
|
+
* the file, which an adapter's prebuild may write, so it runs after it.
|
|
793
1488
|
*/
|
|
794
|
-
const
|
|
1489
|
+
const assertTestFlightExportOptions = async (schemeConfig) => {
|
|
1490
|
+
if (!schemeConfig.exportOptionsPlist) return;
|
|
1491
|
+
const method = await readExportMethod(schemeConfig.exportOptionsPlist);
|
|
1492
|
+
if (!APP_STORE_EXPORT_METHODS.has(method)) throw new Error(`TestFlight takes an .ipa exported for App Store Connect, and the scheme ${schemeConfig.hotUpdaterSchemeName}'s exportOptionsPlist exports with the method ${method || "(none)"}. Set its method to app-store-connect.`);
|
|
1493
|
+
};
|
|
1494
|
+
/**
|
|
1495
|
+
* Requests to the App Store Connect API with `apiKey`: each carries a token
|
|
1496
|
+
* signed with its private key, replaced well before it expires.
|
|
1497
|
+
*/
|
|
1498
|
+
const createAppStoreConnectRequest = ({ apiKey, privateKey, fetch }) => {
|
|
1499
|
+
let token;
|
|
1500
|
+
const authorization = () => {
|
|
1501
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1502
|
+
if (!token || now - token.issuedAtSeconds >= TOKEN_REUSE_SECONDS) {
|
|
1503
|
+
token = {
|
|
1504
|
+
value: createAppStoreConnectToken({
|
|
1505
|
+
privateKey,
|
|
1506
|
+
keyId: apiKey.keyId,
|
|
1507
|
+
issuerId: apiKey.issuerId,
|
|
1508
|
+
issuedAtSeconds: now
|
|
1509
|
+
}),
|
|
1510
|
+
issuedAtSeconds: now
|
|
1511
|
+
};
|
|
1512
|
+
(0, _hot_updater_cli_tools.registerSecretValue)(token.value);
|
|
1513
|
+
}
|
|
1514
|
+
return `Bearer ${token.value}`;
|
|
1515
|
+
};
|
|
1516
|
+
return async (action, method, pathname, body) => {
|
|
1517
|
+
const response = await fetch(`${API_URL$1}${pathname}`, {
|
|
1518
|
+
method,
|
|
1519
|
+
headers: {
|
|
1520
|
+
Authorization: authorization(),
|
|
1521
|
+
...body ? { "Content-Type": "application/json" } : {}
|
|
1522
|
+
},
|
|
1523
|
+
...body ? { body: JSON.stringify(body) } : {}
|
|
1524
|
+
});
|
|
1525
|
+
if (!response.ok) {
|
|
1526
|
+
const error = new AppStoreConnectError(action, response.status, await readApiErrors(response), response.headers.get("retry-after"));
|
|
1527
|
+
if (response.status === 401) throw new Error("App Store Connect refused the API key: check its key ID and issuer ID, and that the key is not revoked.", { cause: error });
|
|
1528
|
+
if (response.status === 403) throw new Error(`${error.message} An API key with the Admin, App Manager, or Developer role uploads builds.`, { cause: error });
|
|
1529
|
+
throw error;
|
|
1530
|
+
}
|
|
1531
|
+
return response.status === 204 ? void 0 : await response.json();
|
|
1532
|
+
};
|
|
1533
|
+
};
|
|
1534
|
+
/** The App Store Connect app of `bundleId`, and its TestFlight page. */
|
|
1535
|
+
const findApp = async (request, bundleId) => {
|
|
1536
|
+
const { data: apps } = await retrying(() => request("Finding the App Store Connect app", "GET", `/v1/apps?${new URLSearchParams({
|
|
1537
|
+
"filter[bundleId]": bundleId,
|
|
1538
|
+
"fields[apps]": "bundleId"
|
|
1539
|
+
})}`));
|
|
1540
|
+
const app = apps.find((app) => app.attributes?.bundleId === bundleId);
|
|
1541
|
+
if (!app) throw new Error(`App Store Connect has no app with the bundle ID ${bundleId} that this API key can see. Create the app in App Store Connect, or use a key of its team.`);
|
|
1542
|
+
return {
|
|
1543
|
+
app,
|
|
1544
|
+
testFlightUrl: `https://appstoreconnect.apple.com/apps/${app.id}/testflight/ios`
|
|
1545
|
+
};
|
|
1546
|
+
};
|
|
1547
|
+
/**
|
|
1548
|
+
* Whether App Store Connect already has the `.ipa`'s build: a build of its
|
|
1549
|
+
* version with its build number, which TestFlight lists or is processing,
|
|
1550
|
+
* as when a reused native build was uploaded before. A build upload that
|
|
1551
|
+
* never finished is no build, so uploading again fails as it would without
|
|
1552
|
+
* this check.
|
|
1553
|
+
*/
|
|
1554
|
+
const findTestFlightBuild = async ({ ipaPath, apiKey, fetch = globalThis.fetch }) => {
|
|
1555
|
+
const [info, privateKey] = await Promise.all([readIpaInfo(ipaPath), readPrivateKey(apiKey.keyPath)]);
|
|
1556
|
+
const request = createAppStoreConnectRequest({
|
|
1557
|
+
apiKey,
|
|
1558
|
+
privateKey,
|
|
1559
|
+
fetch
|
|
1560
|
+
});
|
|
1561
|
+
const { app, testFlightUrl } = await findApp(request, info.bundleId);
|
|
1562
|
+
const { data: builds } = await retrying(() => request("Finding the build in App Store Connect", "GET", `/v1/builds?${new URLSearchParams({
|
|
1563
|
+
"filter[app]": app.id,
|
|
1564
|
+
"filter[version]": info.bundleVersion,
|
|
1565
|
+
"filter[preReleaseVersion.version]": info.shortVersion,
|
|
1566
|
+
"fields[builds]": "version",
|
|
1567
|
+
limit: "1"
|
|
1568
|
+
})}`));
|
|
1569
|
+
return {
|
|
1570
|
+
found: builds.length > 0,
|
|
1571
|
+
testFlightUrl,
|
|
1572
|
+
shortVersion: info.shortVersion,
|
|
1573
|
+
bundleVersion: info.bundleVersion
|
|
1574
|
+
};
|
|
1575
|
+
};
|
|
1576
|
+
/**
|
|
1577
|
+
* Uploads an `.ipa` to App Store Connect, which lists it in TestFlight, with
|
|
1578
|
+
* the App Store Connect API's build uploads: it finds the app by the
|
|
1579
|
+
* `.ipa`'s bundle ID, reserves the build's version and build number, sends
|
|
1580
|
+
* the file in the parts App Store Connect asks for, commits it, and waits
|
|
1581
|
+
* for App Store Connect to accept or reject the build.
|
|
1582
|
+
*/
|
|
1583
|
+
const uploadToTestFlight = async ({ ipaPath, apiKey, fetch = globalThis.fetch, pollIntervalMs = 5e3, processingTimeoutMs = 18e5 }) => {
|
|
1584
|
+
const ipaName = path.default.basename(ipaPath);
|
|
1585
|
+
const [info, privateKey, { size: fileSize }] = await Promise.all([
|
|
1586
|
+
readIpaInfo(ipaPath),
|
|
1587
|
+
readPrivateKey(apiKey.keyPath),
|
|
1588
|
+
fs.default.promises.stat(ipaPath)
|
|
1589
|
+
]);
|
|
1590
|
+
const request = createAppStoreConnectRequest({
|
|
1591
|
+
apiKey,
|
|
1592
|
+
privateKey,
|
|
1593
|
+
fetch
|
|
1594
|
+
});
|
|
1595
|
+
const { app, testFlightUrl } = await findApp(request, info.bundleId);
|
|
1596
|
+
let buildUploadId;
|
|
1597
|
+
let unanswered = false;
|
|
1598
|
+
for (let attempt = 1; buildUploadId === void 0; attempt++) try {
|
|
1599
|
+
({data: {id: buildUploadId}} = await request("Creating the build upload", "POST", "/v1/buildUploads", { data: {
|
|
1600
|
+
type: "buildUploads",
|
|
1601
|
+
attributes: {
|
|
1602
|
+
platform: "IOS",
|
|
1603
|
+
cfBundleShortVersionString: info.shortVersion,
|
|
1604
|
+
cfBundleVersion: info.bundleVersion
|
|
1605
|
+
},
|
|
1606
|
+
relationships: { app: { data: {
|
|
1607
|
+
type: "apps",
|
|
1608
|
+
id: app.id
|
|
1609
|
+
} } }
|
|
1610
|
+
} }));
|
|
1611
|
+
} catch (error) {
|
|
1612
|
+
if (error instanceof AppStoreConnectError && error.status === 409 && error.errors.some((apiError) => apiError.code === DUPLICATE_ERROR_CODE)) {
|
|
1613
|
+
if (unanswered) throw new Error(`App Store Connect already has build ${info.bundleVersion} of version ${info.shortVersion}, which a request of this upload it did not answer may have reserved. Upload with a new build number (CFBundleVersion).`, { cause: error });
|
|
1614
|
+
throw new TestFlightBuildExistsError(info.shortVersion, info.bundleVersion, { cause: error });
|
|
1615
|
+
}
|
|
1616
|
+
if (!isRetryable(error) || attempt === COMMIT_ATTEMPTS) throw error;
|
|
1617
|
+
if (!isRateLimited(error)) unanswered = true;
|
|
1618
|
+
await sleep(retryDelayMs(error, attempt));
|
|
1619
|
+
}
|
|
1620
|
+
_hot_updater_cli_tools.p.log.info(`Uploading ${ipaName} (version ${info.shortVersion}, build ${info.bundleVersion}) to App Store Connect`);
|
|
1621
|
+
const removeUpload = async () => {
|
|
1622
|
+
for (let attempt = 1;; attempt++) try {
|
|
1623
|
+
await request("Removing the build upload", "DELETE", `/v1/buildUploads/${buildUploadId}`);
|
|
1624
|
+
return;
|
|
1625
|
+
} catch (error) {
|
|
1626
|
+
if (error instanceof AppStoreConnectError && error.status === 404) return;
|
|
1627
|
+
if (!isRetryable(error) || attempt === COMMIT_ATTEMPTS) {
|
|
1628
|
+
_hot_updater_cli_tools.p.log.warn(`App Store Connect kept the unfinished build upload ${buildUploadId}, which reserves build ${info.bundleVersion} of version ${info.shortVersion}: ${error.message} Upload with a new build number (CFBundleVersion).`);
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
await sleep(retryDelayMs(error, attempt));
|
|
1632
|
+
}
|
|
1633
|
+
};
|
|
1634
|
+
let fileId;
|
|
795
1635
|
try {
|
|
796
|
-
const {
|
|
797
|
-
"
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1636
|
+
const { data } = await retrying(() => request("Reserving the .ipa upload", "POST", "/v1/buildUploadFiles", { data: {
|
|
1637
|
+
type: "buildUploadFiles",
|
|
1638
|
+
attributes: {
|
|
1639
|
+
assetType: "ASSET",
|
|
1640
|
+
fileName: ipaName,
|
|
1641
|
+
fileSize,
|
|
1642
|
+
uti: "com.apple.ipa"
|
|
1643
|
+
},
|
|
1644
|
+
relationships: { buildUpload: { data: {
|
|
1645
|
+
type: "buildUploads",
|
|
1646
|
+
id: buildUploadId
|
|
1647
|
+
} } }
|
|
1648
|
+
} }), isRateLimited);
|
|
1649
|
+
fileId = data.id;
|
|
1650
|
+
const operations = data.attributes?.uploadOperations ?? [];
|
|
1651
|
+
if (operations.length === 0) throw new Error("App Store Connect returned no upload operations.");
|
|
1652
|
+
await uploadParts({
|
|
1653
|
+
ipaPath,
|
|
1654
|
+
operations,
|
|
1655
|
+
fetch
|
|
1656
|
+
});
|
|
805
1657
|
} catch (error) {
|
|
806
|
-
|
|
1658
|
+
await removeUpload();
|
|
1659
|
+
throw error;
|
|
1660
|
+
}
|
|
1661
|
+
let commitUnanswered = false;
|
|
1662
|
+
for (let attempt = 1;; attempt++) try {
|
|
1663
|
+
await request("Committing the .ipa upload", "PATCH", `/v1/buildUploadFiles/${fileId}`, { data: {
|
|
1664
|
+
type: "buildUploadFiles",
|
|
1665
|
+
id: fileId,
|
|
1666
|
+
attributes: { uploaded: true }
|
|
1667
|
+
} });
|
|
1668
|
+
break;
|
|
1669
|
+
} catch (error) {
|
|
1670
|
+
const unconfirmed = () => new Error(`App Store Connect did not confirm the upload of build ${info.bundleVersion}: ${error.message} Check the build in TestFlight before you upload it again: ${testFlightUrl}`, { cause: error });
|
|
1671
|
+
if (!isRetryable(error)) {
|
|
1672
|
+
if (commitUnanswered) throw unconfirmed();
|
|
1673
|
+
await removeUpload();
|
|
1674
|
+
throw error;
|
|
1675
|
+
}
|
|
1676
|
+
commitUnanswered = true;
|
|
1677
|
+
const fileState = await retrying(() => request("Reading the .ipa upload", "GET", `/v1/buildUploadFiles/${fileId}?fields[buildUploadFiles]=assetDeliveryState`)).then(({ data }) => data.attributes?.assetDeliveryState?.state).catch(() => void 0);
|
|
1678
|
+
if (fileState && fileState !== "AWAITING_UPLOAD") break;
|
|
1679
|
+
if (attempt === COMMIT_ATTEMPTS) throw unconfirmed();
|
|
1680
|
+
await sleep(retryDelayMs(error, attempt));
|
|
1681
|
+
}
|
|
1682
|
+
const deadline = Date.now() + processingTimeoutMs;
|
|
1683
|
+
let reportedProcessing = false;
|
|
1684
|
+
let lastError;
|
|
1685
|
+
for (let failedReads = 0;;) {
|
|
1686
|
+
let readError;
|
|
1687
|
+
const read = await request("Reading the build upload", "GET", `/v1/buildUploads/${buildUploadId}`).catch((error) => {
|
|
1688
|
+
if (!isRetryable(error)) throw error;
|
|
1689
|
+
lastError = readError = error;
|
|
1690
|
+
});
|
|
1691
|
+
failedReads = read ? 0 : failedReads + 1;
|
|
1692
|
+
const state = read ? read.data.attributes?.state ?? {} : {};
|
|
1693
|
+
if (state.state === "PROCESSING") reportedProcessing = true;
|
|
1694
|
+
if (!state.state || IN_PROGRESS_STATES.has(state.state)) {
|
|
1695
|
+
if (Date.now() >= deadline) {
|
|
1696
|
+
if (!reportedProcessing) throw new Error(`App Store Connect did not report build ${info.bundleVersion} processing${lastError instanceof Error ? `: ${lastError.message}` : state.state ? ` (${state.state}).` : "."} Check the build in TestFlight: ${testFlightUrl}`, { cause: lastError });
|
|
1697
|
+
_hot_updater_cli_tools.p.log.warn(`App Store Connect is still processing build ${info.bundleVersion}. It appears in TestFlight once processing finishes: ${testFlightUrl}`);
|
|
1698
|
+
return {
|
|
1699
|
+
appId: app.id,
|
|
1700
|
+
buildUploadId,
|
|
1701
|
+
state: "PROCESSING",
|
|
1702
|
+
testFlightUrl,
|
|
1703
|
+
shortVersion: info.shortVersion,
|
|
1704
|
+
bundleVersion: info.bundleVersion
|
|
1705
|
+
};
|
|
1706
|
+
}
|
|
1707
|
+
await sleep(readError === void 0 ? pollIntervalMs : Math.max(pollIntervalMs, retryDelayMs(readError, failedReads)));
|
|
1708
|
+
continue;
|
|
1709
|
+
}
|
|
1710
|
+
for (const warning of describeDetails(state.warnings ?? [])) _hot_updater_cli_tools.p.log.warn(warning);
|
|
1711
|
+
if (state.state !== "COMPLETE") {
|
|
1712
|
+
const errors = describeDetails(state.errors ?? []);
|
|
1713
|
+
throw new Error([`App Store Connect rejected build ${info.bundleVersion} of version ${info.shortVersion} (${state.state}).`, ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
1714
|
+
}
|
|
1715
|
+
return {
|
|
1716
|
+
appId: app.id,
|
|
1717
|
+
buildUploadId,
|
|
1718
|
+
state: "COMPLETE",
|
|
1719
|
+
testFlightUrl,
|
|
1720
|
+
shortVersion: info.shortVersion,
|
|
1721
|
+
bundleVersion: info.bundleVersion
|
|
1722
|
+
};
|
|
807
1723
|
}
|
|
808
1724
|
};
|
|
809
1725
|
/**
|
|
810
|
-
*
|
|
1726
|
+
* Sends each part App Store Connect asked for: the bytes at its offset, with
|
|
1727
|
+
* only the headers it lists, since the URLs are presigned: another header,
|
|
1728
|
+
* or the App Store Connect token, would break their signature.
|
|
811
1729
|
*/
|
|
812
|
-
const
|
|
813
|
-
const
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1730
|
+
const uploadParts = async ({ ipaPath, operations, fetch }) => {
|
|
1731
|
+
const file = await fs.default.promises.open(ipaPath, "r");
|
|
1732
|
+
try {
|
|
1733
|
+
await runConcurrently(operations, UPLOAD_CONCURRENCY, async (operation, index) => {
|
|
1734
|
+
const body = Buffer.alloc(operation.length);
|
|
1735
|
+
const { bytesRead } = await file.read(body, 0, operation.length, operation.offset);
|
|
1736
|
+
if (bytesRead !== operation.length) throw new Error(`${path.default.basename(ipaPath)} changed while it was uploading.`);
|
|
1737
|
+
const headers = {};
|
|
1738
|
+
for (const { name, value } of operation.requestHeaders ?? []) headers[name] = value;
|
|
1739
|
+
for (let attempt = 1;; attempt++) {
|
|
1740
|
+
const response = await fetch(operation.url, {
|
|
1741
|
+
method: operation.method,
|
|
1742
|
+
headers,
|
|
1743
|
+
body
|
|
1744
|
+
}).catch((error) => error);
|
|
1745
|
+
if (!(response instanceof Error) && response.ok) return;
|
|
1746
|
+
if (!(response instanceof Error || isRetryableStatus(response.status)) || attempt === UPLOAD_ATTEMPTS) throw new Error(`Uploading part ${index + 1} of ${operations.length} failed: ${response instanceof Error ? response.message : `HTTP ${response.status}`}.`);
|
|
1747
|
+
await sleep(retryDelayMs(response, attempt));
|
|
1748
|
+
}
|
|
825
1749
|
});
|
|
1750
|
+
} finally {
|
|
1751
|
+
await file.close();
|
|
826
1752
|
}
|
|
827
|
-
|
|
1753
|
+
};
|
|
1754
|
+
//#endregion
|
|
1755
|
+
//#region src/utils/ciSigningKeychain.ts
|
|
1756
|
+
const API_URL = "https://api.appstoreconnect.apple.com";
|
|
1757
|
+
const CERTIFICATES_URL = "https://developer.apple.com/account/resources/certificates/list";
|
|
1758
|
+
/** Long enough for any build: the keychain never locks while one runs. */
|
|
1759
|
+
const KEYCHAIN_TIMEOUT_SECONDS = 21600;
|
|
1760
|
+
const runSecurity = async (args) => (await (0, execa.execa)("security", args)).stdout;
|
|
1761
|
+
/** The keychain paths `security` prints, one quoted path per line. */
|
|
1762
|
+
const parseKeychains = (stdout) => [...stdout.matchAll(/"([^"]+)"/g)].map((match) => match[1]);
|
|
1763
|
+
const normalizeSerial = (serial) => serial.toUpperCase().replace(/^0+(?=.)/, "");
|
|
1764
|
+
/** The keychain whose state the process restores if it exits first. */
|
|
1765
|
+
let live;
|
|
1766
|
+
const restoreLive = () => {
|
|
1767
|
+
live?.restore();
|
|
1768
|
+
live = void 0;
|
|
1769
|
+
process.off("exit", restoreLive);
|
|
828
1770
|
};
|
|
829
1771
|
/**
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
1772
|
+
* Where builds under one macOS user take turns: the default keychain and the
|
|
1773
|
+
* search list are the user's, not a process's, so two builds that overlap,
|
|
1774
|
+
* such as two self-hosted runner instances, would each restore the other's
|
|
1775
|
+
* keychain. It is in the user's caches, whatever TMPDIR a runner sets.
|
|
833
1776
|
*/
|
|
834
|
-
const
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1777
|
+
const defaultLockPath = () => path.default.join(os.default.homedir(), "Library", "Caches", "hot-updater", "signing-keychain.lock");
|
|
1778
|
+
/** lockf's exit code when `-t 0` finds the lock held. */
|
|
1779
|
+
const EX_TEMPFAIL = 75;
|
|
1780
|
+
/**
|
|
1781
|
+
* Holds `lockPath` with macOS's `lockf`, a kernel lock on the file kept by a
|
|
1782
|
+
* helper that waits on this process's pipe: the kernel releases it when the
|
|
1783
|
+
* helper ends, which is when the pipe closes, so a build that dies, even
|
|
1784
|
+
* killed outright, leaves no lock behind and none needs taking over. The
|
|
1785
|
+
* helper runs in its own process group, out of reach of a terminal's
|
|
1786
|
+
* Ctrl+C, and never keeps this process alive.
|
|
1787
|
+
*/
|
|
1788
|
+
const holdWithLockf = (lockPath, wait) => new Promise((resolve, reject) => {
|
|
1789
|
+
const child = (0, child_process.spawn)("lockf", [
|
|
1790
|
+
"-k",
|
|
1791
|
+
...wait ? [] : ["-t", "0"],
|
|
1792
|
+
lockPath,
|
|
1793
|
+
"/bin/sh",
|
|
1794
|
+
"-c",
|
|
1795
|
+
"echo held; exec cat >/dev/null"
|
|
1796
|
+
], {
|
|
1797
|
+
detached: true,
|
|
1798
|
+
stdio: [
|
|
1799
|
+
"pipe",
|
|
1800
|
+
"pipe",
|
|
1801
|
+
"ignore"
|
|
1802
|
+
]
|
|
1803
|
+
});
|
|
1804
|
+
let held = false;
|
|
1805
|
+
child.stdout.once("data", () => {
|
|
1806
|
+
held = true;
|
|
1807
|
+
child.unref();
|
|
1808
|
+
child.stdin.unref?.();
|
|
1809
|
+
child.stdout.unref?.();
|
|
1810
|
+
const exited = new Promise((done) => child.once("exit", () => done()));
|
|
1811
|
+
resolve(async () => {
|
|
1812
|
+
child.ref();
|
|
1813
|
+
child.stdin.end();
|
|
1814
|
+
await exited;
|
|
1815
|
+
});
|
|
1816
|
+
});
|
|
1817
|
+
child.once("error", reject);
|
|
1818
|
+
child.once("exit", (code) => {
|
|
1819
|
+
if (held) return;
|
|
1820
|
+
if (!wait && code === EX_TEMPFAIL) resolve(void 0);
|
|
1821
|
+
else reject(/* @__PURE__ */ new Error(`lockf ${lockPath} exited with ${code}.`));
|
|
1822
|
+
});
|
|
1823
|
+
});
|
|
1824
|
+
const acquireWithLockf = async (lockPath) => {
|
|
1825
|
+
await fs.default.promises.mkdir(path.default.dirname(lockPath), { recursive: true });
|
|
1826
|
+
const release = await holdWithLockf(lockPath, false);
|
|
1827
|
+
if (release) return release;
|
|
1828
|
+
_hot_updater_cli_tools.p.log.info("Waiting for another build that signs in a keychain of its own under this macOS user.");
|
|
1829
|
+
return await holdWithLockf(lockPath, true);
|
|
839
1830
|
};
|
|
840
1831
|
/**
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
*
|
|
1832
|
+
* Creates the build's keychain and makes it the user's default keychain,
|
|
1833
|
+
* first in the search list, ahead of the keychains already there, so Xcode
|
|
1834
|
+
* still finds a signing identity a self-hosted runner keeps and creates a
|
|
1835
|
+
* new one only when it has none.
|
|
845
1836
|
*/
|
|
846
|
-
const
|
|
847
|
-
|
|
848
|
-
|
|
1837
|
+
const createSigningKeychain = async ({ security = runSecurity, directory = os.default.tmpdir(), lockPath = defaultLockPath(), acquireLock = acquireWithLockf } = {}) => {
|
|
1838
|
+
if (live) throw new Error("A signing keychain is already in use by this process.");
|
|
1839
|
+
live = { restore: () => void 0 };
|
|
1840
|
+
let releaseLock;
|
|
849
1841
|
try {
|
|
850
|
-
|
|
851
|
-
let allDevices = [...devices, ...simulators].filter((device) => device.platform === platform);
|
|
852
|
-
if (options.deviceType) allDevices = allDevices.filter((d) => d.type === options.deviceType);
|
|
853
|
-
if (options.state && options.state !== "all") allDevices = allDevices.filter((d) => d.state === options.state);
|
|
854
|
-
spinner.stop(`Found ${allDevices.length} ${platform} devices`);
|
|
855
|
-
return allDevices;
|
|
1842
|
+
releaseLock = await acquireLock(lockPath);
|
|
856
1843
|
} catch (error) {
|
|
857
|
-
|
|
1844
|
+
live = void 0;
|
|
858
1845
|
throw error;
|
|
859
1846
|
}
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
let
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
_hot_updater_cli_tools.p.log.error("you can't select device manually with non-interactive cli mode(without -i option).");
|
|
879
|
-
process.exit(1);
|
|
1847
|
+
process.on("exit", restoreLive);
|
|
1848
|
+
let originalDefault;
|
|
1849
|
+
let originalSearchList;
|
|
1850
|
+
try {
|
|
1851
|
+
[originalDefault] = parseKeychains(await security([
|
|
1852
|
+
"default-keychain",
|
|
1853
|
+
"-d",
|
|
1854
|
+
"user"
|
|
1855
|
+
]).catch(() => ""));
|
|
1856
|
+
originalSearchList = parseKeychains(await security([
|
|
1857
|
+
"list-keychains",
|
|
1858
|
+
"-d",
|
|
1859
|
+
"user"
|
|
1860
|
+
]));
|
|
1861
|
+
} catch (error) {
|
|
1862
|
+
restoreLive();
|
|
1863
|
+
await releaseLock();
|
|
1864
|
+
throw error;
|
|
880
1865
|
}
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
1866
|
+
const keychainPath = path.default.join(directory, `hot-updater-${crypto.default.randomBytes(6).toString("hex")}.keychain-db`);
|
|
1867
|
+
const password = crypto.default.randomBytes(24).toString("base64url");
|
|
1868
|
+
(0, _hot_updater_cli_tools.registerSecretValue)(password);
|
|
1869
|
+
const restoreArgs = [
|
|
1870
|
+
...originalDefault ? [[
|
|
1871
|
+
"default-keychain",
|
|
1872
|
+
"-d",
|
|
1873
|
+
"user",
|
|
1874
|
+
"-s",
|
|
1875
|
+
originalDefault
|
|
1876
|
+
]] : [],
|
|
1877
|
+
[
|
|
1878
|
+
"list-keychains",
|
|
1879
|
+
"-d",
|
|
1880
|
+
"user",
|
|
1881
|
+
"-s",
|
|
1882
|
+
...originalSearchList
|
|
1883
|
+
],
|
|
1884
|
+
["delete-keychain", keychainPath]
|
|
1885
|
+
];
|
|
1886
|
+
live = { restore: () => {
|
|
1887
|
+
for (const args of restoreArgs) (0, child_process.spawnSync)("security", args);
|
|
1888
|
+
} };
|
|
1889
|
+
let disposal;
|
|
1890
|
+
const dispose = () => {
|
|
1891
|
+
disposal ??= (async () => {
|
|
1892
|
+
const failures = [];
|
|
1893
|
+
for (const args of restoreArgs) await security(args).catch((error) => {
|
|
1894
|
+
failures.push(`security ${args[0]}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1895
|
+
});
|
|
1896
|
+
live = void 0;
|
|
1897
|
+
process.off("exit", restoreLive);
|
|
1898
|
+
await releaseLock().catch(() => void 0);
|
|
1899
|
+
if (failures.length > 0) _hot_updater_cli_tools.p.log.warn(`Restoring the keychains after the build failed (${failures.join("; ")}). Check \`security list-keychains -d user\` and \`security default-keychain -d user\`, and delete ${keychainPath} if it is left.`);
|
|
1900
|
+
})();
|
|
1901
|
+
return disposal;
|
|
1902
|
+
};
|
|
1903
|
+
try {
|
|
1904
|
+
await security([
|
|
1905
|
+
"create-keychain",
|
|
1906
|
+
"-p",
|
|
1907
|
+
password,
|
|
1908
|
+
keychainPath
|
|
1909
|
+
]);
|
|
1910
|
+
await security([
|
|
1911
|
+
"set-keychain-settings",
|
|
1912
|
+
"-lut",
|
|
1913
|
+
String(KEYCHAIN_TIMEOUT_SECONDS),
|
|
1914
|
+
keychainPath
|
|
1915
|
+
]);
|
|
1916
|
+
await security([
|
|
1917
|
+
"unlock-keychain",
|
|
1918
|
+
"-p",
|
|
1919
|
+
password,
|
|
1920
|
+
keychainPath
|
|
1921
|
+
]);
|
|
1922
|
+
await security([
|
|
1923
|
+
"list-keychains",
|
|
1924
|
+
"-d",
|
|
1925
|
+
"user",
|
|
1926
|
+
"-s",
|
|
1927
|
+
keychainPath,
|
|
1928
|
+
...originalSearchList.filter((keychain) => keychain !== keychainPath)
|
|
1929
|
+
]);
|
|
1930
|
+
await security([
|
|
1931
|
+
"default-keychain",
|
|
1932
|
+
"-d",
|
|
1933
|
+
"user",
|
|
1934
|
+
"-s",
|
|
1935
|
+
keychainPath
|
|
1936
|
+
]);
|
|
1937
|
+
} catch (error) {
|
|
1938
|
+
await dispose();
|
|
1939
|
+
throw error;
|
|
1940
|
+
}
|
|
1941
|
+
return {
|
|
1942
|
+
path: keychainPath,
|
|
1943
|
+
certificates: async () => {
|
|
1944
|
+
return ((await security([
|
|
1945
|
+
"find-certificate",
|
|
1946
|
+
"-a",
|
|
1947
|
+
"-p",
|
|
1948
|
+
keychainPath
|
|
1949
|
+
])).match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []).map((pem) => {
|
|
1950
|
+
const certificate = new crypto.default.X509Certificate(pem);
|
|
1951
|
+
return {
|
|
1952
|
+
serialNumber: normalizeSerial(certificate.serialNumber),
|
|
1953
|
+
commonName: certificate.subject.match(/^CN=(.*)$/m)?.[1] ?? certificate.subject
|
|
1954
|
+
};
|
|
1955
|
+
});
|
|
1956
|
+
},
|
|
1957
|
+
dispose
|
|
1958
|
+
};
|
|
1959
|
+
};
|
|
1960
|
+
/**
|
|
1961
|
+
* Revokes the certificates Xcode created in the build's keychain, whose
|
|
1962
|
+
* private keys the keychain's deletion destroys: on a runner that starts
|
|
1963
|
+
* empty, Xcode creates a development certificate for every build, and App
|
|
1964
|
+
* Store Connect refuses new ones once a team has its maximum. Each is found
|
|
1965
|
+
* in the team's certificates by serial number. A revocation that fails is
|
|
1966
|
+
* reported with where to revoke it by hand, and never fails the build.
|
|
1967
|
+
*/
|
|
1968
|
+
const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fetch = globalThis.fetch }) => {
|
|
1969
|
+
const revoked = [];
|
|
1970
|
+
const failed = [];
|
|
1971
|
+
if (certificates.length === 0) return {
|
|
1972
|
+
revoked,
|
|
1973
|
+
failed
|
|
1974
|
+
};
|
|
1975
|
+
const describe = ({ commonName, serialNumber }) => `${commonName} (serial ${serialNumber})`;
|
|
1976
|
+
try {
|
|
1977
|
+
const token = createAppStoreConnectToken({
|
|
1978
|
+
privateKey: privateKey ?? crypto.default.createPrivateKey(await fs.default.promises.readFile(apiKey.keyPath, "utf-8")),
|
|
1979
|
+
keyId: apiKey.keyId,
|
|
1980
|
+
issuerId: apiKey.issuerId
|
|
1981
|
+
});
|
|
1982
|
+
(0, _hot_updater_cli_tools.registerSecretValue)(token);
|
|
1983
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
1984
|
+
const idsBySerial = /* @__PURE__ */ new Map();
|
|
1985
|
+
let next = `${API_URL}/v1/certificates?${new URLSearchParams({
|
|
1986
|
+
"fields[certificates]": "serialNumber",
|
|
1987
|
+
limit: "200"
|
|
1988
|
+
})}`;
|
|
1989
|
+
while (next) {
|
|
1990
|
+
const response = await fetch(next, { headers });
|
|
1991
|
+
if (!response.ok) throw new Error(`Listing the team's certificates failed (HTTP ${response.status}).`);
|
|
1992
|
+
const body = await response.json();
|
|
1993
|
+
for (const { id, attributes } of body.data ?? []) if (attributes?.serialNumber) idsBySerial.set(normalizeSerial(attributes.serialNumber), id);
|
|
1994
|
+
next = body.links?.next;
|
|
886
1995
|
}
|
|
887
|
-
|
|
1996
|
+
for (const certificate of certificates) {
|
|
1997
|
+
const id = idsBySerial.get(certificate.serialNumber);
|
|
1998
|
+
if (id === void 0) continue;
|
|
1999
|
+
const response = await fetch(`${API_URL}/v1/certificates/${id}`, {
|
|
2000
|
+
method: "DELETE",
|
|
2001
|
+
headers
|
|
2002
|
+
});
|
|
2003
|
+
if (response.ok || response.status === 404) revoked.push(describe(certificate));
|
|
2004
|
+
else failed.push(`${describe(certificate)}: HTTP ${response.status}`);
|
|
2005
|
+
}
|
|
2006
|
+
} catch (error) {
|
|
2007
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
2008
|
+
for (const certificate of certificates) if (!revoked.includes(describe(certificate))) failed.push(`${describe(certificate)}: ${reason}`);
|
|
888
2009
|
}
|
|
889
|
-
if (
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
2010
|
+
if (revoked.length > 0) _hot_updater_cli_tools.p.log.info(`Revoked the signing certificates Xcode created for this build, whose keys the build's keychain held: ${revoked.join(", ")}.`);
|
|
2011
|
+
if (failed.length > 0) _hot_updater_cli_tools.p.log.warn(`Revoking the signing certificates Xcode created for this build failed, and their keys are deleted with the build's keychain: ${failed.join("; ")}. Revoke them at ${CERTIFICATES_URL}, since App Store Connect stops creating certificates at a team's maximum.`);
|
|
2012
|
+
return {
|
|
2013
|
+
revoked,
|
|
2014
|
+
failed
|
|
2015
|
+
};
|
|
2016
|
+
};
|
|
2017
|
+
const TERMINATION_SIGNALS = [
|
|
2018
|
+
"SIGHUP",
|
|
2019
|
+
"SIGINT",
|
|
2020
|
+
"SIGTERM"
|
|
2021
|
+
];
|
|
2022
|
+
/**
|
|
2023
|
+
* Runs `run`, the archive and export of a build that signs with an App
|
|
2024
|
+
* Store Connect API key, in a keychain of its own when `enabled` (in CI),
|
|
2025
|
+
* then revokes the certificates Xcode created in it and deletes it. A
|
|
2026
|
+
* termination signal does the same before the process ends with it: the
|
|
2027
|
+
* certificates are read from the keychain before it is deleted, and the key
|
|
2028
|
+
* is read into memory first, since the build's temporary key file is deleted
|
|
2029
|
+
* on the signal. A second signal restores the keychains at once.
|
|
2030
|
+
*/
|
|
2031
|
+
const withSigningKeychain = async ({ enabled, apiKey, run, security, lockPath, acquireLock, fetch }) => {
|
|
2032
|
+
if (!enabled) return run();
|
|
2033
|
+
const privateKey = crypto.default.createPrivateKey(await fs.default.promises.readFile(apiKey.keyPath, "utf-8"));
|
|
2034
|
+
const keychain = await createSigningKeychain({
|
|
2035
|
+
security,
|
|
2036
|
+
lockPath,
|
|
2037
|
+
acquireLock
|
|
2038
|
+
});
|
|
2039
|
+
_hot_updater_cli_tools.p.log.info("Signing in a keychain of this build's own, deleted when it ends (CI is set).");
|
|
2040
|
+
let cleanup;
|
|
2041
|
+
const release = () => {
|
|
2042
|
+
cleanup ??= (async () => {
|
|
2043
|
+
const certificates = await keychain.certificates().catch(() => []);
|
|
2044
|
+
await keychain.dispose();
|
|
2045
|
+
await revokeKeychainCertificates({
|
|
2046
|
+
certificates,
|
|
2047
|
+
apiKey,
|
|
2048
|
+
privateKey,
|
|
2049
|
+
fetch
|
|
2050
|
+
});
|
|
2051
|
+
})();
|
|
2052
|
+
return cleanup;
|
|
2053
|
+
};
|
|
2054
|
+
const stopListening = () => {
|
|
2055
|
+
for (const signal of TERMINATION_SIGNALS) process.off(signal, onSignal);
|
|
2056
|
+
};
|
|
2057
|
+
const onSignal = (signal) => {
|
|
2058
|
+
if (cleanup) {
|
|
2059
|
+
stopListening();
|
|
2060
|
+
restoreLive();
|
|
2061
|
+
process.kill(process.pid, signal);
|
|
2062
|
+
return;
|
|
893
2063
|
}
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
value: d,
|
|
898
|
-
label: `${d.name} (${d.type}) - ${d.state}`,
|
|
899
|
-
hint: d.udid
|
|
900
|
-
}))
|
|
2064
|
+
release().finally(() => {
|
|
2065
|
+
stopListening();
|
|
2066
|
+
if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
|
|
901
2067
|
});
|
|
902
|
-
|
|
903
|
-
|
|
2068
|
+
};
|
|
2069
|
+
for (const signal of TERMINATION_SIGNALS) process.on(signal, onSignal);
|
|
2070
|
+
try {
|
|
2071
|
+
return await run();
|
|
2072
|
+
} finally {
|
|
2073
|
+
await release();
|
|
2074
|
+
stopListening();
|
|
904
2075
|
}
|
|
905
|
-
const bootedDevices = await listDevices(platform, { state: "Booted" });
|
|
906
|
-
if (bootedDevices.length > 0) return bootedDevices[0];
|
|
907
2076
|
};
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
2077
|
+
//#endregion
|
|
2078
|
+
//#region src/utils/readBuiltApp.ts
|
|
2079
|
+
const readInfoValue = async (infoPlist, key) => {
|
|
2080
|
+
const { stdout, exitCode } = await (0, execa.execa)("plutil", [
|
|
2081
|
+
"-extract",
|
|
2082
|
+
key,
|
|
2083
|
+
"raw",
|
|
2084
|
+
"-o",
|
|
2085
|
+
"-",
|
|
2086
|
+
infoPlist
|
|
2087
|
+
], { reject: false });
|
|
2088
|
+
return exitCode === 0 && stdout.trim() ? stdout.trim() : null;
|
|
2089
|
+
};
|
|
2090
|
+
/**
|
|
2091
|
+
* What the built `.app` reports at runtime, from its Info.plist: the
|
|
2092
|
+
* `CFBundleShortVersionString`, `CFBundleVersion` (the build number) and
|
|
2093
|
+
* `HOT_UPDATER_CHANNEL` the build settings,
|
|
2094
|
+
* an `xcconfig` and `extraParams` expanded.
|
|
2095
|
+
*/
|
|
2096
|
+
const readBuiltApp = async (appPath) => {
|
|
2097
|
+
const infoPlist = path.default.join(appPath, "Info.plist");
|
|
2098
|
+
const [appVersion, channel, buildNumber] = await Promise.all([
|
|
2099
|
+
readInfoValue(infoPlist, "CFBundleShortVersionString"),
|
|
2100
|
+
readInfoValue(infoPlist, "HOT_UPDATER_CHANNEL"),
|
|
2101
|
+
readInfoValue(infoPlist, "CFBundleVersion")
|
|
2102
|
+
]);
|
|
2103
|
+
return {
|
|
2104
|
+
appVersion,
|
|
2105
|
+
channel,
|
|
2106
|
+
buildNumber
|
|
2107
|
+
};
|
|
911
2108
|
};
|
|
912
2109
|
//#endregion
|
|
913
|
-
//#region src/
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
2110
|
+
//#region src/buildIos.ts
|
|
2111
|
+
/**
|
|
2112
|
+
* Builds a simulator `.app` for a `simulator` scheme, and otherwise archives
|
|
2113
|
+
* and exports an `.ipa`, or returns the `.xcarchive` of a scheme it cannot
|
|
2114
|
+
* export: one with neither `exportOptionsPlist` nor `signing.ascApiKey`.
|
|
2115
|
+
*/
|
|
2116
|
+
const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials }) => {
|
|
2117
|
+
await assertXcodebuildExist();
|
|
2118
|
+
const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
|
|
2119
|
+
const buildDirectory = await createRandomTmpDir();
|
|
2120
|
+
await fs.default.promises.mkdir(path.default.join(buildDirectory, "archive"), { recursive: true });
|
|
2121
|
+
if (schemeConfig.simulator) {
|
|
2122
|
+
if (schemeConfig.destination.length) {
|
|
2123
|
+
_hot_updater_cli_tools.p.log.error("The scheme option 'simulator' is not compatible with the 'destination' option.");
|
|
926
2124
|
process.exit(1);
|
|
927
2125
|
}
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
2126
|
+
const appDir = path.default.join(buildDirectory, "app");
|
|
2127
|
+
await fs.default.promises.mkdir(appDir, { recursive: true });
|
|
2128
|
+
const { appPath } = await buildXcodeProject({
|
|
2129
|
+
sourceDir: iosProjectRoot,
|
|
2130
|
+
logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build`,
|
|
933
2131
|
platform: schemeConfig.platform,
|
|
934
2132
|
xcodeScheme: schemeConfig.scheme,
|
|
935
2133
|
configuration: schemeConfig.configuration,
|
|
936
|
-
deviceType:
|
|
937
|
-
destination: [{ id: selectedDevice.udid }],
|
|
2134
|
+
deviceType: "simulator",
|
|
938
2135
|
installPods: schemeConfig.installPods,
|
|
939
2136
|
extraParams: schemeConfig.extraParams,
|
|
940
|
-
|
|
2137
|
+
useGenericDestination: true,
|
|
2138
|
+
minBundleId
|
|
941
2139
|
});
|
|
942
|
-
const
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
2140
|
+
const finalAppPath = path.default.join(appDir, path.default.basename(appPath));
|
|
2141
|
+
await fs.default.promises.cp(appPath, finalAppPath, { recursive: true });
|
|
2142
|
+
_hot_updater_cli_tools.p.log.success(`.app file created: ${path.default.basename(appPath)}`);
|
|
2143
|
+
return {
|
|
2144
|
+
buildDirectory,
|
|
2145
|
+
buildArtifactPath: finalAppPath,
|
|
2146
|
+
builtApp: await readBuiltApp(finalAppPath)
|
|
947
2147
|
};
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
2148
|
+
} else {
|
|
2149
|
+
const signing = await resolveXcodeSigning({
|
|
2150
|
+
schemeConfig,
|
|
2151
|
+
credentials
|
|
2152
|
+
});
|
|
2153
|
+
if (!signing) return archiveAndExport({
|
|
2154
|
+
schemeConfig,
|
|
2155
|
+
minBundleId,
|
|
2156
|
+
buildDirectory
|
|
2157
|
+
});
|
|
2158
|
+
const appStoreConnect = await exportsForAppStoreConnect(schemeConfig);
|
|
2159
|
+
if ((0, _hot_updater_cli_tools.isCI)() && !appStoreConnect) _hot_updater_cli_tools.p.log.info("This export isn't for App Store Connect, so it signs in the runner's own keychains and keeps the certificates Xcode creates.");
|
|
2160
|
+
return withSigningKeychain({
|
|
2161
|
+
enabled: (0, _hot_updater_cli_tools.isCI)() && appStoreConnect,
|
|
2162
|
+
apiKey: signing.apiKey,
|
|
2163
|
+
run: () => archiveAndExport({
|
|
2164
|
+
schemeConfig,
|
|
2165
|
+
minBundleId,
|
|
2166
|
+
buildDirectory,
|
|
2167
|
+
authenticationArgs: signing.authenticationArgs
|
|
2168
|
+
})
|
|
952
2169
|
});
|
|
953
|
-
_hot_updater_cli_tools.p.outro("Success 🎉");
|
|
954
|
-
return result;
|
|
955
2170
|
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
2171
|
+
};
|
|
2172
|
+
/**
|
|
2173
|
+
* Archives a device build and exports its `.ipa`, or returns the
|
|
2174
|
+
* `.xcarchive` of a scheme it cannot export.
|
|
2175
|
+
*/
|
|
2176
|
+
const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, authenticationArgs }) => {
|
|
2177
|
+
const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
|
|
2178
|
+
const archiveDir = path.default.join(buildDirectory, "archive");
|
|
2179
|
+
const exportDir = path.default.join(buildDirectory, "export");
|
|
2180
|
+
const { archivePath } = await archiveXcodeProject({
|
|
2181
|
+
authenticationArgs,
|
|
2182
|
+
minBundleId,
|
|
959
2183
|
platform: schemeConfig.platform,
|
|
2184
|
+
sourceDir: iosProjectRoot,
|
|
2185
|
+
xcconfig: schemeConfig.xcconfig,
|
|
960
2186
|
xcodeScheme: schemeConfig.scheme,
|
|
2187
|
+
destination: schemeConfig.destination,
|
|
2188
|
+
extraParams: schemeConfig.extraParams,
|
|
961
2189
|
configuration: schemeConfig.configuration,
|
|
962
|
-
deviceType: device.type,
|
|
963
|
-
destination: [{ id: device.udid }],
|
|
964
2190
|
installPods: schemeConfig.installPods,
|
|
965
|
-
|
|
966
|
-
logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-run`
|
|
2191
|
+
logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build-archive`
|
|
967
2192
|
});
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
2193
|
+
const finalArchivePath = path.default.join(archiveDir, path.default.basename(archivePath));
|
|
2194
|
+
await fs.default.promises.cp(archivePath, finalArchivePath, { recursive: true });
|
|
2195
|
+
const appFromArchive = extractAppFromXcarchive(finalArchivePath);
|
|
2196
|
+
const archivedAppPath = appFromArchive ? path.default.join(archiveDir, path.default.basename(appFromArchive)) : void 0;
|
|
2197
|
+
if (appFromArchive && archivedAppPath) {
|
|
2198
|
+
await fs.default.promises.cp(appFromArchive, archivedAppPath, { recursive: true });
|
|
2199
|
+
_hot_updater_cli_tools.p.log.success(".app extracted from .xcarchive");
|
|
2200
|
+
}
|
|
2201
|
+
if (!schemeConfig.exportOptionsPlist && !authenticationArgs) return {
|
|
2202
|
+
buildDirectory,
|
|
2203
|
+
buildArtifactPath: finalArchivePath,
|
|
2204
|
+
builtApp: archivedAppPath ? await readBuiltApp(archivedAppPath) : void 0
|
|
2205
|
+
};
|
|
2206
|
+
await fs.default.promises.mkdir(exportDir, { recursive: true });
|
|
2207
|
+
const exportOptionsPlist = schemeConfig.exportOptionsPlist ?? await writeAppStoreConnectExportOptions(await createRandomTmpDir());
|
|
2208
|
+
const { exportPath } = await exportXcodeArchive({
|
|
2209
|
+
archivePath,
|
|
2210
|
+
authenticationArgs,
|
|
2211
|
+
exportExtraParams: schemeConfig.exportExtraParams,
|
|
2212
|
+
exportOptionsPlist,
|
|
2213
|
+
sourceDir: iosProjectRoot,
|
|
2214
|
+
logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build-export`
|
|
985
2215
|
});
|
|
986
|
-
|
|
987
|
-
|
|
2216
|
+
await fs.default.promises.cp(exportPath, exportDir, { recursive: true });
|
|
2217
|
+
const ipaFile = fs.default.readdirSync(exportDir).find((file) => file.endsWith(".ipa"));
|
|
2218
|
+
if (!ipaFile) throw new Error("IPA file not found after export");
|
|
2219
|
+
return {
|
|
2220
|
+
buildDirectory,
|
|
2221
|
+
buildArtifactPath: path.default.join(exportDir, ipaFile),
|
|
2222
|
+
builtApp: archivedAppPath ? await readBuiltApp(archivedAppPath) : void 0
|
|
2223
|
+
};
|
|
2224
|
+
};
|
|
2225
|
+
const extractAppFromXcarchive = (archivePath) => {
|
|
2226
|
+
const productsPath = path.default.join(archivePath, "Products", "Applications");
|
|
2227
|
+
if (!fs.default.existsSync(productsPath)) return;
|
|
2228
|
+
const appFile = fs.default.readdirSync(productsPath).find((file) => file.endsWith(".app"));
|
|
2229
|
+
if (appFile) return path.default.join(productsPath, appFile);
|
|
2230
|
+
_hot_updater_cli_tools.p.log.warn("Failed to extract .app from xcarchive");
|
|
988
2231
|
};
|
|
989
2232
|
//#endregion
|
|
990
2233
|
//#region src/utils/enrichNativeBuildIosScheme.ts
|
|
@@ -1009,6 +2252,71 @@ const enrichNativeBuildIosScheme = async ({ scheme, hotUpdaterSchemeName }) => {
|
|
|
1009
2252
|
};
|
|
1010
2253
|
};
|
|
1011
2254
|
//#endregion
|
|
2255
|
+
//#region src/utils/extractBuiltInBundle.ts
|
|
2256
|
+
/** The file React Native's Xcode build phase writes into the `.app`. */
|
|
2257
|
+
const BUILT_IN_BUNDLE = "main.jsbundle";
|
|
2258
|
+
/**
|
|
2259
|
+
* Copies the JavaScript bundle React Native's Xcode build phase wrote into a
|
|
2260
|
+
* `.app` or an `.ipa` (`Payload/<App>.app/main.jsbundle`) to `outputPath`:
|
|
2261
|
+
* the built-in bundle, byte for byte, which no rebuild reproduces.
|
|
2262
|
+
*/
|
|
2263
|
+
const extractBuiltInBundle = async ({ artifactPath, outputPath }) => {
|
|
2264
|
+
await fs.default.promises.mkdir(path.default.dirname(outputPath), { recursive: true });
|
|
2265
|
+
if (path.default.extname(artifactPath) === ".app") {
|
|
2266
|
+
const source = path.default.join(artifactPath, BUILT_IN_BUNDLE);
|
|
2267
|
+
if (!fs.default.existsSync(source)) throw new Error(`${path.default.basename(artifactPath)} has no ${BUILT_IN_BUNDLE}. A debug build loads its bundle from Metro instead.`);
|
|
2268
|
+
await fs.default.promises.copyFile(source, outputPath);
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
2271
|
+
const { stdout } = await (0, execa.execa)("unzip", ["-Z1", artifactPath]);
|
|
2272
|
+
const entry = stdout.split("\n").find((name) => /^Payload\/[^/]+\.app\/main\.jsbundle$/u.test(name.trim()))?.trim();
|
|
2273
|
+
if (entry === void 0) throw new Error(`${path.default.basename(artifactPath)} has no Payload/<App>.app/${BUILT_IN_BUNDLE}.`);
|
|
2274
|
+
await (0, execa.execa)("unzip", [
|
|
2275
|
+
"-p",
|
|
2276
|
+
artifactPath,
|
|
2277
|
+
escapeUnzipPattern(entry)
|
|
2278
|
+
], { stdout: { file: outputPath } });
|
|
2279
|
+
};
|
|
2280
|
+
/**
|
|
2281
|
+
* Unpacks a `.app` that `archiveAppBundle` stored as one zip file into
|
|
2282
|
+
* `outputPath`, which then holds the `.app` directory.
|
|
2283
|
+
*/
|
|
2284
|
+
const extractAppBundle = async ({ archivePath, outputPath }) => {
|
|
2285
|
+
await fs.default.promises.mkdir(outputPath, { recursive: true });
|
|
2286
|
+
await (0, execa.execa)("ditto", [
|
|
2287
|
+
"-x",
|
|
2288
|
+
"-k",
|
|
2289
|
+
archivePath,
|
|
2290
|
+
outputPath
|
|
2291
|
+
]);
|
|
2292
|
+
};
|
|
2293
|
+
/**
|
|
2294
|
+
* A `.app` directory as one zip file, the way Finder and Xcode archive it,
|
|
2295
|
+
* so a simulator build uploads and downloads as a single artifact.
|
|
2296
|
+
*/
|
|
2297
|
+
const archiveAppBundle = async ({ appPath, outputPath }) => {
|
|
2298
|
+
await fs.default.promises.mkdir(path.default.dirname(outputPath), { recursive: true });
|
|
2299
|
+
await fs.default.promises.rm(outputPath, { force: true });
|
|
2300
|
+
await (0, execa.execa)("ditto", [
|
|
2301
|
+
"-c",
|
|
2302
|
+
"-k",
|
|
2303
|
+
"--sequesterRsrc",
|
|
2304
|
+
"--keepParent",
|
|
2305
|
+
appPath,
|
|
2306
|
+
outputPath
|
|
2307
|
+
]);
|
|
2308
|
+
};
|
|
2309
|
+
//#endregion
|
|
2310
|
+
exports.ASC_INTEGRATIONS_URL = ASC_INTEGRATIONS_URL;
|
|
2311
|
+
exports.TestFlightBuildExistsError = TestFlightBuildExistsError;
|
|
2312
|
+
exports.archiveAppBundle = archiveAppBundle;
|
|
2313
|
+
exports.assertTestFlightExportOptions = assertTestFlightExportOptions;
|
|
1012
2314
|
exports.buildIos = buildIos;
|
|
1013
2315
|
exports.enrichNativeBuildIosScheme = enrichNativeBuildIosScheme;
|
|
1014
|
-
exports.
|
|
2316
|
+
exports.extractAppBundle = extractAppBundle;
|
|
2317
|
+
exports.extractBuiltInBundle = extractBuiltInBundle;
|
|
2318
|
+
exports.findTestFlightBuild = findTestFlightBuild;
|
|
2319
|
+
exports.installPodsIfNeeded = installPodsIfNeeded;
|
|
2320
|
+
exports.issueAscApiKey = issueAscApiKey;
|
|
2321
|
+
exports.resolveTestFlightUploadKey = resolveTestFlightUploadKey;
|
|
2322
|
+
exports.uploadToTestFlight = uploadToTestFlight;
|