expo-release 2.2.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/README.md CHANGED
@@ -70,7 +70,7 @@ Reference the upstream README for full input/output docs and recovery operations
70
70
  uses: HansKristoffer/expo-release@v2
71
71
  ```
72
72
 
73
- The action runs `bun ${{ github.action_path }}/src/cli.ts`. The caller must already have checked out the repo (`fetch-depth: 0`), installed Bun, set up EAS, and installed workspace dependencies.
73
+ The action installs its own dependencies (`hanzio`, `zod`) in `${{ github.action_path }}`, then runs `bun ${{ github.action_path }}/src/cli.ts`. The caller must already have checked out the repo (`fetch-depth: 0`), installed Bun, set up EAS, and installed workspace dependencies.
74
74
 
75
75
  ### npm (CLI)
76
76
 
@@ -425,6 +425,8 @@ expo-release operate \
425
425
  [--post-update "bunx posthog-cli hermes upload --directory dist"]
426
426
  ```
427
427
 
428
+ Run `expo-release <command> --help` for every flag. Each flag also reads `EXPO_RELEASE_<FLAG>` (for example `EXPO_RELEASE_IMPACT_PATHS`) when it is not passed; an empty value counts as unset.
429
+
428
430
  `release` is decide + operate (same as the action default). `force-native`, `republish`, and `rollback-embedded` skip decide. `decide` prints JSON including `deployType` and `nativeBuildPending`. When `GITHUB_OUTPUT` is set, decide writes `deploy_type`, `native_build_pending`, `changed`, `fingerprint`, `build_id`, `build_url`. Operate writes `result`.
429
431
 
430
432
  ```bash
package/action.yml CHANGED
@@ -107,6 +107,12 @@ outputs:
107
107
  runs:
108
108
  using: composite
109
109
  steps:
110
+ # The CLI imports hanzio and zod; the action checkout has no node_modules.
111
+ - name: Install expo-release dependencies
112
+ working-directory: ${{ github.action_path }}
113
+ shell: bash
114
+ run: bun install --production --frozen-lockfile --omit peer
115
+
110
116
  - name: Expo release
111
117
  id: release
112
118
  working-directory: ${{ inputs.working-directory }}
package/dist/args.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { CommandContext } from 'hanzio/cli';
2
+ import { z } from 'zod';
1
3
  export declare const DEFAULT_IMPACT_PATHS: readonly ['apps/expo/'];
2
4
  export type ReleasePlatform = 'ios' | 'android';
3
5
  export type CommandName = 'release' | 'decide' | 'operate';
@@ -26,14 +28,51 @@ export type ReleaseOptions = {
26
28
  fingerprint?: string;
27
29
  dryRun: boolean;
28
30
  };
29
- export declare const 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] [--export-source-maps true] [--pre-update <cmd>] [--post-update <cmd>] [--update-extra-args <args>] [--dry-run] [--ignore-expo-impact true]";
30
- export declare function optionalString(value: string | undefined): string | undefined;
31
- export declare function parseImpactPaths(value: string | undefined): string[];
32
- export declare function parsePlatform(value: string | undefined): ReleasePlatform;
33
- export declare function parseBoolean(value: string | undefined, fallback: boolean): boolean;
34
- export declare function parseOperation(value: string | undefined): Operation;
35
- export declare function parseDeployType(value: string | undefined): DeployType;
36
- export declare function parseCommandName(value: string | undefined): CommandName;
31
+ export declare const releaseFlags: z.ZodObject<{
32
+ profile: z.ZodString;
33
+ base: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string | undefined, string>>>;
34
+ head: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string | undefined, string>>>;
35
+ platform: z.ZodDefault<z.ZodEnum<{
36
+ android: "android";
37
+ ios: "ios";
38
+ }>>;
39
+ impactPaths: z.ZodPipe<z.ZodDefault<z.ZodString>, z.ZodTransform<string[], string>>;
40
+ ignoreExpoImpact: z.ZodPipe<z.ZodDefault<z.ZodEnum<{
41
+ false: "false";
42
+ true: "true";
43
+ }>>, z.ZodTransform<boolean, "false" | "true">>;
44
+ operation: z.ZodDefault<z.ZodEnum<{
45
+ deploy: "deploy";
46
+ "force-native": "force-native";
47
+ republish: "republish";
48
+ "retry-ota": "retry-ota";
49
+ "rollback-embedded": "rollback-embedded";
50
+ }>>;
51
+ deployType: z.ZodDefault<z.ZodEnum<{
52
+ native: "native";
53
+ none: "none";
54
+ ota: "ota";
55
+ }>>;
56
+ channel: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string | undefined, string>>>;
57
+ environment: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string | undefined, string>>>;
58
+ autoSubmit: z.ZodPipe<z.ZodDefault<z.ZodEnum<{
59
+ false: "false";
60
+ true: "true";
61
+ }>>, z.ZodTransform<boolean, "false" | "true">>;
62
+ preUpdate: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string | undefined, string>>>;
63
+ postUpdate: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string | undefined, string>>>;
64
+ updateExtraArgs: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string | undefined, string>>>;
65
+ exportSourceMaps: z.ZodPipe<z.ZodDefault<z.ZodEnum<{
66
+ false: "false";
67
+ true: "true";
68
+ }>>, z.ZodTransform<boolean, "false" | "true">>;
69
+ group: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string | undefined, string>>>;
70
+ runtimeVersion: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string | undefined, string>>>;
71
+ message: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string | undefined, string>>>;
72
+ fingerprint: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string | undefined, string>>>;
73
+ dryRun: z.ZodBoolean;
74
+ }, z.core.$strip>;
75
+ export type ReleaseFlags = z.output<typeof releaseFlags>;
76
+ export declare function toReleaseOptions(command: CommandName, { context: flags, env }: CommandContext<ReleaseFlags>): ReleaseOptions;
37
77
  export declare function needsDecide(operation: Operation): boolean;
38
78
  export declare function shouldIgnoreExpoImpact(options: Pick<ReleaseOptions, 'ignoreExpoImpact' | 'operation'>): boolean;
39
- export declare function parseReleaseOptions(args: string[], env?: NodeJS.ProcessEnv): ReleaseOptions;
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] [--export-source-maps 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,82 +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
- "export-source-maps": { type: "string" },
107
- group: { type: "string" },
108
- "runtime-version": { type: "string" },
109
- message: { type: "string" },
110
- fingerprint: { type: "string" },
111
- "dry-run": { type: "boolean", default: false }
112
- }
113
- });
114
- values = parsed.values;
115
- positionals = parsed.positionals;
116
- } catch (error) {
117
- const message = error instanceof Error ? error.message : String(error);
118
- throw new Error(`${USAGE}
119
- ${message}`);
120
- }
121
- if (positionals.length > 1) {
122
- throw new Error(USAGE);
123
- }
124
- const command = parseCommandName(positionals[0] ?? fromEnv(env, "EXPO_RELEASE_COMMAND"));
125
- const profile = firstDefined(values.profile, fromEnv(env, "EXPO_RELEASE_PROFILE"));
126
- if (!profile) {
127
- throw new Error(USAGE);
128
- }
129
- const operation = parseOperation(firstDefined(values.operation, fromEnv(env, "EXPO_RELEASE_OPERATION")));
130
- const group = optionalString(firstDefined(values.group, fromEnv(env, "EXPO_RELEASE_GROUP")));
131
- if (operation === "republish" && !group) {
132
- throw new Error("Usage: --operation republish requires --group <update-group-id>");
133
- }
134
- const channel = firstDefined(values.channel, fromEnv(env, "EXPO_RELEASE_CHANNEL")) ?? profile;
135
- return {
136
- command,
137
- profile,
138
- base: optionalString(firstDefined(values.base, fromEnv(env, "EXPO_RELEASE_BASE"), fromEnv(env, "GITHUB_EVENT_BEFORE"))),
139
- head: optionalString(firstDefined(values.head, fromEnv(env, "EXPO_RELEASE_HEAD"), fromEnv(env, "GITHUB_SHA"))),
140
- platform: parsePlatform(firstDefined(values.platform, fromEnv(env, "EXPO_RELEASE_PLATFORM"))),
141
- impactPaths: parseImpactPaths(firstDefined(values["impact-paths"], fromEnv(env, "EXPO_RELEASE_IMPACT_PATHS"))),
142
- ignoreExpoImpact: parseBoolean(firstDefined(values["ignore-expo-impact"], fromEnv(env, "EXPO_RELEASE_IGNORE_EXPO_IMPACT")), false),
143
- operation,
144
- deployType: parseDeployType(firstDefined(values["deploy-type"], fromEnv(env, "EXPO_RELEASE_DEPLOY_TYPE"))),
145
- channel,
146
- environment: optionalString(firstDefined(values.environment, fromEnv(env, "EXPO_RELEASE_ENVIRONMENT"))),
147
- autoSubmit: parseBoolean(firstDefined(values["auto-submit"], fromEnv(env, "EXPO_RELEASE_AUTO_SUBMIT")), true),
148
- preUpdate: optionalString(firstDefined(values["pre-update"], fromEnv(env, "EXPO_RELEASE_PRE_UPDATE"))),
149
- postUpdate: optionalString(firstDefined(values["post-update"], fromEnv(env, "EXPO_RELEASE_POST_UPDATE"))),
150
- updateExtraArgs: optionalString(firstDefined(values["update-extra-args"], fromEnv(env, "EXPO_RELEASE_UPDATE_EXTRA_ARGS"))),
151
- exportSourceMaps: parseBoolean(firstDefined(values["export-source-maps"], fromEnv(env, "EXPO_RELEASE_EXPORT_SOURCE_MAPS")), false),
152
- group,
153
- runtimeVersion: optionalString(firstDefined(values["runtime-version"], fromEnv(env, "EXPO_RELEASE_RUNTIME_VERSION"))),
154
- message: optionalString(firstDefined(values.message, fromEnv(env, "EXPO_RELEASE_MESSAGE"))),
155
- fingerprint: optionalString(firstDefined(values.fingerprint, fromEnv(env, "EXPO_RELEASE_FINGERPRINT"))),
156
- dryRun: values["dry-run"] === true || parseBoolean(fromEnv(env, "EXPO_RELEASE_DRY_RUN"), false)
157
- };
158
- }
159
86
 
160
87
  // src/github-output.ts
161
88
  import { appendFile } from "fs/promises";
@@ -628,48 +555,52 @@ function toOperationInput(options, decision) {
628
555
  dryRun: options.dryRun
629
556
  };
630
557
  }
631
- async function main() {
632
- const raw = Bun.argv.slice(2);
633
- const hasEnvCommand = Boolean(process.env.EXPO_RELEASE_COMMAND?.trim());
634
- if (raw.length === 0 && !hasEnvCommand || raw[0] === "--help" || raw[0] === "-h") {
635
- process.stdout.write(`${USAGE}
636
- `);
637
- return;
638
- }
639
- const options = parseReleaseOptions(raw);
640
- switch (options.command) {
641
- case "decide": {
642
- const decision = await decideRelease(toDecisionInput(options));
643
- await writeDecideGitHubOutput(decision);
644
- process.stdout.write(`${JSON.stringify(decision)}
558
+ async function decide(options, ctx) {
559
+ const decision = await decideRelease(toDecisionInput(options));
560
+ await writeDecideGitHubOutput(decision);
561
+ ctx.stdout.write(`${JSON.stringify(decision)}
645
562
  `);
646
- return;
647
- }
648
- case "operate": {
649
- const result = await operateRelease(toOperationInput(options, null));
650
- await writeOperateGitHubOutput(result);
651
- process.stdout.write(`${result}
652
- `);
653
- return;
654
- }
655
- case "release": {
656
- let decision = null;
657
- if (needsDecide(options.operation)) {
658
- decision = await decideRelease(toDecisionInput(options));
659
- await writeDecideGitHubOutput(decision);
660
- 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}
661
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);
662
584
  }
663
- const result = await operateRelease(toOperationInput(options, decision));
664
- await writeOperateGitHubOutput(result);
665
- process.stdout.write(`${result}
666
- `);
667
- return;
668
- }
669
- default: {
670
- const _exhaustive = options.command;
671
- throw new Error(`Unsupported command: ${_exhaustive}`);
672
- }
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
+ })
673
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;
674
604
  }
675
- await main();
605
+ var code = await cli.run(argv, { env });
606
+ process.exitCode = code === 2 ? 1 : code;
package/dist/index.js CHANGED
@@ -1,60 +1,60 @@
1
1
  // @bun
2
2
  // src/args.ts
3
- import { parseArgs } from "util";
3
+ import { z } from "zod";
4
4
  var DEFAULT_IMPACT_PATHS = ["apps/expo/"];
5
- 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] [--export-source-maps true] [--pre-update <cmd>] [--post-update <cmd>] [--update-extra-args <args>] [--dry-run] [--ignore-expo-impact true]";
6
- function fromEnv(env, name) {
7
- const value = env[name]?.trim();
8
- return value ? value : undefined;
9
- }
10
- function firstDefined(...values) {
11
- return values.find((value) => value !== undefined && value.length > 0);
12
- }
13
- function optionalString(value) {
14
- const trimmed = value?.trim();
15
- return trimmed ? trimmed : undefined;
16
- }
17
- function parseImpactPaths(value) {
18
- const paths = (value ?? DEFAULT_IMPACT_PATHS.join(",")).split(",").map((path) => path.trim()).filter(Boolean);
19
- if (paths.length === 0) {
20
- throw new Error("At least one --impact-paths entry is required");
21
- }
22
- return paths;
23
- }
24
- function parsePlatform(value) {
25
- if (value === undefined || value === "ios")
26
- return "ios";
27
- if (value === "android")
28
- return "android";
29
- throw new Error("Usage: --platform ios|android");
30
- }
31
- function parseBoolean(value, fallback) {
32
- if (value === undefined)
33
- return fallback;
34
- if (value === "true")
35
- return true;
36
- if (value === "false")
37
- return false;
38
- throw new Error(`Expected true or false, received: ${value}`);
39
- }
40
- function parseOperation(value) {
41
- if (value === undefined || value === "deploy" || value === "force-native" || value === "retry-ota" || value === "republish" || value === "rollback-embedded") {
42
- return value ?? "deploy";
43
- }
44
- throw new Error("Usage: --operation deploy|force-native|retry-ota|republish|rollback-embedded");
45
- }
46
- function parseDeployType(value) {
47
- if (value === undefined || value === "none")
48
- return "none";
49
- if (value === "ota" || value === "native")
50
- return value;
51
- throw new Error("Usage: --deploy-type none|ota|native");
52
- }
53
- function parseCommandName(value) {
54
- if (value === "release" || value === "decide" || value === "operate") {
55
- return value;
56
- }
57
- throw new Error(USAGE);
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}` });
7
+ var releaseFlags = z.object({
8
+ 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"),
12
+ impactPaths: z.string().default(DEFAULT_IMPACT_PATHS.join(",")).transform((value, ctx) => {
13
+ const paths = value.split(",").map((path) => path.trim()).filter(Boolean);
14
+ if (paths.length === 0)
15
+ ctx.addIssue({
16
+ code: "custom",
17
+ message: "At least one --impact-paths entry is required"
18
+ });
19
+ return paths;
20
+ }).meta({ env: "EXPO_RELEASE_IMPACT_PATHS" }).describe("Comma-separated repo-root paths that count as an Expo change"),
21
+ ignoreExpoImpact: trueFalse("IGNORE_EXPO_IMPACT", "false").describe("Check the fingerprint even when no impact path changed"),
22
+ operation: z.enum([
23
+ "deploy",
24
+ "force-native",
25
+ "retry-ota",
26
+ "republish",
27
+ "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+)"),
32
+ autoSubmit: trueFalse("AUTO_SUBMIT", "true").describe("Pass --auto-submit on native builds"),
33
+ preUpdate: text("PRE_UPDATE").describe("Shell command run before eas update"),
34
+ postUpdate: text("POST_UPDATE").describe("Shell command run after a successful eas update"),
35
+ updateExtraArgs: text("UPDATE_EXTRA_ARGS").describe("Extra tokens appended to eas update"),
36
+ exportSourceMaps: trueFalse("EXPORT_SOURCE_MAPS", "false").describe("On OTA, expo export with external source maps, then publish dist"),
37
+ group: text("GROUP").describe("Update group ID for --operation republish"),
38
+ runtimeVersion: text("RUNTIME_VERSION").describe("Runtime version for rollback-embedded (default: the fingerprint)"),
39
+ message: text("MESSAGE").describe("Override the eas update message"),
40
+ 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
+ }).superRefine((flags, ctx) => {
43
+ if (flags.operation === "republish" && !flags.group)
44
+ ctx.addIssue({
45
+ code: "custom",
46
+ path: ["group"],
47
+ message: "required by --operation republish"
48
+ });
49
+ });
50
+ function toReleaseOptions(command, { context: flags, env }) {
51
+ return {
52
+ ...flags,
53
+ command,
54
+ base: flags.base ?? env.GITHUB_EVENT_BEFORE,
55
+ head: flags.head ?? env.GITHUB_SHA,
56
+ channel: flags.channel ?? flags.profile
57
+ };
58
58
  }
59
59
  function needsDecide(operation) {
60
60
  switch (operation) {
@@ -78,82 +78,6 @@ function shouldIgnoreExpoImpact(options) {
78
78
  return true;
79
79
  return process.env.GITHUB_EVENT_NAME === "workflow_dispatch" && options.operation === "deploy";
80
80
  }
81
- function parseReleaseOptions(args, env = process.env) {
82
- let values;
83
- let positionals;
84
- try {
85
- const parsed = parseArgs({
86
- args,
87
- allowPositionals: true,
88
- strict: true,
89
- options: {
90
- profile: { type: "string" },
91
- base: { type: "string" },
92
- head: { type: "string" },
93
- platform: { type: "string" },
94
- "impact-paths": { type: "string" },
95
- "ignore-expo-impact": { type: "string" },
96
- operation: { type: "string" },
97
- "deploy-type": { type: "string" },
98
- channel: { type: "string" },
99
- environment: { type: "string" },
100
- "auto-submit": { type: "string" },
101
- "pre-update": { type: "string" },
102
- "post-update": { type: "string" },
103
- "update-extra-args": { type: "string" },
104
- "export-source-maps": { type: "string" },
105
- group: { type: "string" },
106
- "runtime-version": { type: "string" },
107
- message: { type: "string" },
108
- fingerprint: { type: "string" },
109
- "dry-run": { type: "boolean", default: false }
110
- }
111
- });
112
- values = parsed.values;
113
- positionals = parsed.positionals;
114
- } catch (error) {
115
- const message = error instanceof Error ? error.message : String(error);
116
- throw new Error(`${USAGE}
117
- ${message}`);
118
- }
119
- if (positionals.length > 1) {
120
- throw new Error(USAGE);
121
- }
122
- const command = parseCommandName(positionals[0] ?? fromEnv(env, "EXPO_RELEASE_COMMAND"));
123
- const profile = firstDefined(values.profile, fromEnv(env, "EXPO_RELEASE_PROFILE"));
124
- if (!profile) {
125
- throw new Error(USAGE);
126
- }
127
- const operation = parseOperation(firstDefined(values.operation, fromEnv(env, "EXPO_RELEASE_OPERATION")));
128
- const group = optionalString(firstDefined(values.group, fromEnv(env, "EXPO_RELEASE_GROUP")));
129
- if (operation === "republish" && !group) {
130
- throw new Error("Usage: --operation republish requires --group <update-group-id>");
131
- }
132
- const channel = firstDefined(values.channel, fromEnv(env, "EXPO_RELEASE_CHANNEL")) ?? profile;
133
- return {
134
- command,
135
- profile,
136
- base: optionalString(firstDefined(values.base, fromEnv(env, "EXPO_RELEASE_BASE"), fromEnv(env, "GITHUB_EVENT_BEFORE"))),
137
- head: optionalString(firstDefined(values.head, fromEnv(env, "EXPO_RELEASE_HEAD"), fromEnv(env, "GITHUB_SHA"))),
138
- platform: parsePlatform(firstDefined(values.platform, fromEnv(env, "EXPO_RELEASE_PLATFORM"))),
139
- impactPaths: parseImpactPaths(firstDefined(values["impact-paths"], fromEnv(env, "EXPO_RELEASE_IMPACT_PATHS"))),
140
- ignoreExpoImpact: parseBoolean(firstDefined(values["ignore-expo-impact"], fromEnv(env, "EXPO_RELEASE_IGNORE_EXPO_IMPACT")), false),
141
- operation,
142
- deployType: parseDeployType(firstDefined(values["deploy-type"], fromEnv(env, "EXPO_RELEASE_DEPLOY_TYPE"))),
143
- channel,
144
- environment: optionalString(firstDefined(values.environment, fromEnv(env, "EXPO_RELEASE_ENVIRONMENT"))),
145
- autoSubmit: parseBoolean(firstDefined(values["auto-submit"], fromEnv(env, "EXPO_RELEASE_AUTO_SUBMIT")), true),
146
- preUpdate: optionalString(firstDefined(values["pre-update"], fromEnv(env, "EXPO_RELEASE_PRE_UPDATE"))),
147
- postUpdate: optionalString(firstDefined(values["post-update"], fromEnv(env, "EXPO_RELEASE_POST_UPDATE"))),
148
- updateExtraArgs: optionalString(firstDefined(values["update-extra-args"], fromEnv(env, "EXPO_RELEASE_UPDATE_EXTRA_ARGS"))),
149
- exportSourceMaps: parseBoolean(firstDefined(values["export-source-maps"], fromEnv(env, "EXPO_RELEASE_EXPORT_SOURCE_MAPS")), false),
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
81
  // src/github-output.ts
158
82
  import { appendFile } from "fs/promises";
159
83
  async function appendGitHubOutput(entries) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-release",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "Fingerprint-driven Expo EAS release: OTA update or native store build, plus app runtime helpers",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",
@@ -57,6 +57,10 @@
57
57
  "typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.runtime.json",
58
58
  "verify:package": "node scripts/verify-package.mjs"
59
59
  },
60
+ "dependencies": {
61
+ "hanzio": "^3.1.0",
62
+ "zod": "^4.3.6"
63
+ },
60
64
  "peerDependencies": {
61
65
  "expo-device": ">=55",
62
66
  "expo-notifications": ">=55",
package/src/args.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { parseArgs } from 'node:util'
1
+ import type { CommandContext } from 'hanzio/cli'
2
+ import { z } from 'zod'
2
3
 
3
4
  export const DEFAULT_IMPACT_PATHS = ['apps/expo/'] as const
4
5
 
@@ -39,76 +40,133 @@ export type ReleaseOptions = {
39
40
  dryRun: boolean
40
41
  }
41
42
 
42
- export const USAGE =
43
- '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] [--export-source-maps true] [--pre-update <cmd>] [--post-update <cmd>] [--update-extra-args <args>] [--dry-run] [--ignore-expo-impact true]'
44
-
45
- function fromEnv(env: NodeJS.ProcessEnv, name: string): string | undefined {
46
- const value = env[name]?.trim()
47
- return value ? value : undefined
48
- }
49
-
50
- function firstDefined(
51
- ...values: Array<string | undefined>
52
- ): string | undefined {
53
- return values.find((value) => value !== undefined && value.length > 0)
54
- }
55
-
56
- export function optionalString(value: string | undefined): string | undefined {
57
- const trimmed = value?.trim()
58
- return trimmed ? trimmed : undefined
59
- }
60
-
61
- export function parseImpactPaths(value: string | undefined): string[] {
62
- const paths = (value ?? DEFAULT_IMPACT_PATHS.join(','))
63
- .split(',')
64
- .map((path) => path.trim())
65
- .filter(Boolean)
66
- if (paths.length === 0) {
67
- throw new Error('At least one --impact-paths entry is required')
68
- }
69
- return paths
70
- }
71
-
72
- export function parsePlatform(value: string | undefined): ReleasePlatform {
73
- if (value === undefined || value === 'ios') return 'ios'
74
- if (value === 'android') return 'android'
75
- throw new Error('Usage: --platform ios|android')
76
- }
77
-
78
- export function parseBoolean(value: string | undefined, fallback: boolean) {
79
- if (value === undefined) return fallback
80
- if (value === 'true') return true
81
- if (value === 'false') return false
82
- throw new Error(`Expected true or false, received: ${value}`)
83
- }
84
-
85
- export function parseOperation(value: string | undefined): Operation {
86
- if (
87
- value === undefined ||
88
- value === 'deploy' ||
89
- value === 'force-native' ||
90
- value === 'retry-ota' ||
91
- value === 'republish' ||
92
- value === 'rollback-embedded'
93
- ) {
94
- return value ?? 'deploy'
95
- }
96
- throw new Error(
97
- 'Usage: --operation deploy|force-native|retry-ota|republish|rollback-embedded'
98
- )
99
- }
100
-
101
- export function parseDeployType(value: string | undefined): DeployType {
102
- if (value === undefined || value === 'none') return 'none'
103
- if (value === 'ota' || value === 'native') return value
104
- throw new Error('Usage: --deploy-type none|ota|native')
105
- }
106
-
107
- export function parseCommandName(value: string | undefined): CommandName {
108
- if (value === 'release' || value === 'decide' || value === 'operate') {
109
- return value
43
+ // Every flag falls back to an EXPO_RELEASE_* variable: the composite action passes its inputs that way.
44
+ const text = (env: string) =>
45
+ z
46
+ .string()
47
+ .transform((value) => value.trim() || undefined)
48
+ .optional()
49
+ .meta({ env: `EXPO_RELEASE_${env}` })
50
+
51
+ // `--auto-submit false` takes a value, so these are not boolean switches.
52
+ const trueFalse = (env: string, fallback: 'true' | 'false') =>
53
+ z
54
+ .enum(['true', 'false'])
55
+ .default(fallback)
56
+ .transform((value) => value === 'true')
57
+ .meta({ env: `EXPO_RELEASE_${env}` })
58
+
59
+ export const releaseFlags = z
60
+ .object({
61
+ profile: z
62
+ .string()
63
+ .trim()
64
+ .min(1)
65
+ .meta({ env: 'EXPO_RELEASE_PROFILE' })
66
+ .describe('EAS build profile, also the default update channel'),
67
+ base: text('BASE').describe(
68
+ 'Git base SHA for the impact diff (falls back to GITHUB_EVENT_BEFORE)'
69
+ ),
70
+ head: text('HEAD').describe(
71
+ 'Git head SHA for the impact diff (falls back to GITHUB_SHA)'
72
+ ),
73
+ platform: z
74
+ .enum(['ios', 'android'])
75
+ .default('ios')
76
+ .meta({ env: 'EXPO_RELEASE_PLATFORM' })
77
+ .describe('Native platform'),
78
+ impactPaths: z
79
+ .string()
80
+ .default(DEFAULT_IMPACT_PATHS.join(','))
81
+ .transform((value, ctx) => {
82
+ const paths = value
83
+ .split(',')
84
+ .map((path) => path.trim())
85
+ .filter(Boolean)
86
+ if (paths.length === 0)
87
+ ctx.addIssue({
88
+ code: 'custom',
89
+ message: 'At least one --impact-paths entry is required'
90
+ })
91
+ return paths
92
+ })
93
+ .meta({ env: 'EXPO_RELEASE_IMPACT_PATHS' })
94
+ .describe('Comma-separated repo-root paths that count as an Expo change'),
95
+ ignoreExpoImpact: trueFalse('IGNORE_EXPO_IMPACT', 'false').describe(
96
+ 'Check the fingerprint even when no impact path changed'
97
+ ),
98
+ operation: z
99
+ .enum([
100
+ 'deploy',
101
+ 'force-native',
102
+ 'retry-ota',
103
+ 'republish',
104
+ 'rollback-embedded'
105
+ ])
106
+ .default('deploy')
107
+ .meta({ env: 'EXPO_RELEASE_OPERATION' })
108
+ .describe('What to run'),
109
+ deployType: z
110
+ .enum(['none', 'ota', 'native'])
111
+ .default('none')
112
+ .meta({ env: 'EXPO_RELEASE_DEPLOY_TYPE' })
113
+ .describe('Decision type for operate'),
114
+ channel: text('CHANNEL').describe(
115
+ 'EAS Update channel (default: the profile)'
116
+ ),
117
+ environment: text('ENVIRONMENT').describe(
118
+ 'EAS environment for eas update (required for Expo SDK 55+)'
119
+ ),
120
+ autoSubmit: trueFalse('AUTO_SUBMIT', 'true').describe(
121
+ 'Pass --auto-submit on native builds'
122
+ ),
123
+ preUpdate: text('PRE_UPDATE').describe(
124
+ 'Shell command run before eas update'
125
+ ),
126
+ postUpdate: text('POST_UPDATE').describe(
127
+ 'Shell command run after a successful eas update'
128
+ ),
129
+ updateExtraArgs: text('UPDATE_EXTRA_ARGS').describe(
130
+ 'Extra tokens appended to eas update'
131
+ ),
132
+ exportSourceMaps: trueFalse('EXPORT_SOURCE_MAPS', 'false').describe(
133
+ 'On OTA, expo export with external source maps, then publish dist'
134
+ ),
135
+ group: text('GROUP').describe('Update group ID for --operation republish'),
136
+ runtimeVersion: text('RUNTIME_VERSION').describe(
137
+ 'Runtime version for rollback-embedded (default: the fingerprint)'
138
+ ),
139
+ message: text('MESSAGE').describe('Override the eas update message'),
140
+ fingerprint: text('FINGERPRINT').describe(
141
+ 'Fingerprint hash to use instead of generating one'
142
+ ),
143
+ dryRun: z
144
+ .boolean()
145
+ .meta({ env: 'EXPO_RELEASE_DRY_RUN' })
146
+ .describe('Print mutating EAS commands without running them')
147
+ })
148
+ .superRefine((flags, ctx) => {
149
+ if (flags.operation === 'republish' && !flags.group)
150
+ ctx.addIssue({
151
+ code: 'custom',
152
+ path: ['group'],
153
+ message: 'required by --operation republish'
154
+ })
155
+ })
156
+
157
+ export type ReleaseFlags = z.output<typeof releaseFlags>
158
+
159
+ export function toReleaseOptions(
160
+ command: CommandName,
161
+ { context: flags, env }: CommandContext<ReleaseFlags>
162
+ ): ReleaseOptions {
163
+ return {
164
+ ...flags,
165
+ command,
166
+ base: flags.base ?? env.GITHUB_EVENT_BEFORE,
167
+ head: flags.head ?? env.GITHUB_SHA,
168
+ channel: flags.channel ?? flags.profile
110
169
  }
111
- throw new Error(USAGE)
112
170
  }
113
171
 
114
172
  export function needsDecide(operation: Operation): boolean {
@@ -137,192 +195,3 @@ export function shouldIgnoreExpoImpact(
137
195
  options.operation === 'deploy'
138
196
  )
139
197
  }
140
-
141
- export function parseReleaseOptions(
142
- args: string[],
143
- env: NodeJS.ProcessEnv = process.env
144
- ): ReleaseOptions {
145
- let values: {
146
- profile?: string
147
- base?: string
148
- head?: string
149
- platform?: string
150
- 'impact-paths'?: string
151
- 'ignore-expo-impact'?: string
152
- operation?: string
153
- 'deploy-type'?: string
154
- channel?: string
155
- environment?: string
156
- 'auto-submit'?: string
157
- 'pre-update'?: string
158
- 'post-update'?: string
159
- 'update-extra-args'?: string
160
- 'export-source-maps'?: string
161
- group?: string
162
- 'runtime-version'?: string
163
- message?: string
164
- fingerprint?: string
165
- 'dry-run'?: boolean
166
- }
167
- let positionals: string[]
168
-
169
- try {
170
- const parsed = parseArgs({
171
- args,
172
- allowPositionals: true,
173
- strict: true,
174
- options: {
175
- profile: { type: 'string' },
176
- base: { type: 'string' },
177
- head: { type: 'string' },
178
- platform: { type: 'string' },
179
- 'impact-paths': { type: 'string' },
180
- 'ignore-expo-impact': { type: 'string' },
181
- operation: { type: 'string' },
182
- 'deploy-type': { type: 'string' },
183
- channel: { type: 'string' },
184
- environment: { type: 'string' },
185
- 'auto-submit': { type: 'string' },
186
- 'pre-update': { type: 'string' },
187
- 'post-update': { type: 'string' },
188
- 'update-extra-args': { type: 'string' },
189
- 'export-source-maps': { type: 'string' },
190
- group: { type: 'string' },
191
- 'runtime-version': { type: 'string' },
192
- message: { type: 'string' },
193
- fingerprint: { type: 'string' },
194
- 'dry-run': { type: 'boolean', default: false }
195
- }
196
- })
197
- values = parsed.values
198
- positionals = parsed.positionals
199
- } catch (error) {
200
- const message = error instanceof Error ? error.message : String(error)
201
- throw new Error(`${USAGE}\n${message}`)
202
- }
203
-
204
- if (positionals.length > 1) {
205
- throw new Error(USAGE)
206
- }
207
-
208
- const command = parseCommandName(
209
- positionals[0] ?? fromEnv(env, 'EXPO_RELEASE_COMMAND')
210
- )
211
- const profile = firstDefined(
212
- values.profile,
213
- fromEnv(env, 'EXPO_RELEASE_PROFILE')
214
- )
215
- if (!profile) {
216
- throw new Error(USAGE)
217
- }
218
-
219
- const operation = parseOperation(
220
- firstDefined(values.operation, fromEnv(env, 'EXPO_RELEASE_OPERATION'))
221
- )
222
- const group = optionalString(
223
- firstDefined(values.group, fromEnv(env, 'EXPO_RELEASE_GROUP'))
224
- )
225
- if (operation === 'republish' && !group) {
226
- throw new Error(
227
- 'Usage: --operation republish requires --group <update-group-id>'
228
- )
229
- }
230
-
231
- const channel =
232
- firstDefined(values.channel, fromEnv(env, 'EXPO_RELEASE_CHANNEL')) ??
233
- profile
234
-
235
- return {
236
- command,
237
- profile,
238
- base: optionalString(
239
- firstDefined(
240
- values.base,
241
- fromEnv(env, 'EXPO_RELEASE_BASE'),
242
- fromEnv(env, 'GITHUB_EVENT_BEFORE')
243
- )
244
- ),
245
- head: optionalString(
246
- firstDefined(
247
- values.head,
248
- fromEnv(env, 'EXPO_RELEASE_HEAD'),
249
- fromEnv(env, 'GITHUB_SHA')
250
- )
251
- ),
252
- platform: parsePlatform(
253
- firstDefined(values.platform, fromEnv(env, 'EXPO_RELEASE_PLATFORM'))
254
- ),
255
- impactPaths: parseImpactPaths(
256
- firstDefined(
257
- values['impact-paths'],
258
- fromEnv(env, 'EXPO_RELEASE_IMPACT_PATHS')
259
- )
260
- ),
261
- ignoreExpoImpact: parseBoolean(
262
- firstDefined(
263
- values['ignore-expo-impact'],
264
- fromEnv(env, 'EXPO_RELEASE_IGNORE_EXPO_IMPACT')
265
- ),
266
- false
267
- ),
268
- operation,
269
- deployType: parseDeployType(
270
- firstDefined(
271
- values['deploy-type'],
272
- fromEnv(env, 'EXPO_RELEASE_DEPLOY_TYPE')
273
- )
274
- ),
275
- channel,
276
- environment: optionalString(
277
- firstDefined(values.environment, fromEnv(env, 'EXPO_RELEASE_ENVIRONMENT'))
278
- ),
279
- autoSubmit: parseBoolean(
280
- firstDefined(
281
- values['auto-submit'],
282
- fromEnv(env, 'EXPO_RELEASE_AUTO_SUBMIT')
283
- ),
284
- true
285
- ),
286
- preUpdate: optionalString(
287
- firstDefined(
288
- values['pre-update'],
289
- fromEnv(env, 'EXPO_RELEASE_PRE_UPDATE')
290
- )
291
- ),
292
- postUpdate: optionalString(
293
- firstDefined(
294
- values['post-update'],
295
- fromEnv(env, 'EXPO_RELEASE_POST_UPDATE')
296
- )
297
- ),
298
- updateExtraArgs: optionalString(
299
- firstDefined(
300
- values['update-extra-args'],
301
- fromEnv(env, 'EXPO_RELEASE_UPDATE_EXTRA_ARGS')
302
- )
303
- ),
304
- exportSourceMaps: parseBoolean(
305
- firstDefined(
306
- values['export-source-maps'],
307
- fromEnv(env, 'EXPO_RELEASE_EXPORT_SOURCE_MAPS')
308
- ),
309
- false
310
- ),
311
- group,
312
- runtimeVersion: optionalString(
313
- firstDefined(
314
- values['runtime-version'],
315
- fromEnv(env, 'EXPO_RELEASE_RUNTIME_VERSION')
316
- )
317
- ),
318
- message: optionalString(
319
- firstDefined(values.message, fromEnv(env, 'EXPO_RELEASE_MESSAGE'))
320
- ),
321
- fingerprint: optionalString(
322
- firstDefined(values.fingerprint, fromEnv(env, 'EXPO_RELEASE_FINGERPRINT'))
323
- ),
324
- dryRun:
325
- values['dry-run'] === true ||
326
- parseBoolean(fromEnv(env, 'EXPO_RELEASE_DRY_RUN'), false)
327
- }
328
- }
package/src/cli.ts CHANGED
@@ -1,16 +1,19 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
+ import { type CommandContext, defineCli, defineCommand } from 'hanzio/cli'
3
4
  import {
4
- type ReleaseOptions,
5
- USAGE,
5
+ type CommandName,
6
6
  needsDecide,
7
- parseReleaseOptions,
8
- shouldIgnoreExpoImpact
7
+ type ReleaseFlags,
8
+ type ReleaseOptions,
9
+ releaseFlags,
10
+ shouldIgnoreExpoImpact,
11
+ toReleaseOptions
9
12
  } from './args'
10
13
  import {
11
14
  type DecisionInput,
12
- type ReleaseDecision,
13
15
  decideRelease,
16
+ type ReleaseDecision,
14
17
  writeDecideGitHubOutput
15
18
  } from './decide'
16
19
  import {
@@ -54,51 +57,78 @@ function toOperationInput(
54
57
  }
55
58
  }
56
59
 
57
- async function main() {
58
- const raw = Bun.argv.slice(2)
59
- // The composite action passes everything through EXPO_RELEASE_* env vars and no argv.
60
- const hasEnvCommand = Boolean(process.env.EXPO_RELEASE_COMMAND?.trim())
61
- if (
62
- (raw.length === 0 && !hasEnvCommand) ||
63
- raw[0] === '--help' ||
64
- raw[0] === '-h'
65
- ) {
66
- process.stdout.write(`${USAGE}\n`)
67
- return
68
- }
60
+ type Context = CommandContext<ReleaseFlags>
61
+
62
+ async function decide(options: ReleaseOptions, ctx: Context) {
63
+ const decision = await decideRelease(toDecisionInput(options))
64
+ await writeDecideGitHubOutput(decision)
65
+ ctx.stdout.write(`${JSON.stringify(decision)}\n`)
66
+ return decision
67
+ }
69
68
 
70
- const options = parseReleaseOptions(raw)
69
+ async function operate(
70
+ options: ReleaseOptions,
71
+ decision: ReleaseDecision | null,
72
+ ctx: Context
73
+ ) {
74
+ const result = await operateRelease(toOperationInput(options, decision))
75
+ await writeOperateGitHubOutput(result)
76
+ ctx.stdout.write(`${result}\n`)
77
+ }
71
78
 
72
- switch (options.command) {
73
- case 'decide': {
74
- const decision = await decideRelease(toDecisionInput(options))
75
- await writeDecideGitHubOutput(decision)
76
- process.stdout.write(`${JSON.stringify(decision)}\n`)
77
- return
78
- }
79
- case 'operate': {
80
- const result = await operateRelease(toOperationInput(options, null))
81
- await writeOperateGitHubOutput(result)
82
- process.stdout.write(`${result}\n`)
83
- return
84
- }
85
- case 'release': {
86
- let decision: ReleaseDecision | null = null
87
- if (needsDecide(options.operation)) {
88
- decision = await decideRelease(toDecisionInput(options))
89
- await writeDecideGitHubOutput(decision)
90
- process.stdout.write(`${JSON.stringify(decision)}\n`)
79
+ const COMMANDS: CommandName[] = ['release', 'decide', 'operate']
80
+
81
+ const cli = defineCli({
82
+ name: 'expo-release',
83
+ description:
84
+ 'Fingerprint-driven Expo EAS release: OTA update or native store build',
85
+ // All three commands take the same flags, before or after the command name.
86
+ globals: releaseFlags,
87
+ context: (flags) => flags,
88
+ commands: {
89
+ release: defineCommand({
90
+ description: 'Run decide, then operate on its decision',
91
+ run: async (_input, ctx: Context) => {
92
+ const options = toReleaseOptions('release', ctx)
93
+ const decision = needsDecide(options.operation)
94
+ ? await decide(options, ctx)
95
+ : null
96
+ await operate(options, decision, ctx)
97
+ }
98
+ }),
99
+ decide: defineCommand({
100
+ description:
101
+ 'Print whether this change needs no deploy, an OTA update or a native build',
102
+ run: async (_input, ctx: Context) => {
103
+ await decide(toReleaseOptions('decide', ctx), ctx)
91
104
  }
92
- const result = await operateRelease(toOperationInput(options, decision))
93
- await writeOperateGitHubOutput(result)
94
- process.stdout.write(`${result}\n`)
95
- return
96
- }
97
- default: {
98
- const _exhaustive: never = options.command
99
- throw new Error(`Unsupported command: ${_exhaustive}`)
100
- }
105
+ }),
106
+ operate: defineCommand({
107
+ description: 'Run --operation for --deploy-type',
108
+ run: async (_input, ctx: Context) => {
109
+ await operate(toReleaseOptions('operate', ctx), null, ctx)
110
+ }
111
+ })
101
112
  }
113
+ })
114
+
115
+ // GitHub Actions passes an unset input as an empty string; treat it as unset.
116
+ const env = Object.fromEntries(
117
+ Object.entries(process.env).flatMap(([name, value]) =>
118
+ value?.trim() ? [[name, value.trim()]] : []
119
+ )
120
+ )
121
+
122
+ let argv = process.argv.slice(2)
123
+ if (!argv.some((arg) => COMMANDS.includes(arg as CommandName))) {
124
+ // The composite action names the command in EXPO_RELEASE_COMMAND and passes no argv.
125
+ argv = env.EXPO_RELEASE_COMMAND
126
+ ? [env.EXPO_RELEASE_COMMAND, ...argv]
127
+ : argv.length === 0
128
+ ? ['--help']
129
+ : argv
102
130
  }
103
131
 
104
- await main()
132
+ const code = await cli.run(argv, { env })
133
+ // Bad arguments exit 1, as before hanzio/cli, which uses 2.
134
+ process.exitCode = code === 2 ? 1 : code