expo-release 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
@@ -490,12 +492,30 @@ export const push = createPushNotifications({
490
492
  | Member | Use |
491
493
  |--------|-----|
492
494
  | `prompt()` | After sign-in. Asks once per install (soft-ask, then OS dialog), registers the token on a grant. Never throws; concurrent calls share one dialog. |
493
- | `enable()` / `disable()` | Settings toggle. `enable` requests permission and registers; `disable` calls `registerDevice(null)`. Save the user's preference yourself, before `enable()`: a denied dialog then leaves the preference on with `isEnabled` false, and the token registers on the next mount once permission is granted in device settings. |
494
- | `syncToken()` | Re-registers the current token when permission is granted. |
495
- | `listen({ onReceived, onResponse })` | Subscribes and returns the unsubscribe function. `onResponse` also receives the tap that launched the app. |
495
+ | `enable()` / `disable()` | Settings toggle. `enable` requests permission and registers; `disable` calls `registerDevice(null)`. Use `disable()` to turn push off (also on sign-out) so the token is not re-sent on the next launch. |
496
+ | `syncToken()` | Registers the current token when permission is granted. |
497
+ | `listen({ onReceived, onResponse })` | Subscribes and returns the unsubscribe function. `onResponse` also receives the tap that launched the app. Also keeps the token current: on start and whenever Expo rotates it, a token that differs from the last one sent is registered again. This runs only after a token was sent and `disable()` has not run since. Call it once at the root of the app. |
496
498
  | `getPermissionStatus()`, `getPushToken()`, `requestPermissions()`, `hasPrompted()` | Lower-level steps. |
497
499
 
498
- `usePushNotifications(push, { enabled })` gives a settings screen `permission`, `isEnabled`, `isLoading` and wrapped `prompt`/`enable`/`disable`. `enabled` is the saved preference; while it is on, the token is re-registered on mount.
500
+ `usePushNotifications(push, { enabled, persist })` gives a settings screen `permission`, `isEnabled`, `isLoading` and wrapped `prompt`/`enable`/`disable`. `enabled` is the saved preference; while it is on, the token is re-registered on mount.
501
+
502
+ `persist` saves the preference, for example on the user's account. `enable()` and `disable()` call it first and show the new value right away. If it rejects, the hook restores `enabled`, skips the push step and rethrows, so catch it for your error toast. Saving before `enable()` means a denied dialog leaves the preference on with `isEnabled` false; the token registers on the next mount once permission is granted in device settings. Without `persist`, save the preference yourself before calling `enable()`.
503
+
504
+ ```tsx
505
+ const { isEnabled, isLoading, enable, disable } = usePushNotifications(push, {
506
+ enabled: user.pushEnabled,
507
+ persist: (pushEnabled) => api.user.update({ pushEnabled })
508
+ })
509
+
510
+ const onToggle = async (on: boolean) => {
511
+ try {
512
+ if (!on) return await disable()
513
+ if (!(await enable())) toast('Allow notifications in device settings')
514
+ } catch {
515
+ toast('Could not save your notification setting')
516
+ }
517
+ }
518
+ ```
499
519
 
500
520
  ### Error boundary
501
521
 
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) {