expo-release 2.0.0 → 2.2.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
@@ -17,9 +17,54 @@ decide — git diff + eas fingerprint:generate + eas build:list
17
17
  └─ no matching native build → eas build --auto-submit --no-wait
18
18
  ```
19
19
 
20
+ ## Install and requirements
21
+
22
+ ```sh
23
+ bun add -D expo-release
24
+ bunx expo-release --help
25
+ ```
26
+
27
+ The CLI and GitHub Action require Bun. EAS operations also need an authenticated Expo account and the EAS CLI in the calling project; Expo SDK 55+ OTA updates require an explicit EAS environment. CI verifies Bun 1.4.2. Installing the npm package does not deploy an application.
28
+
29
+ ## Set up with AI
30
+
31
+ Copy the prompt below into Cursor, Claude Code, or another coding agent to wire up expo-release in your existing repo.
32
+
33
+ ```
34
+ Integrate expo-release (https://github.com/HansKristoffer/expo-release) into this codebase for fingerprint-driven Expo EAS releases.
35
+
36
+ Goal: on push to the main deploy branch, when Expo-related paths change, automatically publish an OTA update if a finished native build matches the current fingerprint, or queue a native store build (with auto-submit) when no matching build exists.
37
+
38
+ Do the following in this repo:
39
+
40
+ 1. Inspect the repo layout: find the Expo app directory, monorepo package manager (bun/npm/pnpm), lockfile location, EAS profiles in eas.json, and any app.config.ts variant env vars (e.g. APP_VARIANT, SECRETS_ENV, APS_ENVIRONMENT).
41
+
42
+ 2. Ensure the Expo app is ready for fingerprint-based updates:
43
+ - runtimeVersion: { policy: 'fingerprint' } in app config
44
+ - expo-updates installed and configured
45
+ - channel set on each EAS build profile we deploy
46
+ - submit config in eas.json if we use auto-submit (iOS ascAppId, Android serviceAccountKeyPath)
47
+
48
+ 3. Add .github/workflows/expo-release.yml using the HansKristoffer/expo-release@v2 composite action. Adapt consumer-specific values:
49
+ - paths filter: Expo app dir, shared packages that affect the native bundle, and lockfile if native deps should trigger a release
50
+ - working-directory and impact-paths pointing at the Expo app
51
+ - profile, environment, and channel for each deploy target
52
+ - env vars required by app.config.ts on the action step (APP_VARIANT, etc.)
53
+ - workflow_dispatch inputs for force-native, retry-ota, republish, rollback-embedded
54
+ - fetch-depth: 0 on checkout, Bun/Node setup, dependency install, expo/expo-github-action with pinned eas-version and EXPO_TOKEN secret
55
+
56
+ 4. Document that EXPO_TOKEN must be added as a GitHub secret, and that the first run on a new fingerprint always queues a native build.
57
+
58
+ 5. If we have staging + production (or multiple profiles), use separate jobs or a matrix with profile-specific concurrency groups.
59
+
60
+ 6. Do not change unrelated CI. Keep the diff minimal and match existing workflow conventions in this repo.
61
+
62
+ Reference the upstream README for full input/output docs and recovery operations. Ask me only if a required value (Expo app path, deploy branch, profile names) cannot be inferred from the repo.
63
+ ```
64
+
20
65
  ## Install
21
66
 
22
- Use the composite action. The package is not published to npm.
67
+ ### GitHub Action
23
68
 
24
69
  ```yaml
25
70
  uses: HansKristoffer/expo-release@v2
@@ -27,6 +72,16 @@ uses: HansKristoffer/expo-release@v2
27
72
 
28
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.
29
74
 
75
+ ### npm (CLI)
76
+
77
+ For local use or custom CI:
78
+
79
+ ```bash
80
+ bun add -D expo-release
81
+ ```
82
+
83
+ Then run `expo-release` (see [CLI](#cli)).
84
+
30
85
  Pin `eas-cli` with `expo/expo-github-action`. The CLI invokes `eas` from `PATH` (override with `EAS_BIN` for local tests), so that pin is the version that actually runs.
31
86
 
32
87
  ## Setup checklist
@@ -176,6 +231,7 @@ A manual `deploy` from `workflow_dispatch` ignores the impact diff. That is inte
176
231
  | `pre-update` | _(empty)_ | Shell command before `eas update` (OTA only) |
177
232
  | `post-update` | _(empty)_ | Shell command after a successful `eas update` (OTA only) |
178
233
  | `update-extra-args` | _(empty)_ | Extra tokens appended to `eas update` |
234
+ | `export-source-maps` | `false` | OTA only: `expo export --source-maps external`, refuse if `dist` has no `.map`, then `eas update --input-dir dist --skip-bundler`. Pair with `post-update` to upload those maps for PostHog error tracking. |
179
235
  | `group` | _(empty)_ | Required when `operation` is `republish` |
180
236
  | `runtime-version` | current fingerprint | Used when `operation` is `rollback-embedded` |
181
237
  | `message` | `{profile} {sha} (fp {hash})` | Override the generated `eas update` message |
@@ -224,7 +280,9 @@ bun "${{ github.action_path }}/src/cli.ts" decide \
224
280
 
225
281
  `pre-update`, `post-update`, and `update-extra-args` run only on the OTA path (`deploy` + `ota`, and `retry-ota`). Skip, native builds, republish, and rollback ignore them. Hook commands inherit process env. A non-zero hook exit fails operate; `post-update` does not run if `eas update` fails.
226
282
 
227
- Production apps that export Hermes maps first set them only on the production operate step:
283
+ `export-source-maps` is the shared Hermes export. Set it only on profiles that should publish a pre-exported `dist` (usually production). Staging stays a default `eas update`.
284
+
285
+ Use it with PostHog error tracking: the same `dist` bundle that goes to `eas update` contains the Hermes `.map` files PostHog needs to symbolicate production crashes. Upload them in `post-update` after a successful OTA (`POSTHOG_CLI_API_KEY`, `POSTHOG_CLI_PROJECT_ID`, and `POSTHOG_CLI_HOST` must reach the action step):
228
286
 
229
287
  ```yaml
230
288
  - uses: HansKristoffer/expo-release@v2
@@ -232,8 +290,7 @@ Production apps that export Hermes maps first set them only on the production op
232
290
  command: release
233
291
  profile: production
234
292
  environment: production
235
- pre-update: bun scripts/release/export-hermes.ts
236
- update-extra-args: --input-dir dist --skip-bundler
293
+ export-source-maps: 'true'
237
294
  post-update: bunx posthog-cli hermes upload --directory dist
238
295
  env:
239
296
  APP_VARIANT: production
@@ -241,7 +298,7 @@ Production apps that export Hermes maps first set them only on the production op
241
298
  APS_ENVIRONMENT: production
242
299
  ```
243
300
 
244
- `export-hermes.ts` is app-owned (typically `expo export --source-maps external` plus a Hermes `.map` check). Staging stays a default `eas update`.
301
+ Override the Expo binary with `EXPO_BIN` (defaults to `bunx expo`). `--dry-run` prints the export command and skips the `.map` check.
245
302
 
246
303
  ### Recovery operations
247
304
 
@@ -255,7 +312,7 @@ Production apps that export Hermes maps first set them only on the production op
255
312
 
256
313
  ### Dry run
257
314
 
258
- `--dry-run` still runs `git diff`, `eas fingerprint:generate`, and `eas build:list` so the decision is real. It only prints `eas update`, `eas build`, recovery commands, and OTA hooks.
315
+ `--dry-run` still runs `git diff`, `eas fingerprint:generate`, and `eas build:list` so the decision is real. It only prints `eas update`, `eas build`, recovery commands, `expo export` (when `export-source-maps` is on), and OTA hooks.
259
316
 
260
317
  ```yaml
261
318
  - uses: HansKristoffer/expo-release@v2
@@ -362,14 +419,213 @@ expo-release operate \
362
419
  [--group <update-group-id>] \
363
420
  [--runtime-version <hash>] \
364
421
  [--message "custom message"] \
365
- [--pre-update "bun scripts/release/export-hermes.ts"] \
366
- [--update-extra-args "--input-dir dist --skip-bundler"] \
422
+ [--export-source-maps true] \
423
+ [--pre-update "bun scripts/release/tag-dist.ts"] \
424
+ [--update-extra-args "--private-key-path key"] \
367
425
  [--post-update "bunx posthog-cli hermes upload --directory dist"]
368
426
  ```
369
427
 
370
428
  `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`.
371
429
 
372
430
  ```bash
373
- # local
374
- bun src/cli.ts decide --base HEAD^ --head HEAD --profile production
431
+ # local (after bun add -D expo-release)
432
+ expo-release decide --base HEAD^ --head HEAD --profile production
433
+ ```
434
+
435
+ ## App runtime
436
+
437
+ `expo-release/runtime` has the app side of a release: OTA prompts, push notifications and a render-error boundary. Install the package as a dependency (not `-D`) to use it. `react`, `expo-updates`, `expo-notifications` and `expo-device` are optional peers; the CLI never needs them. The helpers render no UI: pass your toast, dialog and error screen.
438
+
439
+ ### OTA updates
440
+
441
+ Checks once per launch, downloads a new update and hands you a `reload` for your "Restart" prompt. It is off in development and wherever expo-updates is disabled (Expo Go).
442
+
443
+ ```tsx
444
+ import { useOTAUpdates } from 'expo-release/runtime'
445
+
446
+ function AppContent() {
447
+ const { toast } = useToast()
448
+ useOTAUpdates((reload) =>
449
+ toast.show({
450
+ id: 'ota-update-available',
451
+ label: 'Update ready',
452
+ description: 'A new version downloaded. Tap Restart to apply.',
453
+ actionLabel: 'Restart',
454
+ onActionPress: reload
455
+ })
456
+ )
457
+ }
458
+ ```
459
+
460
+ The callback is read through a ref, so an unstable toast function never repeats the check. `checkForOTAUpdate(onUpdateReady)` runs the same check outside React.
461
+
462
+ ### Push notifications
463
+
464
+ Create the service once. It sets the foreground handler and the Android `default` channel, and owns the permission flow and the Expo push token. Your backend call is `registerDevice` (`null` unregisters).
465
+
466
+ ```ts
467
+ import AsyncStorage from '@react-native-async-storage/async-storage'
468
+ import { Alert } from 'react-native'
469
+ import { createPushNotifications } from 'expo-release/runtime'
470
+
471
+ export const push = createPushNotifications({
472
+ storage: AsyncStorage,
473
+ registerDevice: (expoPushToken) =>
474
+ api.user.registerDevice({ platform: Platform.OS, expoPushToken }),
475
+ // Optional soft-ask before the OS dialog.
476
+ confirm: () =>
477
+ new Promise((resolve) =>
478
+ Alert.alert('Turn on alerts?', 'Get notified about new messages.', [
479
+ { text: 'Not now', style: 'cancel', onPress: () => resolve(false) },
480
+ { text: 'Enable', onPress: () => resolve(true) }
481
+ ])
482
+ ),
483
+ onUnavailable: (reason) => {
484
+ if (reason === 'denied') Alert.alert('Notifications off', 'Turn them on in device settings.')
485
+ },
486
+ onError: (message, error) => logger.error(message, error)
487
+ })
488
+ ```
489
+
490
+ | Member | Use |
491
+ |--------|-----|
492
+ | `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. |
496
+ | `getPermissionStatus()`, `getPushToken()`, `requestPermissions()`, `hasPrompted()` | Lower-level steps. |
497
+
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.
499
+
500
+ ### Error boundary
501
+
502
+ ```tsx
503
+ import { ErrorBoundary } from 'expo-release/runtime'
504
+
505
+ <ErrorBoundary
506
+ fallback={({ error, reset }) => <ErrorScreen message={error.message} onRetry={reset} />}
507
+ onError={(error, info) =>
508
+ posthog.captureException(error, { $exception_source: 'error_boundary', component_stack: info.componentStack })
509
+ }
510
+ >
511
+ <App />
512
+ </ErrorBoundary>
513
+ ```
514
+
515
+ Report errors in `onError`: render errors are caught before they become uncaught exceptions, so error-tracking autocapture never sees them.
516
+
517
+ ## Workspace and PR actions
518
+
519
+ Composite actions for Bun monorepos, used as `HansKristoffer/expo-release/.github/actions/<name>@v2`. Pass secrets as inputs.
520
+
521
+ | Action | Does |
522
+ |--------|------|
523
+ | `setup-workspace` | Bun (version from `package.json` `packageManager` by default), optional Node, restore the dependency cache keyed on `bun.lock`, always `bun install --frozen-lockfile`, save on a miss with `save-cache: 'true'`. `cache-paths` and `cache-key-files` are inputs. |
524
+ | `playwright-browsers` | Restores browsers for the installed Playwright version; installs and saves on a miss. |
525
+ | `pr-preview` | Railway preview environment per PR with its own PlanetScale Postgres branch and a Cloudflare-managed host. Creates on open/sync, deletes on close. |
526
+ | `ai-changelog` | Plain-language changelog of a pushed range with Claude; outputs `message` (chat) and `markdown`, optionally POSTs the message to a webhook. |
527
+
528
+ ### Warm the caches on main
529
+
530
+ Caches written by `pull_request` runs are scoped to that PR. Warm them on main so every new PR starts from a hit:
531
+
532
+ ```yaml
533
+ name: Cache Warm
534
+ on:
535
+ push:
536
+ branches: [main]
537
+ workflow_dispatch:
538
+ concurrency:
539
+ group: cache-warm
540
+ cancel-in-progress: true
541
+ permissions:
542
+ contents: read
543
+ jobs:
544
+ warm:
545
+ runs-on: ubuntu-latest
546
+ timeout-minutes: 20
547
+ steps:
548
+ - uses: actions/checkout@v5
549
+ - uses: HansKristoffer/expo-release/.github/actions/setup-workspace@v2
550
+ with:
551
+ save-cache: 'true'
552
+ cache-paths: | # consumer: add generated clients
553
+ node_modules
554
+ packages/*/node_modules
555
+ apps/*/node_modules
556
+ - uses: HansKristoffer/expo-release/.github/actions/playwright-browsers@v2
557
+ with:
558
+ working-directory: apps/platform
559
+ ```
560
+
561
+ Use the same `cache-paths` in every job, since they are part of the cache version.
562
+
563
+ ### PR preview environments
564
+
565
+ The preview duplicates `railway-source-environment` (default `staging`) into `pr-<number>`, points git-connected services at the PR branch, sets `DATABASE_URL` and `DATABASE_URL_DIRECT` to a fresh PlanetScale branch, attaches `pr-<number>.<zone>` with Cloudflare DNS, then sets `BASE_URL`, which triggers the one deployment (run migrations and seeds in your pre-deploy step).
566
+
567
+ ```yaml
568
+ name: PR preview
569
+ on:
570
+ pull_request:
571
+ types: [opened, reopened, synchronize, closed]
572
+ concurrency:
573
+ group: pr-preview-${{ github.event.number }}
574
+ cancel-in-progress: false
575
+ permissions:
576
+ contents: read
577
+ jobs:
578
+ preview:
579
+ # Fork PRs cannot read the secrets.
580
+ if: github.event.pull_request.head.repo.full_name == github.repository
581
+ runs-on: ubuntu-latest
582
+ timeout-minutes: 25
583
+ steps:
584
+ - uses: HansKristoffer/expo-release/.github/actions/pr-preview@v2
585
+ with:
586
+ planetscale-org: ${{ secrets.PLANETSCALE_ORG_NAME }}
587
+ planetscale-database: my-app-staging
588
+ planetscale-service-token-id: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_ID }}
589
+ planetscale-service-token: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }}
590
+ railway-token: ${{ secrets.RAILWAY_STAGING_TOKEN }}
591
+ railway-project-id: ${{ secrets.RAILWAY_STAGING_PROJECT_ID }}
592
+ railway-service: ${{ secrets.RAILWAY_STAGING_SERVICE }}
593
+ cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
594
+ cloudflare-zone-id: ${{ secrets.CLOUDFLARE_ZONE_ID }}
595
+ cloudflare-zone-name: previews.example.com
375
596
  ```
597
+
598
+ The action needs Cloudflare DNS for the preview host; a Railway-generated domain is not supported yet. Requirements: a PlanetScale **Postgres** database with a service token scoped to it (`read_branch`, `create_branch`, `delete_branch`, `create_branch_password`); a Railway account API token; a Cloudflare token with Zone.DNS Edit and the zone's SSL/TLS mode set to Full. Extensions such as `vector` must be enabled on the source branch.
599
+
600
+ ### AI changelog
601
+
602
+ ```yaml
603
+ name: Changelog
604
+ on:
605
+ push:
606
+ branches: [main]
607
+ jobs:
608
+ changelog:
609
+ if: "!contains(github.event.head_commit.message, '[skip ci]')"
610
+ runs-on: ubuntu-latest
611
+ timeout-minutes: 15
612
+ permissions:
613
+ contents: read
614
+ pull-requests: read
615
+ steps:
616
+ - uses: actions/checkout@v5
617
+ with:
618
+ fetch-depth: 0
619
+ - uses: HansKristoffer/expo-release/.github/actions/ai-changelog@v2
620
+ with:
621
+ anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
622
+ webhook-url: ${{ secrets.CHANGELOG_WEBHOOK_URL }}
623
+ webhook-token: ${{ secrets.CHANGELOG_WEBHOOK_TOKEN }}
624
+ webhook-payload: '{"groupId": "${{ secrets.CHANGELOG_GROUP_ID }}"}'
625
+ ```
626
+
627
+ Without `webhook-url`, read `steps.<id>.outputs.message` and `outputs.markdown` yourself. `chat-format` sets the message style (WhatsApp by default) and `instructions` adds audience or product context.
628
+
629
+ ## Releasing
630
+
631
+ Squash PRs with conventional titles (`fix:`, `feat:`, or `feat!:`). Release Please keeps the version and changelog in a release PR; merge that PR to publish with release notes and npm provenance. See [release and recovery instructions](docs/releasing.md).
package/action.yml CHANGED
@@ -63,6 +63,10 @@ inputs:
63
63
  description: Extra tokens appended to eas update (e.g. --input-dir dist --skip-bundler)
64
64
  required: false
65
65
  default: ''
66
+ export-source-maps:
67
+ description: On OTA, expo export with external source maps, then publish dist
68
+ required: false
69
+ default: 'false'
66
70
  group:
67
71
  description: Update group ID required by operation=republish
68
72
  required: false
@@ -123,6 +127,7 @@ runs:
123
127
  EXPO_RELEASE_PRE_UPDATE: ${{ inputs.pre-update }}
124
128
  EXPO_RELEASE_POST_UPDATE: ${{ inputs.post-update }}
125
129
  EXPO_RELEASE_UPDATE_EXTRA_ARGS: ${{ inputs.update-extra-args }}
130
+ EXPO_RELEASE_EXPORT_SOURCE_MAPS: ${{ inputs.export-source-maps }}
126
131
  EXPO_RELEASE_GROUP: ${{ inputs.group }}
127
132
  EXPO_RELEASE_RUNTIME_VERSION: ${{ inputs.runtime-version }}
128
133
  EXPO_RELEASE_MESSAGE: ${{ inputs.message }}
package/dist/args.d.ts CHANGED
@@ -19,13 +19,14 @@ export type ReleaseOptions = {
19
19
  preUpdate?: string;
20
20
  postUpdate?: string;
21
21
  updateExtraArgs?: string;
22
+ exportSourceMaps: boolean;
22
23
  group?: string;
23
24
  runtimeVersion?: string;
24
25
  message?: string;
25
26
  fingerprint?: string;
26
27
  dryRun: boolean;
27
28
  };
28
- 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] [--pre-update <cmd>] [--post-update <cmd>] [--update-extra-args <args>] [--dry-run] [--ignore-expo-impact true]";
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]";
29
30
  export declare function optionalString(value: string | undefined): string | undefined;
30
31
  export declare function parseImpactPaths(value: string | undefined): string[];
31
32
  export declare function parsePlatform(value: string | undefined): ReleasePlatform;
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // src/args.ts
5
5
  import { parseArgs } from "util";
6
6
  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]";
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
8
  function fromEnv(env, name) {
9
9
  const value = env[name]?.trim();
10
10
  return value ? value : undefined;
@@ -103,6 +103,7 @@ function parseReleaseOptions(args, env = process.env) {
103
103
  "pre-update": { type: "string" },
104
104
  "post-update": { type: "string" },
105
105
  "update-extra-args": { type: "string" },
106
+ "export-source-maps": { type: "string" },
106
107
  group: { type: "string" },
107
108
  "runtime-version": { type: "string" },
108
109
  message: { type: "string" },
@@ -147,6 +148,7 @@ ${message}`);
147
148
  preUpdate: optionalString(firstDefined(values["pre-update"], fromEnv(env, "EXPO_RELEASE_PRE_UPDATE"))),
148
149
  postUpdate: optionalString(firstDefined(values["post-update"], fromEnv(env, "EXPO_RELEASE_POST_UPDATE"))),
149
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),
150
152
  group,
151
153
  runtimeVersion: optionalString(firstDefined(values["runtime-version"], fromEnv(env, "EXPO_RELEASE_RUNTIME_VERSION"))),
152
154
  message: optionalString(firstDefined(values.message, fromEnv(env, "EXPO_RELEASE_MESSAGE"))),
@@ -188,16 +190,44 @@ async function runCaptured(command) {
188
190
  child.exited
189
191
  ]);
190
192
  if (exitCode !== 0) {
191
- throw new Error(`Command failed (${command.join(" ")}):
192
- ${stderr || stdout}`);
193
+ throw new Error(describeFailure(command, exitCode, { stderr, stdout }));
193
194
  }
194
195
  return stdout;
195
196
  }
197
+ var OUTPUT_TAIL_LINES = 100;
198
+ function tail(output) {
199
+ const lines = output.trimEnd().split(`
200
+ `);
201
+ if (lines.length <= OUTPUT_TAIL_LINES)
202
+ return lines.join(`
203
+ `);
204
+ const omitted = lines.length - OUTPUT_TAIL_LINES;
205
+ return [
206
+ `... (${omitted} earlier lines omitted)`,
207
+ ...lines.slice(-OUTPUT_TAIL_LINES)
208
+ ].join(`
209
+ `);
210
+ }
211
+ function describeFailure(command, exitCode, output) {
212
+ const sections = [
213
+ `Command failed with exit code ${exitCode}: ${quoteCommand(command)}`
214
+ ];
215
+ if (output.stderr.trim())
216
+ sections.push(`stderr:
217
+ ${tail(output.stderr)}`);
218
+ if (output.stdout.trim())
219
+ sections.push(`stdout:
220
+ ${tail(output.stdout)}`);
221
+ if (sections.length === 1)
222
+ sections.push("(no output)");
223
+ return sections.join(`
224
+ `);
225
+ }
196
226
  async function runInherited(command) {
197
227
  const child = Bun.spawn(command, { stdout: "inherit", stderr: "inherit" });
198
228
  const exitCode = await child.exited;
199
229
  if (exitCode !== 0) {
200
- throw new Error(`Command failed (${command.join(" ")})`);
230
+ throw new Error(`Command failed with exit code ${exitCode}: ${quoteCommand(command)} (output above)`);
201
231
  }
202
232
  return "";
203
233
  }
@@ -394,6 +424,44 @@ async function writeDecideGitHubOutput(decision) {
394
424
  });
395
425
  }
396
426
 
427
+ // src/source-maps.ts
428
+ import { exists } from "fs/promises";
429
+ var SOURCE_MAP_OUTPUT_DIR = "dist";
430
+ function expoCommand(...args) {
431
+ const bin = process.env.EXPO_BIN?.trim();
432
+ if (bin && bin.length > 0)
433
+ return [bin, ...args];
434
+ return ["bunx", "expo", ...args];
435
+ }
436
+ function sourceMapExportCommand(platform) {
437
+ return expoCommand("export", "--platform", platform, "--output-dir", SOURCE_MAP_OUTPUT_DIR, "--source-maps", "external");
438
+ }
439
+ function mergeUpdateArgs(extra, exportSourceMaps) {
440
+ const args = extra ? [...extra] : [];
441
+ if (!exportSourceMaps)
442
+ return args;
443
+ if (!args.includes("--input-dir")) {
444
+ args.push("--input-dir", SOURCE_MAP_OUTPUT_DIR);
445
+ }
446
+ if (!args.includes("--skip-bundler")) {
447
+ args.push("--skip-bundler");
448
+ }
449
+ return args;
450
+ }
451
+ async function defaultHasSourceMaps(directory = SOURCE_MAP_OUTPUT_DIR) {
452
+ if (!await exists(directory))
453
+ return false;
454
+ for await (const _ of new Bun.Glob("**/*.map").scan(directory)) {
455
+ return true;
456
+ }
457
+ return false;
458
+ }
459
+ async function assertSourceMaps(hasSourceMaps = defaultHasSourceMaps) {
460
+ if (await hasSourceMaps())
461
+ return;
462
+ throw new Error("Expo export did not produce Hermes source maps in dist; refusing to publish an OTA update.");
463
+ }
464
+
397
465
  // src/operate.ts
398
466
  function extraUpdateArgs(value) {
399
467
  return value?.split(/\s+/).filter(Boolean) ?? [];
@@ -451,12 +519,18 @@ function mutatingRunner(commandRunner, dryRun) {
451
519
  }
452
520
  async function publishUpdate(input, commandRunner) {
453
521
  await requireEnvironmentIfNeeded(input);
522
+ if (input.exportSourceMaps) {
523
+ await commandRunner(sourceMapExportCommand(input.platform));
524
+ if (input.dryRun !== true) {
525
+ await assertSourceMaps(input.hasSourceMaps);
526
+ }
527
+ }
454
528
  await runHook(input.preUpdate, commandRunner);
455
529
  const command = easCommand("update", "--channel", input.channel, "--platform", input.platform, "--message", updateMessage(input), "--non-interactive");
456
530
  if (input.environment) {
457
531
  command.push("--environment", input.environment);
458
532
  }
459
- command.push(...extraUpdateArgs(input.updateExtraArgs));
533
+ command.push(...mergeUpdateArgs(extraUpdateArgs(input.updateExtraArgs), input.exportSourceMaps === true));
460
534
  await commandRunner(command);
461
535
  await runHook(input.postUpdate, commandRunner);
462
536
  }
@@ -550,12 +624,14 @@ function toOperationInput(options, decision) {
550
624
  preUpdate: options.preUpdate,
551
625
  postUpdate: options.postUpdate,
552
626
  updateExtraArgs: options.updateExtraArgs,
627
+ exportSourceMaps: options.exportSourceMaps,
553
628
  dryRun: options.dryRun
554
629
  };
555
630
  }
556
631
  async function main() {
557
632
  const raw = Bun.argv.slice(2);
558
- if (raw.length === 0 || raw[0] === "--help" || raw[0] === "-h") {
633
+ const hasEnvCommand = Boolean(process.env.EXPO_RELEASE_COMMAND?.trim());
634
+ if (raw.length === 0 && !hasEnvCommand || raw[0] === "--help" || raw[0] === "-h") {
559
635
  process.stdout.write(`${USAGE}
560
636
  `);
561
637
  return;
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // src/args.ts
3
3
  import { parseArgs } from "util";
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] [--pre-update <cmd>] [--post-update <cmd>] [--update-extra-args <args>] [--dry-run] [--ignore-expo-impact true]";
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
6
  function fromEnv(env, name) {
7
7
  const value = env[name]?.trim();
8
8
  return value ? value : undefined;
@@ -101,6 +101,7 @@ function parseReleaseOptions(args, env = process.env) {
101
101
  "pre-update": { type: "string" },
102
102
  "post-update": { type: "string" },
103
103
  "update-extra-args": { type: "string" },
104
+ "export-source-maps": { type: "string" },
104
105
  group: { type: "string" },
105
106
  "runtime-version": { type: "string" },
106
107
  message: { type: "string" },
@@ -145,6 +146,7 @@ ${message}`);
145
146
  preUpdate: optionalString(firstDefined(values["pre-update"], fromEnv(env, "EXPO_RELEASE_PRE_UPDATE"))),
146
147
  postUpdate: optionalString(firstDefined(values["post-update"], fromEnv(env, "EXPO_RELEASE_POST_UPDATE"))),
147
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),
148
150
  group,
149
151
  runtimeVersion: optionalString(firstDefined(values["runtime-version"], fromEnv(env, "EXPO_RELEASE_RUNTIME_VERSION"))),
150
152
  message: optionalString(firstDefined(values.message, fromEnv(env, "EXPO_RELEASE_MESSAGE"))),
@@ -185,16 +187,44 @@ async function runCaptured(command) {
185
187
  child.exited
186
188
  ]);
187
189
  if (exitCode !== 0) {
188
- throw new Error(`Command failed (${command.join(" ")}):
189
- ${stderr || stdout}`);
190
+ throw new Error(describeFailure(command, exitCode, { stderr, stdout }));
190
191
  }
191
192
  return stdout;
192
193
  }
194
+ var OUTPUT_TAIL_LINES = 100;
195
+ function tail(output) {
196
+ const lines = output.trimEnd().split(`
197
+ `);
198
+ if (lines.length <= OUTPUT_TAIL_LINES)
199
+ return lines.join(`
200
+ `);
201
+ const omitted = lines.length - OUTPUT_TAIL_LINES;
202
+ return [
203
+ `... (${omitted} earlier lines omitted)`,
204
+ ...lines.slice(-OUTPUT_TAIL_LINES)
205
+ ].join(`
206
+ `);
207
+ }
208
+ function describeFailure(command, exitCode, output) {
209
+ const sections = [
210
+ `Command failed with exit code ${exitCode}: ${quoteCommand(command)}`
211
+ ];
212
+ if (output.stderr.trim())
213
+ sections.push(`stderr:
214
+ ${tail(output.stderr)}`);
215
+ if (output.stdout.trim())
216
+ sections.push(`stdout:
217
+ ${tail(output.stdout)}`);
218
+ if (sections.length === 1)
219
+ sections.push("(no output)");
220
+ return sections.join(`
221
+ `);
222
+ }
193
223
  async function runInherited(command) {
194
224
  const child = Bun.spawn(command, { stdout: "inherit", stderr: "inherit" });
195
225
  const exitCode = await child.exited;
196
226
  if (exitCode !== 0) {
197
- throw new Error(`Command failed (${command.join(" ")})`);
227
+ throw new Error(`Command failed with exit code ${exitCode}: ${quoteCommand(command)} (output above)`);
198
228
  }
199
229
  return "";
200
230
  }
@@ -390,6 +420,44 @@ async function writeDecideGitHubOutput(decision) {
390
420
  build_url: decision.build?.buildUrl ?? ""
391
421
  });
392
422
  }
423
+ // src/source-maps.ts
424
+ import { exists } from "fs/promises";
425
+ var SOURCE_MAP_OUTPUT_DIR = "dist";
426
+ function expoCommand(...args) {
427
+ const bin = process.env.EXPO_BIN?.trim();
428
+ if (bin && bin.length > 0)
429
+ return [bin, ...args];
430
+ return ["bunx", "expo", ...args];
431
+ }
432
+ function sourceMapExportCommand(platform) {
433
+ return expoCommand("export", "--platform", platform, "--output-dir", SOURCE_MAP_OUTPUT_DIR, "--source-maps", "external");
434
+ }
435
+ function mergeUpdateArgs(extra, exportSourceMaps) {
436
+ const args = extra ? [...extra] : [];
437
+ if (!exportSourceMaps)
438
+ return args;
439
+ if (!args.includes("--input-dir")) {
440
+ args.push("--input-dir", SOURCE_MAP_OUTPUT_DIR);
441
+ }
442
+ if (!args.includes("--skip-bundler")) {
443
+ args.push("--skip-bundler");
444
+ }
445
+ return args;
446
+ }
447
+ async function defaultHasSourceMaps(directory = SOURCE_MAP_OUTPUT_DIR) {
448
+ if (!await exists(directory))
449
+ return false;
450
+ for await (const _ of new Bun.Glob("**/*.map").scan(directory)) {
451
+ return true;
452
+ }
453
+ return false;
454
+ }
455
+ async function assertSourceMaps(hasSourceMaps = defaultHasSourceMaps) {
456
+ if (await hasSourceMaps())
457
+ return;
458
+ throw new Error("Expo export did not produce Hermes source maps in dist; refusing to publish an OTA update.");
459
+ }
460
+
393
461
  // src/operate.ts
394
462
  function extraUpdateArgs(value) {
395
463
  return value?.split(/\s+/).filter(Boolean) ?? [];
@@ -447,12 +515,18 @@ function mutatingRunner(commandRunner, dryRun) {
447
515
  }
448
516
  async function publishUpdate(input, commandRunner) {
449
517
  await requireEnvironmentIfNeeded(input);
518
+ if (input.exportSourceMaps) {
519
+ await commandRunner(sourceMapExportCommand(input.platform));
520
+ if (input.dryRun !== true) {
521
+ await assertSourceMaps(input.hasSourceMaps);
522
+ }
523
+ }
450
524
  await runHook(input.preUpdate, commandRunner);
451
525
  const command = easCommand("update", "--channel", input.channel, "--platform", input.platform, "--message", updateMessage(input), "--non-interactive");
452
526
  if (input.environment) {
453
527
  command.push("--environment", input.environment);
454
528
  }
455
- command.push(...extraUpdateArgs(input.updateExtraArgs));
529
+ command.push(...mergeUpdateArgs(extraUpdateArgs(input.updateExtraArgs), input.exportSourceMaps === true));
456
530
  await commandRunner(command);
457
531
  await runHook(input.postUpdate, commandRunner);
458
532
  }
@@ -519,7 +593,7 @@ async function writeOperateGitHubOutput(result) {
519
593
  await appendGitHubOutput({ result });
520
594
  }
521
595
  export {
522
- operateRelease,
596
+ DEFAULT_IMPACT_PATHS,
523
597
  decideRelease,
524
- DEFAULT_IMPACT_PATHS
598
+ operateRelease
525
599
  };