expo-release 2.3.2 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -124
- package/action.yml +63 -12
- package/dist/args.d.ts +4 -2
- package/dist/cli.js +848 -131
- package/dist/decide.d.ts +26 -0
- package/dist/doctor.d.ts +30 -0
- package/dist/eas-json.d.ts +25 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +401 -113
- package/dist/operate.d.ts +8 -1
- package/dist/run.d.ts +42 -2
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +74 -14
- package/dist/runtime/ota.d.ts +23 -3
- package/dist/runtime/push.d.ts +15 -3
- package/dist/source-maps.d.ts +1 -0
- package/package.json +7 -3
- package/src/args.ts +13 -8
- package/src/cli.ts +224 -26
- package/src/decide.ts +236 -22
- package/src/doctor.ts +296 -0
- package/src/eas-json.ts +128 -0
- package/src/index.ts +2 -0
- package/src/operate.ts +97 -53
- package/src/run.ts +259 -37
- package/src/runtime/index.ts +2 -0
- package/src/runtime/ota.ts +92 -15
- package/src/runtime/push.ts +46 -13
- package/src/runtime/react-native.d.ts +10 -0
- package/src/source-maps.ts +4 -1
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
4
|
// src/cli.ts
|
|
5
|
+
import { relative } from "path";
|
|
5
6
|
import {
|
|
6
7
|
CliUsageError,
|
|
7
8
|
defineCli,
|
|
@@ -16,7 +17,7 @@ var trimmed = (schema) => z.preprocess((value) => typeof value === "string" ? va
|
|
|
16
17
|
var trueFalse = (env, fallback) => trimmed(z.enum(["true", "false"])).default(fallback).transform((value) => value === "true").meta({ env: `EXPO_RELEASE_${env}` });
|
|
17
18
|
var releaseFlags = z.object({
|
|
18
19
|
profile: z.string().trim().min(1).meta({ env: "EXPO_RELEASE_PROFILE" }).describe("EAS build profile, also the default update channel"),
|
|
19
|
-
base: text("BASE").
|
|
20
|
+
base: text("BASE").describe("Git base SHA for the impact diff (default: the merge base on a pull request, else github.event.before)"),
|
|
20
21
|
head: text("HEAD").meta({ env: ["EXPO_RELEASE_HEAD", "GITHUB_SHA"] }).describe("Git head SHA for the impact diff"),
|
|
21
22
|
platform: trimmed(z.enum(["ios", "android"])).default("ios").meta({ env: "EXPO_RELEASE_PLATFORM" }).describe("Native platform"),
|
|
22
23
|
impactPaths: z.string().default(DEFAULT_IMPACT_PATHS.join(",")).transform((value, ctx) => {
|
|
@@ -37,8 +38,8 @@ var releaseFlags = z.object({
|
|
|
37
38
|
"rollback-embedded"
|
|
38
39
|
])).default("deploy").meta({ env: "EXPO_RELEASE_OPERATION" }).describe("What to run"),
|
|
39
40
|
deployType: trimmed(z.enum(["none", "ota", "native"])).default("none").meta({ env: "EXPO_RELEASE_DEPLOY_TYPE" }).describe("Decision type for operate"),
|
|
40
|
-
channel: text("CHANNEL").describe("EAS Update channel (default: the profile)"),
|
|
41
|
-
environment: text("ENVIRONMENT").describe("EAS environment for eas update (required for Expo SDK 55
|
|
41
|
+
channel: text("CHANNEL").describe("EAS Update channel (default: the eas.json profile channel, else the profile name)"),
|
|
42
|
+
environment: text("ENVIRONMENT").describe("EAS environment for eas update (required for Expo SDK 55+, where it defaults to the eas.json profile environment)"),
|
|
42
43
|
autoSubmit: trueFalse("AUTO_SUBMIT", "true").describe("Pass --auto-submit on native builds"),
|
|
43
44
|
preUpdate: text("PRE_UPDATE").describe("Shell command run before eas update"),
|
|
44
45
|
postUpdate: text("POST_UPDATE").describe("Shell command run after a successful eas update"),
|
|
@@ -57,11 +58,13 @@ var releaseFlags = z.object({
|
|
|
57
58
|
message: "required by --operation republish"
|
|
58
59
|
});
|
|
59
60
|
});
|
|
60
|
-
function toReleaseOptions(command, { context: flags }) {
|
|
61
|
+
function toReleaseOptions(command, { context: flags }, buildProfile = null) {
|
|
62
|
+
const environment = flags.environment ?? buildProfile?.environment;
|
|
61
63
|
return {
|
|
62
64
|
...flags,
|
|
63
65
|
command,
|
|
64
|
-
channel: flags.channel ?? flags.profile
|
|
66
|
+
channel: flags.channel ?? buildProfile?.channel ?? flags.profile,
|
|
67
|
+
...environment ? { environment } : {}
|
|
65
68
|
};
|
|
66
69
|
}
|
|
67
70
|
function needsDecide(operation) {
|
|
@@ -105,22 +108,119 @@ ${delimiter}`;
|
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
// src/run.ts
|
|
111
|
+
import { existsSync } from "fs";
|
|
112
|
+
import { dirname, join } from "path";
|
|
108
113
|
function easCommand(...args) {
|
|
109
114
|
const bin = process.env.EAS_BIN?.trim();
|
|
110
|
-
return [bin && bin.length > 0 ? bin : "eas", ...args];
|
|
115
|
+
return [bin && bin.length > 0 ? bin : localBin("eas") ?? "eas", ...args];
|
|
111
116
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
var localBins = new Map;
|
|
118
|
+
function localBin(name) {
|
|
119
|
+
const cwd = process.cwd();
|
|
120
|
+
const cacheKey = `${cwd}\x00${name}`;
|
|
121
|
+
const cached = localBins.get(cacheKey);
|
|
122
|
+
if (cached !== undefined)
|
|
123
|
+
return cached;
|
|
124
|
+
let bin = null;
|
|
125
|
+
for (let dir = cwd;; dir = dirname(dir)) {
|
|
126
|
+
const candidate = join(dir, "node_modules", ".bin", name);
|
|
127
|
+
if (existsSync(candidate)) {
|
|
128
|
+
bin = candidate;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
if (existsSync(join(dir, ".git")) || dirname(dir) === dir)
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
localBins.set(cacheKey, bin);
|
|
135
|
+
return bin;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
class CommandError extends Error {
|
|
139
|
+
tail;
|
|
140
|
+
constructor(message, tail) {
|
|
141
|
+
super(message);
|
|
142
|
+
this.tail = tail;
|
|
143
|
+
this.name = "CommandError";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
var ERROR_TAIL_LINES = 20;
|
|
147
|
+
function lastLines(output, count = ERROR_TAIL_LINES) {
|
|
148
|
+
return output.split(`
|
|
149
|
+
`).map((line) => line.trimEnd()).filter((line) => line.trim().length > 0).slice(-count);
|
|
150
|
+
}
|
|
151
|
+
var DRAIN_AFTER_EXIT_MS = 1000;
|
|
152
|
+
function collect(stream, echo, sink) {
|
|
153
|
+
const reader = stream.getReader();
|
|
154
|
+
const decoder = new TextDecoder;
|
|
155
|
+
let text = "";
|
|
156
|
+
const done = (async () => {
|
|
157
|
+
try {
|
|
158
|
+
for (;; ) {
|
|
159
|
+
const { value, done } = await reader.read();
|
|
160
|
+
if (done)
|
|
161
|
+
break;
|
|
162
|
+
echo?.write(value);
|
|
163
|
+
const part = decoder.decode(value, { stream: true });
|
|
164
|
+
text += part;
|
|
165
|
+
sink.push(part);
|
|
166
|
+
}
|
|
167
|
+
} catch {}
|
|
168
|
+
const rest = decoder.decode();
|
|
169
|
+
sink.push(rest);
|
|
170
|
+
text += rest;
|
|
171
|
+
})();
|
|
172
|
+
return {
|
|
173
|
+
done,
|
|
174
|
+
stop: () => reader.cancel().catch(() => {
|
|
175
|
+
return;
|
|
176
|
+
}),
|
|
177
|
+
text: () => text
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
async function spawn(command, echoStdout, echoStderr = true) {
|
|
181
|
+
const child = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" });
|
|
182
|
+
const combined = [];
|
|
183
|
+
const out = collect(child.stdout, echoStdout ? process.stdout : null, combined);
|
|
184
|
+
const err = collect(child.stderr, echoStderr ? process.stderr : null, combined);
|
|
185
|
+
const exitCode = await child.exited;
|
|
186
|
+
const drained = Promise.all([out.done, err.done]);
|
|
187
|
+
let timer;
|
|
188
|
+
const timedOut = await Promise.race([
|
|
189
|
+
drained.then(() => false),
|
|
190
|
+
new Promise((resolve) => {
|
|
191
|
+
timer = setTimeout(() => resolve(true), DRAIN_AFTER_EXIT_MS);
|
|
192
|
+
})
|
|
121
193
|
]);
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
if (timedOut) {
|
|
196
|
+
await Promise.all([out.stop(), err.stop()]);
|
|
197
|
+
await drained;
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
stdout: out.text(),
|
|
201
|
+
stderr: err.text(),
|
|
202
|
+
exitCode,
|
|
203
|
+
tail: lastLines(combined.join(""))
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
async function runCaptured(command) {
|
|
207
|
+
const { stdout, stderr, exitCode, tail } = await spawn(command, false);
|
|
208
|
+
if (exitCode !== 0) {
|
|
209
|
+
throw new CommandError(describeFailure(command, exitCode, { stderr, stdout }), tail);
|
|
210
|
+
}
|
|
211
|
+
return stdout;
|
|
212
|
+
}
|
|
213
|
+
async function runSilent(command) {
|
|
214
|
+
const { stdout, stderr, exitCode, tail } = await spawn(command, false, false);
|
|
122
215
|
if (exitCode !== 0) {
|
|
123
|
-
throw new
|
|
216
|
+
throw new CommandError(describeFailure(command, exitCode, { stderr, stdout }), tail);
|
|
217
|
+
}
|
|
218
|
+
return stdout;
|
|
219
|
+
}
|
|
220
|
+
async function runInherited(command) {
|
|
221
|
+
const { stdout, exitCode, tail } = await spawn(command, true);
|
|
222
|
+
if (exitCode !== 0) {
|
|
223
|
+
throw new CommandError(`Command failed with exit code ${exitCode}: ${quoteCommand(command)} (output above)`, tail);
|
|
124
224
|
}
|
|
125
225
|
return stdout;
|
|
126
226
|
}
|
|
@@ -153,21 +253,28 @@ ${tail(output.stdout)}`);
|
|
|
153
253
|
return sections.join(`
|
|
154
254
|
`);
|
|
155
255
|
}
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
256
|
+
function parseJsonDocument(output) {
|
|
257
|
+
const starts = [0];
|
|
258
|
+
for (const match of output.matchAll(/\n(?=[[{])/g)) {
|
|
259
|
+
starts.push(match.index + 1);
|
|
260
|
+
}
|
|
261
|
+
for (const start of starts) {
|
|
262
|
+
try {
|
|
263
|
+
return {
|
|
264
|
+
value: JSON.parse(output.slice(start)),
|
|
265
|
+
preamble: output.slice(0, start).trim()
|
|
266
|
+
};
|
|
267
|
+
} catch {}
|
|
161
268
|
}
|
|
162
|
-
|
|
269
|
+
throw new Error(`Expected JSON output, received:
|
|
270
|
+
${tail(output)}`);
|
|
163
271
|
}
|
|
164
272
|
function parseJson(output) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
273
|
+
const { value, preamble } = parseJsonDocument(output);
|
|
274
|
+
if (preamble)
|
|
275
|
+
process.stderr.write(`${preamble}
|
|
276
|
+
`);
|
|
277
|
+
return value;
|
|
171
278
|
}
|
|
172
279
|
function getRecord(value, context) {
|
|
173
280
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
@@ -176,7 +283,7 @@ function getRecord(value, context) {
|
|
|
176
283
|
return value;
|
|
177
284
|
}
|
|
178
285
|
function parseFingerprintHash(output) {
|
|
179
|
-
const record = getRecord(
|
|
286
|
+
const record = getRecord(parseJsonDocument(output).value, "EAS fingerprint");
|
|
180
287
|
if (typeof record.hash !== "string" || record.hash.length === 0) {
|
|
181
288
|
throw new Error("EAS fingerprint output did not contain a full hash");
|
|
182
289
|
}
|
|
@@ -189,8 +296,85 @@ function dryRunCommand(command) {
|
|
|
189
296
|
process.stderr.write(`[dry-run] ${quoteCommand(command)}
|
|
190
297
|
`);
|
|
191
298
|
}
|
|
192
|
-
|
|
193
|
-
|
|
299
|
+
|
|
300
|
+
class StageError extends Error {
|
|
301
|
+
stage;
|
|
302
|
+
tail;
|
|
303
|
+
constructor(stage, message, tail = []) {
|
|
304
|
+
super(message);
|
|
305
|
+
this.stage = stage;
|
|
306
|
+
this.tail = tail;
|
|
307
|
+
this.name = "StageError";
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async function stage(name, run) {
|
|
311
|
+
const started = performance.now();
|
|
312
|
+
try {
|
|
313
|
+
return await run();
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (error instanceof StageError)
|
|
316
|
+
throw error;
|
|
317
|
+
throw new StageError(name, error instanceof Error ? error.message : String(error), error instanceof CommandError ? error.tail : []);
|
|
318
|
+
} finally {
|
|
319
|
+
const seconds = ((performance.now() - started) / 1000).toFixed(1);
|
|
320
|
+
process.stderr.write(`[expo-release] ${name}: ${seconds}s
|
|
321
|
+
`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
async function generateFingerprint(input, commandRunner, explainConfig) {
|
|
325
|
+
const output = await commandRunner(easCommand("fingerprint:generate", "--platform", input.platform, "--build-profile", input.profile, "--json", "--non-interactive"));
|
|
326
|
+
const { preamble } = parseJsonDocument(output);
|
|
327
|
+
if (preamble) {
|
|
328
|
+
const reason = explainConfig ? await explainConfig() : "";
|
|
329
|
+
throw new CommandError([
|
|
330
|
+
"eas fingerprint:generate could not load the app config and fell back to a default one, so its fingerprint may not match what EAS builds.",
|
|
331
|
+
`eas-cli printed:
|
|
332
|
+
${preamble}`,
|
|
333
|
+
reason && `expo config said:
|
|
334
|
+
${reason}`
|
|
335
|
+
].filter(Boolean).join(`
|
|
336
|
+
`), lastLines(reason || preamble));
|
|
337
|
+
}
|
|
338
|
+
return parseFingerprintHash(output);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/source-maps.ts
|
|
342
|
+
import { exists } from "fs/promises";
|
|
343
|
+
var SOURCE_MAP_OUTPUT_DIR = "dist";
|
|
344
|
+
function expoCommand(...args) {
|
|
345
|
+
const bin = process.env.EXPO_BIN?.trim();
|
|
346
|
+
if (bin && bin.length > 0)
|
|
347
|
+
return [bin, ...args];
|
|
348
|
+
const local = localBin("expo");
|
|
349
|
+
return local ? [local, ...args] : ["bunx", "expo", ...args];
|
|
350
|
+
}
|
|
351
|
+
function sourceMapExportCommand(platform) {
|
|
352
|
+
return expoCommand("export", "--platform", platform, "--output-dir", SOURCE_MAP_OUTPUT_DIR, "--source-maps", "external");
|
|
353
|
+
}
|
|
354
|
+
function mergeUpdateArgs(extra, exportSourceMaps) {
|
|
355
|
+
const args = extra ? [...extra] : [];
|
|
356
|
+
if (!exportSourceMaps)
|
|
357
|
+
return args;
|
|
358
|
+
if (!args.includes("--input-dir")) {
|
|
359
|
+
args.push("--input-dir", SOURCE_MAP_OUTPUT_DIR);
|
|
360
|
+
}
|
|
361
|
+
if (!args.includes("--skip-bundler")) {
|
|
362
|
+
args.push("--skip-bundler");
|
|
363
|
+
}
|
|
364
|
+
return args;
|
|
365
|
+
}
|
|
366
|
+
async function defaultHasSourceMaps(directory = SOURCE_MAP_OUTPUT_DIR) {
|
|
367
|
+
if (!await exists(directory))
|
|
368
|
+
return false;
|
|
369
|
+
for await (const _ of new Bun.Glob("**/*.map").scan(directory)) {
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
async function assertSourceMaps(hasSourceMaps = defaultHasSourceMaps) {
|
|
375
|
+
if (await hasSourceMaps())
|
|
376
|
+
return;
|
|
377
|
+
throw new Error("Expo export did not produce Hermes source maps in dist; refusing to publish an OTA update.");
|
|
194
378
|
}
|
|
195
379
|
|
|
196
380
|
// src/decide.ts
|
|
@@ -229,8 +413,8 @@ function normalizeBuildStatus(value) {
|
|
|
229
413
|
return null;
|
|
230
414
|
}
|
|
231
415
|
function dashboardBuildUrl(record) {
|
|
232
|
-
const ownerName = nestedString(record, "app", "ownerAccount", "name");
|
|
233
|
-
const slug = nestedString(record, "app", "slug");
|
|
416
|
+
const ownerName = nestedString(record, "project", "ownerAccount", "name") ?? nestedString(record, "app", "ownerAccount", "name");
|
|
417
|
+
const slug = nestedString(record, "project", "slug") ?? nestedString(record, "app", "slug");
|
|
234
418
|
if (typeof record.id !== "string" || !ownerName || !slug) {
|
|
235
419
|
return;
|
|
236
420
|
}
|
|
@@ -255,13 +439,17 @@ function parseBuildList(output) {
|
|
|
255
439
|
}
|
|
256
440
|
const buildUrl = dashboardBuildUrl(record);
|
|
257
441
|
const fingerprint = nestedString(record, "fingerprint", "hash");
|
|
258
|
-
const runtimeVersion = nestedString(record, "runtime", "version");
|
|
442
|
+
const runtimeVersion = nestedString(record, "runtimeVersion") ?? nestedString(record, "runtime", "version");
|
|
443
|
+
const errorCode = nestedString(record, "error", "errorCode");
|
|
444
|
+
const errorMessage = nestedString(record, "error", "message");
|
|
445
|
+
const error = [errorCode, errorMessage].filter(Boolean).join(": ");
|
|
259
446
|
builds.push({
|
|
260
447
|
id: record.id,
|
|
261
448
|
status,
|
|
262
449
|
...buildUrl ? { buildUrl } : {},
|
|
263
450
|
...fingerprint ? { fingerprint } : {},
|
|
264
|
-
...runtimeVersion ? { runtimeVersion } : {}
|
|
451
|
+
...runtimeVersion ? { runtimeVersion } : {},
|
|
452
|
+
...error ? { error } : {}
|
|
265
453
|
});
|
|
266
454
|
}
|
|
267
455
|
return builds;
|
|
@@ -278,26 +466,71 @@ function hasExpoImpact(paths, impactPaths = DEFAULT_IMPACT_PATHS) {
|
|
|
278
466
|
function resolveBuildDecision(builds) {
|
|
279
467
|
const finished = builds.find((build) => build.status === "finished");
|
|
280
468
|
if (finished) {
|
|
281
|
-
return {
|
|
469
|
+
return {
|
|
470
|
+
deployType: "ota",
|
|
471
|
+
reason: "finished-build",
|
|
472
|
+
nativeBuildPending: false,
|
|
473
|
+
build: finished
|
|
474
|
+
};
|
|
282
475
|
}
|
|
283
476
|
const inProgress = builds.find((build) => ACTIVE_BUILD_STATUSES.includes(build.status));
|
|
284
477
|
if (inProgress) {
|
|
285
478
|
return {
|
|
286
479
|
deployType: "ota",
|
|
480
|
+
reason: "build-in-progress",
|
|
287
481
|
nativeBuildPending: true,
|
|
288
482
|
build: inProgress
|
|
289
483
|
};
|
|
290
484
|
}
|
|
291
485
|
const newest = builds[0];
|
|
292
486
|
if (newest?.status === "errored") {
|
|
293
|
-
const
|
|
294
|
-
|
|
487
|
+
const lines = [
|
|
488
|
+
`The newest native build for this fingerprint failed on EAS${newest.error ? `: ${newest.error}` : ` (build ${newest.id})`}`,
|
|
489
|
+
newest.buildUrl,
|
|
490
|
+
"Fix it, then run the workflow with operation force-native."
|
|
491
|
+
].filter((line) => Boolean(line));
|
|
492
|
+
throw new StageError("errored build", lines.join(`
|
|
493
|
+
`), lines);
|
|
295
494
|
}
|
|
296
|
-
return {
|
|
495
|
+
return {
|
|
496
|
+
deployType: "native",
|
|
497
|
+
reason: "no-build-for-fingerprint",
|
|
498
|
+
nativeBuildPending: false,
|
|
499
|
+
build: null
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
async function readPullRequestBase() {
|
|
503
|
+
const path = process.env.GITHUB_EVENT_PATH;
|
|
504
|
+
if (!path)
|
|
505
|
+
return;
|
|
506
|
+
try {
|
|
507
|
+
const event = await Bun.file(path).json();
|
|
508
|
+
const sha = event.pull_request?.base?.sha;
|
|
509
|
+
return typeof sha === "string" && sha ? sha : undefined;
|
|
510
|
+
} catch {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
async function defaultBase(head, commandRunner) {
|
|
515
|
+
const event = process.env.GITHUB_EVENT_NAME;
|
|
516
|
+
if (event === "pull_request" || event === "pull_request_target") {
|
|
517
|
+
const base = await readPullRequestBase();
|
|
518
|
+
if (base && head) {
|
|
519
|
+
try {
|
|
520
|
+
const mergeBase = (await commandRunner(["git", "merge-base", base, head])).trim();
|
|
521
|
+
if (mergeBase)
|
|
522
|
+
return mergeBase;
|
|
523
|
+
} catch {}
|
|
524
|
+
}
|
|
525
|
+
if (base)
|
|
526
|
+
return base;
|
|
527
|
+
}
|
|
528
|
+
return process.env.GITHUB_EVENT_BEFORE?.trim() || undefined;
|
|
297
529
|
}
|
|
298
530
|
async function getChangedFiles(input, commandRunner) {
|
|
299
531
|
const head = input.head;
|
|
300
|
-
|
|
532
|
+
const base = input.base ?? await defaultBase(head, commandRunner);
|
|
533
|
+
if (!base || !head || /^0+$/.test(base)) {
|
|
301
534
|
return { files: [], unreachable: true };
|
|
302
535
|
}
|
|
303
536
|
try {
|
|
@@ -307,7 +540,7 @@ async function getChangedFiles(input, commandRunner) {
|
|
|
307
540
|
"diff.relative=false",
|
|
308
541
|
"diff",
|
|
309
542
|
"--name-only",
|
|
310
|
-
|
|
543
|
+
base,
|
|
311
544
|
head
|
|
312
545
|
]);
|
|
313
546
|
return {
|
|
@@ -322,25 +555,95 @@ async function getChangedFiles(input, commandRunner) {
|
|
|
322
555
|
async function getMatchingBuilds(input, fingerprint, commandRunner) {
|
|
323
556
|
return parseBuildList(await commandRunner(easCommand("build:list", "--platform", input.platform, "--build-profile", input.profile, "--fingerprint-hash", fingerprint, "--limit", "20", "--json", "--non-interactive")));
|
|
324
557
|
}
|
|
558
|
+
function fingerprintSources(value) {
|
|
559
|
+
const sources = value?.sources;
|
|
560
|
+
const map = new Map;
|
|
561
|
+
if (!Array.isArray(sources))
|
|
562
|
+
return map;
|
|
563
|
+
for (const source of sources) {
|
|
564
|
+
const key = source.type === "contents" ? source.id : source.filePath ?? source.id;
|
|
565
|
+
if (typeof key === "string")
|
|
566
|
+
map.set(key, source.hash ?? null);
|
|
567
|
+
}
|
|
568
|
+
return map;
|
|
569
|
+
}
|
|
570
|
+
var MAX_DIFF_SOURCES = 30;
|
|
571
|
+
function diffFingerprintSources(compare) {
|
|
572
|
+
const record = getRecord(compare, "EAS fingerprint:compare");
|
|
573
|
+
const before = fingerprintSources(record.fingerprint1);
|
|
574
|
+
const after = fingerprintSources(record.fingerprint2);
|
|
575
|
+
const lines = [];
|
|
576
|
+
for (const [key, hash] of after) {
|
|
577
|
+
if (!before.has(key))
|
|
578
|
+
lines.push(`added ${key}`);
|
|
579
|
+
else if (before.get(key) !== hash)
|
|
580
|
+
lines.push(`changed ${key}`);
|
|
581
|
+
}
|
|
582
|
+
for (const key of before.keys()) {
|
|
583
|
+
if (!after.has(key))
|
|
584
|
+
lines.push(`removed ${key}`);
|
|
585
|
+
}
|
|
586
|
+
if (lines.length <= MAX_DIFF_SOURCES)
|
|
587
|
+
return lines;
|
|
588
|
+
return [
|
|
589
|
+
...lines.slice(0, MAX_DIFF_SOURCES),
|
|
590
|
+
`... and ${lines.length - MAX_DIFF_SOURCES} more`
|
|
591
|
+
];
|
|
592
|
+
}
|
|
593
|
+
async function explainNativeDecision(input, commandRunner) {
|
|
594
|
+
try {
|
|
595
|
+
return await stage("fingerprint diff", async () => {
|
|
596
|
+
const [last] = parseBuildList(await commandRunner(easCommand("build:list", "--platform", input.platform, "--build-profile", input.profile, "--status", "finished", "--limit", "1", "--json", "--non-interactive")));
|
|
597
|
+
if (!last)
|
|
598
|
+
return null;
|
|
599
|
+
const command = easCommand("fingerprint:compare", "--build-id", last.id, "--json", "--non-interactive");
|
|
600
|
+
if (input.environment)
|
|
601
|
+
command.push("--environment", input.environment);
|
|
602
|
+
const { value } = parseJsonDocument(await commandRunner(command));
|
|
603
|
+
return {
|
|
604
|
+
buildId: last.id,
|
|
605
|
+
...last.buildUrl ? { buildUrl: last.buildUrl } : {},
|
|
606
|
+
sources: diffFingerprintSources(value)
|
|
607
|
+
};
|
|
608
|
+
});
|
|
609
|
+
} catch (error) {
|
|
610
|
+
process.stderr.write(`Warning: could not compare the fingerprint with the last finished build: ${error instanceof Error ? error.message : String(error)}
|
|
611
|
+
`);
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
async function explainConfigFailure(commandRunner) {
|
|
616
|
+
try {
|
|
617
|
+
await commandRunner(expoCommand("config", "--json"));
|
|
618
|
+
return "";
|
|
619
|
+
} catch (error) {
|
|
620
|
+
return error instanceof Error ? error.message : String(error);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
325
623
|
async function decideRelease(input, commandRunner = runCaptured) {
|
|
326
|
-
const { files: changedFiles, unreachable } = await getChangedFiles(input, commandRunner);
|
|
624
|
+
const { files: changedFiles, unreachable } = await stage("diff", () => getChangedFiles(input, commandRunner));
|
|
327
625
|
if (!input.ignoreExpoImpact && !unreachable && !hasExpoImpact(changedFiles, input.impactPaths)) {
|
|
328
626
|
return {
|
|
329
627
|
deployType: "none",
|
|
628
|
+
reason: "no-impact-path",
|
|
330
629
|
nativeBuildPending: false,
|
|
331
630
|
changedFiles,
|
|
332
631
|
fingerprint: null,
|
|
333
632
|
build: null
|
|
334
633
|
};
|
|
335
634
|
}
|
|
336
|
-
|
|
337
|
-
const
|
|
635
|
+
await input.preflight?.();
|
|
636
|
+
const fingerprint = await stage("fingerprint", () => generateFingerprint(input, commandRunner, () => explainConfigFailure(commandRunner)));
|
|
637
|
+
const builds = await stage("build list", () => getMatchingBuilds(input, fingerprint, commandRunner));
|
|
638
|
+
const resolved = resolveBuildDecision(builds);
|
|
338
639
|
return {
|
|
339
640
|
deployType: resolved.deployType,
|
|
641
|
+
reason: resolved.reason,
|
|
340
642
|
nativeBuildPending: resolved.nativeBuildPending,
|
|
341
643
|
changedFiles,
|
|
342
644
|
fingerprint,
|
|
343
|
-
build: resolved.build
|
|
645
|
+
build: resolved.build,
|
|
646
|
+
...resolved.deployType === "native" ? { fingerprintDiff: await explainNativeDecision(input, commandRunner) } : {}
|
|
344
647
|
};
|
|
345
648
|
}
|
|
346
649
|
async function writeDecideGitHubOutput(decision) {
|
|
@@ -350,52 +653,22 @@ async function writeDecideGitHubOutput(decision) {
|
|
|
350
653
|
changed: String(decision.deployType !== "none"),
|
|
351
654
|
fingerprint: decision.fingerprint ?? "",
|
|
352
655
|
build_id: decision.build?.id ?? "",
|
|
353
|
-
build_url: decision.build?.buildUrl ?? ""
|
|
656
|
+
build_url: decision.build?.buildUrl ?? "",
|
|
657
|
+
reason: decision.reason,
|
|
658
|
+
changed_sources: decision.fingerprintDiff?.sources.join(`
|
|
659
|
+
`) ?? "",
|
|
660
|
+
compared_build_url: decision.fingerprintDiff?.buildUrl ?? decision.fingerprintDiff?.buildId ?? ""
|
|
354
661
|
});
|
|
355
662
|
}
|
|
356
663
|
|
|
357
|
-
// src/
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
if (bin && bin.length > 0)
|
|
363
|
-
return [bin, ...args];
|
|
364
|
-
return ["bunx", "expo", ...args];
|
|
365
|
-
}
|
|
366
|
-
function sourceMapExportCommand(platform) {
|
|
367
|
-
return expoCommand("export", "--platform", platform, "--output-dir", SOURCE_MAP_OUTPUT_DIR, "--source-maps", "external");
|
|
368
|
-
}
|
|
369
|
-
function mergeUpdateArgs(extra, exportSourceMaps) {
|
|
370
|
-
const args = extra ? [...extra] : [];
|
|
371
|
-
if (!exportSourceMaps)
|
|
372
|
-
return args;
|
|
373
|
-
if (!args.includes("--input-dir")) {
|
|
374
|
-
args.push("--input-dir", SOURCE_MAP_OUTPUT_DIR);
|
|
375
|
-
}
|
|
376
|
-
if (!args.includes("--skip-bundler")) {
|
|
377
|
-
args.push("--skip-bundler");
|
|
664
|
+
// src/operate.ts
|
|
665
|
+
function splitArgs(value) {
|
|
666
|
+
const args = [];
|
|
667
|
+
for (const match of (value ?? "").matchAll(/"((?:\\.|[^"\\])*)"|'([^']*)'|(\S+)/g)) {
|
|
668
|
+
args.push(match[1]?.replace(/\\(.)/g, "$1") ?? match[2] ?? match[3] ?? "");
|
|
378
669
|
}
|
|
379
670
|
return args;
|
|
380
671
|
}
|
|
381
|
-
async function defaultHasSourceMaps(directory = SOURCE_MAP_OUTPUT_DIR) {
|
|
382
|
-
if (!await exists(directory))
|
|
383
|
-
return false;
|
|
384
|
-
for await (const _ of new Bun.Glob("**/*.map").scan(directory)) {
|
|
385
|
-
return true;
|
|
386
|
-
}
|
|
387
|
-
return false;
|
|
388
|
-
}
|
|
389
|
-
async function assertSourceMaps(hasSourceMaps = defaultHasSourceMaps) {
|
|
390
|
-
if (await hasSourceMaps())
|
|
391
|
-
return;
|
|
392
|
-
throw new Error("Expo export did not produce Hermes source maps in dist; refusing to publish an OTA update.");
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// src/operate.ts
|
|
396
|
-
function extraUpdateArgs(value) {
|
|
397
|
-
return value?.split(/\s+/).filter(Boolean) ?? [];
|
|
398
|
-
}
|
|
399
672
|
function updateMessage(input) {
|
|
400
673
|
if (input.message)
|
|
401
674
|
return input.message;
|
|
@@ -420,23 +693,25 @@ async function defaultReadPackageJson() {
|
|
|
420
693
|
return null;
|
|
421
694
|
return file.json();
|
|
422
695
|
}
|
|
696
|
+
async function readExpoSdkMajor(readPackageJson = defaultReadPackageJson) {
|
|
697
|
+
const pkg = await readPackageJson();
|
|
698
|
+
if (!pkg || typeof pkg !== "object")
|
|
699
|
+
return null;
|
|
700
|
+
const record = pkg;
|
|
701
|
+
return parseExpoSdkMajor(record.dependencies?.expo ?? record.devDependencies?.expo);
|
|
702
|
+
}
|
|
423
703
|
async function requireEnvironmentIfNeeded(input) {
|
|
424
704
|
if (input.environment)
|
|
425
705
|
return;
|
|
426
|
-
const
|
|
427
|
-
if (!pkg || typeof pkg !== "object" || pkg === null)
|
|
428
|
-
return;
|
|
429
|
-
const record = pkg;
|
|
430
|
-
const version = record.dependencies?.expo ?? record.devDependencies?.expo;
|
|
431
|
-
const major = parseExpoSdkMajor(version);
|
|
706
|
+
const major = await readExpoSdkMajor(input.readPackageJson);
|
|
432
707
|
if (major !== null && major >= 55) {
|
|
433
|
-
throw new Error("EAS Update requires --environment for Expo SDK 55+. Pass --environment <name>.");
|
|
708
|
+
throw new Error("EAS Update requires --environment for Expo SDK 55+. Pass --environment <name> or set build.<profile>.environment in eas.json.");
|
|
434
709
|
}
|
|
435
710
|
}
|
|
436
|
-
async function runHook(command, commandRunner) {
|
|
711
|
+
async function runHook(name, command, commandRunner) {
|
|
437
712
|
if (!command)
|
|
438
713
|
return;
|
|
439
|
-
await commandRunner(["bash", "-c", command]);
|
|
714
|
+
await stage(name, () => commandRunner(["bash", "-c", command]));
|
|
440
715
|
}
|
|
441
716
|
function mutatingRunner(commandRunner, dryRun) {
|
|
442
717
|
return async (command) => {
|
|
@@ -450,33 +725,44 @@ function mutatingRunner(commandRunner, dryRun) {
|
|
|
450
725
|
async function publishUpdate(input, commandRunner) {
|
|
451
726
|
await requireEnvironmentIfNeeded(input);
|
|
452
727
|
if (input.exportSourceMaps) {
|
|
453
|
-
await
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
728
|
+
await stage("ota export", async () => {
|
|
729
|
+
await commandRunner(sourceMapExportCommand(input.platform));
|
|
730
|
+
if (input.dryRun !== true) {
|
|
731
|
+
await assertSourceMaps(input.hasSourceMaps);
|
|
732
|
+
}
|
|
733
|
+
});
|
|
457
734
|
}
|
|
458
|
-
await runHook(input.preUpdate, commandRunner);
|
|
735
|
+
await runHook("pre-update", input.preUpdate, commandRunner);
|
|
459
736
|
const command = easCommand("update", "--channel", input.channel, "--platform", input.platform, "--message", updateMessage(input), "--non-interactive");
|
|
460
737
|
if (input.environment) {
|
|
461
738
|
command.push("--environment", input.environment);
|
|
462
739
|
}
|
|
463
|
-
command.push(...mergeUpdateArgs(
|
|
464
|
-
await commandRunner(command);
|
|
465
|
-
await runHook(input.postUpdate, commandRunner);
|
|
740
|
+
command.push(...mergeUpdateArgs(splitArgs(input.updateExtraArgs), input.exportSourceMaps === true));
|
|
741
|
+
await stage("ota update", () => commandRunner(command));
|
|
742
|
+
await runHook("post-update", input.postUpdate, commandRunner);
|
|
466
743
|
}
|
|
467
744
|
async function queueNativeBuild(input, commandRunner) {
|
|
468
|
-
const command = easCommand("build", "--platform", input.platform, "--profile", input.profile, "--non-interactive", "--no-wait");
|
|
745
|
+
const command = easCommand("build", "--platform", input.platform, "--profile", input.profile, "--non-interactive", "--no-wait", "--json");
|
|
469
746
|
if (input.autoSubmit) {
|
|
470
747
|
command.push("--auto-submit");
|
|
471
748
|
}
|
|
472
|
-
await commandRunner(command);
|
|
749
|
+
const output = await stage("native build", () => commandRunner(command));
|
|
750
|
+
try {
|
|
751
|
+
const queued = output.trim() ? parseBuildList(output)[0] : undefined;
|
|
752
|
+
if (queued)
|
|
753
|
+
input.onQueuedBuild?.(queued);
|
|
754
|
+
} catch (error) {
|
|
755
|
+
process.stderr.write(`Warning: could not read the queued build from eas build --json: ${error instanceof Error ? error.message : String(error)}
|
|
756
|
+
`);
|
|
757
|
+
}
|
|
758
|
+
return "native-build";
|
|
473
759
|
}
|
|
474
760
|
async function resolveRuntimeVersion(input, commandRunner) {
|
|
475
761
|
if (input.runtimeVersion)
|
|
476
762
|
return input.runtimeVersion;
|
|
477
763
|
if (input.fingerprint)
|
|
478
764
|
return input.fingerprint;
|
|
479
|
-
return generateFingerprint(input, commandRunner);
|
|
765
|
+
return stage("fingerprint", () => generateFingerprint(input, commandRunner));
|
|
480
766
|
}
|
|
481
767
|
async function operateRelease(input, commandRunner = runInherited) {
|
|
482
768
|
const execute = mutatingRunner(commandRunner, input.dryRun === true);
|
|
@@ -485,19 +771,19 @@ async function operateRelease(input, commandRunner = runInherited) {
|
|
|
485
771
|
if (!input.group) {
|
|
486
772
|
throw new Error("Usage: --operation republish requires --group <update-group-id>");
|
|
487
773
|
}
|
|
774
|
+
const group = input.group;
|
|
488
775
|
await requireEnvironmentIfNeeded(input);
|
|
489
|
-
await execute(easCommand("update:republish", "--group",
|
|
776
|
+
await stage("recovery", () => execute(easCommand("update:republish", "--group", group, "--platform", input.platform, "--message", updateMessage(input), "--non-interactive")));
|
|
490
777
|
return "recovery";
|
|
491
778
|
}
|
|
492
779
|
case "rollback-embedded": {
|
|
493
780
|
await requireEnvironmentIfNeeded(input);
|
|
494
781
|
const runtimeVersion = await resolveRuntimeVersion(input, commandRunner);
|
|
495
|
-
await execute(easCommand("update:roll-back-to-embedded", "--channel", input.channel, "--platform", input.platform, "--runtime-version", runtimeVersion, "--message", updateMessage(input), "--non-interactive"));
|
|
782
|
+
await stage("recovery", () => execute(easCommand("update:roll-back-to-embedded", "--channel", input.channel, "--platform", input.platform, "--runtime-version", runtimeVersion, "--message", updateMessage(input), "--non-interactive")));
|
|
496
783
|
return "recovery";
|
|
497
784
|
}
|
|
498
785
|
case "force-native":
|
|
499
|
-
|
|
500
|
-
return "native-build";
|
|
786
|
+
return queueNativeBuild(input, execute);
|
|
501
787
|
case "retry-ota":
|
|
502
788
|
await publishUpdate(input, execute);
|
|
503
789
|
return "ota";
|
|
@@ -512,8 +798,7 @@ async function operateRelease(input, commandRunner = runInherited) {
|
|
|
512
798
|
case "none":
|
|
513
799
|
return "skipped";
|
|
514
800
|
case "native":
|
|
515
|
-
|
|
516
|
-
return "native-build";
|
|
801
|
+
return queueNativeBuild(input, execute);
|
|
517
802
|
case "ota":
|
|
518
803
|
await publishUpdate(input, execute);
|
|
519
804
|
return "ota";
|
|
@@ -523,19 +808,355 @@ async function operateRelease(input, commandRunner = runInherited) {
|
|
|
523
808
|
}
|
|
524
809
|
}
|
|
525
810
|
}
|
|
526
|
-
async function writeOperateGitHubOutput(result) {
|
|
527
|
-
await appendGitHubOutput({
|
|
811
|
+
async function writeOperateGitHubOutput(result, queuedBuild) {
|
|
812
|
+
await appendGitHubOutput({
|
|
813
|
+
result,
|
|
814
|
+
queued_build_id: queuedBuild?.id ?? "",
|
|
815
|
+
queued_build_url: queuedBuild?.buildUrl ?? ""
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// src/doctor.ts
|
|
820
|
+
var CREDENTIAL_FILE = /\.(keystore|jks|p8|p12|mobileprovision)$/;
|
|
821
|
+
async function tryRun(runner, command) {
|
|
822
|
+
try {
|
|
823
|
+
return { ok: true, output: await runner(command) };
|
|
824
|
+
} catch (error) {
|
|
825
|
+
return {
|
|
826
|
+
ok: false,
|
|
827
|
+
error: error instanceof Error ? error.message : String(error)
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
async function defaultReadFile(path) {
|
|
832
|
+
const file = Bun.file(path);
|
|
833
|
+
return await file.exists() ? file.text() : null;
|
|
834
|
+
}
|
|
835
|
+
function versionOf(output) {
|
|
836
|
+
return output.match(/(\d+\.\d+\.\d+)/)?.[1];
|
|
837
|
+
}
|
|
838
|
+
async function checkTools(input, runner, findings) {
|
|
839
|
+
const env = input.env ?? process.env;
|
|
840
|
+
const eas = await tryRun(runner, easCommand("--version"));
|
|
841
|
+
if (!eas.ok) {
|
|
842
|
+
findings.push({
|
|
843
|
+
level: "error",
|
|
844
|
+
message: "eas-cli did not run.",
|
|
845
|
+
fix: "Add eas-cli to the app devDependencies, or set it up with expo/expo-github-action."
|
|
846
|
+
});
|
|
847
|
+
} else {
|
|
848
|
+
const wanted = input.easJson?.cli?.version;
|
|
849
|
+
const actual = versionOf(eas.output);
|
|
850
|
+
if (wanted && actual && !Bun.semver.satisfies(actual, wanted)) {
|
|
851
|
+
findings.push({
|
|
852
|
+
level: "error",
|
|
853
|
+
message: `eas-cli ${actual} does not satisfy eas.json cli.version ${wanted}.`,
|
|
854
|
+
fix: `Pin eas-cli ${wanted} in the app devDependencies or the workflow.`
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
if (env.CI && !env.EXPO_TOKEN) {
|
|
859
|
+
findings.push({
|
|
860
|
+
level: "error",
|
|
861
|
+
message: "EXPO_TOKEN is not set.",
|
|
862
|
+
fix: "Add the EXPO_TOKEN secret (https://expo.dev/settings/access-tokens) and pass it to the job."
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
const bun = input.buildProfile?.bun;
|
|
866
|
+
const bunVersion = input.bunVersion ?? Bun.version;
|
|
867
|
+
if (bun && bun !== bunVersion) {
|
|
868
|
+
findings.push({
|
|
869
|
+
level: "error",
|
|
870
|
+
message: `This job runs Bun ${bunVersion}, the "${input.profile}" profile builds with Bun ${bun}. The fingerprint and lockfile can differ between them.`,
|
|
871
|
+
fix: `Use Bun ${bun} in CI, or set build.${input.profile}.bun to ${bunVersion}.`
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
const node = input.buildProfile?.node;
|
|
875
|
+
if (node) {
|
|
876
|
+
const actual = await tryRun(runner, ["node", "--version"]);
|
|
877
|
+
const version = actual.ok ? versionOf(actual.output) : undefined;
|
|
878
|
+
if (version && version !== node) {
|
|
879
|
+
findings.push({
|
|
880
|
+
level: "warning",
|
|
881
|
+
message: `This job runs Node ${version}, the "${input.profile}" profile builds with Node ${node}.`,
|
|
882
|
+
fix: `Use Node ${node} in CI (actions/setup-node).`
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
async function checkProfile(input, findings) {
|
|
888
|
+
if (!input.easJson) {
|
|
889
|
+
findings.push({
|
|
890
|
+
level: "error",
|
|
891
|
+
message: "No eas.json in the working directory.",
|
|
892
|
+
fix: "Set working-directory to the Expo app, or run `eas build:configure` there."
|
|
893
|
+
});
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (!input.buildProfile) {
|
|
897
|
+
const names = Object.keys(input.easJson.build ?? {}).join(", ") || "none";
|
|
898
|
+
findings.push({
|
|
899
|
+
level: "error",
|
|
900
|
+
message: `eas.json has no build profile "${input.profile}" (profiles: ${names}).`
|
|
901
|
+
});
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
if (!input.buildProfile.channel) {
|
|
905
|
+
findings.push({
|
|
906
|
+
level: "warning",
|
|
907
|
+
message: `Build profile "${input.profile}" has no channel, so its builds receive no EAS updates.`,
|
|
908
|
+
fix: `Set build.${input.profile}.channel in eas.json.`
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
if (input.autoSubmit && !input.easJson.submit?.[input.profile]) {
|
|
912
|
+
findings.push({
|
|
913
|
+
level: "warning",
|
|
914
|
+
message: `auto-submit is on, but eas.json has no submit.${input.profile}.`,
|
|
915
|
+
fix: input.platform === "ios" ? `Set submit.${input.profile}.ios.ascAppId.` : `Set submit.${input.profile}.android.serviceAccountKeyPath, pointing at a file from an EAS file secret.`
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
if (!input.environment) {
|
|
919
|
+
const major = await readExpoSdkMajor(input.readPackageJson);
|
|
920
|
+
if (major !== null && major >= 55) {
|
|
921
|
+
findings.push({
|
|
922
|
+
level: "error",
|
|
923
|
+
message: "Expo SDK 55+ needs an EAS environment for eas update.",
|
|
924
|
+
fix: `Set build.${input.profile}.environment in eas.json, or pass --environment.`
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
async function checkTrackedFiles(runner, findings, readFile) {
|
|
930
|
+
const listed = await tryRun(runner, ["git", "ls-files"]);
|
|
931
|
+
if (!listed.ok)
|
|
932
|
+
return;
|
|
933
|
+
const files = listed.output.split(`
|
|
934
|
+
`).filter(Boolean);
|
|
935
|
+
const credentials = files.filter((file) => CREDENTIAL_FILE.test(file) && !/(^|\/)debug\.keystore$/.test(file));
|
|
936
|
+
for (const file of files) {
|
|
937
|
+
if (!file.endsWith(".json") || credentials.includes(file))
|
|
938
|
+
continue;
|
|
939
|
+
const text = await readFile(file);
|
|
940
|
+
if (text && text.length < 50000 && /"type"\s*:\s*"service_account"/.test(text)) {
|
|
941
|
+
credentials.push(file);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
if (credentials.length > 0) {
|
|
945
|
+
findings.push({
|
|
946
|
+
level: "error",
|
|
947
|
+
message: `Credentials are committed: ${credentials.join(", ")}.`,
|
|
948
|
+
fix: "Remove them from git, rotate them, and keep them in EAS (`eas credentials`, EAS file secrets)."
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
const generated = files.filter((file) => /^\.expo\//.test(file) || /\.log$/.test(file));
|
|
952
|
+
if (generated.length > 0) {
|
|
953
|
+
findings.push({
|
|
954
|
+
level: "warning",
|
|
955
|
+
message: `Generated files are committed: ${generated.slice(0, 5).join(", ")}${generated.length > 5 ? `, and ${generated.length - 5} more` : ""}.`,
|
|
956
|
+
fix: "git rm --cached them and add .expo/ and *.log to .gitignore."
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
const nativeDirs = ["android", "ios"].filter((dir) => files.some((file) => file.startsWith(`${dir}/`)));
|
|
960
|
+
if (nativeDirs.length === 0)
|
|
961
|
+
return;
|
|
962
|
+
const ignore = await readFile(".fingerprintignore") ?? "";
|
|
963
|
+
const lines = ignore.split(`
|
|
964
|
+
`).map((line) => line.trim().replace(/^\//, ""));
|
|
965
|
+
const hidden = nativeDirs.filter((dir) => lines.some((line) => [dir, `${dir}/`, `${dir}/*`, `${dir}/**`, `${dir}/**/*`].includes(line)));
|
|
966
|
+
if (hidden.length > 0) {
|
|
967
|
+
findings.push({
|
|
968
|
+
level: "error",
|
|
969
|
+
message: `${hidden.join(" and ")} are committed, and .fingerprintignore hides them: EAS builds the committed tree, but a change there never asks for a native build.`,
|
|
970
|
+
fix: "Either delete and gitignore the native folders (continuous native generation), or stop ignoring them in .fingerprintignore."
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
return nativeDirs;
|
|
974
|
+
}
|
|
975
|
+
async function checkAppConfig(runner, findings, nativeDirs) {
|
|
976
|
+
const config = await tryRun(runner, expoCommand("config", "--json"));
|
|
977
|
+
if (!config.ok) {
|
|
978
|
+
findings.push({
|
|
979
|
+
level: "error",
|
|
980
|
+
message: `The app config did not load:
|
|
981
|
+
${config.error}`,
|
|
982
|
+
fix: "Run `npx expo config` in the app with the same environment variables."
|
|
983
|
+
});
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
let parsed = {};
|
|
987
|
+
try {
|
|
988
|
+
parsed = JSON.parse(config.output);
|
|
989
|
+
} catch {
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
const policy = parsed.runtimeVersion?.policy;
|
|
993
|
+
if (policy !== "fingerprint") {
|
|
994
|
+
findings.push({
|
|
995
|
+
level: "warning",
|
|
996
|
+
message: `runtimeVersion is ${JSON.stringify(parsed.runtimeVersion ?? null)}, not { policy: 'fingerprint' }: an OTA can reach a binary whose native code differs.`,
|
|
997
|
+
fix: "Set runtimeVersion: { policy: 'fingerprint' } in the app config."
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
if (nativeDirs.length > 0 && Array.isArray(parsed.plugins) && parsed.plugins.length > 0) {
|
|
1001
|
+
findings.push({
|
|
1002
|
+
level: "warning",
|
|
1003
|
+
message: `${nativeDirs.join(" and ")} are committed while the app config has plugins: EAS builds the committed tree and never applies config plugin changes.`,
|
|
1004
|
+
fix: "Delete and gitignore the native folders, or run `npx expo prebuild` and commit after every plugin change."
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
async function runDoctor(input, runner = runSilent) {
|
|
1009
|
+
const findings = [];
|
|
1010
|
+
const readFile = input.readFile ?? defaultReadFile;
|
|
1011
|
+
await checkProfile(input, findings);
|
|
1012
|
+
await checkTools(input, runner, findings);
|
|
1013
|
+
const nativeDirs = await checkTrackedFiles(runner, findings, readFile) ?? [];
|
|
1014
|
+
if (input.full)
|
|
1015
|
+
await checkAppConfig(runner, findings, nativeDirs);
|
|
1016
|
+
return findings;
|
|
1017
|
+
}
|
|
1018
|
+
function escapeAnnotation(text) {
|
|
1019
|
+
return text.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll(`
|
|
1020
|
+
`, "%0A");
|
|
1021
|
+
}
|
|
1022
|
+
function formatFindings(findings, options = {}) {
|
|
1023
|
+
return findings.map((finding) => {
|
|
1024
|
+
const level = options.asWarnings ? "warning" : finding.level;
|
|
1025
|
+
const text = finding.fix ? `${finding.message}
|
|
1026
|
+
Fix: ${finding.fix}` : finding.message;
|
|
1027
|
+
return options.github ? `::${level} title=expo-release doctor::${escapeAnnotation(text)}` : `${level}: ${text}`;
|
|
1028
|
+
}).join(`
|
|
1029
|
+
`);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// src/eas-json.ts
|
|
1033
|
+
async function readEasJson(path = "eas.json") {
|
|
1034
|
+
const file = Bun.file(path);
|
|
1035
|
+
if (!await file.exists())
|
|
1036
|
+
return null;
|
|
1037
|
+
const text = await file.text();
|
|
1038
|
+
let value;
|
|
1039
|
+
try {
|
|
1040
|
+
value = JSON.parse(text);
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
const json5 = Bun.JSON5;
|
|
1043
|
+
if (!json5)
|
|
1044
|
+
throw error;
|
|
1045
|
+
value = json5.parse(text);
|
|
1046
|
+
}
|
|
1047
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1048
|
+
throw new Error(`${path} must hold an object`);
|
|
1049
|
+
}
|
|
1050
|
+
return value;
|
|
1051
|
+
}
|
|
1052
|
+
function asRecord(value) {
|
|
1053
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined;
|
|
1054
|
+
}
|
|
1055
|
+
function merge(base, update) {
|
|
1056
|
+
const result = { ...base, ...update };
|
|
1057
|
+
for (const key of ["env", "ios", "android"]) {
|
|
1058
|
+
const a = asRecord(base[key]);
|
|
1059
|
+
const b = asRecord(update[key]);
|
|
1060
|
+
if (a && b)
|
|
1061
|
+
result[key] = key === "env" ? { ...a, ...b } : merge(a, b);
|
|
1062
|
+
}
|
|
1063
|
+
return result;
|
|
1064
|
+
}
|
|
1065
|
+
function resolveRaw(easJson, name, depth) {
|
|
1066
|
+
if (depth >= 5) {
|
|
1067
|
+
throw new Error('eas.json: too long a chain of "extends"; check the build profiles for a cycle');
|
|
1068
|
+
}
|
|
1069
|
+
const profile = asRecord(easJson.build?.[name]);
|
|
1070
|
+
if (!profile) {
|
|
1071
|
+
if (depth === 0)
|
|
1072
|
+
return null;
|
|
1073
|
+
throw new Error(`eas.json: build profile extends missing "${name}"`);
|
|
1074
|
+
}
|
|
1075
|
+
const { extends: parent, ...rest } = profile;
|
|
1076
|
+
return typeof parent === "string" ? merge(resolveRaw(easJson, parent, depth + 1) ?? {}, rest) : rest;
|
|
1077
|
+
}
|
|
1078
|
+
function resolveBuildProfile(easJson, name, platform) {
|
|
1079
|
+
const raw = resolveRaw(easJson, name, 0);
|
|
1080
|
+
if (!raw)
|
|
1081
|
+
return null;
|
|
1082
|
+
const { ios, android, ...base } = raw;
|
|
1083
|
+
const resolved = merge(base, asRecord(platform === "ios" ? ios : android) ?? {});
|
|
1084
|
+
const text = (key) => typeof resolved[key] === "string" && resolved[key] !== "" ? resolved[key] : undefined;
|
|
1085
|
+
const env = asRecord(resolved.env);
|
|
1086
|
+
return {
|
|
1087
|
+
channel: text("channel"),
|
|
1088
|
+
environment: text("environment"),
|
|
1089
|
+
node: text("node"),
|
|
1090
|
+
bun: text("bun"),
|
|
1091
|
+
...env ? {
|
|
1092
|
+
env: Object.fromEntries(Object.entries(env).map(([key, value]) => [key, String(value)]))
|
|
1093
|
+
} : {}
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
function applyProfileEnv(profile, env = process.env) {
|
|
1097
|
+
const applied = [];
|
|
1098
|
+
for (const [key, value] of Object.entries(profile?.env ?? {})) {
|
|
1099
|
+
if (env[key] !== undefined)
|
|
1100
|
+
continue;
|
|
1101
|
+
env[key] = value;
|
|
1102
|
+
applied.push(key);
|
|
1103
|
+
}
|
|
1104
|
+
return applied;
|
|
528
1105
|
}
|
|
529
1106
|
|
|
530
1107
|
// src/cli.ts
|
|
531
|
-
|
|
1108
|
+
var inGitHubActions = process.env.GITHUB_ACTIONS === "true";
|
|
1109
|
+
if (process.env.RUNNER_ENVIRONMENT === "self-hosted" && process.env.RUNNER_TEMP && !process.env.TMPDIR) {
|
|
1110
|
+
process.env.TMPDIR = process.env.RUNNER_TEMP;
|
|
1111
|
+
}
|
|
1112
|
+
var current = null;
|
|
1113
|
+
async function prepare(command, ctx) {
|
|
1114
|
+
const platform = ctx.context.platform;
|
|
1115
|
+
const easJson = await readEasJson();
|
|
1116
|
+
const buildProfile = easJson ? resolveBuildProfile(easJson, ctx.context.profile, platform) : null;
|
|
1117
|
+
const applied = applyProfileEnv(buildProfile);
|
|
1118
|
+
if (applied.length > 0) {
|
|
1119
|
+
process.stderr.write(`[expo-release] env from eas.json build.${ctx.context.profile}: ${applied.join(", ")}
|
|
1120
|
+
`);
|
|
1121
|
+
}
|
|
1122
|
+
const sdk = await readExpoSdkMajor().catch(() => null);
|
|
1123
|
+
const defaults = buildProfile && (sdk === null || sdk < 55) ? { ...buildProfile, environment: undefined } : buildProfile;
|
|
1124
|
+
current = {
|
|
1125
|
+
options: toReleaseOptions(command, ctx, defaults),
|
|
1126
|
+
easJson,
|
|
1127
|
+
buildProfile
|
|
1128
|
+
};
|
|
1129
|
+
return current;
|
|
1130
|
+
}
|
|
1131
|
+
function doctorInput(prepared, full) {
|
|
1132
|
+
const { options } = prepared;
|
|
1133
|
+
return {
|
|
1134
|
+
profile: options.profile,
|
|
1135
|
+
platform: options.platform,
|
|
1136
|
+
environment: options.environment,
|
|
1137
|
+
autoSubmit: options.autoSubmit,
|
|
1138
|
+
easJson: prepared.easJson,
|
|
1139
|
+
buildProfile: prepared.buildProfile,
|
|
1140
|
+
full
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
function toDecisionInput(prepared) {
|
|
1144
|
+
const { options } = prepared;
|
|
532
1145
|
return {
|
|
533
1146
|
base: options.base,
|
|
534
1147
|
head: options.head,
|
|
535
1148
|
profile: options.profile,
|
|
536
1149
|
platform: options.platform,
|
|
537
1150
|
impactPaths: options.impactPaths,
|
|
538
|
-
ignoreExpoImpact: shouldIgnoreExpoImpact(options)
|
|
1151
|
+
ignoreExpoImpact: shouldIgnoreExpoImpact(options),
|
|
1152
|
+
environment: options.environment,
|
|
1153
|
+
preflight: async () => {
|
|
1154
|
+
const findings = await runDoctor(doctorInput(prepared, false)).catch(() => []);
|
|
1155
|
+
if (findings.length > 0) {
|
|
1156
|
+
process.stderr.write(`${formatFindings(findings, { asWarnings: true, github: inGitHubActions })}
|
|
1157
|
+
`);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
539
1160
|
};
|
|
540
1161
|
}
|
|
541
1162
|
function toOperationInput(options, decision) {
|
|
@@ -558,52 +1179,148 @@ function toOperationInput(options, decision) {
|
|
|
558
1179
|
dryRun: options.dryRun
|
|
559
1180
|
};
|
|
560
1181
|
}
|
|
561
|
-
async function decide(
|
|
562
|
-
const decision = await decideRelease(toDecisionInput(
|
|
1182
|
+
async function decide(prepared, ctx) {
|
|
1183
|
+
const decision = await decideRelease(toDecisionInput(prepared));
|
|
563
1184
|
await writeDecideGitHubOutput(decision);
|
|
564
1185
|
ctx.stdout.write(`${JSON.stringify(decision)}
|
|
565
1186
|
`);
|
|
566
1187
|
return decision;
|
|
567
1188
|
}
|
|
568
1189
|
async function operate(options, decision, ctx) {
|
|
569
|
-
|
|
570
|
-
await
|
|
1190
|
+
let queued;
|
|
1191
|
+
const result = await operateRelease({
|
|
1192
|
+
...toOperationInput(options, decision),
|
|
1193
|
+
onQueuedBuild: (build) => {
|
|
1194
|
+
queued = build;
|
|
1195
|
+
}
|
|
1196
|
+
});
|
|
1197
|
+
await writeOperateGitHubOutput(result, queued);
|
|
571
1198
|
ctx.stdout.write(`${result}
|
|
572
1199
|
`);
|
|
573
1200
|
}
|
|
1201
|
+
function nextStep(stage, options) {
|
|
1202
|
+
const app = relative(process.env.GITHUB_WORKSPACE ?? process.cwd(), process.cwd());
|
|
1203
|
+
const from = app ? `From ${app}: ` : "";
|
|
1204
|
+
const { platform, profile } = options;
|
|
1205
|
+
switch (stage) {
|
|
1206
|
+
case "doctor":
|
|
1207
|
+
return "Fix the findings above; `expo-release doctor` runs the same checks.";
|
|
1208
|
+
case "diff":
|
|
1209
|
+
return "Check out with fetch-depth: 0 so both commits of the diff exist.";
|
|
1210
|
+
case "fingerprint":
|
|
1211
|
+
return `The app config or EAS project did not load. ${from}\`npx expo config\` and \`eas fingerprint:generate --platform ${platform} --build-profile ${profile}\` with the job's environment variables.`;
|
|
1212
|
+
case "build list":
|
|
1213
|
+
case "fingerprint diff":
|
|
1214
|
+
return `Check that EXPO_TOKEN can read this project. ${from}\`eas build:list --platform ${platform} --build-profile ${profile}\`.`;
|
|
1215
|
+
case "errored build":
|
|
1216
|
+
return "Fix the build failure on EAS, then run the workflow with operation force-native.";
|
|
1217
|
+
case "native build":
|
|
1218
|
+
return `A native build that fails before it is queued usually needs one interactive run. ${from}\`eas credentials:configure-build --platform ${platform} --profile ${profile}\`.`;
|
|
1219
|
+
case "ota export":
|
|
1220
|
+
case "ota update":
|
|
1221
|
+
return "Fix the bundle error above, then run the workflow with operation retry-ota.";
|
|
1222
|
+
case "pre-update":
|
|
1223
|
+
case "post-update":
|
|
1224
|
+
return `The ${stage} command failed; it is the workflow's own step.`;
|
|
1225
|
+
case "recovery":
|
|
1226
|
+
return "Check the group or runtime version passed to the recovery operation.";
|
|
1227
|
+
default: {
|
|
1228
|
+
const _exhaustive = stage;
|
|
1229
|
+
return _exhaustive;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
function escapeData(text) {
|
|
1234
|
+
return text.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll(`
|
|
1235
|
+
`, "%0A");
|
|
1236
|
+
}
|
|
1237
|
+
function escapeProperty(text) {
|
|
1238
|
+
return escapeData(text).replaceAll(":", "%3A").replaceAll(",", "%2C");
|
|
1239
|
+
}
|
|
1240
|
+
async function reportFailure(error, io) {
|
|
1241
|
+
const stage = error instanceof StageError ? error.stage : undefined;
|
|
1242
|
+
const options = current?.options;
|
|
1243
|
+
const said = error instanceof StageError ? error.tail : [];
|
|
1244
|
+
const next = stage && options ? nextStep(stage, options) : undefined;
|
|
1245
|
+
io.stderr.write(`${error.message}
|
|
1246
|
+
`);
|
|
1247
|
+
if (inGitHubActions) {
|
|
1248
|
+
const where = options ? ` (${options.profile}, ${options.platform})` : "";
|
|
1249
|
+
const title = `expo-release: ${stage ?? "release"} failed${where}`;
|
|
1250
|
+
const message = said.slice(-3).join(`
|
|
1251
|
+
`) || error.message;
|
|
1252
|
+
io.stderr.write(`::error title=${escapeProperty(title)}::${escapeData(message)}
|
|
1253
|
+
`);
|
|
1254
|
+
}
|
|
1255
|
+
await appendGitHubOutput({
|
|
1256
|
+
result: "failed",
|
|
1257
|
+
failed_stage: stage ?? "",
|
|
1258
|
+
failure: [
|
|
1259
|
+
said.length > 0 ? `EAS said:
|
|
1260
|
+
${said.join(`
|
|
1261
|
+
`)}` : error.message,
|
|
1262
|
+
next ? `Next: ${next}` : ""
|
|
1263
|
+
].filter(Boolean).join(`
|
|
1264
|
+
`)
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
574
1267
|
var cli = defineCli({
|
|
575
1268
|
name: "expo-release",
|
|
576
1269
|
description: "Fingerprint-driven Expo EAS release: OTA update or native store build",
|
|
577
1270
|
globals: releaseFlags,
|
|
578
1271
|
context: (flags) => flags,
|
|
579
1272
|
defaultCommand: (env) => env.EXPO_RELEASE_COMMAND?.trim() || undefined,
|
|
580
|
-
onError: (error, io) => {
|
|
581
|
-
if (error instanceof CliUsageError
|
|
582
|
-
|
|
583
|
-
|
|
1273
|
+
onError: async (error, io) => {
|
|
1274
|
+
if (error instanceof CliUsageError) {
|
|
1275
|
+
if (error.kind === "missing-command" && process.argv.length <= 2) {
|
|
1276
|
+
io.stdout.write(error.help);
|
|
1277
|
+
return 0;
|
|
1278
|
+
}
|
|
1279
|
+
const code = io.print();
|
|
1280
|
+
return code === 2 ? 1 : code;
|
|
584
1281
|
}
|
|
585
|
-
|
|
586
|
-
|
|
1282
|
+
if (error instanceof Error) {
|
|
1283
|
+
await reportFailure(error, io);
|
|
1284
|
+
return 1;
|
|
1285
|
+
}
|
|
1286
|
+
return io.print();
|
|
587
1287
|
},
|
|
588
1288
|
commands: {
|
|
589
1289
|
release: defineCommand({
|
|
590
1290
|
description: "Run decide, then operate on its decision",
|
|
591
1291
|
run: async (_input, ctx) => {
|
|
592
|
-
const
|
|
593
|
-
const decision = needsDecide(options.operation) ? await decide(
|
|
594
|
-
await operate(options, decision, ctx);
|
|
1292
|
+
const prepared = await prepare("release", ctx);
|
|
1293
|
+
const decision = needsDecide(prepared.options.operation) ? await decide(prepared, ctx) : null;
|
|
1294
|
+
await operate(prepared.options, decision, ctx);
|
|
595
1295
|
}
|
|
596
1296
|
}),
|
|
597
1297
|
decide: defineCommand({
|
|
598
|
-
description: "Print whether this change needs no deploy, an OTA update or a native build",
|
|
1298
|
+
description: "Print whether this change needs no deploy, an OTA update or a native build, and why",
|
|
599
1299
|
run: async (_input, ctx) => {
|
|
600
|
-
await decide(
|
|
1300
|
+
await decide(await prepare("decide", ctx), ctx);
|
|
601
1301
|
}
|
|
602
1302
|
}),
|
|
603
1303
|
operate: defineCommand({
|
|
604
1304
|
description: "Run --operation for --deploy-type",
|
|
605
1305
|
run: async (_input, ctx) => {
|
|
606
|
-
await operate(
|
|
1306
|
+
await operate((await prepare("operate", ctx)).options, null, ctx);
|
|
1307
|
+
}
|
|
1308
|
+
}),
|
|
1309
|
+
doctor: defineCommand({
|
|
1310
|
+
description: "Check the EAS setup, the app config and the committed files; exit 1 on an error",
|
|
1311
|
+
run: async (_input, ctx) => {
|
|
1312
|
+
const prepared = await prepare("doctor", ctx);
|
|
1313
|
+
const findings = await runDoctor(doctorInput(prepared, true));
|
|
1314
|
+
if (findings.length === 0) {
|
|
1315
|
+
ctx.stdout.write(`No problems found.
|
|
1316
|
+
`);
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
ctx.stdout.write(`${formatFindings(findings, { github: inGitHubActions })}
|
|
1320
|
+
`);
|
|
1321
|
+
if (findings.some((finding) => finding.level === "error")) {
|
|
1322
|
+
throw new StageError("doctor", `${findings.filter((f) => f.level === "error").length} doctor check(s) failed.`);
|
|
1323
|
+
}
|
|
607
1324
|
}
|
|
608
1325
|
})
|
|
609
1326
|
}
|