expo-release 2.3.1 → 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/dist/cli.js CHANGED
@@ -2,18 +2,24 @@
2
2
  // @bun
3
3
 
4
4
  // src/cli.ts
5
- import { defineCli, defineCommand } from "hanzio/cli";
5
+ import { relative } from "path";
6
+ import {
7
+ CliUsageError,
8
+ defineCli,
9
+ defineCommand
10
+ } from "hanzio/cli";
6
11
 
7
12
  // src/args.ts
8
13
  import { z } from "zod";
9
14
  var DEFAULT_IMPACT_PATHS = ["apps/expo/"];
10
15
  var text = (env) => z.string().transform((value) => value.trim() || undefined).optional().meta({ env: `EXPO_RELEASE_${env}` });
11
- var trueFalse = (env, fallback) => z.enum(["true", "false"]).default(fallback).transform((value) => value === "true").meta({ env: `EXPO_RELEASE_${env}` });
16
+ var trimmed = (schema) => z.preprocess((value) => typeof value === "string" ? value.trim() : value, schema);
17
+ var trueFalse = (env, fallback) => trimmed(z.enum(["true", "false"])).default(fallback).transform((value) => value === "true").meta({ env: `EXPO_RELEASE_${env}` });
12
18
  var releaseFlags = z.object({
13
19
  profile: z.string().trim().min(1).meta({ env: "EXPO_RELEASE_PROFILE" }).describe("EAS build profile, also the default update channel"),
14
- base: text("BASE").describe("Git base SHA for the impact diff (falls back to GITHUB_EVENT_BEFORE)"),
15
- head: text("HEAD").describe("Git head SHA for the impact diff (falls back to GITHUB_SHA)"),
16
- platform: z.enum(["ios", "android"]).default("ios").meta({ env: "EXPO_RELEASE_PLATFORM" }).describe("Native platform"),
20
+ base: text("BASE").describe("Git base SHA for the impact diff (default: the merge base on a pull request, else github.event.before)"),
21
+ head: text("HEAD").meta({ env: ["EXPO_RELEASE_HEAD", "GITHUB_SHA"] }).describe("Git head SHA for the impact diff"),
22
+ platform: trimmed(z.enum(["ios", "android"])).default("ios").meta({ env: "EXPO_RELEASE_PLATFORM" }).describe("Native platform"),
17
23
  impactPaths: z.string().default(DEFAULT_IMPACT_PATHS.join(",")).transform((value, ctx) => {
18
24
  const paths = value.split(",").map((path) => path.trim()).filter(Boolean);
19
25
  if (paths.length === 0)
@@ -24,16 +30,16 @@ var releaseFlags = z.object({
24
30
  return paths;
25
31
  }).meta({ env: "EXPO_RELEASE_IMPACT_PATHS" }).describe("Comma-separated repo-root paths that count as an Expo change"),
26
32
  ignoreExpoImpact: trueFalse("IGNORE_EXPO_IMPACT", "false").describe("Check the fingerprint even when no impact path changed"),
27
- operation: z.enum([
33
+ operation: trimmed(z.enum([
28
34
  "deploy",
29
35
  "force-native",
30
36
  "retry-ota",
31
37
  "republish",
32
38
  "rollback-embedded"
33
- ]).default("deploy").meta({ env: "EXPO_RELEASE_OPERATION" }).describe("What to run"),
34
- deployType: z.enum(["none", "ota", "native"]).default("none").meta({ env: "EXPO_RELEASE_DEPLOY_TYPE" }).describe("Decision type for operate"),
35
- channel: text("CHANNEL").describe("EAS Update channel (default: the profile)"),
36
- environment: text("ENVIRONMENT").describe("EAS environment for eas update (required for Expo SDK 55+)"),
39
+ ])).default("deploy").meta({ env: "EXPO_RELEASE_OPERATION" }).describe("What to run"),
40
+ deployType: trimmed(z.enum(["none", "ota", "native"])).default("none").meta({ env: "EXPO_RELEASE_DEPLOY_TYPE" }).describe("Decision type for operate"),
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)"),
37
43
  autoSubmit: trueFalse("AUTO_SUBMIT", "true").describe("Pass --auto-submit on native builds"),
38
44
  preUpdate: text("PRE_UPDATE").describe("Shell command run before eas update"),
39
45
  postUpdate: text("POST_UPDATE").describe("Shell command run after a successful eas update"),
@@ -43,7 +49,7 @@ var releaseFlags = z.object({
43
49
  runtimeVersion: text("RUNTIME_VERSION").describe("Runtime version for rollback-embedded (default: the fingerprint)"),
44
50
  message: text("MESSAGE").describe("Override the eas update message"),
45
51
  fingerprint: text("FINGERPRINT").describe("Fingerprint hash to use instead of generating one"),
46
- dryRun: z.boolean().meta({ env: "EXPO_RELEASE_DRY_RUN" }).describe("Print mutating EAS commands without running them")
52
+ dryRun: z.preprocess((value) => typeof value === "string" && /^\s*(true|false)\s*$/.test(value) ? value.trim() === "true" : value, z.boolean()).meta({ env: "EXPO_RELEASE_DRY_RUN" }).describe("Print mutating EAS commands without running them")
47
53
  }).superRefine((flags, ctx) => {
48
54
  if (flags.operation === "republish" && !flags.group)
49
55
  ctx.addIssue({
@@ -52,13 +58,13 @@ var releaseFlags = z.object({
52
58
  message: "required by --operation republish"
53
59
  });
54
60
  });
55
- function toReleaseOptions(command, { context: flags, env }) {
61
+ function toReleaseOptions(command, { context: flags }, buildProfile = null) {
62
+ const environment = flags.environment ?? buildProfile?.environment;
56
63
  return {
57
64
  ...flags,
58
65
  command,
59
- base: flags.base ?? env.GITHUB_EVENT_BEFORE,
60
- head: flags.head ?? env.GITHUB_SHA,
61
- channel: flags.channel ?? flags.profile
66
+ channel: flags.channel ?? buildProfile?.channel ?? flags.profile,
67
+ ...environment ? { environment } : {}
62
68
  };
63
69
  }
64
70
  function needsDecide(operation) {
@@ -102,22 +108,119 @@ ${delimiter}`;
102
108
  }
103
109
 
104
110
  // src/run.ts
111
+ import { existsSync } from "fs";
112
+ import { dirname, join } from "path";
105
113
  function easCommand(...args) {
106
114
  const bin = process.env.EAS_BIN?.trim();
107
- return [bin && bin.length > 0 ? bin : "eas", ...args];
115
+ return [bin && bin.length > 0 ? bin : localBin("eas") ?? "eas", ...args];
108
116
  }
109
- async function runCaptured(command) {
110
- const child = Bun.spawn(command, {
111
- stdout: "pipe",
112
- stderr: "pipe"
113
- });
114
- const [stdout, stderr, exitCode] = await Promise.all([
115
- new Response(child.stdout).text(),
116
- new Response(child.stderr).text(),
117
- child.exited
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
+ })
118
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);
119
215
  if (exitCode !== 0) {
120
- throw new Error(describeFailure(command, exitCode, { stderr, stdout }));
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);
121
224
  }
122
225
  return stdout;
123
226
  }
@@ -150,21 +253,28 @@ ${tail(output.stdout)}`);
150
253
  return sections.join(`
151
254
  `);
152
255
  }
153
- async function runInherited(command) {
154
- const child = Bun.spawn(command, { stdout: "inherit", stderr: "inherit" });
155
- const exitCode = await child.exited;
156
- if (exitCode !== 0) {
157
- throw new Error(`Command failed with exit code ${exitCode}: ${quoteCommand(command)} (output above)`);
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 {}
158
268
  }
159
- return "";
269
+ throw new Error(`Expected JSON output, received:
270
+ ${tail(output)}`);
160
271
  }
161
272
  function parseJson(output) {
162
- try {
163
- return JSON.parse(output);
164
- } catch {
165
- throw new Error(`Expected JSON output, received:
166
- ${output}`);
167
- }
273
+ const { value, preamble } = parseJsonDocument(output);
274
+ if (preamble)
275
+ process.stderr.write(`${preamble}
276
+ `);
277
+ return value;
168
278
  }
169
279
  function getRecord(value, context) {
170
280
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
@@ -173,7 +283,7 @@ function getRecord(value, context) {
173
283
  return value;
174
284
  }
175
285
  function parseFingerprintHash(output) {
176
- const record = getRecord(parseJson(output), "EAS fingerprint");
286
+ const record = getRecord(parseJsonDocument(output).value, "EAS fingerprint");
177
287
  if (typeof record.hash !== "string" || record.hash.length === 0) {
178
288
  throw new Error("EAS fingerprint output did not contain a full hash");
179
289
  }
@@ -186,8 +296,85 @@ function dryRunCommand(command) {
186
296
  process.stderr.write(`[dry-run] ${quoteCommand(command)}
187
297
  `);
188
298
  }
189
- async function generateFingerprint(input, commandRunner) {
190
- return parseFingerprintHash(await commandRunner(easCommand("fingerprint:generate", "--platform", input.platform, "--build-profile", input.profile, "--json", "--non-interactive")));
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.");
191
378
  }
192
379
 
193
380
  // src/decide.ts
@@ -226,8 +413,8 @@ function normalizeBuildStatus(value) {
226
413
  return null;
227
414
  }
228
415
  function dashboardBuildUrl(record) {
229
- const ownerName = nestedString(record, "app", "ownerAccount", "name");
230
- 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");
231
418
  if (typeof record.id !== "string" || !ownerName || !slug) {
232
419
  return;
233
420
  }
@@ -252,13 +439,17 @@ function parseBuildList(output) {
252
439
  }
253
440
  const buildUrl = dashboardBuildUrl(record);
254
441
  const fingerprint = nestedString(record, "fingerprint", "hash");
255
- 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(": ");
256
446
  builds.push({
257
447
  id: record.id,
258
448
  status,
259
449
  ...buildUrl ? { buildUrl } : {},
260
450
  ...fingerprint ? { fingerprint } : {},
261
- ...runtimeVersion ? { runtimeVersion } : {}
451
+ ...runtimeVersion ? { runtimeVersion } : {},
452
+ ...error ? { error } : {}
262
453
  });
263
454
  }
264
455
  return builds;
@@ -275,26 +466,71 @@ function hasExpoImpact(paths, impactPaths = DEFAULT_IMPACT_PATHS) {
275
466
  function resolveBuildDecision(builds) {
276
467
  const finished = builds.find((build) => build.status === "finished");
277
468
  if (finished) {
278
- return { deployType: "ota", nativeBuildPending: false, build: finished };
469
+ return {
470
+ deployType: "ota",
471
+ reason: "finished-build",
472
+ nativeBuildPending: false,
473
+ build: finished
474
+ };
279
475
  }
280
476
  const inProgress = builds.find((build) => ACTIVE_BUILD_STATUSES.includes(build.status));
281
477
  if (inProgress) {
282
478
  return {
283
479
  deployType: "ota",
480
+ reason: "build-in-progress",
284
481
  nativeBuildPending: true,
285
482
  build: inProgress
286
483
  };
287
484
  }
288
485
  const newest = builds[0];
289
486
  if (newest?.status === "errored") {
290
- const location = newest.buildUrl ?? newest.id;
291
- throw new Error(`The most recent native build for this fingerprint failed (${location}). Fix the failure or use --operation force-native to queue another build.`);
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);
292
494
  }
293
- return { deployType: "native", nativeBuildPending: false, build: null };
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;
294
529
  }
295
530
  async function getChangedFiles(input, commandRunner) {
296
531
  const head = input.head;
297
- if (!input.base || !head || /^0+$/.test(input.base)) {
532
+ const base = input.base ?? await defaultBase(head, commandRunner);
533
+ if (!base || !head || /^0+$/.test(base)) {
298
534
  return { files: [], unreachable: true };
299
535
  }
300
536
  try {
@@ -304,7 +540,7 @@ async function getChangedFiles(input, commandRunner) {
304
540
  "diff.relative=false",
305
541
  "diff",
306
542
  "--name-only",
307
- input.base,
543
+ base,
308
544
  head
309
545
  ]);
310
546
  return {
@@ -319,25 +555,95 @@ async function getChangedFiles(input, commandRunner) {
319
555
  async function getMatchingBuilds(input, fingerprint, commandRunner) {
320
556
  return parseBuildList(await commandRunner(easCommand("build:list", "--platform", input.platform, "--build-profile", input.profile, "--fingerprint-hash", fingerprint, "--limit", "20", "--json", "--non-interactive")));
321
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
+ }
322
623
  async function decideRelease(input, commandRunner = runCaptured) {
323
- const { files: changedFiles, unreachable } = await getChangedFiles(input, commandRunner);
624
+ const { files: changedFiles, unreachable } = await stage("diff", () => getChangedFiles(input, commandRunner));
324
625
  if (!input.ignoreExpoImpact && !unreachable && !hasExpoImpact(changedFiles, input.impactPaths)) {
325
626
  return {
326
627
  deployType: "none",
628
+ reason: "no-impact-path",
327
629
  nativeBuildPending: false,
328
630
  changedFiles,
329
631
  fingerprint: null,
330
632
  build: null
331
633
  };
332
634
  }
333
- const fingerprint = await generateFingerprint(input, commandRunner);
334
- const resolved = resolveBuildDecision(await getMatchingBuilds(input, fingerprint, commandRunner));
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);
335
639
  return {
336
640
  deployType: resolved.deployType,
641
+ reason: resolved.reason,
337
642
  nativeBuildPending: resolved.nativeBuildPending,
338
643
  changedFiles,
339
644
  fingerprint,
340
- build: resolved.build
645
+ build: resolved.build,
646
+ ...resolved.deployType === "native" ? { fingerprintDiff: await explainNativeDecision(input, commandRunner) } : {}
341
647
  };
342
648
  }
343
649
  async function writeDecideGitHubOutput(decision) {
@@ -347,52 +653,22 @@ async function writeDecideGitHubOutput(decision) {
347
653
  changed: String(decision.deployType !== "none"),
348
654
  fingerprint: decision.fingerprint ?? "",
349
655
  build_id: decision.build?.id ?? "",
350
- 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 ?? ""
351
661
  });
352
662
  }
353
663
 
354
- // src/source-maps.ts
355
- import { exists } from "fs/promises";
356
- var SOURCE_MAP_OUTPUT_DIR = "dist";
357
- function expoCommand(...args) {
358
- const bin = process.env.EXPO_BIN?.trim();
359
- if (bin && bin.length > 0)
360
- return [bin, ...args];
361
- return ["bunx", "expo", ...args];
362
- }
363
- function sourceMapExportCommand(platform) {
364
- return expoCommand("export", "--platform", platform, "--output-dir", SOURCE_MAP_OUTPUT_DIR, "--source-maps", "external");
365
- }
366
- function mergeUpdateArgs(extra, exportSourceMaps) {
367
- const args = extra ? [...extra] : [];
368
- if (!exportSourceMaps)
369
- return args;
370
- if (!args.includes("--input-dir")) {
371
- args.push("--input-dir", SOURCE_MAP_OUTPUT_DIR);
372
- }
373
- if (!args.includes("--skip-bundler")) {
374
- 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] ?? "");
375
669
  }
376
670
  return args;
377
671
  }
378
- async function defaultHasSourceMaps(directory = SOURCE_MAP_OUTPUT_DIR) {
379
- if (!await exists(directory))
380
- return false;
381
- for await (const _ of new Bun.Glob("**/*.map").scan(directory)) {
382
- return true;
383
- }
384
- return false;
385
- }
386
- async function assertSourceMaps(hasSourceMaps = defaultHasSourceMaps) {
387
- if (await hasSourceMaps())
388
- return;
389
- throw new Error("Expo export did not produce Hermes source maps in dist; refusing to publish an OTA update.");
390
- }
391
-
392
- // src/operate.ts
393
- function extraUpdateArgs(value) {
394
- return value?.split(/\s+/).filter(Boolean) ?? [];
395
- }
396
672
  function updateMessage(input) {
397
673
  if (input.message)
398
674
  return input.message;
@@ -417,23 +693,25 @@ async function defaultReadPackageJson() {
417
693
  return null;
418
694
  return file.json();
419
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
+ }
420
703
  async function requireEnvironmentIfNeeded(input) {
421
704
  if (input.environment)
422
705
  return;
423
- const pkg = await (input.readPackageJson ?? defaultReadPackageJson)();
424
- if (!pkg || typeof pkg !== "object" || pkg === null)
425
- return;
426
- const record = pkg;
427
- const version = record.dependencies?.expo ?? record.devDependencies?.expo;
428
- const major = parseExpoSdkMajor(version);
706
+ const major = await readExpoSdkMajor(input.readPackageJson);
429
707
  if (major !== null && major >= 55) {
430
- 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.");
431
709
  }
432
710
  }
433
- async function runHook(command, commandRunner) {
711
+ async function runHook(name, command, commandRunner) {
434
712
  if (!command)
435
713
  return;
436
- await commandRunner(["bash", "-c", command]);
714
+ await stage(name, () => commandRunner(["bash", "-c", command]));
437
715
  }
438
716
  function mutatingRunner(commandRunner, dryRun) {
439
717
  return async (command) => {
@@ -447,33 +725,44 @@ function mutatingRunner(commandRunner, dryRun) {
447
725
  async function publishUpdate(input, commandRunner) {
448
726
  await requireEnvironmentIfNeeded(input);
449
727
  if (input.exportSourceMaps) {
450
- await commandRunner(sourceMapExportCommand(input.platform));
451
- if (input.dryRun !== true) {
452
- await assertSourceMaps(input.hasSourceMaps);
453
- }
728
+ await stage("ota export", async () => {
729
+ await commandRunner(sourceMapExportCommand(input.platform));
730
+ if (input.dryRun !== true) {
731
+ await assertSourceMaps(input.hasSourceMaps);
732
+ }
733
+ });
454
734
  }
455
- await runHook(input.preUpdate, commandRunner);
735
+ await runHook("pre-update", input.preUpdate, commandRunner);
456
736
  const command = easCommand("update", "--channel", input.channel, "--platform", input.platform, "--message", updateMessage(input), "--non-interactive");
457
737
  if (input.environment) {
458
738
  command.push("--environment", input.environment);
459
739
  }
460
- command.push(...mergeUpdateArgs(extraUpdateArgs(input.updateExtraArgs), input.exportSourceMaps === true));
461
- await commandRunner(command);
462
- 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);
463
743
  }
464
744
  async function queueNativeBuild(input, commandRunner) {
465
- 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");
466
746
  if (input.autoSubmit) {
467
747
  command.push("--auto-submit");
468
748
  }
469
- 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";
470
759
  }
471
760
  async function resolveRuntimeVersion(input, commandRunner) {
472
761
  if (input.runtimeVersion)
473
762
  return input.runtimeVersion;
474
763
  if (input.fingerprint)
475
764
  return input.fingerprint;
476
- return generateFingerprint(input, commandRunner);
765
+ return stage("fingerprint", () => generateFingerprint(input, commandRunner));
477
766
  }
478
767
  async function operateRelease(input, commandRunner = runInherited) {
479
768
  const execute = mutatingRunner(commandRunner, input.dryRun === true);
@@ -482,19 +771,19 @@ async function operateRelease(input, commandRunner = runInherited) {
482
771
  if (!input.group) {
483
772
  throw new Error("Usage: --operation republish requires --group <update-group-id>");
484
773
  }
774
+ const group = input.group;
485
775
  await requireEnvironmentIfNeeded(input);
486
- await execute(easCommand("update:republish", "--group", input.group, "--platform", input.platform, "--message", updateMessage(input), "--non-interactive"));
776
+ await stage("recovery", () => execute(easCommand("update:republish", "--group", group, "--platform", input.platform, "--message", updateMessage(input), "--non-interactive")));
487
777
  return "recovery";
488
778
  }
489
779
  case "rollback-embedded": {
490
780
  await requireEnvironmentIfNeeded(input);
491
781
  const runtimeVersion = await resolveRuntimeVersion(input, commandRunner);
492
- 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")));
493
783
  return "recovery";
494
784
  }
495
785
  case "force-native":
496
- await queueNativeBuild(input, execute);
497
- return "native-build";
786
+ return queueNativeBuild(input, execute);
498
787
  case "retry-ota":
499
788
  await publishUpdate(input, execute);
500
789
  return "ota";
@@ -509,8 +798,7 @@ async function operateRelease(input, commandRunner = runInherited) {
509
798
  case "none":
510
799
  return "skipped";
511
800
  case "native":
512
- await queueNativeBuild(input, execute);
513
- return "native-build";
801
+ return queueNativeBuild(input, execute);
514
802
  case "ota":
515
803
  await publishUpdate(input, execute);
516
804
  return "ota";
@@ -520,19 +808,355 @@ async function operateRelease(input, commandRunner = runInherited) {
520
808
  }
521
809
  }
522
810
  }
523
- async function writeOperateGitHubOutput(result) {
524
- await appendGitHubOutput({ result });
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;
525
1105
  }
526
1106
 
527
1107
  // src/cli.ts
528
- function toDecisionInput(options) {
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;
529
1145
  return {
530
1146
  base: options.base,
531
1147
  head: options.head,
532
1148
  profile: options.profile,
533
1149
  platform: options.platform,
534
1150
  impactPaths: options.impactPaths,
535
- 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
+ }
536
1160
  };
537
1161
  }
538
1162
  function toOperationInput(options, decision) {
@@ -555,52 +1179,150 @@ function toOperationInput(options, decision) {
555
1179
  dryRun: options.dryRun
556
1180
  };
557
1181
  }
558
- async function decide(options, ctx) {
559
- const decision = await decideRelease(toDecisionInput(options));
1182
+ async function decide(prepared, ctx) {
1183
+ const decision = await decideRelease(toDecisionInput(prepared));
560
1184
  await writeDecideGitHubOutput(decision);
561
1185
  ctx.stdout.write(`${JSON.stringify(decision)}
562
1186
  `);
563
1187
  return decision;
564
1188
  }
565
1189
  async function operate(options, decision, ctx) {
566
- const result = await operateRelease(toOperationInput(options, decision));
567
- await writeOperateGitHubOutput(result);
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);
568
1198
  ctx.stdout.write(`${result}
569
1199
  `);
570
1200
  }
571
- var COMMANDS = ["release", "decide", "operate"];
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
+ }
572
1267
  var cli = defineCli({
573
1268
  name: "expo-release",
574
1269
  description: "Fingerprint-driven Expo EAS release: OTA update or native store build",
575
1270
  globals: releaseFlags,
576
1271
  context: (flags) => flags,
1272
+ defaultCommand: (env) => env.EXPO_RELEASE_COMMAND?.trim() || undefined,
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;
1281
+ }
1282
+ if (error instanceof Error) {
1283
+ await reportFailure(error, io);
1284
+ return 1;
1285
+ }
1286
+ return io.print();
1287
+ },
577
1288
  commands: {
578
1289
  release: defineCommand({
579
1290
  description: "Run decide, then operate on its decision",
580
1291
  run: async (_input, ctx) => {
581
- const options = toReleaseOptions("release", ctx);
582
- const decision = needsDecide(options.operation) ? await decide(options, ctx) : null;
583
- 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);
584
1295
  }
585
1296
  }),
586
1297
  decide: defineCommand({
587
- 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",
588
1299
  run: async (_input, ctx) => {
589
- await decide(toReleaseOptions("decide", ctx), ctx);
1300
+ await decide(await prepare("decide", ctx), ctx);
590
1301
  }
591
1302
  }),
592
1303
  operate: defineCommand({
593
1304
  description: "Run --operation for --deploy-type",
594
1305
  run: async (_input, ctx) => {
595
- await operate(toReleaseOptions("operate", ctx), null, ctx);
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
+ }
596
1324
  }
597
1325
  })
598
1326
  }
599
1327
  });
600
- var env = Object.fromEntries(Object.entries(process.env).flatMap(([name, value]) => value?.trim() ? [[name, value.trim()]] : []));
601
- var argv = process.argv.slice(2);
602
- if (!argv.some((arg) => COMMANDS.includes(arg))) {
603
- argv = env.EXPO_RELEASE_COMMAND ? [env.EXPO_RELEASE_COMMAND, ...argv] : argv.length === 0 ? ["--help"] : argv;
604
- }
605
- var code = await cli.run(argv, { env });
606
- process.exitCode = code === 2 ? 1 : code;
1328
+ process.exitCode = await cli.run();