expo-release 2.0.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,62 +1,65 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
3
 
4
+ // src/cli.ts
5
+ import { defineCli, defineCommand } from "hanzio/cli";
6
+
4
7
  // src/args.ts
5
- import { parseArgs } from "util";
8
+ import { z } from "zod";
6
9
  var DEFAULT_IMPACT_PATHS = ["apps/expo/"];
7
- var USAGE = "Usage: expo-release <release|decide|operate> --profile <profile> [--base <sha>] [--head <sha>] [--platform ios|android] [--impact-paths apps/expo/] [--operation deploy|force-native|retry-ota|republish|rollback-embedded] [--deploy-type none|ota|native] [--channel <channel>] [--environment <env>] [--group <id>] [--runtime-version <hash>] [--message <msg>] [--fingerprint <hash>] [--auto-submit true] [--pre-update <cmd>] [--post-update <cmd>] [--update-extra-args <args>] [--dry-run] [--ignore-expo-impact true]";
8
- function fromEnv(env, name) {
9
- const value = env[name]?.trim();
10
- return value ? value : undefined;
11
- }
12
- function firstDefined(...values) {
13
- return values.find((value) => value !== undefined && value.length > 0);
14
- }
15
- function optionalString(value) {
16
- const trimmed = value?.trim();
17
- return trimmed ? trimmed : undefined;
18
- }
19
- function parseImpactPaths(value) {
20
- const paths = (value ?? DEFAULT_IMPACT_PATHS.join(",")).split(",").map((path) => path.trim()).filter(Boolean);
21
- if (paths.length === 0) {
22
- throw new Error("At least one --impact-paths entry is required");
23
- }
24
- return paths;
25
- }
26
- function parsePlatform(value) {
27
- if (value === undefined || value === "ios")
28
- return "ios";
29
- if (value === "android")
30
- return "android";
31
- throw new Error("Usage: --platform ios|android");
32
- }
33
- function parseBoolean(value, fallback) {
34
- if (value === undefined)
35
- return fallback;
36
- if (value === "true")
37
- return true;
38
- if (value === "false")
39
- return false;
40
- throw new Error(`Expected true or false, received: ${value}`);
41
- }
42
- function parseOperation(value) {
43
- if (value === undefined || value === "deploy" || value === "force-native" || value === "retry-ota" || value === "republish" || value === "rollback-embedded") {
44
- return value ?? "deploy";
45
- }
46
- throw new Error("Usage: --operation deploy|force-native|retry-ota|republish|rollback-embedded");
47
- }
48
- function parseDeployType(value) {
49
- if (value === undefined || value === "none")
50
- return "none";
51
- if (value === "ota" || value === "native")
52
- return value;
53
- throw new Error("Usage: --deploy-type none|ota|native");
54
- }
55
- function parseCommandName(value) {
56
- if (value === "release" || value === "decide" || value === "operate") {
57
- return value;
58
- }
59
- throw new Error(USAGE);
10
+ 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}` });
12
+ var releaseFlags = z.object({
13
+ 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"),
17
+ impactPaths: z.string().default(DEFAULT_IMPACT_PATHS.join(",")).transform((value, ctx) => {
18
+ const paths = value.split(",").map((path) => path.trim()).filter(Boolean);
19
+ if (paths.length === 0)
20
+ ctx.addIssue({
21
+ code: "custom",
22
+ message: "At least one --impact-paths entry is required"
23
+ });
24
+ return paths;
25
+ }).meta({ env: "EXPO_RELEASE_IMPACT_PATHS" }).describe("Comma-separated repo-root paths that count as an Expo change"),
26
+ ignoreExpoImpact: trueFalse("IGNORE_EXPO_IMPACT", "false").describe("Check the fingerprint even when no impact path changed"),
27
+ operation: z.enum([
28
+ "deploy",
29
+ "force-native",
30
+ "retry-ota",
31
+ "republish",
32
+ "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+)"),
37
+ autoSubmit: trueFalse("AUTO_SUBMIT", "true").describe("Pass --auto-submit on native builds"),
38
+ preUpdate: text("PRE_UPDATE").describe("Shell command run before eas update"),
39
+ postUpdate: text("POST_UPDATE").describe("Shell command run after a successful eas update"),
40
+ updateExtraArgs: text("UPDATE_EXTRA_ARGS").describe("Extra tokens appended to eas update"),
41
+ exportSourceMaps: trueFalse("EXPORT_SOURCE_MAPS", "false").describe("On OTA, expo export with external source maps, then publish dist"),
42
+ group: text("GROUP").describe("Update group ID for --operation republish"),
43
+ runtimeVersion: text("RUNTIME_VERSION").describe("Runtime version for rollback-embedded (default: the fingerprint)"),
44
+ message: text("MESSAGE").describe("Override the eas update message"),
45
+ 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")
47
+ }).superRefine((flags, ctx) => {
48
+ if (flags.operation === "republish" && !flags.group)
49
+ ctx.addIssue({
50
+ code: "custom",
51
+ path: ["group"],
52
+ message: "required by --operation republish"
53
+ });
54
+ });
55
+ function toReleaseOptions(command, { context: flags, env }) {
56
+ return {
57
+ ...flags,
58
+ command,
59
+ base: flags.base ?? env.GITHUB_EVENT_BEFORE,
60
+ head: flags.head ?? env.GITHUB_SHA,
61
+ channel: flags.channel ?? flags.profile
62
+ };
60
63
  }
61
64
  function needsDecide(operation) {
62
65
  switch (operation) {
@@ -80,80 +83,6 @@ function shouldIgnoreExpoImpact(options) {
80
83
  return true;
81
84
  return process.env.GITHUB_EVENT_NAME === "workflow_dispatch" && options.operation === "deploy";
82
85
  }
83
- function parseReleaseOptions(args, env = process.env) {
84
- let values;
85
- let positionals;
86
- try {
87
- const parsed = parseArgs({
88
- args,
89
- allowPositionals: true,
90
- strict: true,
91
- options: {
92
- profile: { type: "string" },
93
- base: { type: "string" },
94
- head: { type: "string" },
95
- platform: { type: "string" },
96
- "impact-paths": { type: "string" },
97
- "ignore-expo-impact": { type: "string" },
98
- operation: { type: "string" },
99
- "deploy-type": { type: "string" },
100
- channel: { type: "string" },
101
- environment: { type: "string" },
102
- "auto-submit": { type: "string" },
103
- "pre-update": { type: "string" },
104
- "post-update": { type: "string" },
105
- "update-extra-args": { type: "string" },
106
- group: { type: "string" },
107
- "runtime-version": { type: "string" },
108
- message: { type: "string" },
109
- fingerprint: { type: "string" },
110
- "dry-run": { type: "boolean", default: false }
111
- }
112
- });
113
- values = parsed.values;
114
- positionals = parsed.positionals;
115
- } catch (error) {
116
- const message = error instanceof Error ? error.message : String(error);
117
- throw new Error(`${USAGE}
118
- ${message}`);
119
- }
120
- if (positionals.length > 1) {
121
- throw new Error(USAGE);
122
- }
123
- const command = parseCommandName(positionals[0] ?? fromEnv(env, "EXPO_RELEASE_COMMAND"));
124
- const profile = firstDefined(values.profile, fromEnv(env, "EXPO_RELEASE_PROFILE"));
125
- if (!profile) {
126
- throw new Error(USAGE);
127
- }
128
- const operation = parseOperation(firstDefined(values.operation, fromEnv(env, "EXPO_RELEASE_OPERATION")));
129
- const group = optionalString(firstDefined(values.group, fromEnv(env, "EXPO_RELEASE_GROUP")));
130
- if (operation === "republish" && !group) {
131
- throw new Error("Usage: --operation republish requires --group <update-group-id>");
132
- }
133
- const channel = firstDefined(values.channel, fromEnv(env, "EXPO_RELEASE_CHANNEL")) ?? profile;
134
- return {
135
- command,
136
- profile,
137
- base: optionalString(firstDefined(values.base, fromEnv(env, "EXPO_RELEASE_BASE"), fromEnv(env, "GITHUB_EVENT_BEFORE"))),
138
- head: optionalString(firstDefined(values.head, fromEnv(env, "EXPO_RELEASE_HEAD"), fromEnv(env, "GITHUB_SHA"))),
139
- platform: parsePlatform(firstDefined(values.platform, fromEnv(env, "EXPO_RELEASE_PLATFORM"))),
140
- impactPaths: parseImpactPaths(firstDefined(values["impact-paths"], fromEnv(env, "EXPO_RELEASE_IMPACT_PATHS"))),
141
- ignoreExpoImpact: parseBoolean(firstDefined(values["ignore-expo-impact"], fromEnv(env, "EXPO_RELEASE_IGNORE_EXPO_IMPACT")), false),
142
- operation,
143
- deployType: parseDeployType(firstDefined(values["deploy-type"], fromEnv(env, "EXPO_RELEASE_DEPLOY_TYPE"))),
144
- channel,
145
- environment: optionalString(firstDefined(values.environment, fromEnv(env, "EXPO_RELEASE_ENVIRONMENT"))),
146
- autoSubmit: parseBoolean(firstDefined(values["auto-submit"], fromEnv(env, "EXPO_RELEASE_AUTO_SUBMIT")), true),
147
- preUpdate: optionalString(firstDefined(values["pre-update"], fromEnv(env, "EXPO_RELEASE_PRE_UPDATE"))),
148
- postUpdate: optionalString(firstDefined(values["post-update"], fromEnv(env, "EXPO_RELEASE_POST_UPDATE"))),
149
- updateExtraArgs: optionalString(firstDefined(values["update-extra-args"], fromEnv(env, "EXPO_RELEASE_UPDATE_EXTRA_ARGS"))),
150
- group,
151
- runtimeVersion: optionalString(firstDefined(values["runtime-version"], fromEnv(env, "EXPO_RELEASE_RUNTIME_VERSION"))),
152
- message: optionalString(firstDefined(values.message, fromEnv(env, "EXPO_RELEASE_MESSAGE"))),
153
- fingerprint: optionalString(firstDefined(values.fingerprint, fromEnv(env, "EXPO_RELEASE_FINGERPRINT"))),
154
- dryRun: values["dry-run"] === true || parseBoolean(fromEnv(env, "EXPO_RELEASE_DRY_RUN"), false)
155
- };
156
- }
157
86
 
158
87
  // src/github-output.ts
159
88
  import { appendFile } from "fs/promises";
@@ -188,16 +117,44 @@ async function runCaptured(command) {
188
117
  child.exited
189
118
  ]);
190
119
  if (exitCode !== 0) {
191
- throw new Error(`Command failed (${command.join(" ")}):
192
- ${stderr || stdout}`);
120
+ throw new Error(describeFailure(command, exitCode, { stderr, stdout }));
193
121
  }
194
122
  return stdout;
195
123
  }
124
+ var OUTPUT_TAIL_LINES = 100;
125
+ function tail(output) {
126
+ const lines = output.trimEnd().split(`
127
+ `);
128
+ if (lines.length <= OUTPUT_TAIL_LINES)
129
+ return lines.join(`
130
+ `);
131
+ const omitted = lines.length - OUTPUT_TAIL_LINES;
132
+ return [
133
+ `... (${omitted} earlier lines omitted)`,
134
+ ...lines.slice(-OUTPUT_TAIL_LINES)
135
+ ].join(`
136
+ `);
137
+ }
138
+ function describeFailure(command, exitCode, output) {
139
+ const sections = [
140
+ `Command failed with exit code ${exitCode}: ${quoteCommand(command)}`
141
+ ];
142
+ if (output.stderr.trim())
143
+ sections.push(`stderr:
144
+ ${tail(output.stderr)}`);
145
+ if (output.stdout.trim())
146
+ sections.push(`stdout:
147
+ ${tail(output.stdout)}`);
148
+ if (sections.length === 1)
149
+ sections.push("(no output)");
150
+ return sections.join(`
151
+ `);
152
+ }
196
153
  async function runInherited(command) {
197
154
  const child = Bun.spawn(command, { stdout: "inherit", stderr: "inherit" });
198
155
  const exitCode = await child.exited;
199
156
  if (exitCode !== 0) {
200
- throw new Error(`Command failed (${command.join(" ")})`);
157
+ throw new Error(`Command failed with exit code ${exitCode}: ${quoteCommand(command)} (output above)`);
201
158
  }
202
159
  return "";
203
160
  }
@@ -394,6 +351,44 @@ async function writeDecideGitHubOutput(decision) {
394
351
  });
395
352
  }
396
353
 
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");
375
+ }
376
+ return args;
377
+ }
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
+
397
392
  // src/operate.ts
398
393
  function extraUpdateArgs(value) {
399
394
  return value?.split(/\s+/).filter(Boolean) ?? [];
@@ -451,12 +446,18 @@ function mutatingRunner(commandRunner, dryRun) {
451
446
  }
452
447
  async function publishUpdate(input, commandRunner) {
453
448
  await requireEnvironmentIfNeeded(input);
449
+ if (input.exportSourceMaps) {
450
+ await commandRunner(sourceMapExportCommand(input.platform));
451
+ if (input.dryRun !== true) {
452
+ await assertSourceMaps(input.hasSourceMaps);
453
+ }
454
+ }
454
455
  await runHook(input.preUpdate, commandRunner);
455
456
  const command = easCommand("update", "--channel", input.channel, "--platform", input.platform, "--message", updateMessage(input), "--non-interactive");
456
457
  if (input.environment) {
457
458
  command.push("--environment", input.environment);
458
459
  }
459
- command.push(...extraUpdateArgs(input.updateExtraArgs));
460
+ command.push(...mergeUpdateArgs(extraUpdateArgs(input.updateExtraArgs), input.exportSourceMaps === true));
460
461
  await commandRunner(command);
461
462
  await runHook(input.postUpdate, commandRunner);
462
463
  }
@@ -550,50 +551,56 @@ function toOperationInput(options, decision) {
550
551
  preUpdate: options.preUpdate,
551
552
  postUpdate: options.postUpdate,
552
553
  updateExtraArgs: options.updateExtraArgs,
554
+ exportSourceMaps: options.exportSourceMaps,
553
555
  dryRun: options.dryRun
554
556
  };
555
557
  }
556
- async function main() {
557
- const raw = Bun.argv.slice(2);
558
- if (raw.length === 0 || raw[0] === "--help" || raw[0] === "-h") {
559
- process.stdout.write(`${USAGE}
558
+ async function decide(options, ctx) {
559
+ const decision = await decideRelease(toDecisionInput(options));
560
+ await writeDecideGitHubOutput(decision);
561
+ ctx.stdout.write(`${JSON.stringify(decision)}
560
562
  `);
561
- return;
562
- }
563
- const options = parseReleaseOptions(raw);
564
- switch (options.command) {
565
- case "decide": {
566
- const decision = await decideRelease(toDecisionInput(options));
567
- await writeDecideGitHubOutput(decision);
568
- process.stdout.write(`${JSON.stringify(decision)}
569
- `);
570
- return;
571
- }
572
- case "operate": {
573
- const result = await operateRelease(toOperationInput(options, null));
574
- await writeOperateGitHubOutput(result);
575
- process.stdout.write(`${result}
576
- `);
577
- return;
578
- }
579
- case "release": {
580
- let decision = null;
581
- if (needsDecide(options.operation)) {
582
- decision = await decideRelease(toDecisionInput(options));
583
- await writeDecideGitHubOutput(decision);
584
- process.stdout.write(`${JSON.stringify(decision)}
563
+ return decision;
564
+ }
565
+ async function operate(options, decision, ctx) {
566
+ const result = await operateRelease(toOperationInput(options, decision));
567
+ await writeOperateGitHubOutput(result);
568
+ ctx.stdout.write(`${result}
585
569
  `);
570
+ }
571
+ var COMMANDS = ["release", "decide", "operate"];
572
+ var cli = defineCli({
573
+ name: "expo-release",
574
+ description: "Fingerprint-driven Expo EAS release: OTA update or native store build",
575
+ globals: releaseFlags,
576
+ context: (flags) => flags,
577
+ commands: {
578
+ release: defineCommand({
579
+ description: "Run decide, then operate on its decision",
580
+ 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);
586
584
  }
587
- const result = await operateRelease(toOperationInput(options, decision));
588
- await writeOperateGitHubOutput(result);
589
- process.stdout.write(`${result}
590
- `);
591
- return;
592
- }
593
- default: {
594
- const _exhaustive = options.command;
595
- throw new Error(`Unsupported command: ${_exhaustive}`);
596
- }
585
+ }),
586
+ decide: defineCommand({
587
+ description: "Print whether this change needs no deploy, an OTA update or a native build",
588
+ run: async (_input, ctx) => {
589
+ await decide(toReleaseOptions("decide", ctx), ctx);
590
+ }
591
+ }),
592
+ operate: defineCommand({
593
+ description: "Run --operation for --deploy-type",
594
+ run: async (_input, ctx) => {
595
+ await operate(toReleaseOptions("operate", ctx), null, ctx);
596
+ }
597
+ })
597
598
  }
599
+ });
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;
598
604
  }
599
- await main();
605
+ var code = await cli.run(argv, { env });
606
+ process.exitCode = code === 2 ? 1 : code;