@hot-updater/apple-helper 1.1.4 → 1.2.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1934 -530
- package/dist/index.d.cts +198 -22
- package/dist/index.d.mts +199 -22
- package/dist/index.mjs +1932 -544
- 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$3 = [
|
|
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$3) process.off(signal, onSignal);
|
|
182
|
+
};
|
|
183
|
+
process.on("exit", remove);
|
|
184
|
+
for (const signal of TERMINATION_SIGNALS$3) 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,19 +525,108 @@ 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
|
|
94
580
|
//#region src/utils/createRandomTmpDir.ts
|
|
581
|
+
const TERMINATION_SIGNALS$2 = [
|
|
582
|
+
"SIGHUP",
|
|
583
|
+
"SIGINT",
|
|
584
|
+
"SIGTERM"
|
|
585
|
+
];
|
|
586
|
+
const REMOVE_OPTIONS = {
|
|
587
|
+
force: true,
|
|
588
|
+
recursive: true,
|
|
589
|
+
maxRetries: 3
|
|
590
|
+
};
|
|
591
|
+
const liveDirectories = /* @__PURE__ */ new Set();
|
|
592
|
+
const onSignal = (signal) => {
|
|
593
|
+
removeLiveDirectories();
|
|
594
|
+
if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
|
|
595
|
+
};
|
|
596
|
+
const removeLiveDirectories = () => {
|
|
597
|
+
for (const directory of liveDirectories) try {
|
|
598
|
+
(0, node_fs.rmSync)(directory, REMOVE_OPTIONS);
|
|
599
|
+
} catch {}
|
|
600
|
+
liveDirectories.clear();
|
|
601
|
+
stopListening();
|
|
602
|
+
};
|
|
603
|
+
const startListening = () => {
|
|
604
|
+
process.on("exit", removeLiveDirectories);
|
|
605
|
+
for (const signal of TERMINATION_SIGNALS$2) process.on(signal, onSignal);
|
|
606
|
+
};
|
|
607
|
+
const stopListening = () => {
|
|
608
|
+
process.off("exit", removeLiveDirectories);
|
|
609
|
+
for (const signal of TERMINATION_SIGNALS$2) process.off(signal, onSignal);
|
|
610
|
+
};
|
|
611
|
+
/**
|
|
612
|
+
* Creates a directory in the system's temporary directory, which
|
|
613
|
+
* `removeTmpDir` removes, and which is otherwise removed when the process
|
|
614
|
+
* exits or is stopped by a signal.
|
|
615
|
+
*/
|
|
95
616
|
const createRandomTmpDir = async (prefix = "apple-helper-") => {
|
|
96
|
-
|
|
617
|
+
const directory = await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), prefix));
|
|
618
|
+
if (liveDirectories.size === 0) startListening();
|
|
619
|
+
liveDirectories.add(directory);
|
|
620
|
+
return directory;
|
|
621
|
+
};
|
|
622
|
+
/**
|
|
623
|
+
* Removes a directory `createRandomTmpDir` created. It stays tracked until
|
|
624
|
+
* it is gone, so a signal during a removal that takes seconds, such as
|
|
625
|
+
* DerivedData's, still finishes it.
|
|
626
|
+
*/
|
|
627
|
+
const removeTmpDir = async (directory) => {
|
|
628
|
+
await (0, node_fs_promises.rm)(directory, REMOVE_OPTIONS);
|
|
629
|
+
if (liveDirectories.delete(directory) && liveDirectories.size === 0) stopListening();
|
|
97
630
|
};
|
|
98
631
|
//#endregion
|
|
99
632
|
//#region src/utils/platform.ts
|
|
@@ -166,9 +699,15 @@ const parseXcodeProjectInfo = async (sourceDir) => {
|
|
|
166
699
|
//#endregion
|
|
167
700
|
//#region src/utils/prettifyXcodebuildError.ts
|
|
168
701
|
const prettifyXcodebuildError = (error) => {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
702
|
+
if (error instanceof _hot_updater_cli_tools.BuildFailedError) {
|
|
703
|
+
const errorLines = error.summary.join("\n");
|
|
704
|
+
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;
|
|
705
|
+
return new _hot_updater_cli_tools.BuildFailedError(`Xcodebuild failed: ${message}`, {
|
|
706
|
+
logPath: error.logPath,
|
|
707
|
+
summary: error.summary,
|
|
708
|
+
cause: error.cause
|
|
709
|
+
});
|
|
710
|
+
}
|
|
172
711
|
return /* @__PURE__ */ new Error(`Xcodebuild failed: ${error}`);
|
|
173
712
|
};
|
|
174
713
|
//#endregion
|
|
@@ -211,13 +750,20 @@ const createXcodebuildLogger = ({ logPrefix }) => new _hot_updater_cli_tools.Bui
|
|
|
211
750
|
"BUILD SUCCEEDED",
|
|
212
751
|
"ARCHIVE SUCCEEDED"
|
|
213
752
|
]
|
|
214
|
-
]
|
|
753
|
+
],
|
|
754
|
+
failureSummary: {
|
|
755
|
+
lines: [/(^|:\s)error: /, /^\*\* [A-Z ]+ FAILED \*\*$/],
|
|
756
|
+
blocks: [{
|
|
757
|
+
start: "The following build commands failed:",
|
|
758
|
+
end: /^\(\d+ failures?\)$/,
|
|
759
|
+
includeEnd: true
|
|
760
|
+
}]
|
|
761
|
+
}
|
|
215
762
|
});
|
|
216
763
|
//#endregion
|
|
217
764
|
//#region src/utils/runXcodebuildWithLogging.ts
|
|
218
765
|
let cachedXcbeautifyAvailability = null;
|
|
219
|
-
const TERMINATION_SIGNALS = ["SIGINT", "SIGTERM"];
|
|
220
|
-
const IGNORED_PIPELINE_ERROR_CODES = ["EPIPE", "ERR_STREAM_PREMATURE_CLOSE"];
|
|
766
|
+
const TERMINATION_SIGNALS$1 = ["SIGINT", "SIGTERM"];
|
|
221
767
|
const isXcbeautifyAvailable = async () => {
|
|
222
768
|
if (cachedXcbeautifyAvailability !== null) return cachedXcbeautifyAvailability;
|
|
223
769
|
try {
|
|
@@ -233,6 +779,7 @@ const createRawXcodebuildProcess = ({ args, env, sourceDir }) => {
|
|
|
233
779
|
return (0, execa.execa)("xcodebuild", args, {
|
|
234
780
|
cwd: sourceDir,
|
|
235
781
|
all: true,
|
|
782
|
+
buffer: false,
|
|
236
783
|
env: {
|
|
237
784
|
...process.env,
|
|
238
785
|
...env,
|
|
@@ -243,29 +790,17 @@ const createRawXcodebuildProcess = ({ args, env, sourceDir }) => {
|
|
|
243
790
|
const createXcbeautifyProcess = ({ cwd }) => {
|
|
244
791
|
return (0, execa.execa)("xcbeautify", [], {
|
|
245
792
|
cwd,
|
|
246
|
-
all: true
|
|
793
|
+
all: true,
|
|
794
|
+
buffer: false
|
|
247
795
|
});
|
|
248
796
|
};
|
|
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;
|
|
797
|
+
/** Why xcodebuild failed, without its command line. */
|
|
798
|
+
const describeFailure = (error) => {
|
|
799
|
+
if (error instanceof execa.ExecaError) {
|
|
800
|
+
if (error.signal) return `xcodebuild was stopped by ${error.signal}.`;
|
|
801
|
+
if (error.exitCode !== void 0) return `xcodebuild exited with code ${error.exitCode}.`;
|
|
264
802
|
}
|
|
265
|
-
|
|
266
|
-
const processOutputStream = async ({ outputStream, logger }) => {
|
|
267
|
-
if (!outputStream) return;
|
|
268
|
-
await logger.processStream(outputStream);
|
|
803
|
+
return error instanceof Error ? error.message : String(error);
|
|
269
804
|
};
|
|
270
805
|
const runXcodebuildWithLogging = async ({ args, env, sourceDir, successMessage, failureMessage, logPrefix }) => {
|
|
271
806
|
const useXcbeautify = await isXcbeautifyAvailable();
|
|
@@ -275,97 +810,185 @@ const runXcodebuildWithLogging = async ({ args, env, sourceDir, successMessage,
|
|
|
275
810
|
const terminateActiveProcesses = () => {
|
|
276
811
|
for (const activeProcess of activeProcesses) activeProcess.kill();
|
|
277
812
|
};
|
|
278
|
-
for (const signal of TERMINATION_SIGNALS) process.once(signal, terminateActiveProcesses);
|
|
813
|
+
for (const signal of TERMINATION_SIGNALS$1) process.once(signal, terminateActiveProcesses);
|
|
279
814
|
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
|
|
815
|
+
const xcodebuildProcess = createRawXcodebuildProcess({
|
|
816
|
+
args,
|
|
817
|
+
env,
|
|
818
|
+
sourceDir
|
|
819
|
+
});
|
|
820
|
+
activeProcesses.push(xcodebuildProcess);
|
|
821
|
+
const xcodebuildOutput = xcodebuildProcess.all ?? xcodebuildProcess.stdout;
|
|
822
|
+
let xcbeautifyProcess = null;
|
|
823
|
+
if (useXcbeautify && xcodebuildOutput) {
|
|
824
|
+
xcbeautifyProcess = createXcbeautifyProcess({ cwd: sourceDir });
|
|
825
|
+
activeProcesses.push(xcbeautifyProcess);
|
|
826
|
+
}
|
|
827
|
+
const logging = xcodebuildOutput ? logger.processStream(xcodebuildOutput, { display: xcbeautifyProcess ? xcbeautifyProcess.all ?? xcbeautifyProcess.stdout ?? void 0 : void 0 }) : Promise.resolve();
|
|
828
|
+
const xcbeautifyInput = xcbeautifyProcess?.stdin;
|
|
829
|
+
if (xcodebuildOutput && xcbeautifyInput) {
|
|
830
|
+
xcbeautifyInput.on("error", () => {
|
|
831
|
+
xcodebuildOutput.unpipe(xcbeautifyInput);
|
|
314
832
|
});
|
|
315
|
-
|
|
833
|
+
xcodebuildOutput.pipe(xcbeautifyInput);
|
|
316
834
|
}
|
|
835
|
+
const [xcodebuildResult, loggingResult] = await Promise.allSettled([
|
|
836
|
+
xcodebuildProcess,
|
|
837
|
+
logging,
|
|
838
|
+
xcbeautifyProcess
|
|
839
|
+
]);
|
|
840
|
+
if (xcodebuildResult.status === "rejected") throw xcodebuildResult.reason;
|
|
841
|
+
if (loggingResult.status === "rejected") throw loggingResult.reason;
|
|
317
842
|
logger.stop(successMessage);
|
|
318
843
|
} catch (error) {
|
|
319
844
|
logger.writeError(error);
|
|
320
845
|
logger.stop(failureMessage, false);
|
|
321
|
-
throw error
|
|
846
|
+
throw new _hot_updater_cli_tools.BuildFailedError(`${failureMessage}: ${describeFailure(error)}`, {
|
|
847
|
+
logPath: logger.logPath,
|
|
848
|
+
summary: logger.failureSummary,
|
|
849
|
+
cause: error
|
|
850
|
+
});
|
|
322
851
|
} finally {
|
|
323
|
-
for (const signal of TERMINATION_SIGNALS) process.off(signal, terminateActiveProcesses);
|
|
852
|
+
for (const signal of TERMINATION_SIGNALS$1) process.off(signal, terminateActiveProcesses);
|
|
324
853
|
await logger.close();
|
|
325
854
|
}
|
|
326
855
|
};
|
|
327
856
|
//#endregion
|
|
857
|
+
//#region src/utils/writeBuildXcconfig.ts
|
|
858
|
+
const SDK_CONDITIONS = [
|
|
859
|
+
"",
|
|
860
|
+
"[sdk=iphoneos*]",
|
|
861
|
+
"[sdk=iphonesimulator*]"
|
|
862
|
+
];
|
|
863
|
+
/**
|
|
864
|
+
* Finds a setting of `keys` that an included xcconfig sets with a condition
|
|
865
|
+
* the build xcconfig does not, such as `[config=Release]` or `[arch=arm64]`,
|
|
866
|
+
* which would outrank the build's own. It follows `#include`s, which are
|
|
867
|
+
* relative to the including file.
|
|
868
|
+
*/
|
|
869
|
+
const findConditionalSetting = async (files, keys, visited = /* @__PURE__ */ new Set()) => {
|
|
870
|
+
for (const file of files) {
|
|
871
|
+
if (visited.has(file) || !fs.default.existsSync(file)) continue;
|
|
872
|
+
visited.add(file);
|
|
873
|
+
const lines = (await fs.default.promises.readFile(file, "utf-8")).split("\n");
|
|
874
|
+
for (const [index, line] of lines.entries()) {
|
|
875
|
+
const include = line.match(/^\s*#include\??\s*"([^"]+)"/)?.[1];
|
|
876
|
+
if (include) {
|
|
877
|
+
const found = await findConditionalSetting([path.default.resolve(path.default.dirname(file), include)], keys, visited);
|
|
878
|
+
if (found) return found;
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
const [, key, conditions] = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)((?:\[[^\]]*\])+)\s*=/) ?? [];
|
|
882
|
+
if (key && keys.includes(key) && !SDK_CONDITIONS.includes(conditions)) return `${file}:${index + 1} sets ${key}${conditions}`;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
/**
|
|
887
|
+
* Writes the xcconfig a build loads, which sets the CLI's build settings
|
|
888
|
+
* over every other. xcodebuild ranks an `-xcconfig`'s settings above the
|
|
889
|
+
* command line's, so the scheme's `xcconfig` and each `-xcconfig` in
|
|
890
|
+
* `extraParams` are included, in order, before `settings`, and the build
|
|
891
|
+
* loads only this one.
|
|
892
|
+
*/
|
|
893
|
+
const writeBuildXcconfig = async ({ directory, sourceDir, xcconfig, extraParams = [], settings }) => {
|
|
894
|
+
const included = xcconfig ? [xcconfig] : [];
|
|
895
|
+
const otherParams = [];
|
|
896
|
+
for (let i = 0; i < extraParams.length; i++) if (extraParams[i] === "-xcconfig" && i + 1 < extraParams.length) included.push(extraParams[++i]);
|
|
897
|
+
else otherParams.push(extraParams[i]);
|
|
898
|
+
const includedFiles = included.map((file) => path.default.resolve(sourceDir, file));
|
|
899
|
+
const conditional = await findConditionalSetting(includedFiles, Object.keys(settings));
|
|
900
|
+
if (conditional) throw new Error(`${conditional}, which would outrank the value the build sets. Set it without that condition, or remove it.`);
|
|
901
|
+
const buildXcconfig = path.default.join(directory, "HotUpdater.xcconfig");
|
|
902
|
+
await fs.default.promises.writeFile(buildXcconfig, [
|
|
903
|
+
...includedFiles.map((file) => `#include "${file}"`),
|
|
904
|
+
...Object.entries(settings).flatMap(([key, value]) => SDK_CONDITIONS.map((condition) => `${key}${condition} = ${value}`)),
|
|
905
|
+
""
|
|
906
|
+
].join("\n"));
|
|
907
|
+
return {
|
|
908
|
+
xcconfig: buildXcconfig,
|
|
909
|
+
extraParams: otherParams
|
|
910
|
+
};
|
|
911
|
+
};
|
|
912
|
+
/**
|
|
913
|
+
* Automatic signing over a target set to manual signing, whose provisioning
|
|
914
|
+
* profile and identity Xcode would otherwise report as conflicting.
|
|
915
|
+
*/
|
|
916
|
+
const AUTOMATIC_SIGNING = {
|
|
917
|
+
CODE_SIGN_STYLE: "Automatic",
|
|
918
|
+
PROVISIONING_PROFILE: "",
|
|
919
|
+
PROVISIONING_PROFILE_SPECIFIER: "",
|
|
920
|
+
CODE_SIGN_IDENTITY: "Apple Development"
|
|
921
|
+
};
|
|
922
|
+
/**
|
|
923
|
+
* The settings a build sets over the scheme's: the MIN_BUNDLE_ID the CLI
|
|
924
|
+
* issues and reports, and automatic signing when an App Store Connect API
|
|
925
|
+
* key signs, which a target set to manual signing would not use.
|
|
926
|
+
*/
|
|
927
|
+
const buildSettingsOverScheme = ({ minBundleId, authenticationArgs }) => ({
|
|
928
|
+
HOT_UPDATER_MIN_BUNDLE_ID: minBundleId,
|
|
929
|
+
...authenticationArgs ? AUTOMATIC_SIGNING : {}
|
|
930
|
+
});
|
|
931
|
+
//#endregion
|
|
328
932
|
//#region src/builder/archiveXcodeProject.ts
|
|
329
|
-
|
|
330
|
-
|
|
933
|
+
/**
|
|
934
|
+
* Archives the scheme into `archiveDirectory`. The build's xcconfig is
|
|
935
|
+
* written to a temporary directory, removed whether the archive succeeds or
|
|
936
|
+
* not.
|
|
937
|
+
*/
|
|
938
|
+
const archiveXcodeProject = async ({ sourceDir, archiveDirectory, platform, installPods, destination, extraParams, configuration, xcodeScheme, xcconfig, logPrefix, minBundleId, authenticationArgs }) => {
|
|
331
939
|
if (installPods) await installPodsIfNeeded(sourceDir);
|
|
332
|
-
const
|
|
940
|
+
const xcodeProject = await parseXcodeProjectInfo(sourceDir);
|
|
333
941
|
const archiveName = `${xcodeProject.name.replace(".xcworkspace", "").replace(".xcodeproj", "")}.xcarchive`;
|
|
334
|
-
const archivePath = path.default.join(
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
942
|
+
const archivePath = path.default.join(archiveDirectory, archiveName);
|
|
943
|
+
const tmpDir = await createRandomTmpDir();
|
|
944
|
+
try {
|
|
945
|
+
const build = await writeBuildXcconfig({
|
|
946
|
+
directory: tmpDir,
|
|
947
|
+
sourceDir,
|
|
948
|
+
xcconfig,
|
|
949
|
+
extraParams,
|
|
950
|
+
settings: buildSettingsOverScheme({
|
|
951
|
+
minBundleId,
|
|
952
|
+
authenticationArgs
|
|
953
|
+
})
|
|
954
|
+
});
|
|
955
|
+
const archiveArgs = prepareArchiveArgs({
|
|
956
|
+
authenticationArgs,
|
|
957
|
+
minBundleId,
|
|
958
|
+
archivePath,
|
|
959
|
+
platform,
|
|
960
|
+
sourceDir,
|
|
961
|
+
xcodeProject,
|
|
962
|
+
configuration,
|
|
963
|
+
extraParams: build.extraParams,
|
|
964
|
+
destination,
|
|
965
|
+
xcconfig: build.xcconfig,
|
|
966
|
+
xcodeScheme
|
|
967
|
+
});
|
|
968
|
+
_hot_updater_cli_tools.p.log.info(`Xcode Archive Settings:
|
|
349
969
|
Project ${xcodeProject.name}
|
|
350
970
|
Scheme ${xcodeScheme}
|
|
351
971
|
Platform ${platform}
|
|
352
972
|
Command xcodebuild ${archiveArgs.join(" ")}
|
|
353
973
|
`);
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
974
|
+
try {
|
|
975
|
+
await runXcodebuildWithLogging({
|
|
976
|
+
args: archiveArgs,
|
|
977
|
+
env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
|
|
978
|
+
failureMessage: "Archive failed",
|
|
979
|
+
logPrefix,
|
|
980
|
+
sourceDir,
|
|
981
|
+
successMessage: "Archive completed successfully"
|
|
982
|
+
});
|
|
983
|
+
return { archivePath };
|
|
984
|
+
} catch (error) {
|
|
985
|
+
throw prettifyXcodebuildError(error);
|
|
986
|
+
}
|
|
987
|
+
} finally {
|
|
988
|
+
await removeTmpDir(tmpDir);
|
|
366
989
|
}
|
|
367
990
|
};
|
|
368
|
-
const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xcodeProject, configuration = "Release", xcconfig, extraParams, destination = [], minBundleId }) => {
|
|
991
|
+
const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xcodeProject, configuration = "Release", xcconfig, extraParams, destination = [], minBundleId, authenticationArgs }) => {
|
|
369
992
|
const args = [
|
|
370
993
|
xcodeProject.isWorkspace ? "-workspace" : "-project",
|
|
371
994
|
path.default.join(sourceDir, xcodeProject.name),
|
|
@@ -378,6 +1001,7 @@ const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xco
|
|
|
378
1001
|
archivePath,
|
|
379
1002
|
`HOT_UPDATER_MIN_BUNDLE_ID=${minBundleId}`
|
|
380
1003
|
];
|
|
1004
|
+
if (authenticationArgs) args.push(...authenticationArgs);
|
|
381
1005
|
if (xcconfig) args.push("-xcconfig", xcconfig);
|
|
382
1006
|
if (extraParams) args.push(...extraParams);
|
|
383
1007
|
const resolvedDestinations = resolveDestinations({
|
|
@@ -394,62 +1018,86 @@ const prepareArchiveArgs = ({ xcodeScheme, archivePath, platform, sourceDir, xco
|
|
|
394
1018
|
};
|
|
395
1019
|
//#endregion
|
|
396
1020
|
//#region src/builder/buildXcodeProject.ts
|
|
397
|
-
|
|
398
|
-
|
|
1021
|
+
/**
|
|
1022
|
+
* Builds the scheme in a temporary DerivedData directory, copies the built
|
|
1023
|
+
* `.app` to `outputDirectory`, and removes DerivedData, which takes 10 GB or
|
|
1024
|
+
* more, whether the build succeeds or not.
|
|
1025
|
+
*/
|
|
1026
|
+
const buildXcodeProject = async ({ sourceDir, outputDirectory, platform, xcodeScheme, configuration, deviceType, logPrefix, destination = [], useGenericDestination = false, installPods, extraParams, minBundleId, authenticationArgs }) => {
|
|
399
1027
|
if (installPods) await installPodsIfNeeded(sourceDir);
|
|
400
|
-
const
|
|
401
|
-
const
|
|
402
|
-
destinations: destination,
|
|
403
|
-
useGeneric: useGenericDestination
|
|
404
|
-
});
|
|
405
|
-
if (resolvedDestinations.length === 0) resolvedDestinations.push(getDefaultDestination({
|
|
406
|
-
deviceType,
|
|
407
|
-
platform,
|
|
408
|
-
useGeneric: useGenericDestination
|
|
409
|
-
}));
|
|
410
|
-
const minBundleId = (0, _hot_updater_plugin_core.generateMinBundleId)();
|
|
411
|
-
const buildArgs = prepareBuildArgs({
|
|
412
|
-
minBundleId,
|
|
413
|
-
configuration,
|
|
414
|
-
derivedDataPath,
|
|
415
|
-
deviceType,
|
|
416
|
-
resolvedDestinations,
|
|
417
|
-
extraParams,
|
|
418
|
-
platform,
|
|
419
|
-
xcodeScheme,
|
|
420
|
-
sourceDir,
|
|
421
|
-
xcodeProject
|
|
422
|
-
});
|
|
423
|
-
_hot_updater_cli_tools.p.log.info(`Xcode Build Settings:
|
|
424
|
-
Project ${xcodeProject.name}
|
|
425
|
-
Scheme ${xcodeScheme}
|
|
426
|
-
Configuration ${configuration}
|
|
427
|
-
Platform ${platform}
|
|
428
|
-
Device Type ${deviceType}
|
|
429
|
-
Command xcodebuild ${buildArgs.join(" ")}
|
|
430
|
-
`);
|
|
1028
|
+
const xcodeProject = await parseXcodeProjectInfo(sourceDir);
|
|
1029
|
+
const tmpDir = await createRandomTmpDir();
|
|
431
1030
|
try {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
1031
|
+
const derivedDataPath = path.default.join(tmpDir, "DerivedData");
|
|
1032
|
+
const resolvedDestinations = resolveDestinations({
|
|
1033
|
+
destinations: destination,
|
|
1034
|
+
useGeneric: useGenericDestination
|
|
1035
|
+
});
|
|
1036
|
+
if (resolvedDestinations.length === 0) resolvedDestinations.push(getDefaultDestination({
|
|
1037
|
+
deviceType,
|
|
1038
|
+
platform,
|
|
1039
|
+
useGeneric: useGenericDestination
|
|
1040
|
+
}));
|
|
1041
|
+
const build = await writeBuildXcconfig({
|
|
1042
|
+
directory: tmpDir,
|
|
435
1043
|
sourceDir,
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
1044
|
+
extraParams,
|
|
1045
|
+
settings: buildSettingsOverScheme({
|
|
1046
|
+
minBundleId,
|
|
1047
|
+
authenticationArgs
|
|
1048
|
+
})
|
|
439
1049
|
});
|
|
440
|
-
|
|
1050
|
+
const buildArgs = prepareBuildArgs({
|
|
1051
|
+
authenticationArgs,
|
|
1052
|
+
xcconfig: build.xcconfig,
|
|
1053
|
+
minBundleId,
|
|
441
1054
|
configuration,
|
|
442
1055
|
derivedDataPath,
|
|
1056
|
+
deviceType,
|
|
443
1057
|
resolvedDestinations,
|
|
1058
|
+
extraParams: build.extraParams,
|
|
1059
|
+
platform,
|
|
444
1060
|
xcodeScheme,
|
|
445
1061
|
sourceDir,
|
|
446
1062
|
xcodeProject
|
|
447
1063
|
});
|
|
448
|
-
|
|
449
|
-
|
|
1064
|
+
_hot_updater_cli_tools.p.log.info(`Xcode Build Settings:
|
|
1065
|
+
Project ${xcodeProject.name}
|
|
1066
|
+
Scheme ${xcodeScheme}
|
|
1067
|
+
Configuration ${configuration}
|
|
1068
|
+
Platform ${platform}
|
|
1069
|
+
Device Type ${deviceType}
|
|
1070
|
+
Command xcodebuild ${buildArgs.join(" ")}
|
|
1071
|
+
`);
|
|
1072
|
+
let builtAppPath;
|
|
1073
|
+
try {
|
|
1074
|
+
await runXcodebuildWithLogging({
|
|
1075
|
+
args: buildArgs,
|
|
1076
|
+
env: { HOT_UPDATER_MIN_BUNDLE_ID: minBundleId },
|
|
1077
|
+
sourceDir,
|
|
1078
|
+
logPrefix,
|
|
1079
|
+
successMessage: "Build completed successfully",
|
|
1080
|
+
failureMessage: "Build failed"
|
|
1081
|
+
});
|
|
1082
|
+
({appPath: builtAppPath} = await getBuildSettings({
|
|
1083
|
+
configuration,
|
|
1084
|
+
derivedDataPath,
|
|
1085
|
+
resolvedDestinations,
|
|
1086
|
+
xcodeScheme,
|
|
1087
|
+
sourceDir,
|
|
1088
|
+
xcodeProject
|
|
1089
|
+
}));
|
|
1090
|
+
} catch (error) {
|
|
1091
|
+
throw prettifyXcodebuildError(error);
|
|
1092
|
+
}
|
|
1093
|
+
const appPath = path.default.join(outputDirectory, path.default.basename(builtAppPath));
|
|
1094
|
+
await fs.default.promises.cp(builtAppPath, appPath, { recursive: true });
|
|
1095
|
+
return { appPath };
|
|
1096
|
+
} finally {
|
|
1097
|
+
await removeTmpDir(tmpDir);
|
|
450
1098
|
}
|
|
451
1099
|
};
|
|
452
|
-
const prepareBuildArgs = ({ xcodeProject, sourceDir, platform, xcodeScheme, configuration, deviceType, resolvedDestinations, derivedDataPath, extraParams, minBundleId }) => {
|
|
1100
|
+
const prepareBuildArgs = ({ xcodeProject, sourceDir, platform, xcodeScheme, configuration, deviceType, resolvedDestinations, derivedDataPath, extraParams, minBundleId, authenticationArgs, xcconfig }) => {
|
|
453
1101
|
const sdk = deviceType === "simulator" ? platformConfigs[platform].simulatorSdk : platformConfigs[platform].deviceSdk;
|
|
454
1102
|
const args = [
|
|
455
1103
|
xcodeProject.isWorkspace ? "-workspace" : "-project",
|
|
@@ -465,6 +1113,8 @@ const prepareBuildArgs = ({ xcodeProject, sourceDir, platform, xcodeScheme, conf
|
|
|
465
1113
|
`HOT_UPDATER_MIN_BUNDLE_ID=${minBundleId}`,
|
|
466
1114
|
"build"
|
|
467
1115
|
];
|
|
1116
|
+
if (authenticationArgs) args.push(...authenticationArgs);
|
|
1117
|
+
args.push("-xcconfig", xcconfig);
|
|
468
1118
|
if (extraParams) args.push(...extraParams);
|
|
469
1119
|
for (const dest of resolvedDestinations) args.push("-destination", dest);
|
|
470
1120
|
return args;
|
|
@@ -501,9 +1151,10 @@ const getBuildSettings = async ({ xcodeProject, sourceDir, xcodeScheme, configur
|
|
|
501
1151
|
};
|
|
502
1152
|
//#endregion
|
|
503
1153
|
//#region src/builder/exportXcodeArchive.ts
|
|
504
|
-
|
|
505
|
-
|
|
1154
|
+
/** Exports the archive into `exportPath`. */
|
|
1155
|
+
const exportXcodeArchive = async ({ archivePath, exportPath, sourceDir, exportExtraParams, exportOptionsPlist, logPrefix, authenticationArgs }) => {
|
|
506
1156
|
const exportArgs = prepareExportArgs({
|
|
1157
|
+
authenticationArgs,
|
|
507
1158
|
archivePath,
|
|
508
1159
|
exportPath,
|
|
509
1160
|
exportOptionsPlist,
|
|
@@ -522,7 +1173,7 @@ const exportXcodeArchive = async ({ archivePath, sourceDir, exportExtraParams, e
|
|
|
522
1173
|
throw prettifyXcodebuildError(error);
|
|
523
1174
|
}
|
|
524
1175
|
};
|
|
525
|
-
const prepareExportArgs = ({ exportPath, archivePath, exportExtraParams, exportOptionsPlist }) => {
|
|
1176
|
+
const prepareExportArgs = ({ exportPath, archivePath, exportExtraParams, exportOptionsPlist, authenticationArgs }) => {
|
|
526
1177
|
const args = [
|
|
527
1178
|
"-exportArchive",
|
|
528
1179
|
"-archivePath",
|
|
@@ -530,6 +1181,7 @@ const prepareExportArgs = ({ exportPath, archivePath, exportExtraParams, exportO
|
|
|
530
1181
|
"-exportPath",
|
|
531
1182
|
exportPath
|
|
532
1183
|
];
|
|
1184
|
+
if (authenticationArgs) args.push(...authenticationArgs);
|
|
533
1185
|
if (exportExtraParams) args.push(...exportExtraParams);
|
|
534
1186
|
args.push("-exportOptionsPlist", exportOptionsPlist);
|
|
535
1187
|
return args;
|
|
@@ -545,446 +1197,1133 @@ const assertXcodebuildExist = async () => {
|
|
|
545
1197
|
}
|
|
546
1198
|
};
|
|
547
1199
|
//#endregion
|
|
548
|
-
//#region src/
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
1200
|
+
//#region src/utils/escapeUnzipPattern.ts
|
|
1201
|
+
/**
|
|
1202
|
+
* A zip entry name as `unzip` reads a literal member. It takes `*`, `?` and
|
|
1203
|
+
* `[...]` in a member name as wildcards, which a backslash escapes, so an
|
|
1204
|
+
* app named `My[Dev].app` would otherwise match nothing.
|
|
1205
|
+
*/
|
|
1206
|
+
const escapeUnzipPattern = (name) => name.replace(/[[\]*?\\]/gu, "\\$&");
|
|
1207
|
+
//#endregion
|
|
1208
|
+
//#region src/utils/readIpaInfo.ts
|
|
1209
|
+
const INFO_PLIST_KEYS = {
|
|
1210
|
+
bundleId: "CFBundleIdentifier",
|
|
1211
|
+
shortVersion: "CFBundleShortVersionString",
|
|
1212
|
+
bundleVersion: "CFBundleVersion"
|
|
1213
|
+
};
|
|
1214
|
+
/**
|
|
1215
|
+
* Reads the app's `Payload/<App>.app/Info.plist` out of an `.ipa`, never a
|
|
1216
|
+
* framework's or an extension's, which nest deeper.
|
|
1217
|
+
*/
|
|
1218
|
+
const readIpaInfo = async (ipaPath) => {
|
|
1219
|
+
const { stdout } = await (0, execa.execa)("unzip", ["-Z1", ipaPath]);
|
|
1220
|
+
const entry = stdout.split("\n").map((name) => name.trim()).find((name) => /^Payload\/[^/]+\.app\/Info\.plist$/u.test(name));
|
|
1221
|
+
if (entry === void 0) throw new Error(`${path.default.basename(ipaPath)} has no Payload/<App>.app/Info.plist.`);
|
|
1222
|
+
const directory = await createRandomTmpDir();
|
|
1223
|
+
try {
|
|
1224
|
+
const infoPlist = path.default.join(directory, "Info.plist");
|
|
1225
|
+
await (0, execa.execa)("unzip", [
|
|
1226
|
+
"-p",
|
|
1227
|
+
ipaPath,
|
|
1228
|
+
escapeUnzipPattern(entry)
|
|
1229
|
+
], { stdout: { file: infoPlist } });
|
|
1230
|
+
const info = {};
|
|
1231
|
+
for (const [field, key] of Object.entries(INFO_PLIST_KEYS)) {
|
|
1232
|
+
const { stdout: value, exitCode } = await (0, execa.execa)("plutil", [
|
|
1233
|
+
"-extract",
|
|
1234
|
+
key,
|
|
1235
|
+
"raw",
|
|
1236
|
+
"-o",
|
|
1237
|
+
"-",
|
|
1238
|
+
infoPlist
|
|
1239
|
+
], { reject: false });
|
|
1240
|
+
if (exitCode !== 0 || !value.trim()) throw new Error(`${path.default.basename(ipaPath)}'s Info.plist has no ${key}.`);
|
|
1241
|
+
info[field] = value.trim();
|
|
560
1242
|
}
|
|
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
|
|
1243
|
+
return info;
|
|
1244
|
+
} finally {
|
|
1245
|
+
await fs.default.promises.rm(directory, {
|
|
1246
|
+
recursive: true,
|
|
1247
|
+
force: true
|
|
573
1248
|
});
|
|
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`
|
|
592
|
-
});
|
|
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
1249
|
}
|
|
622
1250
|
};
|
|
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
1251
|
//#endregion
|
|
631
|
-
//#region src/
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
1252
|
+
//#region src/utils/xcodeSigning.ts
|
|
1253
|
+
/**
|
|
1254
|
+
* How a device build signs with automatic signing, authenticated by the
|
|
1255
|
+
* scheme's App Store Connect API key: the xcodebuild arguments, and the key.
|
|
1256
|
+
* The key is resolved only here, for a device build of a scheme with
|
|
1257
|
+
* `signing`. Without `credentials`, it resolves from the environment or the
|
|
1258
|
+
* config's `secrets` adapter, and the temporary files are deleted when the
|
|
1259
|
+
* process exits.
|
|
1260
|
+
*/
|
|
1261
|
+
const resolveXcodeSigning = async ({ schemeConfig, credentials }) => {
|
|
1262
|
+
const reference = schemeConfig.signing?.ascApiKey;
|
|
1263
|
+
if (!reference) return;
|
|
1264
|
+
const apiKey = await (credentials ?? (0, _hot_updater_cli_tools.createCredentialResolver)()).resolve({
|
|
1265
|
+
kind: "asc-api-key",
|
|
1266
|
+
name: (0, _hot_updater_cli_tools.parseSecretReference)(reference, "signing.ascApiKey")
|
|
641
1267
|
});
|
|
1268
|
+
return {
|
|
1269
|
+
authenticationArgs: [
|
|
1270
|
+
"-allowProvisioningUpdates",
|
|
1271
|
+
"-authenticationKeyPath",
|
|
1272
|
+
apiKey.keyPath,
|
|
1273
|
+
"-authenticationKeyID",
|
|
1274
|
+
apiKey.keyId,
|
|
1275
|
+
"-authenticationKeyIssuerID",
|
|
1276
|
+
apiKey.issuerId
|
|
1277
|
+
],
|
|
1278
|
+
apiKey
|
|
1279
|
+
};
|
|
642
1280
|
};
|
|
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
|
-
}
|
|
1281
|
+
const APP_STORE_CONNECT_EXPORT_OPTIONS = `<?xml version="1.0" encoding="UTF-8"?>
|
|
1282
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1283
|
+
<plist version="1.0">
|
|
1284
|
+
<dict>
|
|
1285
|
+
<key>method</key>
|
|
1286
|
+
<string>app-store-connect</string>
|
|
1287
|
+
<key>signingStyle</key>
|
|
1288
|
+
<string>automatic</string>
|
|
1289
|
+
</dict>
|
|
1290
|
+
</plist>
|
|
1291
|
+
`;
|
|
1292
|
+
/**
|
|
1293
|
+
* Writes the export options of a scheme that signs with an App Store Connect
|
|
1294
|
+
* API key and sets no `exportOptionsPlist`: an App Store Connect export with
|
|
1295
|
+
* automatic signing.
|
|
1296
|
+
*/
|
|
1297
|
+
const writeAppStoreConnectExportOptions = async (directory) => {
|
|
1298
|
+
const exportOptionsPlist = path.default.join(directory, "ExportOptions.plist");
|
|
1299
|
+
await fs.default.promises.writeFile(exportOptionsPlist, APP_STORE_CONNECT_EXPORT_OPTIONS);
|
|
1300
|
+
return exportOptionsPlist;
|
|
682
1301
|
};
|
|
1302
|
+
/** The export methods whose `.ipa` App Store Connect takes. */
|
|
1303
|
+
const APP_STORE_EXPORT_METHODS = /* @__PURE__ */ new Set(["app-store", "app-store-connect"]);
|
|
1304
|
+
/** The `method` of export options, or "" when it has none or can't be read. */
|
|
1305
|
+
const readExportMethod = async (exportOptionsPlist) => {
|
|
1306
|
+
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);
|
|
1307
|
+
if (xml) return xml[1];
|
|
1308
|
+
const { stdout, exitCode } = await (0, execa.execa)("plutil", [
|
|
1309
|
+
"-extract",
|
|
1310
|
+
"method",
|
|
1311
|
+
"raw",
|
|
1312
|
+
"-o",
|
|
1313
|
+
"-",
|
|
1314
|
+
exportOptionsPlist
|
|
1315
|
+
], { reject: false });
|
|
1316
|
+
return exitCode === 0 ? stdout.trim() : "";
|
|
1317
|
+
};
|
|
1318
|
+
/**
|
|
1319
|
+
* Whether a device build exports for App Store Connect: with its
|
|
1320
|
+
* `exportOptionsPlist`'s method, or, without one, as a scheme signing with
|
|
1321
|
+
* an App Store Connect API key does. Such an `.ipa` is signed with Apple's
|
|
1322
|
+
* cloud-managed distribution certificate, never with a development
|
|
1323
|
+
* certificate the archive was signed with.
|
|
1324
|
+
*/
|
|
1325
|
+
const exportsForAppStoreConnect = async (schemeConfig) => !schemeConfig.exportOptionsPlist || APP_STORE_EXPORT_METHODS.has(await readExportMethod(schemeConfig.exportOptionsPlist));
|
|
683
1326
|
//#endregion
|
|
684
|
-
//#region src/
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
1327
|
+
//#region src/uploadToTestFlight.ts
|
|
1328
|
+
const API_URL$1 = "https://api.appstoreconnect.apple.com";
|
|
1329
|
+
/** Apple accepts tokens that expire within 20 minutes. */
|
|
1330
|
+
const TOKEN_LIFETIME_SECONDS = 1200;
|
|
1331
|
+
/** A token is replaced well before it expires, so no request carries a stale one. */
|
|
1332
|
+
const TOKEN_REUSE_SECONDS = 600;
|
|
1333
|
+
/** The parts of an `.ipa` sent at once. */
|
|
1334
|
+
const UPLOAD_CONCURRENCY = 4;
|
|
1335
|
+
/** Attempts to create the build upload and to commit the uploaded `.ipa`. */
|
|
1336
|
+
const COMMIT_ATTEMPTS = 3;
|
|
1337
|
+
/** Attempts per part: a part is a plain PUT, safe to repeat. */
|
|
1338
|
+
const UPLOAD_ATTEMPTS = 3;
|
|
1339
|
+
const IN_PROGRESS_STATES = /* @__PURE__ */ new Set(["AWAITING_UPLOAD", "PROCESSING"]);
|
|
1340
|
+
/** The error App Store Connect answers with when a build number is taken. */
|
|
1341
|
+
const DUPLICATE_ERROR_CODE = "ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE";
|
|
1342
|
+
/**
|
|
1343
|
+
* App Store Connect refused the upload: it already has a build with the
|
|
1344
|
+
* `.ipa`'s version and build number, from this or another upload, and takes
|
|
1345
|
+
* each build number once per version.
|
|
1346
|
+
*/
|
|
1347
|
+
var TestFlightBuildExistsError = class extends Error {
|
|
1348
|
+
shortVersion;
|
|
1349
|
+
bundleVersion;
|
|
1350
|
+
name = "TestFlightBuildExistsError";
|
|
1351
|
+
constructor(shortVersion, bundleVersion, options) {
|
|
1352
|
+
super(`App Store Connect already has build ${bundleVersion} of version ${shortVersion}. Each build of a version needs a new build number (CFBundleVersion).`, options);
|
|
1353
|
+
this.shortVersion = shortVersion;
|
|
1354
|
+
this.bundleVersion = bundleVersion;
|
|
1355
|
+
}
|
|
696
1356
|
};
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1357
|
+
/** An App Store Connect API request that failed, with the errors it answered. */
|
|
1358
|
+
var AppStoreConnectError = class extends Error {
|
|
1359
|
+
status;
|
|
1360
|
+
errors;
|
|
1361
|
+
/** The answer's `Retry-After` header, for a rate limit. */
|
|
1362
|
+
retryAfter;
|
|
1363
|
+
constructor(action, status, errors, retryAfter = null) {
|
|
1364
|
+
const details = errors.map((error) => error.detail ?? error.title ?? error.code).filter(Boolean);
|
|
1365
|
+
super(`${action} failed (HTTP ${status})${details.length > 0 ? `: ${details.join(" ")}` : "."}`);
|
|
1366
|
+
this.name = "AppStoreConnectError";
|
|
1367
|
+
this.status = status;
|
|
1368
|
+
this.errors = errors;
|
|
1369
|
+
this.retryAfter = retryAfter;
|
|
1370
|
+
}
|
|
707
1371
|
};
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
1372
|
+
/**
|
|
1373
|
+
* An App Store Connect API token: an ES256 JWT, whose signature JWS encodes
|
|
1374
|
+
* as the raw 64-byte r || s, not the DER Node signs by default.
|
|
1375
|
+
*/
|
|
1376
|
+
const createAppStoreConnectToken = ({ privateKey, keyId, issuerId, issuedAtSeconds = Math.floor(Date.now() / 1e3) }) => {
|
|
1377
|
+
const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
1378
|
+
const signingInput = `${encode({
|
|
1379
|
+
alg: "ES256",
|
|
1380
|
+
kid: keyId,
|
|
1381
|
+
typ: "JWT"
|
|
1382
|
+
})}.${encode({
|
|
1383
|
+
iss: issuerId,
|
|
1384
|
+
iat: issuedAtSeconds,
|
|
1385
|
+
exp: issuedAtSeconds + TOKEN_LIFETIME_SECONDS,
|
|
1386
|
+
aud: "appstoreconnect-v1"
|
|
1387
|
+
})}`;
|
|
1388
|
+
return `${signingInput}.${crypto.default.sign("sha256", Buffer.from(signingInput), {
|
|
1389
|
+
key: privateKey,
|
|
1390
|
+
dsaEncoding: "ieee-p1363"
|
|
1391
|
+
}).toString("base64url")}`;
|
|
1392
|
+
};
|
|
1393
|
+
const readPrivateKey = async (keyPath) => {
|
|
711
1394
|
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}`);
|
|
1395
|
+
return crypto.default.createPrivateKey(await fs.default.promises.readFile(keyPath, "utf-8"));
|
|
1396
|
+
} catch {
|
|
1397
|
+
throw new Error("The App Store Connect API key is not a .p8 private key.");
|
|
722
1398
|
}
|
|
723
1399
|
};
|
|
724
|
-
const
|
|
725
|
-
const
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
1400
|
+
const readApiErrors = async (response) => {
|
|
1401
|
+
const body = await response.json().catch(() => void 0);
|
|
1402
|
+
return Array.isArray(body?.errors) ? body.errors : [];
|
|
1403
|
+
};
|
|
1404
|
+
const describeDetails = (details) => details.map((detail) => `${detail.description ?? "(no description)"}${detail.code ? ` (${detail.code})` : ""}`);
|
|
1405
|
+
/** Answers a request may outlive: a timeout, a rate limit, a server error. */
|
|
1406
|
+
const isRetryableStatus = (status) => status === 408 || status === 429 || status >= 500;
|
|
1407
|
+
/**
|
|
1408
|
+
* Whether a request may succeed when sent again: a retryable answer, or a
|
|
1409
|
+
* network failure, which fetch rejects with a TypeError. A refused key or
|
|
1410
|
+
* request is not.
|
|
1411
|
+
*/
|
|
1412
|
+
const isRetryable = (error) => error instanceof AppStoreConnectError ? isRetryableStatus(error.status) : error instanceof TypeError;
|
|
1413
|
+
/** Whether App Store Connect refused a request for its rate limit only. */
|
|
1414
|
+
const isRateLimited = (error) => error instanceof AppStoreConnectError && error.status === 429;
|
|
1415
|
+
/**
|
|
1416
|
+
* Runs `request`, again after a failure `retryable` accepts, up to the
|
|
1417
|
+
* attempts.
|
|
1418
|
+
*/
|
|
1419
|
+
const retrying = async (request, retryable = isRetryable) => {
|
|
1420
|
+
for (let attempt = 1;; attempt++) try {
|
|
1421
|
+
return await request();
|
|
735
1422
|
} catch (error) {
|
|
736
|
-
|
|
737
|
-
|
|
1423
|
+
if (!retryable(error) || attempt === COMMIT_ATTEMPTS) throw error;
|
|
1424
|
+
await sleep(retryDelayMs(error, attempt));
|
|
738
1425
|
}
|
|
739
1426
|
};
|
|
740
|
-
const
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
1427
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1428
|
+
/** The longest wait before a retry, so a run stays bounded. */
|
|
1429
|
+
const MAX_RETRY_DELAY_MS = 6e4;
|
|
1430
|
+
/**
|
|
1431
|
+
* The first wait after a rate limit without `Retry-After`: App Store Connect
|
|
1432
|
+
* limits a key per rolling hour, so a retry within seconds meets it again.
|
|
1433
|
+
*/
|
|
1434
|
+
const RATE_LIMIT_DELAY_MS = 15e3;
|
|
1435
|
+
/**
|
|
1436
|
+
* How long to wait before sending a request again after `failure`, an
|
|
1437
|
+
* answer or the error it raised: the `Retry-After` seconds it asks for, a
|
|
1438
|
+
* longer backoff for a rate limit without one, and else a growing backoff,
|
|
1439
|
+
* all within a bound.
|
|
1440
|
+
*/
|
|
1441
|
+
const retryDelayMs = (failure, attempt) => {
|
|
1442
|
+
const { status, retryAfter } = failure instanceof Response ? {
|
|
1443
|
+
status: failure.status,
|
|
1444
|
+
retryAfter: failure.headers.get("retry-after")
|
|
1445
|
+
} : failure instanceof AppStoreConnectError ? failure : {
|
|
1446
|
+
status: 0,
|
|
1447
|
+
retryAfter: null
|
|
1448
|
+
};
|
|
1449
|
+
const seconds = retryAfter === null || retryAfter.trim() === "" ? NaN : Number(retryAfter);
|
|
1450
|
+
const delay = Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : status === 429 ? RATE_LIMIT_DELAY_MS * attempt : 1e3 * attempt;
|
|
1451
|
+
return Math.min(delay, MAX_RETRY_DELAY_MS);
|
|
1452
|
+
};
|
|
1453
|
+
/**
|
|
1454
|
+
* Runs `run` over `items`, at most `concurrency` at a time. After the first
|
|
1455
|
+
* failure no item starts, and it throws that failure once every running item
|
|
1456
|
+
* has settled, so nothing is still sending when the caller cleans up.
|
|
1457
|
+
*/
|
|
1458
|
+
const runConcurrently = async (items, concurrency, run) => {
|
|
1459
|
+
let next = 0;
|
|
1460
|
+
let failure;
|
|
1461
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
1462
|
+
while (!failure && next < items.length) {
|
|
1463
|
+
const index = next++;
|
|
1464
|
+
try {
|
|
1465
|
+
await run(items[index], index);
|
|
1466
|
+
} catch (error) {
|
|
1467
|
+
failure ??= { error };
|
|
1468
|
+
}
|
|
751
1469
|
}
|
|
752
|
-
|
|
1470
|
+
}));
|
|
1471
|
+
if (failure) throw failure.error;
|
|
1472
|
+
};
|
|
1473
|
+
/**
|
|
1474
|
+
* A signing build setting's assignment: the key, any conditions such as
|
|
1475
|
+
* `[sdk=iphoneos*]`, and the value, without a trailing comment.
|
|
1476
|
+
*/
|
|
1477
|
+
const SIGNING_SETTING = /^\s*(CODE_SIGNING_ALLOWED|CODE_SIGNING_REQUIRED)((?:\[[^\]]*\])*)\s*=\s*([^\s/]*)\s*(?:\/\/.*)?$/u;
|
|
1478
|
+
/** A scheme's `exportOptionsPlist`, which resolves from the project's root. */
|
|
1479
|
+
const schemeFile = (file) => path.default.isAbsolute(file) ? file : path.default.resolve((0, _hot_updater_cli_tools.getCwd)(), file);
|
|
1480
|
+
/** Applies one assignment, as Xcode does: the last unconditional one wins. */
|
|
1481
|
+
const applySetting = (settings, line, label) => {
|
|
1482
|
+
const [, key, conditions, value] = line.match(SIGNING_SETTING) ?? [];
|
|
1483
|
+
if (!key) return;
|
|
1484
|
+
if (conditions) settings.conditional.add(key);
|
|
1485
|
+
else settings.values.set(key, {
|
|
1486
|
+
value,
|
|
1487
|
+
where: `${line.trim()} in ${label}`
|
|
1488
|
+
});
|
|
1489
|
+
};
|
|
1490
|
+
/**
|
|
1491
|
+
* Applies an xcconfig's signing settings over `settings`, in order,
|
|
1492
|
+
* following its `#include`s, which resolve from the including file, as
|
|
1493
|
+
* Xcode reads it. A file included again applies again; only an include
|
|
1494
|
+
* cycle (`including`) stops.
|
|
1495
|
+
*/
|
|
1496
|
+
const applyXcconfig = async (file, label, settings, including) => {
|
|
1497
|
+
if (including.has(file)) return;
|
|
1498
|
+
including.add(file);
|
|
1499
|
+
const uncommented = (await fs.default.promises.readFile(file, "utf-8").catch(() => void 0))?.replace(/\/\*[\s\S]*?\*\//gu, (comment) => comment.replace(/[^\n]/gu, ""));
|
|
1500
|
+
for (const line of uncommented?.split("\n") ?? []) {
|
|
1501
|
+
const include = line.match(/^\s*#include\??\s*"([^"]+)"/u)?.[1];
|
|
1502
|
+
if (include) await applyXcconfig(path.default.resolve(path.default.dirname(file), include), include, settings, including);
|
|
1503
|
+
else applySetting(settings, line, label);
|
|
753
1504
|
}
|
|
1505
|
+
including.delete(file);
|
|
754
1506
|
};
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
*
|
|
759
|
-
*
|
|
760
|
-
*
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
1507
|
+
/**
|
|
1508
|
+
* Where the scheme turns code signing off, after every override the build
|
|
1509
|
+
* applies: the `extraParams` settings first, then the xcconfigs the build
|
|
1510
|
+
* xcconfig includes over them, in order, its `xcconfig` and each
|
|
1511
|
+
* `-xcconfig` in `extraParams`, which resolve from `ios/`, where xcodebuild
|
|
1512
|
+
* runs (`writeBuildXcconfig`). Undefined when it signs, or when a
|
|
1513
|
+
* conditional setting, such as `[sdk=iphoneos*]`, may decide: the build
|
|
1514
|
+
* does.
|
|
1515
|
+
*/
|
|
1516
|
+
const codeSigningOff = async (iosScheme) => {
|
|
1517
|
+
const extraParams = iosScheme.extraParams ?? [];
|
|
1518
|
+
const settings = {
|
|
1519
|
+
values: /* @__PURE__ */ new Map(),
|
|
1520
|
+
conditional: /* @__PURE__ */ new Set()
|
|
1521
|
+
};
|
|
1522
|
+
for (const entry of extraParams) applySetting(settings, entry, "extraParams");
|
|
1523
|
+
const xcconfigs = [...iosScheme.xcconfig ? [iosScheme.xcconfig] : [], ...extraParams.flatMap((entry, index) => entry === "-xcconfig" && index + 1 < extraParams.length ? [extraParams[index + 1]] : [])];
|
|
1524
|
+
for (const xcconfig of xcconfigs) await applyXcconfig(path.default.resolve((0, _hot_updater_cli_tools.getCwd)(), "ios", xcconfig), xcconfig, settings, /* @__PURE__ */ new Set());
|
|
1525
|
+
for (const [key, { value, where }] of settings.values) if (/^no$/iu.test(value) && !settings.conditional.has(key)) return where;
|
|
771
1526
|
};
|
|
772
1527
|
/**
|
|
773
|
-
*
|
|
1528
|
+
* The App Store Connect API key a scheme's `.ipa` uploads with: the
|
|
1529
|
+
* `signing.ascApiKey` it signs with, or else the `asc-api-key` item. It reads
|
|
1530
|
+
* the config, and the scheme's files that exist before the native projects
|
|
1531
|
+
* are written, and throws, saying why, for a scheme whose build App Store
|
|
1532
|
+
* Connect would not take: a simulator `.app`, a Debug configuration, which
|
|
1533
|
+
* loads its JavaScript from Metro, code signing turned off, an
|
|
1534
|
+
* `.xcarchive` it cannot export, or export options of another method. An
|
|
1535
|
+
* `exportOptionsPlist` the adapter's prebuild writes later is checked after
|
|
1536
|
+
* it (`assertTestFlightExportOptions`).
|
|
774
1537
|
*/
|
|
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}`);
|
|
1538
|
+
const resolveTestFlightUploadKey = async ({ scheme, iosScheme }) => {
|
|
1539
|
+
if (iosScheme.simulator) throw new Error(`TestFlight takes an .ipa, and the scheme ${scheme} builds a simulator .app.`);
|
|
1540
|
+
const configuration = iosScheme.configuration ?? "Release";
|
|
1541
|
+
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.`);
|
|
1542
|
+
const unsigned = await codeSigningOff(iosScheme);
|
|
1543
|
+
if (unsigned) throw new Error(`TestFlight takes a signed .ipa, and the scheme ${scheme} turns code signing off (${unsigned}).`);
|
|
1544
|
+
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.`);
|
|
1545
|
+
if (iosScheme.exportOptionsPlist && fs.default.existsSync(schemeFile(iosScheme.exportOptionsPlist))) {
|
|
1546
|
+
const method = await readExportMethod(schemeFile(iosScheme.exportOptionsPlist));
|
|
1547
|
+
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
1548
|
}
|
|
1549
|
+
return {
|
|
1550
|
+
kind: "asc-api-key",
|
|
1551
|
+
name: iosScheme.signing ? (0, _hot_updater_cli_tools.parseSecretReference)(iosScheme.signing.ascApiKey, "signing.ascApiKey") : "asc-api-key"
|
|
1552
|
+
};
|
|
1553
|
+
};
|
|
1554
|
+
/**
|
|
1555
|
+
* Throws when the scheme's `exportOptionsPlist` exports with another method
|
|
1556
|
+
* than App Store Connect's, whose `.ipa` App Store Connect refuses. It reads
|
|
1557
|
+
* the file, which an adapter's prebuild may write, so it runs after it.
|
|
1558
|
+
*/
|
|
1559
|
+
const assertTestFlightExportOptions = async (schemeConfig) => {
|
|
1560
|
+
if (!schemeConfig.exportOptionsPlist) return;
|
|
1561
|
+
const method = await readExportMethod(schemeConfig.exportOptionsPlist);
|
|
1562
|
+
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.`);
|
|
1563
|
+
};
|
|
1564
|
+
/**
|
|
1565
|
+
* Requests to the App Store Connect API with `apiKey`: each carries a token
|
|
1566
|
+
* signed with its private key, replaced well before it expires.
|
|
1567
|
+
*/
|
|
1568
|
+
const createAppStoreConnectRequest = ({ apiKey, privateKey, fetch }) => {
|
|
1569
|
+
let token;
|
|
1570
|
+
const authorization = () => {
|
|
1571
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1572
|
+
if (!token || now - token.issuedAtSeconds >= TOKEN_REUSE_SECONDS) {
|
|
1573
|
+
token = {
|
|
1574
|
+
value: createAppStoreConnectToken({
|
|
1575
|
+
privateKey,
|
|
1576
|
+
keyId: apiKey.keyId,
|
|
1577
|
+
issuerId: apiKey.issuerId,
|
|
1578
|
+
issuedAtSeconds: now
|
|
1579
|
+
}),
|
|
1580
|
+
issuedAtSeconds: now
|
|
1581
|
+
};
|
|
1582
|
+
(0, _hot_updater_cli_tools.registerSecretValue)(token.value);
|
|
1583
|
+
}
|
|
1584
|
+
return `Bearer ${token.value}`;
|
|
1585
|
+
};
|
|
1586
|
+
return async (action, method, pathname, body) => {
|
|
1587
|
+
const response = await fetch(`${API_URL$1}${pathname}`, {
|
|
1588
|
+
method,
|
|
1589
|
+
headers: {
|
|
1590
|
+
Authorization: authorization(),
|
|
1591
|
+
...body ? { "Content-Type": "application/json" } : {}
|
|
1592
|
+
},
|
|
1593
|
+
...body ? { body: JSON.stringify(body) } : {}
|
|
1594
|
+
});
|
|
1595
|
+
if (!response.ok) {
|
|
1596
|
+
const error = new AppStoreConnectError(action, response.status, await readApiErrors(response), response.headers.get("retry-after"));
|
|
1597
|
+
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 });
|
|
1598
|
+
if (response.status === 403) throw new Error(`${error.message} An API key with the Admin, App Manager, or Developer role uploads builds.`, { cause: error });
|
|
1599
|
+
throw error;
|
|
1600
|
+
}
|
|
1601
|
+
return response.status === 204 ? void 0 : await response.json();
|
|
1602
|
+
};
|
|
1603
|
+
};
|
|
1604
|
+
/** The App Store Connect app of `bundleId`, and its TestFlight page. */
|
|
1605
|
+
const findApp = async (request, bundleId) => {
|
|
1606
|
+
const { data: apps } = await retrying(() => request("Finding the App Store Connect app", "GET", `/v1/apps?${new URLSearchParams({
|
|
1607
|
+
"filter[bundleId]": bundleId,
|
|
1608
|
+
"fields[apps]": "bundleId"
|
|
1609
|
+
})}`));
|
|
1610
|
+
const app = apps.find((app) => app.attributes?.bundleId === bundleId);
|
|
1611
|
+
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.`);
|
|
1612
|
+
return {
|
|
1613
|
+
app,
|
|
1614
|
+
testFlightUrl: `https://appstoreconnect.apple.com/apps/${app.id}/testflight/ios`
|
|
1615
|
+
};
|
|
790
1616
|
};
|
|
791
1617
|
/**
|
|
792
|
-
*
|
|
1618
|
+
* Whether App Store Connect already has the `.ipa`'s build: a build of its
|
|
1619
|
+
* version with its build number, which TestFlight lists or is processing,
|
|
1620
|
+
* as when a reused native build was uploaded before. A build upload that
|
|
1621
|
+
* never finished is no build, so uploading again fails as it would without
|
|
1622
|
+
* this check.
|
|
793
1623
|
*/
|
|
794
|
-
const
|
|
1624
|
+
const findTestFlightBuild = async ({ ipaPath, apiKey, fetch = globalThis.fetch }) => {
|
|
1625
|
+
const [info, privateKey] = await Promise.all([readIpaInfo(ipaPath), readPrivateKey(apiKey.keyPath)]);
|
|
1626
|
+
const request = createAppStoreConnectRequest({
|
|
1627
|
+
apiKey,
|
|
1628
|
+
privateKey,
|
|
1629
|
+
fetch
|
|
1630
|
+
});
|
|
1631
|
+
const { app, testFlightUrl } = await findApp(request, info.bundleId);
|
|
1632
|
+
const { data: builds } = await retrying(() => request("Finding the build in App Store Connect", "GET", `/v1/builds?${new URLSearchParams({
|
|
1633
|
+
"filter[app]": app.id,
|
|
1634
|
+
"filter[version]": info.bundleVersion,
|
|
1635
|
+
"filter[preReleaseVersion.version]": info.shortVersion,
|
|
1636
|
+
"fields[builds]": "version",
|
|
1637
|
+
limit: "1"
|
|
1638
|
+
})}`));
|
|
1639
|
+
return {
|
|
1640
|
+
found: builds.length > 0,
|
|
1641
|
+
testFlightUrl,
|
|
1642
|
+
shortVersion: info.shortVersion,
|
|
1643
|
+
bundleVersion: info.bundleVersion
|
|
1644
|
+
};
|
|
1645
|
+
};
|
|
1646
|
+
/**
|
|
1647
|
+
* Uploads an `.ipa` to App Store Connect, which lists it in TestFlight, with
|
|
1648
|
+
* the App Store Connect API's build uploads: it finds the app by the
|
|
1649
|
+
* `.ipa`'s bundle ID, reserves the build's version and build number, sends
|
|
1650
|
+
* the file in the parts App Store Connect asks for, commits it, and waits
|
|
1651
|
+
* for App Store Connect to accept or reject the build.
|
|
1652
|
+
*/
|
|
1653
|
+
const uploadToTestFlight = async ({ ipaPath, apiKey, fetch = globalThis.fetch, pollIntervalMs = 5e3, processingTimeoutMs = 18e5 }) => {
|
|
1654
|
+
const ipaName = path.default.basename(ipaPath);
|
|
1655
|
+
const [info, privateKey, { size: fileSize }] = await Promise.all([
|
|
1656
|
+
readIpaInfo(ipaPath),
|
|
1657
|
+
readPrivateKey(apiKey.keyPath),
|
|
1658
|
+
fs.default.promises.stat(ipaPath)
|
|
1659
|
+
]);
|
|
1660
|
+
const request = createAppStoreConnectRequest({
|
|
1661
|
+
apiKey,
|
|
1662
|
+
privateKey,
|
|
1663
|
+
fetch
|
|
1664
|
+
});
|
|
1665
|
+
const { app, testFlightUrl } = await findApp(request, info.bundleId);
|
|
1666
|
+
let buildUploadId;
|
|
1667
|
+
let unanswered = false;
|
|
1668
|
+
for (let attempt = 1; buildUploadId === void 0; attempt++) try {
|
|
1669
|
+
({data: {id: buildUploadId}} = await request("Creating the build upload", "POST", "/v1/buildUploads", { data: {
|
|
1670
|
+
type: "buildUploads",
|
|
1671
|
+
attributes: {
|
|
1672
|
+
platform: "IOS",
|
|
1673
|
+
cfBundleShortVersionString: info.shortVersion,
|
|
1674
|
+
cfBundleVersion: info.bundleVersion
|
|
1675
|
+
},
|
|
1676
|
+
relationships: { app: { data: {
|
|
1677
|
+
type: "apps",
|
|
1678
|
+
id: app.id
|
|
1679
|
+
} } }
|
|
1680
|
+
} }));
|
|
1681
|
+
} catch (error) {
|
|
1682
|
+
if (error instanceof AppStoreConnectError && error.status === 409 && error.errors.some((apiError) => apiError.code === DUPLICATE_ERROR_CODE)) {
|
|
1683
|
+
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 });
|
|
1684
|
+
throw new TestFlightBuildExistsError(info.shortVersion, info.bundleVersion, { cause: error });
|
|
1685
|
+
}
|
|
1686
|
+
if (!isRetryable(error) || attempt === COMMIT_ATTEMPTS) throw error;
|
|
1687
|
+
if (!isRateLimited(error)) unanswered = true;
|
|
1688
|
+
await sleep(retryDelayMs(error, attempt));
|
|
1689
|
+
}
|
|
1690
|
+
_hot_updater_cli_tools.p.log.info(`Uploading ${ipaName} (version ${info.shortVersion}, build ${info.bundleVersion}) to App Store Connect`);
|
|
1691
|
+
const removeUpload = async () => {
|
|
1692
|
+
for (let attempt = 1;; attempt++) try {
|
|
1693
|
+
await request("Removing the build upload", "DELETE", `/v1/buildUploads/${buildUploadId}`);
|
|
1694
|
+
return;
|
|
1695
|
+
} catch (error) {
|
|
1696
|
+
if (error instanceof AppStoreConnectError && error.status === 404) return;
|
|
1697
|
+
if (!isRetryable(error) || attempt === COMMIT_ATTEMPTS) {
|
|
1698
|
+
_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).`);
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
await sleep(retryDelayMs(error, attempt));
|
|
1702
|
+
}
|
|
1703
|
+
};
|
|
1704
|
+
let fileId;
|
|
795
1705
|
try {
|
|
796
|
-
const {
|
|
797
|
-
"
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1706
|
+
const { data } = await retrying(() => request("Reserving the .ipa upload", "POST", "/v1/buildUploadFiles", { data: {
|
|
1707
|
+
type: "buildUploadFiles",
|
|
1708
|
+
attributes: {
|
|
1709
|
+
assetType: "ASSET",
|
|
1710
|
+
fileName: ipaName,
|
|
1711
|
+
fileSize,
|
|
1712
|
+
uti: "com.apple.ipa"
|
|
1713
|
+
},
|
|
1714
|
+
relationships: { buildUpload: { data: {
|
|
1715
|
+
type: "buildUploads",
|
|
1716
|
+
id: buildUploadId
|
|
1717
|
+
} } }
|
|
1718
|
+
} }), isRateLimited);
|
|
1719
|
+
fileId = data.id;
|
|
1720
|
+
const operations = data.attributes?.uploadOperations ?? [];
|
|
1721
|
+
if (operations.length === 0) throw new Error("App Store Connect returned no upload operations.");
|
|
1722
|
+
await uploadParts({
|
|
1723
|
+
ipaPath,
|
|
1724
|
+
operations,
|
|
1725
|
+
fetch
|
|
1726
|
+
});
|
|
805
1727
|
} catch (error) {
|
|
806
|
-
|
|
1728
|
+
await removeUpload();
|
|
1729
|
+
throw error;
|
|
1730
|
+
}
|
|
1731
|
+
let commitUnanswered = false;
|
|
1732
|
+
for (let attempt = 1;; attempt++) try {
|
|
1733
|
+
await request("Committing the .ipa upload", "PATCH", `/v1/buildUploadFiles/${fileId}`, { data: {
|
|
1734
|
+
type: "buildUploadFiles",
|
|
1735
|
+
id: fileId,
|
|
1736
|
+
attributes: { uploaded: true }
|
|
1737
|
+
} });
|
|
1738
|
+
break;
|
|
1739
|
+
} catch (error) {
|
|
1740
|
+
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 });
|
|
1741
|
+
if (!isRetryable(error)) {
|
|
1742
|
+
if (commitUnanswered) throw unconfirmed();
|
|
1743
|
+
await removeUpload();
|
|
1744
|
+
throw error;
|
|
1745
|
+
}
|
|
1746
|
+
commitUnanswered = true;
|
|
1747
|
+
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);
|
|
1748
|
+
if (fileState && fileState !== "AWAITING_UPLOAD") break;
|
|
1749
|
+
if (attempt === COMMIT_ATTEMPTS) throw unconfirmed();
|
|
1750
|
+
await sleep(retryDelayMs(error, attempt));
|
|
1751
|
+
}
|
|
1752
|
+
const deadline = Date.now() + processingTimeoutMs;
|
|
1753
|
+
let reportedProcessing = false;
|
|
1754
|
+
let lastError;
|
|
1755
|
+
for (let failedReads = 0;;) {
|
|
1756
|
+
let readError;
|
|
1757
|
+
const read = await request("Reading the build upload", "GET", `/v1/buildUploads/${buildUploadId}`).catch((error) => {
|
|
1758
|
+
if (!isRetryable(error)) throw error;
|
|
1759
|
+
lastError = readError = error;
|
|
1760
|
+
});
|
|
1761
|
+
failedReads = read ? 0 : failedReads + 1;
|
|
1762
|
+
const state = read ? read.data.attributes?.state ?? {} : {};
|
|
1763
|
+
if (state.state === "PROCESSING") reportedProcessing = true;
|
|
1764
|
+
if (!state.state || IN_PROGRESS_STATES.has(state.state)) {
|
|
1765
|
+
if (Date.now() >= deadline) {
|
|
1766
|
+
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 });
|
|
1767
|
+
_hot_updater_cli_tools.p.log.warn(`App Store Connect is still processing build ${info.bundleVersion}. It appears in TestFlight once processing finishes: ${testFlightUrl}`);
|
|
1768
|
+
return {
|
|
1769
|
+
appId: app.id,
|
|
1770
|
+
buildUploadId,
|
|
1771
|
+
state: "PROCESSING",
|
|
1772
|
+
testFlightUrl,
|
|
1773
|
+
shortVersion: info.shortVersion,
|
|
1774
|
+
bundleVersion: info.bundleVersion
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
await sleep(readError === void 0 ? pollIntervalMs : Math.max(pollIntervalMs, retryDelayMs(readError, failedReads)));
|
|
1778
|
+
continue;
|
|
1779
|
+
}
|
|
1780
|
+
for (const warning of describeDetails(state.warnings ?? [])) _hot_updater_cli_tools.p.log.warn(warning);
|
|
1781
|
+
if (state.state !== "COMPLETE") {
|
|
1782
|
+
const errors = describeDetails(state.errors ?? []);
|
|
1783
|
+
throw new Error([`App Store Connect rejected build ${info.bundleVersion} of version ${info.shortVersion} (${state.state}).`, ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
1784
|
+
}
|
|
1785
|
+
return {
|
|
1786
|
+
appId: app.id,
|
|
1787
|
+
buildUploadId,
|
|
1788
|
+
state: "COMPLETE",
|
|
1789
|
+
testFlightUrl,
|
|
1790
|
+
shortVersion: info.shortVersion,
|
|
1791
|
+
bundleVersion: info.bundleVersion
|
|
1792
|
+
};
|
|
807
1793
|
}
|
|
808
1794
|
};
|
|
809
1795
|
/**
|
|
810
|
-
*
|
|
1796
|
+
* Sends each part App Store Connect asked for: the bytes at its offset, with
|
|
1797
|
+
* only the headers it lists, since the URLs are presigned: another header,
|
|
1798
|
+
* or the App Store Connect token, would break their signature.
|
|
811
1799
|
*/
|
|
812
|
-
const
|
|
813
|
-
const
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1800
|
+
const uploadParts = async ({ ipaPath, operations, fetch }) => {
|
|
1801
|
+
const file = await fs.default.promises.open(ipaPath, "r");
|
|
1802
|
+
try {
|
|
1803
|
+
await runConcurrently(operations, UPLOAD_CONCURRENCY, async (operation, index) => {
|
|
1804
|
+
const body = Buffer.alloc(operation.length);
|
|
1805
|
+
const { bytesRead } = await file.read(body, 0, operation.length, operation.offset);
|
|
1806
|
+
if (bytesRead !== operation.length) throw new Error(`${path.default.basename(ipaPath)} changed while it was uploading.`);
|
|
1807
|
+
const headers = {};
|
|
1808
|
+
for (const { name, value } of operation.requestHeaders ?? []) headers[name] = value;
|
|
1809
|
+
for (let attempt = 1;; attempt++) {
|
|
1810
|
+
const response = await fetch(operation.url, {
|
|
1811
|
+
method: operation.method,
|
|
1812
|
+
headers,
|
|
1813
|
+
body
|
|
1814
|
+
}).catch((error) => error);
|
|
1815
|
+
if (!(response instanceof Error) && response.ok) return;
|
|
1816
|
+
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}`}.`);
|
|
1817
|
+
await sleep(retryDelayMs(response, attempt));
|
|
1818
|
+
}
|
|
825
1819
|
});
|
|
1820
|
+
} finally {
|
|
1821
|
+
await file.close();
|
|
826
1822
|
}
|
|
827
|
-
|
|
1823
|
+
};
|
|
1824
|
+
//#endregion
|
|
1825
|
+
//#region src/utils/ciSigningKeychain.ts
|
|
1826
|
+
const API_URL = "https://api.appstoreconnect.apple.com";
|
|
1827
|
+
const CERTIFICATES_URL = "https://developer.apple.com/account/resources/certificates/list";
|
|
1828
|
+
/** Long enough for any build: the keychain never locks while one runs. */
|
|
1829
|
+
const KEYCHAIN_TIMEOUT_SECONDS = 21600;
|
|
1830
|
+
const runSecurity = async (args) => (await (0, execa.execa)("security", args)).stdout;
|
|
1831
|
+
/** The keychain paths `security` prints, one quoted path per line. */
|
|
1832
|
+
const parseKeychains = (stdout) => [...stdout.matchAll(/"([^"]+)"/g)].map((match) => match[1]);
|
|
1833
|
+
const normalizeSerial = (serial) => serial.toUpperCase().replace(/^0+(?=.)/, "");
|
|
1834
|
+
/** The keychain whose state the process restores if it exits first. */
|
|
1835
|
+
let live;
|
|
1836
|
+
const restoreLive = () => {
|
|
1837
|
+
live?.restore();
|
|
1838
|
+
live = void 0;
|
|
1839
|
+
process.off("exit", restoreLive);
|
|
828
1840
|
};
|
|
829
1841
|
/**
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
1842
|
+
* Where builds under one macOS user take turns: the default keychain and the
|
|
1843
|
+
* search list are the user's, not a process's, so two builds that overlap,
|
|
1844
|
+
* such as two self-hosted runner instances, would each restore the other's
|
|
1845
|
+
* keychain. It is in the user's caches, whatever TMPDIR a runner sets.
|
|
833
1846
|
*/
|
|
834
|
-
const
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1847
|
+
const defaultLockPath = () => path.default.join(os.default.homedir(), "Library", "Caches", "hot-updater", "signing-keychain.lock");
|
|
1848
|
+
/** lockf's exit code when `-t 0` finds the lock held. */
|
|
1849
|
+
const EX_TEMPFAIL = 75;
|
|
1850
|
+
/**
|
|
1851
|
+
* Holds `lockPath` with macOS's `lockf`, a kernel lock on the file kept by a
|
|
1852
|
+
* helper that waits on this process's pipe: the kernel releases it when the
|
|
1853
|
+
* helper ends, which is when the pipe closes, so a build that dies, even
|
|
1854
|
+
* killed outright, leaves no lock behind and none needs taking over. The
|
|
1855
|
+
* helper runs in its own process group, out of reach of a terminal's
|
|
1856
|
+
* Ctrl+C, and never keeps this process alive.
|
|
1857
|
+
*/
|
|
1858
|
+
const holdWithLockf = (lockPath, wait) => new Promise((resolve, reject) => {
|
|
1859
|
+
const child = (0, child_process.spawn)("lockf", [
|
|
1860
|
+
"-k",
|
|
1861
|
+
...wait ? [] : ["-t", "0"],
|
|
1862
|
+
lockPath,
|
|
1863
|
+
"/bin/sh",
|
|
1864
|
+
"-c",
|
|
1865
|
+
"echo held; exec cat >/dev/null"
|
|
1866
|
+
], {
|
|
1867
|
+
detached: true,
|
|
1868
|
+
stdio: [
|
|
1869
|
+
"pipe",
|
|
1870
|
+
"pipe",
|
|
1871
|
+
"ignore"
|
|
1872
|
+
]
|
|
1873
|
+
});
|
|
1874
|
+
let held = false;
|
|
1875
|
+
child.stdout.once("data", () => {
|
|
1876
|
+
held = true;
|
|
1877
|
+
child.unref();
|
|
1878
|
+
child.stdin.unref?.();
|
|
1879
|
+
child.stdout.unref?.();
|
|
1880
|
+
const exited = new Promise((done) => child.once("exit", () => done()));
|
|
1881
|
+
resolve(async () => {
|
|
1882
|
+
child.ref();
|
|
1883
|
+
child.stdin.end();
|
|
1884
|
+
await exited;
|
|
1885
|
+
});
|
|
1886
|
+
});
|
|
1887
|
+
child.once("error", reject);
|
|
1888
|
+
child.once("exit", (code) => {
|
|
1889
|
+
if (held) return;
|
|
1890
|
+
if (!wait && code === EX_TEMPFAIL) resolve(void 0);
|
|
1891
|
+
else reject(/* @__PURE__ */ new Error(`lockf ${lockPath} exited with ${code}.`));
|
|
1892
|
+
});
|
|
1893
|
+
});
|
|
1894
|
+
const acquireWithLockf = async (lockPath) => {
|
|
1895
|
+
await fs.default.promises.mkdir(path.default.dirname(lockPath), { recursive: true });
|
|
1896
|
+
const release = await holdWithLockf(lockPath, false);
|
|
1897
|
+
if (release) return release;
|
|
1898
|
+
_hot_updater_cli_tools.p.log.info("Waiting for another build that signs in a keychain of its own under this macOS user.");
|
|
1899
|
+
return await holdWithLockf(lockPath, true);
|
|
839
1900
|
};
|
|
840
1901
|
/**
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
*
|
|
1902
|
+
* Creates the build's keychain and makes it the user's default keychain,
|
|
1903
|
+
* first in the search list, ahead of the keychains already there, so Xcode
|
|
1904
|
+
* still finds a signing identity a self-hosted runner keeps and creates a
|
|
1905
|
+
* new one only when it has none.
|
|
845
1906
|
*/
|
|
846
|
-
const
|
|
847
|
-
|
|
848
|
-
|
|
1907
|
+
const createSigningKeychain = async ({ security = runSecurity, directory = os.default.tmpdir(), lockPath = defaultLockPath(), acquireLock = acquireWithLockf } = {}) => {
|
|
1908
|
+
if (live) throw new Error("A signing keychain is already in use by this process.");
|
|
1909
|
+
live = { restore: () => void 0 };
|
|
1910
|
+
let releaseLock;
|
|
849
1911
|
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;
|
|
1912
|
+
releaseLock = await acquireLock(lockPath);
|
|
856
1913
|
} catch (error) {
|
|
857
|
-
|
|
1914
|
+
live = void 0;
|
|
858
1915
|
throw error;
|
|
859
1916
|
}
|
|
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);
|
|
1917
|
+
process.on("exit", restoreLive);
|
|
1918
|
+
let originalDefault;
|
|
1919
|
+
let originalSearchList;
|
|
1920
|
+
try {
|
|
1921
|
+
[originalDefault] = parseKeychains(await security([
|
|
1922
|
+
"default-keychain",
|
|
1923
|
+
"-d",
|
|
1924
|
+
"user"
|
|
1925
|
+
]).catch(() => ""));
|
|
1926
|
+
originalSearchList = parseKeychains(await security([
|
|
1927
|
+
"list-keychains",
|
|
1928
|
+
"-d",
|
|
1929
|
+
"user"
|
|
1930
|
+
]));
|
|
1931
|
+
} catch (error) {
|
|
1932
|
+
restoreLive();
|
|
1933
|
+
await releaseLock();
|
|
1934
|
+
throw error;
|
|
880
1935
|
}
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
1936
|
+
const keychainPath = path.default.join(directory, `hot-updater-${crypto.default.randomBytes(6).toString("hex")}.keychain-db`);
|
|
1937
|
+
const password = crypto.default.randomBytes(24).toString("base64url");
|
|
1938
|
+
(0, _hot_updater_cli_tools.registerSecretValue)(password);
|
|
1939
|
+
const restoreArgs = [
|
|
1940
|
+
...originalDefault ? [[
|
|
1941
|
+
"default-keychain",
|
|
1942
|
+
"-d",
|
|
1943
|
+
"user",
|
|
1944
|
+
"-s",
|
|
1945
|
+
originalDefault
|
|
1946
|
+
]] : [],
|
|
1947
|
+
[
|
|
1948
|
+
"list-keychains",
|
|
1949
|
+
"-d",
|
|
1950
|
+
"user",
|
|
1951
|
+
"-s",
|
|
1952
|
+
...originalSearchList
|
|
1953
|
+
],
|
|
1954
|
+
["delete-keychain", keychainPath]
|
|
1955
|
+
];
|
|
1956
|
+
live = { restore: () => {
|
|
1957
|
+
for (const args of restoreArgs) (0, child_process.spawnSync)("security", args);
|
|
1958
|
+
} };
|
|
1959
|
+
let disposal;
|
|
1960
|
+
const dispose = () => {
|
|
1961
|
+
disposal ??= (async () => {
|
|
1962
|
+
const failures = [];
|
|
1963
|
+
for (const args of restoreArgs) await security(args).catch((error) => {
|
|
1964
|
+
failures.push(`security ${args[0]}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1965
|
+
});
|
|
1966
|
+
live = void 0;
|
|
1967
|
+
process.off("exit", restoreLive);
|
|
1968
|
+
await releaseLock().catch(() => void 0);
|
|
1969
|
+
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.`);
|
|
1970
|
+
})();
|
|
1971
|
+
return disposal;
|
|
1972
|
+
};
|
|
1973
|
+
try {
|
|
1974
|
+
await security([
|
|
1975
|
+
"create-keychain",
|
|
1976
|
+
"-p",
|
|
1977
|
+
password,
|
|
1978
|
+
keychainPath
|
|
1979
|
+
]);
|
|
1980
|
+
await security([
|
|
1981
|
+
"set-keychain-settings",
|
|
1982
|
+
"-lut",
|
|
1983
|
+
String(KEYCHAIN_TIMEOUT_SECONDS),
|
|
1984
|
+
keychainPath
|
|
1985
|
+
]);
|
|
1986
|
+
await security([
|
|
1987
|
+
"unlock-keychain",
|
|
1988
|
+
"-p",
|
|
1989
|
+
password,
|
|
1990
|
+
keychainPath
|
|
1991
|
+
]);
|
|
1992
|
+
await security([
|
|
1993
|
+
"list-keychains",
|
|
1994
|
+
"-d",
|
|
1995
|
+
"user",
|
|
1996
|
+
"-s",
|
|
1997
|
+
keychainPath,
|
|
1998
|
+
...originalSearchList.filter((keychain) => keychain !== keychainPath)
|
|
1999
|
+
]);
|
|
2000
|
+
await security([
|
|
2001
|
+
"default-keychain",
|
|
2002
|
+
"-d",
|
|
2003
|
+
"user",
|
|
2004
|
+
"-s",
|
|
2005
|
+
keychainPath
|
|
2006
|
+
]);
|
|
2007
|
+
} catch (error) {
|
|
2008
|
+
await dispose();
|
|
2009
|
+
throw error;
|
|
2010
|
+
}
|
|
2011
|
+
return {
|
|
2012
|
+
path: keychainPath,
|
|
2013
|
+
certificates: async () => {
|
|
2014
|
+
return ((await security([
|
|
2015
|
+
"find-certificate",
|
|
2016
|
+
"-a",
|
|
2017
|
+
"-p",
|
|
2018
|
+
keychainPath
|
|
2019
|
+
])).match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []).map((pem) => {
|
|
2020
|
+
const certificate = new crypto.default.X509Certificate(pem);
|
|
2021
|
+
return {
|
|
2022
|
+
serialNumber: normalizeSerial(certificate.serialNumber),
|
|
2023
|
+
commonName: certificate.subject.match(/^CN=(.*)$/m)?.[1] ?? certificate.subject
|
|
2024
|
+
};
|
|
2025
|
+
});
|
|
2026
|
+
},
|
|
2027
|
+
dispose
|
|
2028
|
+
};
|
|
2029
|
+
};
|
|
2030
|
+
/**
|
|
2031
|
+
* Revokes the certificates Xcode created in the build's keychain, whose
|
|
2032
|
+
* private keys the keychain's deletion destroys: on a runner that starts
|
|
2033
|
+
* empty, Xcode creates a development certificate for every build, and App
|
|
2034
|
+
* Store Connect refuses new ones once a team has its maximum. Each is found
|
|
2035
|
+
* in the team's certificates by serial number. A revocation that fails is
|
|
2036
|
+
* reported with where to revoke it by hand, and never fails the build.
|
|
2037
|
+
*/
|
|
2038
|
+
const revokeKeychainCertificates = async ({ certificates, apiKey, privateKey, fetch = globalThis.fetch }) => {
|
|
2039
|
+
const revoked = [];
|
|
2040
|
+
const failed = [];
|
|
2041
|
+
if (certificates.length === 0) return {
|
|
2042
|
+
revoked,
|
|
2043
|
+
failed
|
|
2044
|
+
};
|
|
2045
|
+
const describe = ({ commonName, serialNumber }) => `${commonName} (serial ${serialNumber})`;
|
|
2046
|
+
try {
|
|
2047
|
+
const token = createAppStoreConnectToken({
|
|
2048
|
+
privateKey: privateKey ?? crypto.default.createPrivateKey(await fs.default.promises.readFile(apiKey.keyPath, "utf-8")),
|
|
2049
|
+
keyId: apiKey.keyId,
|
|
2050
|
+
issuerId: apiKey.issuerId
|
|
2051
|
+
});
|
|
2052
|
+
(0, _hot_updater_cli_tools.registerSecretValue)(token);
|
|
2053
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
2054
|
+
const idsBySerial = /* @__PURE__ */ new Map();
|
|
2055
|
+
let next = `${API_URL}/v1/certificates?${new URLSearchParams({
|
|
2056
|
+
"fields[certificates]": "serialNumber",
|
|
2057
|
+
limit: "200"
|
|
2058
|
+
})}`;
|
|
2059
|
+
while (next) {
|
|
2060
|
+
const response = await fetch(next, { headers });
|
|
2061
|
+
if (!response.ok) throw new Error(`Listing the team's certificates failed (HTTP ${response.status}).`);
|
|
2062
|
+
const body = await response.json();
|
|
2063
|
+
for (const { id, attributes } of body.data ?? []) if (attributes?.serialNumber) idsBySerial.set(normalizeSerial(attributes.serialNumber), id);
|
|
2064
|
+
next = body.links?.next;
|
|
886
2065
|
}
|
|
887
|
-
|
|
2066
|
+
for (const certificate of certificates) {
|
|
2067
|
+
const id = idsBySerial.get(certificate.serialNumber);
|
|
2068
|
+
if (id === void 0) continue;
|
|
2069
|
+
const response = await fetch(`${API_URL}/v1/certificates/${id}`, {
|
|
2070
|
+
method: "DELETE",
|
|
2071
|
+
headers
|
|
2072
|
+
});
|
|
2073
|
+
if (response.ok || response.status === 404) revoked.push(describe(certificate));
|
|
2074
|
+
else failed.push(`${describe(certificate)}: HTTP ${response.status}`);
|
|
2075
|
+
}
|
|
2076
|
+
} catch (error) {
|
|
2077
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
2078
|
+
for (const certificate of certificates) if (!revoked.includes(describe(certificate))) failed.push(`${describe(certificate)}: ${reason}`);
|
|
888
2079
|
}
|
|
889
|
-
if (
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
2080
|
+
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(", ")}.`);
|
|
2081
|
+
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.`);
|
|
2082
|
+
return {
|
|
2083
|
+
revoked,
|
|
2084
|
+
failed
|
|
2085
|
+
};
|
|
2086
|
+
};
|
|
2087
|
+
const TERMINATION_SIGNALS = [
|
|
2088
|
+
"SIGHUP",
|
|
2089
|
+
"SIGINT",
|
|
2090
|
+
"SIGTERM"
|
|
2091
|
+
];
|
|
2092
|
+
/**
|
|
2093
|
+
* Runs `run`, the archive and export of a build that signs with an App
|
|
2094
|
+
* Store Connect API key, in a keychain of its own when `enabled` (in CI),
|
|
2095
|
+
* then revokes the certificates Xcode created in it and deletes it. A
|
|
2096
|
+
* termination signal does the same before the process ends with it: the
|
|
2097
|
+
* certificates are read from the keychain before it is deleted, and the key
|
|
2098
|
+
* is read into memory first, since the build's temporary key file is deleted
|
|
2099
|
+
* on the signal. A second signal restores the keychains at once.
|
|
2100
|
+
*/
|
|
2101
|
+
const withSigningKeychain = async ({ enabled, apiKey, run, security, lockPath, acquireLock, fetch }) => {
|
|
2102
|
+
if (!enabled) return run();
|
|
2103
|
+
const privateKey = crypto.default.createPrivateKey(await fs.default.promises.readFile(apiKey.keyPath, "utf-8"));
|
|
2104
|
+
const keychain = await createSigningKeychain({
|
|
2105
|
+
security,
|
|
2106
|
+
lockPath,
|
|
2107
|
+
acquireLock
|
|
2108
|
+
});
|
|
2109
|
+
_hot_updater_cli_tools.p.log.info("Signing in a keychain of this build's own, deleted when it ends (CI is set).");
|
|
2110
|
+
let cleanup;
|
|
2111
|
+
const release = () => {
|
|
2112
|
+
cleanup ??= (async () => {
|
|
2113
|
+
const certificates = await keychain.certificates().catch(() => []);
|
|
2114
|
+
await keychain.dispose();
|
|
2115
|
+
await revokeKeychainCertificates({
|
|
2116
|
+
certificates,
|
|
2117
|
+
apiKey,
|
|
2118
|
+
privateKey,
|
|
2119
|
+
fetch
|
|
2120
|
+
});
|
|
2121
|
+
})();
|
|
2122
|
+
return cleanup;
|
|
2123
|
+
};
|
|
2124
|
+
const stopListening = () => {
|
|
2125
|
+
for (const signal of TERMINATION_SIGNALS) process.off(signal, onSignal);
|
|
2126
|
+
};
|
|
2127
|
+
const onSignal = (signal) => {
|
|
2128
|
+
if (cleanup) {
|
|
2129
|
+
stopListening();
|
|
2130
|
+
restoreLive();
|
|
2131
|
+
process.kill(process.pid, signal);
|
|
2132
|
+
return;
|
|
893
2133
|
}
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
value: d,
|
|
898
|
-
label: `${d.name} (${d.type}) - ${d.state}`,
|
|
899
|
-
hint: d.udid
|
|
900
|
-
}))
|
|
2134
|
+
release().finally(() => {
|
|
2135
|
+
stopListening();
|
|
2136
|
+
if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
|
|
901
2137
|
});
|
|
902
|
-
|
|
903
|
-
|
|
2138
|
+
};
|
|
2139
|
+
for (const signal of TERMINATION_SIGNALS) process.on(signal, onSignal);
|
|
2140
|
+
try {
|
|
2141
|
+
return await run();
|
|
2142
|
+
} finally {
|
|
2143
|
+
await release();
|
|
2144
|
+
stopListening();
|
|
904
2145
|
}
|
|
905
|
-
const bootedDevices = await listDevices(platform, { state: "Booted" });
|
|
906
|
-
if (bootedDevices.length > 0) return bootedDevices[0];
|
|
907
2146
|
};
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
2147
|
+
//#endregion
|
|
2148
|
+
//#region src/utils/readBuiltApp.ts
|
|
2149
|
+
const readInfoValue = async (infoPlist, key) => {
|
|
2150
|
+
const { stdout, exitCode } = await (0, execa.execa)("plutil", [
|
|
2151
|
+
"-extract",
|
|
2152
|
+
key,
|
|
2153
|
+
"raw",
|
|
2154
|
+
"-o",
|
|
2155
|
+
"-",
|
|
2156
|
+
infoPlist
|
|
2157
|
+
], { reject: false });
|
|
2158
|
+
return exitCode === 0 && stdout.trim() ? stdout.trim() : null;
|
|
2159
|
+
};
|
|
2160
|
+
/**
|
|
2161
|
+
* What the built `.app` reports at runtime, from its Info.plist: the
|
|
2162
|
+
* `CFBundleShortVersionString`, `CFBundleVersion` (the build number) and
|
|
2163
|
+
* `HOT_UPDATER_CHANNEL` the build settings,
|
|
2164
|
+
* an `xcconfig` and `extraParams` expanded.
|
|
2165
|
+
*/
|
|
2166
|
+
const readBuiltApp = async (appPath) => {
|
|
2167
|
+
const infoPlist = path.default.join(appPath, "Info.plist");
|
|
2168
|
+
const [appVersion, channel, buildNumber] = await Promise.all([
|
|
2169
|
+
readInfoValue(infoPlist, "CFBundleShortVersionString"),
|
|
2170
|
+
readInfoValue(infoPlist, "HOT_UPDATER_CHANNEL"),
|
|
2171
|
+
readInfoValue(infoPlist, "CFBundleVersion")
|
|
2172
|
+
]);
|
|
2173
|
+
return {
|
|
2174
|
+
appVersion,
|
|
2175
|
+
channel,
|
|
2176
|
+
buildNumber
|
|
2177
|
+
};
|
|
911
2178
|
};
|
|
912
2179
|
//#endregion
|
|
913
|
-
//#region src/
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
2180
|
+
//#region src/buildIos.ts
|
|
2181
|
+
/**
|
|
2182
|
+
* Builds a simulator `.app` for a `simulator` scheme, and otherwise archives
|
|
2183
|
+
* and exports an `.ipa`, or returns the `.xcarchive` of a scheme it cannot
|
|
2184
|
+
* export: one with neither `exportOptionsPlist` nor `signing.ascApiKey`.
|
|
2185
|
+
*
|
|
2186
|
+
* The artifact is in a temporary `buildDirectory`, which `dispose` removes
|
|
2187
|
+
* once the caller has copied it out. A failed build removes it, and every
|
|
2188
|
+
* other directory the build used, itself.
|
|
2189
|
+
*/
|
|
2190
|
+
const buildIos = async ({ schemeConfig, minBundleId = (0, _hot_updater_cli_tools.createMinBundleId)(), credentials }) => {
|
|
2191
|
+
await assertXcodebuildExist();
|
|
2192
|
+
if (schemeConfig.simulator && schemeConfig.destination.length) {
|
|
2193
|
+
_hot_updater_cli_tools.p.log.error("The scheme option 'simulator' is not compatible with the 'destination' option.");
|
|
2194
|
+
process.exit(1);
|
|
2195
|
+
}
|
|
2196
|
+
const buildDirectory = await createRandomTmpDir();
|
|
2197
|
+
try {
|
|
2198
|
+
return {
|
|
2199
|
+
...await buildInto({
|
|
2200
|
+
schemeConfig,
|
|
2201
|
+
minBundleId,
|
|
2202
|
+
credentials,
|
|
2203
|
+
buildDirectory
|
|
2204
|
+
}),
|
|
2205
|
+
dispose: () => removeTmpDir(buildDirectory)
|
|
2206
|
+
};
|
|
2207
|
+
} catch (error) {
|
|
2208
|
+
await removeTmpDir(buildDirectory);
|
|
2209
|
+
throw error;
|
|
2210
|
+
}
|
|
2211
|
+
};
|
|
2212
|
+
const buildInto = async ({ schemeConfig, minBundleId, credentials, buildDirectory }) => {
|
|
2213
|
+
const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
|
|
2214
|
+
if (schemeConfig.simulator) {
|
|
2215
|
+
const appDir = path.default.join(buildDirectory, "app");
|
|
2216
|
+
await fs.default.promises.mkdir(appDir, { recursive: true });
|
|
2217
|
+
const { appPath } = await buildXcodeProject({
|
|
2218
|
+
sourceDir: iosProjectRoot,
|
|
2219
|
+
outputDirectory: appDir,
|
|
2220
|
+
logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build`,
|
|
933
2221
|
platform: schemeConfig.platform,
|
|
934
2222
|
xcodeScheme: schemeConfig.scheme,
|
|
935
2223
|
configuration: schemeConfig.configuration,
|
|
936
|
-
deviceType:
|
|
937
|
-
destination: [{ id: selectedDevice.udid }],
|
|
2224
|
+
deviceType: "simulator",
|
|
938
2225
|
installPods: schemeConfig.installPods,
|
|
939
2226
|
extraParams: schemeConfig.extraParams,
|
|
940
|
-
|
|
2227
|
+
useGenericDestination: true,
|
|
2228
|
+
minBundleId
|
|
941
2229
|
});
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
2230
|
+
_hot_updater_cli_tools.p.log.success(`.app file created: ${path.default.basename(appPath)}`);
|
|
2231
|
+
return {
|
|
2232
|
+
buildDirectory,
|
|
2233
|
+
buildArtifactPath: appPath,
|
|
2234
|
+
builtApp: await readBuiltApp(appPath)
|
|
947
2235
|
};
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
2236
|
+
} else {
|
|
2237
|
+
const signing = await resolveXcodeSigning({
|
|
2238
|
+
schemeConfig,
|
|
2239
|
+
credentials
|
|
2240
|
+
});
|
|
2241
|
+
if (!signing) return archiveAndExport({
|
|
2242
|
+
schemeConfig,
|
|
2243
|
+
minBundleId,
|
|
2244
|
+
buildDirectory
|
|
2245
|
+
});
|
|
2246
|
+
const appStoreConnect = await exportsForAppStoreConnect(schemeConfig);
|
|
2247
|
+
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.");
|
|
2248
|
+
return withSigningKeychain({
|
|
2249
|
+
enabled: (0, _hot_updater_cli_tools.isCI)() && appStoreConnect,
|
|
2250
|
+
apiKey: signing.apiKey,
|
|
2251
|
+
run: () => archiveAndExport({
|
|
2252
|
+
schemeConfig,
|
|
2253
|
+
minBundleId,
|
|
2254
|
+
buildDirectory,
|
|
2255
|
+
authenticationArgs: signing.authenticationArgs
|
|
2256
|
+
})
|
|
952
2257
|
});
|
|
953
|
-
_hot_updater_cli_tools.p.outro("Success 🎉");
|
|
954
|
-
return result;
|
|
955
2258
|
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
2259
|
+
};
|
|
2260
|
+
/**
|
|
2261
|
+
* Archives a device build and exports its `.ipa`, or returns the
|
|
2262
|
+
* `.xcarchive` of a scheme it cannot export.
|
|
2263
|
+
*/
|
|
2264
|
+
const archiveAndExport = async ({ schemeConfig, minBundleId, buildDirectory, authenticationArgs }) => {
|
|
2265
|
+
const iosProjectRoot = path.default.join((0, _hot_updater_cli_tools.getCwd)(), "ios");
|
|
2266
|
+
const archiveDir = path.default.join(buildDirectory, "archive");
|
|
2267
|
+
const exportDir = path.default.join(buildDirectory, "export");
|
|
2268
|
+
await fs.default.promises.mkdir(archiveDir, { recursive: true });
|
|
2269
|
+
const { archivePath } = await archiveXcodeProject({
|
|
2270
|
+
archiveDirectory: archiveDir,
|
|
2271
|
+
authenticationArgs,
|
|
2272
|
+
minBundleId,
|
|
959
2273
|
platform: schemeConfig.platform,
|
|
2274
|
+
sourceDir: iosProjectRoot,
|
|
2275
|
+
xcconfig: schemeConfig.xcconfig,
|
|
960
2276
|
xcodeScheme: schemeConfig.scheme,
|
|
2277
|
+
destination: schemeConfig.destination,
|
|
2278
|
+
extraParams: schemeConfig.extraParams,
|
|
961
2279
|
configuration: schemeConfig.configuration,
|
|
962
|
-
deviceType: device.type,
|
|
963
|
-
destination: [{ id: device.udid }],
|
|
964
2280
|
installPods: schemeConfig.installPods,
|
|
965
|
-
|
|
966
|
-
logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-run`
|
|
967
|
-
});
|
|
968
|
-
const runnerOptions = {
|
|
969
|
-
bundleIdentifier: schemeConfig.bundleIdentifier,
|
|
970
|
-
sourceDir: iosProjectPath,
|
|
971
|
-
launch: true
|
|
972
|
-
};
|
|
973
|
-
if (device.type === "simulator") await installAndLaunchOnSimulator({
|
|
974
|
-
device,
|
|
975
|
-
appPath: result.appPath,
|
|
976
|
-
options: {
|
|
977
|
-
...runnerOptions,
|
|
978
|
-
infoPlistPath: result.infoPlistPath
|
|
979
|
-
}
|
|
2281
|
+
logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build-archive`
|
|
980
2282
|
});
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
2283
|
+
const appFromArchive = extractAppFromXcarchive(archivePath);
|
|
2284
|
+
const archivedAppPath = appFromArchive ? path.default.join(archiveDir, path.default.basename(appFromArchive)) : void 0;
|
|
2285
|
+
if (appFromArchive && archivedAppPath) {
|
|
2286
|
+
await fs.default.promises.cp(appFromArchive, archivedAppPath, { recursive: true });
|
|
2287
|
+
_hot_updater_cli_tools.p.log.success(".app extracted from .xcarchive");
|
|
2288
|
+
}
|
|
2289
|
+
if (!schemeConfig.exportOptionsPlist && !authenticationArgs) return {
|
|
2290
|
+
buildDirectory,
|
|
2291
|
+
buildArtifactPath: archivePath,
|
|
2292
|
+
builtApp: archivedAppPath ? await readBuiltApp(archivedAppPath) : void 0
|
|
2293
|
+
};
|
|
2294
|
+
await fs.default.promises.mkdir(exportDir, { recursive: true });
|
|
2295
|
+
const exportArchive = (exportOptionsPlist) => exportXcodeArchive({
|
|
2296
|
+
archivePath,
|
|
2297
|
+
exportPath: exportDir,
|
|
2298
|
+
authenticationArgs,
|
|
2299
|
+
exportExtraParams: schemeConfig.exportExtraParams,
|
|
2300
|
+
exportOptionsPlist,
|
|
2301
|
+
sourceDir: iosProjectRoot,
|
|
2302
|
+
logPrefix: `ios-${schemeConfig.hotUpdaterSchemeName}-build-export`
|
|
985
2303
|
});
|
|
986
|
-
|
|
987
|
-
|
|
2304
|
+
if (schemeConfig.exportOptionsPlist) await exportArchive(schemeConfig.exportOptionsPlist);
|
|
2305
|
+
else {
|
|
2306
|
+
const exportOptionsDir = await createRandomTmpDir();
|
|
2307
|
+
try {
|
|
2308
|
+
await exportArchive(await writeAppStoreConnectExportOptions(exportOptionsDir));
|
|
2309
|
+
} finally {
|
|
2310
|
+
await removeTmpDir(exportOptionsDir);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
const ipaFile = fs.default.readdirSync(exportDir).find((file) => file.endsWith(".ipa"));
|
|
2314
|
+
if (!ipaFile) throw new Error("IPA file not found after export");
|
|
2315
|
+
return {
|
|
2316
|
+
buildDirectory,
|
|
2317
|
+
buildArtifactPath: path.default.join(exportDir, ipaFile),
|
|
2318
|
+
builtApp: archivedAppPath ? await readBuiltApp(archivedAppPath) : void 0
|
|
2319
|
+
};
|
|
2320
|
+
};
|
|
2321
|
+
const extractAppFromXcarchive = (archivePath) => {
|
|
2322
|
+
const productsPath = path.default.join(archivePath, "Products", "Applications");
|
|
2323
|
+
if (!fs.default.existsSync(productsPath)) return;
|
|
2324
|
+
const appFile = fs.default.readdirSync(productsPath).find((file) => file.endsWith(".app"));
|
|
2325
|
+
if (appFile) return path.default.join(productsPath, appFile);
|
|
2326
|
+
_hot_updater_cli_tools.p.log.warn("Failed to extract .app from xcarchive");
|
|
988
2327
|
};
|
|
989
2328
|
//#endregion
|
|
990
2329
|
//#region src/utils/enrichNativeBuildIosScheme.ts
|
|
@@ -1009,6 +2348,71 @@ const enrichNativeBuildIosScheme = async ({ scheme, hotUpdaterSchemeName }) => {
|
|
|
1009
2348
|
};
|
|
1010
2349
|
};
|
|
1011
2350
|
//#endregion
|
|
2351
|
+
//#region src/utils/extractBuiltInBundle.ts
|
|
2352
|
+
/** The file React Native's Xcode build phase writes into the `.app`. */
|
|
2353
|
+
const BUILT_IN_BUNDLE = "main.jsbundle";
|
|
2354
|
+
/**
|
|
2355
|
+
* Copies the JavaScript bundle React Native's Xcode build phase wrote into a
|
|
2356
|
+
* `.app` or an `.ipa` (`Payload/<App>.app/main.jsbundle`) to `outputPath`:
|
|
2357
|
+
* the built-in bundle, byte for byte, which no rebuild reproduces.
|
|
2358
|
+
*/
|
|
2359
|
+
const extractBuiltInBundle = async ({ artifactPath, outputPath }) => {
|
|
2360
|
+
await fs.default.promises.mkdir(path.default.dirname(outputPath), { recursive: true });
|
|
2361
|
+
if (path.default.extname(artifactPath) === ".app") {
|
|
2362
|
+
const source = path.default.join(artifactPath, BUILT_IN_BUNDLE);
|
|
2363
|
+
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.`);
|
|
2364
|
+
await fs.default.promises.copyFile(source, outputPath);
|
|
2365
|
+
return;
|
|
2366
|
+
}
|
|
2367
|
+
const { stdout } = await (0, execa.execa)("unzip", ["-Z1", artifactPath]);
|
|
2368
|
+
const entry = stdout.split("\n").find((name) => /^Payload\/[^/]+\.app\/main\.jsbundle$/u.test(name.trim()))?.trim();
|
|
2369
|
+
if (entry === void 0) throw new Error(`${path.default.basename(artifactPath)} has no Payload/<App>.app/${BUILT_IN_BUNDLE}.`);
|
|
2370
|
+
await (0, execa.execa)("unzip", [
|
|
2371
|
+
"-p",
|
|
2372
|
+
artifactPath,
|
|
2373
|
+
escapeUnzipPattern(entry)
|
|
2374
|
+
], { stdout: { file: outputPath } });
|
|
2375
|
+
};
|
|
2376
|
+
/**
|
|
2377
|
+
* Unpacks a `.app` that `archiveAppBundle` stored as one zip file into
|
|
2378
|
+
* `outputPath`, which then holds the `.app` directory.
|
|
2379
|
+
*/
|
|
2380
|
+
const extractAppBundle = async ({ archivePath, outputPath }) => {
|
|
2381
|
+
await fs.default.promises.mkdir(outputPath, { recursive: true });
|
|
2382
|
+
await (0, execa.execa)("ditto", [
|
|
2383
|
+
"-x",
|
|
2384
|
+
"-k",
|
|
2385
|
+
archivePath,
|
|
2386
|
+
outputPath
|
|
2387
|
+
]);
|
|
2388
|
+
};
|
|
2389
|
+
/**
|
|
2390
|
+
* A `.app` directory as one zip file, the way Finder and Xcode archive it,
|
|
2391
|
+
* so a simulator build uploads and downloads as a single artifact.
|
|
2392
|
+
*/
|
|
2393
|
+
const archiveAppBundle = async ({ appPath, outputPath }) => {
|
|
2394
|
+
await fs.default.promises.mkdir(path.default.dirname(outputPath), { recursive: true });
|
|
2395
|
+
await fs.default.promises.rm(outputPath, { force: true });
|
|
2396
|
+
await (0, execa.execa)("ditto", [
|
|
2397
|
+
"-c",
|
|
2398
|
+
"-k",
|
|
2399
|
+
"--sequesterRsrc",
|
|
2400
|
+
"--keepParent",
|
|
2401
|
+
appPath,
|
|
2402
|
+
outputPath
|
|
2403
|
+
]);
|
|
2404
|
+
};
|
|
2405
|
+
//#endregion
|
|
2406
|
+
exports.ASC_INTEGRATIONS_URL = ASC_INTEGRATIONS_URL;
|
|
2407
|
+
exports.TestFlightBuildExistsError = TestFlightBuildExistsError;
|
|
2408
|
+
exports.archiveAppBundle = archiveAppBundle;
|
|
2409
|
+
exports.assertTestFlightExportOptions = assertTestFlightExportOptions;
|
|
1012
2410
|
exports.buildIos = buildIos;
|
|
1013
2411
|
exports.enrichNativeBuildIosScheme = enrichNativeBuildIosScheme;
|
|
1014
|
-
exports.
|
|
2412
|
+
exports.extractAppBundle = extractAppBundle;
|
|
2413
|
+
exports.extractBuiltInBundle = extractBuiltInBundle;
|
|
2414
|
+
exports.findTestFlightBuild = findTestFlightBuild;
|
|
2415
|
+
exports.installPodsIfNeeded = installPodsIfNeeded;
|
|
2416
|
+
exports.issueAscApiKey = issueAscApiKey;
|
|
2417
|
+
exports.resolveTestFlightUploadKey = resolveTestFlightUploadKey;
|
|
2418
|
+
exports.uploadToTestFlight = uploadToTestFlight;
|