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/index.js CHANGED
@@ -3,12 +3,13 @@
3
3
  import { z } from "zod";
4
4
  var DEFAULT_IMPACT_PATHS = ["apps/expo/"];
5
5
  var text = (env) => z.string().transform((value) => value.trim() || undefined).optional().meta({ env: `EXPO_RELEASE_${env}` });
6
- var trueFalse = (env, fallback) => z.enum(["true", "false"]).default(fallback).transform((value) => value === "true").meta({ env: `EXPO_RELEASE_${env}` });
6
+ var trimmed = (schema) => z.preprocess((value) => typeof value === "string" ? value.trim() : value, schema);
7
+ var trueFalse = (env, fallback) => trimmed(z.enum(["true", "false"])).default(fallback).transform((value) => value === "true").meta({ env: `EXPO_RELEASE_${env}` });
7
8
  var releaseFlags = z.object({
8
9
  profile: z.string().trim().min(1).meta({ env: "EXPO_RELEASE_PROFILE" }).describe("EAS build profile, also the default update channel"),
9
- base: text("BASE").describe("Git base SHA for the impact diff (falls back to GITHUB_EVENT_BEFORE)"),
10
- head: text("HEAD").describe("Git head SHA for the impact diff (falls back to GITHUB_SHA)"),
11
- platform: z.enum(["ios", "android"]).default("ios").meta({ env: "EXPO_RELEASE_PLATFORM" }).describe("Native platform"),
10
+ base: text("BASE").describe("Git base SHA for the impact diff (default: the merge base on a pull request, else github.event.before)"),
11
+ head: text("HEAD").meta({ env: ["EXPO_RELEASE_HEAD", "GITHUB_SHA"] }).describe("Git head SHA for the impact diff"),
12
+ platform: trimmed(z.enum(["ios", "android"])).default("ios").meta({ env: "EXPO_RELEASE_PLATFORM" }).describe("Native platform"),
12
13
  impactPaths: z.string().default(DEFAULT_IMPACT_PATHS.join(",")).transform((value, ctx) => {
13
14
  const paths = value.split(",").map((path) => path.trim()).filter(Boolean);
14
15
  if (paths.length === 0)
@@ -19,16 +20,16 @@ var releaseFlags = z.object({
19
20
  return paths;
20
21
  }).meta({ env: "EXPO_RELEASE_IMPACT_PATHS" }).describe("Comma-separated repo-root paths that count as an Expo change"),
21
22
  ignoreExpoImpact: trueFalse("IGNORE_EXPO_IMPACT", "false").describe("Check the fingerprint even when no impact path changed"),
22
- operation: z.enum([
23
+ operation: trimmed(z.enum([
23
24
  "deploy",
24
25
  "force-native",
25
26
  "retry-ota",
26
27
  "republish",
27
28
  "rollback-embedded"
28
- ]).default("deploy").meta({ env: "EXPO_RELEASE_OPERATION" }).describe("What to run"),
29
- deployType: z.enum(["none", "ota", "native"]).default("none").meta({ env: "EXPO_RELEASE_DEPLOY_TYPE" }).describe("Decision type for operate"),
30
- channel: text("CHANNEL").describe("EAS Update channel (default: the profile)"),
31
- environment: text("ENVIRONMENT").describe("EAS environment for eas update (required for Expo SDK 55+)"),
29
+ ])).default("deploy").meta({ env: "EXPO_RELEASE_OPERATION" }).describe("What to run"),
30
+ deployType: trimmed(z.enum(["none", "ota", "native"])).default("none").meta({ env: "EXPO_RELEASE_DEPLOY_TYPE" }).describe("Decision type for operate"),
31
+ channel: text("CHANNEL").describe("EAS Update channel (default: the eas.json profile channel, else the profile name)"),
32
+ environment: text("ENVIRONMENT").describe("EAS environment for eas update (required for Expo SDK 55+, where it defaults to the eas.json profile environment)"),
32
33
  autoSubmit: trueFalse("AUTO_SUBMIT", "true").describe("Pass --auto-submit on native builds"),
33
34
  preUpdate: text("PRE_UPDATE").describe("Shell command run before eas update"),
34
35
  postUpdate: text("POST_UPDATE").describe("Shell command run after a successful eas update"),
@@ -38,7 +39,7 @@ var releaseFlags = z.object({
38
39
  runtimeVersion: text("RUNTIME_VERSION").describe("Runtime version for rollback-embedded (default: the fingerprint)"),
39
40
  message: text("MESSAGE").describe("Override the eas update message"),
40
41
  fingerprint: text("FINGERPRINT").describe("Fingerprint hash to use instead of generating one"),
41
- dryRun: z.boolean().meta({ env: "EXPO_RELEASE_DRY_RUN" }).describe("Print mutating EAS commands without running them")
42
+ 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")
42
43
  }).superRefine((flags, ctx) => {
43
44
  if (flags.operation === "republish" && !flags.group)
44
45
  ctx.addIssue({
@@ -47,13 +48,13 @@ var releaseFlags = z.object({
47
48
  message: "required by --operation republish"
48
49
  });
49
50
  });
50
- function toReleaseOptions(command, { context: flags, env }) {
51
+ function toReleaseOptions(command, { context: flags }, buildProfile = null) {
52
+ const environment = flags.environment ?? buildProfile?.environment;
51
53
  return {
52
54
  ...flags,
53
55
  command,
54
- base: flags.base ?? env.GITHUB_EVENT_BEFORE,
55
- head: flags.head ?? env.GITHUB_SHA,
56
- channel: flags.channel ?? flags.profile
56
+ channel: flags.channel ?? buildProfile?.channel ?? flags.profile,
57
+ ...environment ? { environment } : {}
57
58
  };
58
59
  }
59
60
  function needsDecide(operation) {
@@ -96,22 +97,119 @@ ${delimiter}`;
96
97
  }
97
98
 
98
99
  // src/run.ts
100
+ import { existsSync } from "fs";
101
+ import { dirname, join } from "path";
99
102
  function easCommand(...args) {
100
103
  const bin = process.env.EAS_BIN?.trim();
101
- return [bin && bin.length > 0 ? bin : "eas", ...args];
104
+ return [bin && bin.length > 0 ? bin : localBin("eas") ?? "eas", ...args];
105
+ }
106
+ var localBins = new Map;
107
+ function localBin(name) {
108
+ const cwd = process.cwd();
109
+ const cacheKey = `${cwd}\x00${name}`;
110
+ const cached = localBins.get(cacheKey);
111
+ if (cached !== undefined)
112
+ return cached;
113
+ let bin = null;
114
+ for (let dir = cwd;; dir = dirname(dir)) {
115
+ const candidate = join(dir, "node_modules", ".bin", name);
116
+ if (existsSync(candidate)) {
117
+ bin = candidate;
118
+ break;
119
+ }
120
+ if (existsSync(join(dir, ".git")) || dirname(dir) === dir)
121
+ break;
122
+ }
123
+ localBins.set(cacheKey, bin);
124
+ return bin;
102
125
  }
103
- async function runCaptured(command) {
104
- const child = Bun.spawn(command, {
105
- stdout: "pipe",
106
- stderr: "pipe"
107
- });
108
- const [stdout, stderr, exitCode] = await Promise.all([
109
- new Response(child.stdout).text(),
110
- new Response(child.stderr).text(),
111
- child.exited
126
+
127
+ class CommandError extends Error {
128
+ tail;
129
+ constructor(message, tail) {
130
+ super(message);
131
+ this.tail = tail;
132
+ this.name = "CommandError";
133
+ }
134
+ }
135
+ var ERROR_TAIL_LINES = 20;
136
+ function lastLines(output, count = ERROR_TAIL_LINES) {
137
+ return output.split(`
138
+ `).map((line) => line.trimEnd()).filter((line) => line.trim().length > 0).slice(-count);
139
+ }
140
+ var DRAIN_AFTER_EXIT_MS = 1000;
141
+ function collect(stream, echo, sink) {
142
+ const reader = stream.getReader();
143
+ const decoder = new TextDecoder;
144
+ let text = "";
145
+ const done = (async () => {
146
+ try {
147
+ for (;; ) {
148
+ const { value, done } = await reader.read();
149
+ if (done)
150
+ break;
151
+ echo?.write(value);
152
+ const part = decoder.decode(value, { stream: true });
153
+ text += part;
154
+ sink.push(part);
155
+ }
156
+ } catch {}
157
+ const rest = decoder.decode();
158
+ sink.push(rest);
159
+ text += rest;
160
+ })();
161
+ return {
162
+ done,
163
+ stop: () => reader.cancel().catch(() => {
164
+ return;
165
+ }),
166
+ text: () => text
167
+ };
168
+ }
169
+ async function spawn(command, echoStdout, echoStderr = true) {
170
+ const child = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" });
171
+ const combined = [];
172
+ const out = collect(child.stdout, echoStdout ? process.stdout : null, combined);
173
+ const err = collect(child.stderr, echoStderr ? process.stderr : null, combined);
174
+ const exitCode = await child.exited;
175
+ const drained = Promise.all([out.done, err.done]);
176
+ let timer;
177
+ const timedOut = await Promise.race([
178
+ drained.then(() => false),
179
+ new Promise((resolve) => {
180
+ timer = setTimeout(() => resolve(true), DRAIN_AFTER_EXIT_MS);
181
+ })
112
182
  ]);
183
+ clearTimeout(timer);
184
+ if (timedOut) {
185
+ await Promise.all([out.stop(), err.stop()]);
186
+ await drained;
187
+ }
188
+ return {
189
+ stdout: out.text(),
190
+ stderr: err.text(),
191
+ exitCode,
192
+ tail: lastLines(combined.join(""))
193
+ };
194
+ }
195
+ async function runCaptured(command) {
196
+ const { stdout, stderr, exitCode, tail } = await spawn(command, false);
113
197
  if (exitCode !== 0) {
114
- throw new Error(describeFailure(command, exitCode, { stderr, stdout }));
198
+ throw new CommandError(describeFailure(command, exitCode, { stderr, stdout }), tail);
199
+ }
200
+ return stdout;
201
+ }
202
+ async function runSilent(command) {
203
+ const { stdout, stderr, exitCode, tail } = await spawn(command, false, false);
204
+ if (exitCode !== 0) {
205
+ throw new CommandError(describeFailure(command, exitCode, { stderr, stdout }), tail);
206
+ }
207
+ return stdout;
208
+ }
209
+ async function runInherited(command) {
210
+ const { stdout, exitCode, tail } = await spawn(command, true);
211
+ if (exitCode !== 0) {
212
+ throw new CommandError(`Command failed with exit code ${exitCode}: ${quoteCommand(command)} (output above)`, tail);
115
213
  }
116
214
  return stdout;
117
215
  }
@@ -144,21 +242,28 @@ ${tail(output.stdout)}`);
144
242
  return sections.join(`
145
243
  `);
146
244
  }
147
- async function runInherited(command) {
148
- const child = Bun.spawn(command, { stdout: "inherit", stderr: "inherit" });
149
- const exitCode = await child.exited;
150
- if (exitCode !== 0) {
151
- throw new Error(`Command failed with exit code ${exitCode}: ${quoteCommand(command)} (output above)`);
245
+ function parseJsonDocument(output) {
246
+ const starts = [0];
247
+ for (const match of output.matchAll(/\n(?=[[{])/g)) {
248
+ starts.push(match.index + 1);
249
+ }
250
+ for (const start of starts) {
251
+ try {
252
+ return {
253
+ value: JSON.parse(output.slice(start)),
254
+ preamble: output.slice(0, start).trim()
255
+ };
256
+ } catch {}
152
257
  }
153
- return "";
258
+ throw new Error(`Expected JSON output, received:
259
+ ${tail(output)}`);
154
260
  }
155
261
  function parseJson(output) {
156
- try {
157
- return JSON.parse(output);
158
- } catch {
159
- throw new Error(`Expected JSON output, received:
160
- ${output}`);
161
- }
262
+ const { value, preamble } = parseJsonDocument(output);
263
+ if (preamble)
264
+ process.stderr.write(`${preamble}
265
+ `);
266
+ return value;
162
267
  }
163
268
  function getRecord(value, context) {
164
269
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
@@ -167,7 +272,7 @@ function getRecord(value, context) {
167
272
  return value;
168
273
  }
169
274
  function parseFingerprintHash(output) {
170
- const record = getRecord(parseJson(output), "EAS fingerprint");
275
+ const record = getRecord(parseJsonDocument(output).value, "EAS fingerprint");
171
276
  if (typeof record.hash !== "string" || record.hash.length === 0) {
172
277
  throw new Error("EAS fingerprint output did not contain a full hash");
173
278
  }
@@ -180,8 +285,85 @@ function dryRunCommand(command) {
180
285
  process.stderr.write(`[dry-run] ${quoteCommand(command)}
181
286
  `);
182
287
  }
183
- async function generateFingerprint(input, commandRunner) {
184
- return parseFingerprintHash(await commandRunner(easCommand("fingerprint:generate", "--platform", input.platform, "--build-profile", input.profile, "--json", "--non-interactive")));
288
+
289
+ class StageError extends Error {
290
+ stage;
291
+ tail;
292
+ constructor(stage, message, tail = []) {
293
+ super(message);
294
+ this.stage = stage;
295
+ this.tail = tail;
296
+ this.name = "StageError";
297
+ }
298
+ }
299
+ async function stage(name, run) {
300
+ const started = performance.now();
301
+ try {
302
+ return await run();
303
+ } catch (error) {
304
+ if (error instanceof StageError)
305
+ throw error;
306
+ throw new StageError(name, error instanceof Error ? error.message : String(error), error instanceof CommandError ? error.tail : []);
307
+ } finally {
308
+ const seconds = ((performance.now() - started) / 1000).toFixed(1);
309
+ process.stderr.write(`[expo-release] ${name}: ${seconds}s
310
+ `);
311
+ }
312
+ }
313
+ async function generateFingerprint(input, commandRunner, explainConfig) {
314
+ const output = await commandRunner(easCommand("fingerprint:generate", "--platform", input.platform, "--build-profile", input.profile, "--json", "--non-interactive"));
315
+ const { preamble } = parseJsonDocument(output);
316
+ if (preamble) {
317
+ const reason = explainConfig ? await explainConfig() : "";
318
+ throw new CommandError([
319
+ "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.",
320
+ `eas-cli printed:
321
+ ${preamble}`,
322
+ reason && `expo config said:
323
+ ${reason}`
324
+ ].filter(Boolean).join(`
325
+ `), lastLines(reason || preamble));
326
+ }
327
+ return parseFingerprintHash(output);
328
+ }
329
+
330
+ // src/source-maps.ts
331
+ import { exists } from "fs/promises";
332
+ var SOURCE_MAP_OUTPUT_DIR = "dist";
333
+ function expoCommand(...args) {
334
+ const bin = process.env.EXPO_BIN?.trim();
335
+ if (bin && bin.length > 0)
336
+ return [bin, ...args];
337
+ const local = localBin("expo");
338
+ return local ? [local, ...args] : ["bunx", "expo", ...args];
339
+ }
340
+ function sourceMapExportCommand(platform) {
341
+ return expoCommand("export", "--platform", platform, "--output-dir", SOURCE_MAP_OUTPUT_DIR, "--source-maps", "external");
342
+ }
343
+ function mergeUpdateArgs(extra, exportSourceMaps) {
344
+ const args = extra ? [...extra] : [];
345
+ if (!exportSourceMaps)
346
+ return args;
347
+ if (!args.includes("--input-dir")) {
348
+ args.push("--input-dir", SOURCE_MAP_OUTPUT_DIR);
349
+ }
350
+ if (!args.includes("--skip-bundler")) {
351
+ args.push("--skip-bundler");
352
+ }
353
+ return args;
354
+ }
355
+ async function defaultHasSourceMaps(directory = SOURCE_MAP_OUTPUT_DIR) {
356
+ if (!await exists(directory))
357
+ return false;
358
+ for await (const _ of new Bun.Glob("**/*.map").scan(directory)) {
359
+ return true;
360
+ }
361
+ return false;
362
+ }
363
+ async function assertSourceMaps(hasSourceMaps = defaultHasSourceMaps) {
364
+ if (await hasSourceMaps())
365
+ return;
366
+ throw new Error("Expo export did not produce Hermes source maps in dist; refusing to publish an OTA update.");
185
367
  }
186
368
 
187
369
  // src/decide.ts
@@ -220,8 +402,8 @@ function normalizeBuildStatus(value) {
220
402
  return null;
221
403
  }
222
404
  function dashboardBuildUrl(record) {
223
- const ownerName = nestedString(record, "app", "ownerAccount", "name");
224
- const slug = nestedString(record, "app", "slug");
405
+ const ownerName = nestedString(record, "project", "ownerAccount", "name") ?? nestedString(record, "app", "ownerAccount", "name");
406
+ const slug = nestedString(record, "project", "slug") ?? nestedString(record, "app", "slug");
225
407
  if (typeof record.id !== "string" || !ownerName || !slug) {
226
408
  return;
227
409
  }
@@ -246,13 +428,17 @@ function parseBuildList(output) {
246
428
  }
247
429
  const buildUrl = dashboardBuildUrl(record);
248
430
  const fingerprint = nestedString(record, "fingerprint", "hash");
249
- const runtimeVersion = nestedString(record, "runtime", "version");
431
+ const runtimeVersion = nestedString(record, "runtimeVersion") ?? nestedString(record, "runtime", "version");
432
+ const errorCode = nestedString(record, "error", "errorCode");
433
+ const errorMessage = nestedString(record, "error", "message");
434
+ const error = [errorCode, errorMessage].filter(Boolean).join(": ");
250
435
  builds.push({
251
436
  id: record.id,
252
437
  status,
253
438
  ...buildUrl ? { buildUrl } : {},
254
439
  ...fingerprint ? { fingerprint } : {},
255
- ...runtimeVersion ? { runtimeVersion } : {}
440
+ ...runtimeVersion ? { runtimeVersion } : {},
441
+ ...error ? { error } : {}
256
442
  });
257
443
  }
258
444
  return builds;
@@ -269,26 +455,71 @@ function hasExpoImpact(paths, impactPaths = DEFAULT_IMPACT_PATHS) {
269
455
  function resolveBuildDecision(builds) {
270
456
  const finished = builds.find((build) => build.status === "finished");
271
457
  if (finished) {
272
- return { deployType: "ota", nativeBuildPending: false, build: finished };
458
+ return {
459
+ deployType: "ota",
460
+ reason: "finished-build",
461
+ nativeBuildPending: false,
462
+ build: finished
463
+ };
273
464
  }
274
465
  const inProgress = builds.find((build) => ACTIVE_BUILD_STATUSES.includes(build.status));
275
466
  if (inProgress) {
276
467
  return {
277
468
  deployType: "ota",
469
+ reason: "build-in-progress",
278
470
  nativeBuildPending: true,
279
471
  build: inProgress
280
472
  };
281
473
  }
282
474
  const newest = builds[0];
283
475
  if (newest?.status === "errored") {
284
- const location = newest.buildUrl ?? newest.id;
285
- 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.`);
476
+ const lines = [
477
+ `The newest native build for this fingerprint failed on EAS${newest.error ? `: ${newest.error}` : ` (build ${newest.id})`}`,
478
+ newest.buildUrl,
479
+ "Fix it, then run the workflow with operation force-native."
480
+ ].filter((line) => Boolean(line));
481
+ throw new StageError("errored build", lines.join(`
482
+ `), lines);
286
483
  }
287
- return { deployType: "native", nativeBuildPending: false, build: null };
484
+ return {
485
+ deployType: "native",
486
+ reason: "no-build-for-fingerprint",
487
+ nativeBuildPending: false,
488
+ build: null
489
+ };
490
+ }
491
+ async function readPullRequestBase() {
492
+ const path = process.env.GITHUB_EVENT_PATH;
493
+ if (!path)
494
+ return;
495
+ try {
496
+ const event = await Bun.file(path).json();
497
+ const sha = event.pull_request?.base?.sha;
498
+ return typeof sha === "string" && sha ? sha : undefined;
499
+ } catch {
500
+ return;
501
+ }
502
+ }
503
+ async function defaultBase(head, commandRunner) {
504
+ const event = process.env.GITHUB_EVENT_NAME;
505
+ if (event === "pull_request" || event === "pull_request_target") {
506
+ const base = await readPullRequestBase();
507
+ if (base && head) {
508
+ try {
509
+ const mergeBase = (await commandRunner(["git", "merge-base", base, head])).trim();
510
+ if (mergeBase)
511
+ return mergeBase;
512
+ } catch {}
513
+ }
514
+ if (base)
515
+ return base;
516
+ }
517
+ return process.env.GITHUB_EVENT_BEFORE?.trim() || undefined;
288
518
  }
289
519
  async function getChangedFiles(input, commandRunner) {
290
520
  const head = input.head;
291
- if (!input.base || !head || /^0+$/.test(input.base)) {
521
+ const base = input.base ?? await defaultBase(head, commandRunner);
522
+ if (!base || !head || /^0+$/.test(base)) {
292
523
  return { files: [], unreachable: true };
293
524
  }
294
525
  try {
@@ -298,7 +529,7 @@ async function getChangedFiles(input, commandRunner) {
298
529
  "diff.relative=false",
299
530
  "diff",
300
531
  "--name-only",
301
- input.base,
532
+ base,
302
533
  head
303
534
  ]);
304
535
  return {
@@ -313,25 +544,95 @@ async function getChangedFiles(input, commandRunner) {
313
544
  async function getMatchingBuilds(input, fingerprint, commandRunner) {
314
545
  return parseBuildList(await commandRunner(easCommand("build:list", "--platform", input.platform, "--build-profile", input.profile, "--fingerprint-hash", fingerprint, "--limit", "20", "--json", "--non-interactive")));
315
546
  }
547
+ function fingerprintSources(value) {
548
+ const sources = value?.sources;
549
+ const map = new Map;
550
+ if (!Array.isArray(sources))
551
+ return map;
552
+ for (const source of sources) {
553
+ const key = source.type === "contents" ? source.id : source.filePath ?? source.id;
554
+ if (typeof key === "string")
555
+ map.set(key, source.hash ?? null);
556
+ }
557
+ return map;
558
+ }
559
+ var MAX_DIFF_SOURCES = 30;
560
+ function diffFingerprintSources(compare) {
561
+ const record = getRecord(compare, "EAS fingerprint:compare");
562
+ const before = fingerprintSources(record.fingerprint1);
563
+ const after = fingerprintSources(record.fingerprint2);
564
+ const lines = [];
565
+ for (const [key, hash] of after) {
566
+ if (!before.has(key))
567
+ lines.push(`added ${key}`);
568
+ else if (before.get(key) !== hash)
569
+ lines.push(`changed ${key}`);
570
+ }
571
+ for (const key of before.keys()) {
572
+ if (!after.has(key))
573
+ lines.push(`removed ${key}`);
574
+ }
575
+ if (lines.length <= MAX_DIFF_SOURCES)
576
+ return lines;
577
+ return [
578
+ ...lines.slice(0, MAX_DIFF_SOURCES),
579
+ `... and ${lines.length - MAX_DIFF_SOURCES} more`
580
+ ];
581
+ }
582
+ async function explainNativeDecision(input, commandRunner) {
583
+ try {
584
+ return await stage("fingerprint diff", async () => {
585
+ const [last] = parseBuildList(await commandRunner(easCommand("build:list", "--platform", input.platform, "--build-profile", input.profile, "--status", "finished", "--limit", "1", "--json", "--non-interactive")));
586
+ if (!last)
587
+ return null;
588
+ const command = easCommand("fingerprint:compare", "--build-id", last.id, "--json", "--non-interactive");
589
+ if (input.environment)
590
+ command.push("--environment", input.environment);
591
+ const { value } = parseJsonDocument(await commandRunner(command));
592
+ return {
593
+ buildId: last.id,
594
+ ...last.buildUrl ? { buildUrl: last.buildUrl } : {},
595
+ sources: diffFingerprintSources(value)
596
+ };
597
+ });
598
+ } catch (error) {
599
+ process.stderr.write(`Warning: could not compare the fingerprint with the last finished build: ${error instanceof Error ? error.message : String(error)}
600
+ `);
601
+ return null;
602
+ }
603
+ }
604
+ async function explainConfigFailure(commandRunner) {
605
+ try {
606
+ await commandRunner(expoCommand("config", "--json"));
607
+ return "";
608
+ } catch (error) {
609
+ return error instanceof Error ? error.message : String(error);
610
+ }
611
+ }
316
612
  async function decideRelease(input, commandRunner = runCaptured) {
317
- const { files: changedFiles, unreachable } = await getChangedFiles(input, commandRunner);
613
+ const { files: changedFiles, unreachable } = await stage("diff", () => getChangedFiles(input, commandRunner));
318
614
  if (!input.ignoreExpoImpact && !unreachable && !hasExpoImpact(changedFiles, input.impactPaths)) {
319
615
  return {
320
616
  deployType: "none",
617
+ reason: "no-impact-path",
321
618
  nativeBuildPending: false,
322
619
  changedFiles,
323
620
  fingerprint: null,
324
621
  build: null
325
622
  };
326
623
  }
327
- const fingerprint = await generateFingerprint(input, commandRunner);
328
- const resolved = resolveBuildDecision(await getMatchingBuilds(input, fingerprint, commandRunner));
624
+ await input.preflight?.();
625
+ const fingerprint = await stage("fingerprint", () => generateFingerprint(input, commandRunner, () => explainConfigFailure(commandRunner)));
626
+ const builds = await stage("build list", () => getMatchingBuilds(input, fingerprint, commandRunner));
627
+ const resolved = resolveBuildDecision(builds);
329
628
  return {
330
629
  deployType: resolved.deployType,
630
+ reason: resolved.reason,
331
631
  nativeBuildPending: resolved.nativeBuildPending,
332
632
  changedFiles,
333
633
  fingerprint,
334
- build: resolved.build
634
+ build: resolved.build,
635
+ ...resolved.deployType === "native" ? { fingerprintDiff: await explainNativeDecision(input, commandRunner) } : {}
335
636
  };
336
637
  }
337
638
  async function writeDecideGitHubOutput(decision) {
@@ -341,51 +642,21 @@ async function writeDecideGitHubOutput(decision) {
341
642
  changed: String(decision.deployType !== "none"),
342
643
  fingerprint: decision.fingerprint ?? "",
343
644
  build_id: decision.build?.id ?? "",
344
- build_url: decision.build?.buildUrl ?? ""
645
+ build_url: decision.build?.buildUrl ?? "",
646
+ reason: decision.reason,
647
+ changed_sources: decision.fingerprintDiff?.sources.join(`
648
+ `) ?? "",
649
+ compared_build_url: decision.fingerprintDiff?.buildUrl ?? decision.fingerprintDiff?.buildId ?? ""
345
650
  });
346
651
  }
347
- // src/source-maps.ts
348
- import { exists } from "fs/promises";
349
- var SOURCE_MAP_OUTPUT_DIR = "dist";
350
- function expoCommand(...args) {
351
- const bin = process.env.EXPO_BIN?.trim();
352
- if (bin && bin.length > 0)
353
- return [bin, ...args];
354
- return ["bunx", "expo", ...args];
355
- }
356
- function sourceMapExportCommand(platform) {
357
- return expoCommand("export", "--platform", platform, "--output-dir", SOURCE_MAP_OUTPUT_DIR, "--source-maps", "external");
358
- }
359
- function mergeUpdateArgs(extra, exportSourceMaps) {
360
- const args = extra ? [...extra] : [];
361
- if (!exportSourceMaps)
362
- return args;
363
- if (!args.includes("--input-dir")) {
364
- args.push("--input-dir", SOURCE_MAP_OUTPUT_DIR);
365
- }
366
- if (!args.includes("--skip-bundler")) {
367
- args.push("--skip-bundler");
652
+ // src/operate.ts
653
+ function splitArgs(value) {
654
+ const args = [];
655
+ for (const match of (value ?? "").matchAll(/"((?:\\.|[^"\\])*)"|'([^']*)'|(\S+)/g)) {
656
+ args.push(match[1]?.replace(/\\(.)/g, "$1") ?? match[2] ?? match[3] ?? "");
368
657
  }
369
658
  return args;
370
659
  }
371
- async function defaultHasSourceMaps(directory = SOURCE_MAP_OUTPUT_DIR) {
372
- if (!await exists(directory))
373
- return false;
374
- for await (const _ of new Bun.Glob("**/*.map").scan(directory)) {
375
- return true;
376
- }
377
- return false;
378
- }
379
- async function assertSourceMaps(hasSourceMaps = defaultHasSourceMaps) {
380
- if (await hasSourceMaps())
381
- return;
382
- throw new Error("Expo export did not produce Hermes source maps in dist; refusing to publish an OTA update.");
383
- }
384
-
385
- // src/operate.ts
386
- function extraUpdateArgs(value) {
387
- return value?.split(/\s+/).filter(Boolean) ?? [];
388
- }
389
660
  function updateMessage(input) {
390
661
  if (input.message)
391
662
  return input.message;
@@ -410,23 +681,25 @@ async function defaultReadPackageJson() {
410
681
  return null;
411
682
  return file.json();
412
683
  }
684
+ async function readExpoSdkMajor(readPackageJson = defaultReadPackageJson) {
685
+ const pkg = await readPackageJson();
686
+ if (!pkg || typeof pkg !== "object")
687
+ return null;
688
+ const record = pkg;
689
+ return parseExpoSdkMajor(record.dependencies?.expo ?? record.devDependencies?.expo);
690
+ }
413
691
  async function requireEnvironmentIfNeeded(input) {
414
692
  if (input.environment)
415
693
  return;
416
- const pkg = await (input.readPackageJson ?? defaultReadPackageJson)();
417
- if (!pkg || typeof pkg !== "object" || pkg === null)
418
- return;
419
- const record = pkg;
420
- const version = record.dependencies?.expo ?? record.devDependencies?.expo;
421
- const major = parseExpoSdkMajor(version);
694
+ const major = await readExpoSdkMajor(input.readPackageJson);
422
695
  if (major !== null && major >= 55) {
423
- throw new Error("EAS Update requires --environment for Expo SDK 55+. Pass --environment <name>.");
696
+ throw new Error("EAS Update requires --environment for Expo SDK 55+. Pass --environment <name> or set build.<profile>.environment in eas.json.");
424
697
  }
425
698
  }
426
- async function runHook(command, commandRunner) {
699
+ async function runHook(name, command, commandRunner) {
427
700
  if (!command)
428
701
  return;
429
- await commandRunner(["bash", "-c", command]);
702
+ await stage(name, () => commandRunner(["bash", "-c", command]));
430
703
  }
431
704
  function mutatingRunner(commandRunner, dryRun) {
432
705
  return async (command) => {
@@ -440,33 +713,44 @@ function mutatingRunner(commandRunner, dryRun) {
440
713
  async function publishUpdate(input, commandRunner) {
441
714
  await requireEnvironmentIfNeeded(input);
442
715
  if (input.exportSourceMaps) {
443
- await commandRunner(sourceMapExportCommand(input.platform));
444
- if (input.dryRun !== true) {
445
- await assertSourceMaps(input.hasSourceMaps);
446
- }
716
+ await stage("ota export", async () => {
717
+ await commandRunner(sourceMapExportCommand(input.platform));
718
+ if (input.dryRun !== true) {
719
+ await assertSourceMaps(input.hasSourceMaps);
720
+ }
721
+ });
447
722
  }
448
- await runHook(input.preUpdate, commandRunner);
723
+ await runHook("pre-update", input.preUpdate, commandRunner);
449
724
  const command = easCommand("update", "--channel", input.channel, "--platform", input.platform, "--message", updateMessage(input), "--non-interactive");
450
725
  if (input.environment) {
451
726
  command.push("--environment", input.environment);
452
727
  }
453
- command.push(...mergeUpdateArgs(extraUpdateArgs(input.updateExtraArgs), input.exportSourceMaps === true));
454
- await commandRunner(command);
455
- await runHook(input.postUpdate, commandRunner);
728
+ command.push(...mergeUpdateArgs(splitArgs(input.updateExtraArgs), input.exportSourceMaps === true));
729
+ await stage("ota update", () => commandRunner(command));
730
+ await runHook("post-update", input.postUpdate, commandRunner);
456
731
  }
457
732
  async function queueNativeBuild(input, commandRunner) {
458
- const command = easCommand("build", "--platform", input.platform, "--profile", input.profile, "--non-interactive", "--no-wait");
733
+ const command = easCommand("build", "--platform", input.platform, "--profile", input.profile, "--non-interactive", "--no-wait", "--json");
459
734
  if (input.autoSubmit) {
460
735
  command.push("--auto-submit");
461
736
  }
462
- await commandRunner(command);
737
+ const output = await stage("native build", () => commandRunner(command));
738
+ try {
739
+ const queued = output.trim() ? parseBuildList(output)[0] : undefined;
740
+ if (queued)
741
+ input.onQueuedBuild?.(queued);
742
+ } catch (error) {
743
+ process.stderr.write(`Warning: could not read the queued build from eas build --json: ${error instanceof Error ? error.message : String(error)}
744
+ `);
745
+ }
746
+ return "native-build";
463
747
  }
464
748
  async function resolveRuntimeVersion(input, commandRunner) {
465
749
  if (input.runtimeVersion)
466
750
  return input.runtimeVersion;
467
751
  if (input.fingerprint)
468
752
  return input.fingerprint;
469
- return generateFingerprint(input, commandRunner);
753
+ return stage("fingerprint", () => generateFingerprint(input, commandRunner));
470
754
  }
471
755
  async function operateRelease(input, commandRunner = runInherited) {
472
756
  const execute = mutatingRunner(commandRunner, input.dryRun === true);
@@ -475,19 +759,19 @@ async function operateRelease(input, commandRunner = runInherited) {
475
759
  if (!input.group) {
476
760
  throw new Error("Usage: --operation republish requires --group <update-group-id>");
477
761
  }
762
+ const group = input.group;
478
763
  await requireEnvironmentIfNeeded(input);
479
- await execute(easCommand("update:republish", "--group", input.group, "--platform", input.platform, "--message", updateMessage(input), "--non-interactive"));
764
+ await stage("recovery", () => execute(easCommand("update:republish", "--group", group, "--platform", input.platform, "--message", updateMessage(input), "--non-interactive")));
480
765
  return "recovery";
481
766
  }
482
767
  case "rollback-embedded": {
483
768
  await requireEnvironmentIfNeeded(input);
484
769
  const runtimeVersion = await resolveRuntimeVersion(input, commandRunner);
485
- await execute(easCommand("update:roll-back-to-embedded", "--channel", input.channel, "--platform", input.platform, "--runtime-version", runtimeVersion, "--message", updateMessage(input), "--non-interactive"));
770
+ await stage("recovery", () => execute(easCommand("update:roll-back-to-embedded", "--channel", input.channel, "--platform", input.platform, "--runtime-version", runtimeVersion, "--message", updateMessage(input), "--non-interactive")));
486
771
  return "recovery";
487
772
  }
488
773
  case "force-native":
489
- await queueNativeBuild(input, execute);
490
- return "native-build";
774
+ return queueNativeBuild(input, execute);
491
775
  case "retry-ota":
492
776
  await publishUpdate(input, execute);
493
777
  return "ota";
@@ -502,8 +786,7 @@ async function operateRelease(input, commandRunner = runInherited) {
502
786
  case "none":
503
787
  return "skipped";
504
788
  case "native":
505
- await queueNativeBuild(input, execute);
506
- return "native-build";
789
+ return queueNativeBuild(input, execute);
507
790
  case "ota":
508
791
  await publishUpdate(input, execute);
509
792
  return "ota";
@@ -513,8 +796,12 @@ async function operateRelease(input, commandRunner = runInherited) {
513
796
  }
514
797
  }
515
798
  }
516
- async function writeOperateGitHubOutput(result) {
517
- await appendGitHubOutput({ result });
799
+ async function writeOperateGitHubOutput(result, queuedBuild) {
800
+ await appendGitHubOutput({
801
+ result,
802
+ queued_build_id: queuedBuild?.id ?? "",
803
+ queued_build_url: queuedBuild?.buildUrl ?? ""
804
+ });
518
805
  }
519
806
  export {
520
807
  DEFAULT_IMPACT_PATHS,