expo-release 2.3.2 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,8 +13,9 @@ decide — git diff + eas fingerprint:generate + eas build:list
13
13
  ├─ no matching impact paths → skip
14
14
  ├─ finished native build with this fingerprint → eas update
15
15
  ├─ matching native build in progress → OTA only
16
- ├─ newest matching build errored → fail (use force-native after fixing)
16
+ ├─ newest matching build errored → fail with EAS's error and the build link
17
17
  └─ no matching native build → eas build --auto-submit --no-wait
18
+ (and name the fingerprint sources that changed)
18
19
  ```
19
20
 
20
21
  ## Install and requirements
@@ -24,7 +25,11 @@ bun add -D expo-release
24
25
  bunx expo-release --help
25
26
  ```
26
27
 
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
+ 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 EAS environment (from `--environment` or the eas.json build profile). CI verifies Bun 1.4.2. Installing the npm package does not deploy an application.
29
+
30
+ In GitHub Actions, use the action (`uses: HansKristoffer/expo-release@v2`). It installs its own dependencies (`hanzio`, `zod`) in `${{ github.action_path }}` and runs `bun ${{ github.action_path }}/src/cli.ts`. The caller must already have checked out the repo (`fetch-depth: 0`), installed Bun and installed the workspace dependencies. Elsewhere, run the `expo-release` bin (see [CLI](#cli)).
31
+
32
+ `eas` and `expo` resolve to the app's own `node_modules/.bin` (searched from the working directory up to the repository root), then to `eas` on `PATH` and `bunx expo`. So the eas-cli version in the app's lockfile is the one that runs; pin it there, and `expo/expo-github-action` is no longer needed. `EAS_BIN` and `EXPO_BIN` override both. `EXPO_TOKEN` must reach the job.
28
33
 
29
34
  ## Set up with AI
30
35
 
@@ -42,16 +47,16 @@ Do the following in this repo:
42
47
  2. Ensure the Expo app is ready for fingerprint-based updates:
43
48
  - runtimeVersion: { policy: 'fingerprint' } in app config
44
49
  - 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)
50
+ - channel and environment set on each EAS build profile we deploy, and the variant env vars in the profile's env
51
+ - submit config in eas.json if we use auto-submit (iOS ascAppId, Android serviceAccountKeyPath from an EAS file secret, never a committed file)
47
52
 
48
53
  3. Add .github/workflows/expo-release.yml using the HansKristoffer/expo-release@v2 composite action. Adapt consumer-specific values:
49
54
  - paths filter: Expo app dir, shared packages that affect the native bundle, and lockfile if native deps should trigger a release
50
55
  - 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.)
56
+ - profile for each deploy target (channel, environment and env come from eas.json)
53
57
  - 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
58
+ - fetch-depth: 0 on checkout, the setup-workspace action, eas-cli pinned in the app devDependencies, and the EXPO_TOKEN secret
59
+ - run `bunx expo-release doctor --profile <profile>` from the Expo app and fix what it reports
55
60
 
56
61
  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
62
 
@@ -62,37 +67,18 @@ Do the following in this repo:
62
67
  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
68
  ```
64
69
 
65
- ## Install
66
-
67
- ### GitHub Action
68
-
69
- ```yaml
70
- uses: HansKristoffer/expo-release@v2
71
- ```
72
-
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
-
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
-
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.
86
-
87
70
  ## Setup checklist
88
71
 
72
+ `expo-release doctor --profile <profile>`, run from the Expo app, checks most of this list.
73
+
89
74
  - [ ] GitHub secret `EXPO_TOKEN` from https://expo.dev/settings/access-tokens
90
75
  - [ ] `runtimeVersion: { policy: 'fingerprint' }` and `expo-updates` in the Expo app
91
- - [ ] `eas.json` channel on each profile you deploy (production, staging, …)
76
+ - [ ] `eas.json` `channel` and `environment` on each build profile you deploy (production, staging, …), and the variables `app.config.ts` reads in its `env`
92
77
  - [ ] `eas.json` submit config when you use `--auto-submit`:
93
78
  - iOS: `submit.<profile>.ios.ascAppId`
94
- - Android: `submit.<profile>.android.serviceAccountKeyPath`
95
- - [ ] EAS credentials (`eas credentials` from the Expo app directory)
79
+ - Android: `submit.<profile>.android.serviceAccountKeyPath`. Keep the key in an EAS file secret, not in git.
80
+ - [ ] EAS credentials (`eas credentials` from the Expo app directory). A new bundle identifier needs one interactive `eas credentials:configure-build --platform ios --profile <profile>` before CI can build it.
81
+ - [ ] No keystores, `.p8`/`.p12` files, provisioning profiles, service-account JSON, `.expo/` or logs in git. With continuous native generation, `android/` and `ios/` are gitignored too.
96
82
  - [ ] At least one finished native build **per profile and platform** on the current fingerprint. The first run against a new app always queues a native build.
97
83
  - [ ] `--environment` on every OTA for Expo SDK 55+
98
84
  - [ ] Add lockfiles (`bun.lock`, `package-lock.json`) to `impact-paths` if a dependency bump should trigger a fingerprint check. The default `apps/expo/` path does not include the workspace lockfile.
@@ -153,84 +139,56 @@ jobs:
153
139
  runs-on: ubuntu-latest
154
140
  timeout-minutes: 45
155
141
  env:
156
- # consumer: only if app.config.ts requires public URLs at eval time
157
- EXPO_PUBLIC_API_URL: https://example.com
158
- EXPO_PUBLIC_PLATFORM_URL: https://example.com
142
+ EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
159
143
  steps:
160
144
  - name: Checkout
161
- uses: actions/checkout@v7
145
+ uses: actions/checkout@v5
162
146
  with:
163
147
  fetch-depth: 0
148
+ clean: false # keeps node_modules on a self-hosted runner
164
149
 
165
- - name: Setup Bun
166
- uses: oven-sh/setup-bun@v2
167
- with:
168
- bun-version-file: .bun-version
169
-
170
- - name: Setup Node
171
- uses: actions/setup-node@v7
150
+ # Bun from package.json packageManager, Node to match the eas.json
151
+ # profile's `node`, the dependency cache and `bun install`. eas-cli comes
152
+ # from the app's devDependencies.
153
+ - name: Setup workspace
154
+ uses: HansKristoffer/expo-release/.github/actions/setup-workspace@v2
172
155
  with:
173
156
  node-version: 22.12.0
174
157
 
175
- - name: Restore Bun workspace cache
176
- uses: actions/cache@v6
177
- with:
178
- path: |
179
- node_modules
180
- packages/*/node_modules
181
- apps/*/node_modules
182
- key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
183
- restore-keys: |
184
- ${{ runner.os }}-bun-
185
-
186
- - name: Setup Expo and EAS
187
- uses: expo/expo-github-action@v8
188
- with:
189
- eas-version: 16.32.0
190
- token: ${{ secrets.EXPO_TOKEN }}
191
-
192
- - name: Install dependencies
193
- run: bun install --frozen-lockfile
194
-
195
158
  - name: Expo release
196
159
  uses: HansKristoffer/expo-release@v2
197
160
  with:
198
161
  command: release
199
- profile: production
162
+ profile: production # channel, environment and env come from eas.json
200
163
  operation: ${{ github.event.inputs.operation || 'deploy' }}
201
- environment: production
202
164
  impact-paths: apps/expo/
203
165
  working-directory: apps/expo
204
166
  group: ${{ github.event.inputs.group }}
205
167
  runtime-version: ${{ github.event.inputs.runtime-version }}
206
- env:
207
- # Set variant keys on the action step (or the job). They must reach
208
- # fingerprint:generate, build:list, eas build, and eas update.
209
- APP_VARIANT: production
210
- SECRETS_ENV: production
211
- APS_ENVIRONMENT: production
212
168
  ```
213
169
 
170
+ The release reads `build.<profile>` from `eas.json` in `working-directory` (following `extends`, like eas-cli): its `channel` is the default for `channel`; on Expo SDK 55+ its `environment` is the default for `environment` (before 55, `--environment` also turns off `.env` loading in `eas update`, so it is never added unasked); and each variable in its `env` that the job has not set is set for every EAS command and hook, as EAS sets them on the build machine. Inputs and variables you set win.
171
+
214
172
  A manual `deploy` from `workflow_dispatch` ignores the impact diff. That is intentional: dispatch after an unrelated commit should still release, not silently skip.
215
173
 
216
174
  ### Action inputs
217
175
 
218
176
  | Input | Default | Notes |
219
177
  |-------|---------|--------|
220
- | `command` | `release` | `release` (decide + operate), `decide` (PR checks), or `operate` |
221
- | `profile` | `production` | EAS build profile; default update channel |
178
+ | `command` | `release` | `release` (decide + operate), `decide` (PR checks), `operate`, or `doctor` |
179
+ | `profile` | `production` | EAS build profile. Its eas.json `channel`, `environment` and `env` are the defaults |
222
180
  | `operation` | `deploy` | See recovery operations below |
223
181
  | `deploy-type` | `none` | Used only when `command` is `operate` |
224
- | `base` / `head` | event before / `GITHUB_SHA` | Git range for the impact diff |
182
+ | `base` / `head` | merge base on a PR, else event before / `GITHUB_SHA` | Git range for the impact diff |
225
183
  | `impact-paths` | `apps/expo/` | Comma-separated repo-root prefixes |
226
- | `environment` | _(empty)_ | Passed to `eas update`. Required for Expo SDK 55+ |
227
- | `channel` | same as `profile` | EAS Update channel |
184
+ | `environment` | eas.json profile `environment` on SDK 55+ | Passed to `eas update`. Required for Expo SDK 55+ |
185
+ | `channel` | eas.json profile `channel`, else `profile` | EAS Update channel |
228
186
  | `platform` | `ios` | `ios` or `android`. Passed through to `eas update` and `eas build` |
229
187
  | `auto-submit` | `true` | Native builds use `--auto-submit` |
230
188
  | `working-directory` | `apps/expo` | Where `eas` runs |
231
189
  | `pre-update` | _(empty)_ | Shell command before `eas update` (OTA only) |
232
190
  | `post-update` | _(empty)_ | Shell command after a successful `eas update` (OTA only) |
233
- | `update-extra-args` | _(empty)_ | Extra tokens appended to `eas update` |
191
+ | `update-extra-args` | _(empty)_ | Extra arguments appended to `eas update`, split like a shell (quotes group) |
234
192
  | `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. |
235
193
  | `group` | _(empty)_ | Required when `operation` is `republish` |
236
194
  | `runtime-version` | current fingerprint | Used when `operation` is `rollback-embedded` |
@@ -242,14 +200,28 @@ A manual `deploy` from `workflow_dispatch` ignores the impact diff. That is inte
242
200
  | Output | Notes |
243
201
  |--------|--------|
244
202
  | `deploy_type` | Stable PR-check type: `none`, `ota`, `native` |
203
+ | `changed` | `true` unless `deploy_type` is `none` |
204
+ | `reason` | Why: `no-impact-path`, `finished-build`, `build-in-progress`, `no-build-for-fingerprint` |
205
+ | `changed_sources` | For a native decision: fingerprint sources changed since the profile's last finished build (`changed node_modules/expo-camera`), one per line |
245
206
  | `native_build_pending` | `true` when a matching native build is still in progress |
246
207
  | `fingerprint` | Current EAS fingerprint hash |
247
208
  | `build_id` / `build_url` | Matching EAS build, if any |
248
- | `result` | Operate result (`skipped`, `ota`, `native-build`, `recovery`) |
209
+ | `queued_build_id` / `queued_build_url` | The native build operate queued, if any |
210
+ | `result` | Operate result (`skipped`, `ota`, `native-build`, `recovery`), or `failed` |
211
+ | `failed_stage` | On failure, the step: `fingerprint`, `build list`, `errored build`, `native build`, `ota export`, `ota update`, `pre-update`, `post-update`, `recovery`, `doctor` |
212
+ | `failure` | On failure, the last lines the command printed and a fixed next step for that stage |
213
+
214
+ ### When a release fails
215
+
216
+ Every EAS command runs as a named stage, and the log shows how long each took. A failure ends with one annotation that names the stage, profile and platform and quotes EAS's last lines, for example `expo-release: native build failed (production, ios): Credentials are not set up.`, without a stack trace. The job summary repeats the last 20 lines and a next step chosen by stage, and `failed_stage` lets a workflow branch on the stage instead of matching EAS's wording.
217
+
218
+ When `eas fingerprint:generate` prints a warning before its JSON, the app config did not load and eas-cli fell back to its own `@expo/config`. The decision then fails and quotes `expo config`'s error, because that fingerprint may not be the one EAS builds with.
219
+
220
+ An errored build for the current fingerprint fails the run with EAS's error code and message and the build's dashboard link. Fix it, then dispatch `operation: force-native`.
249
221
 
250
222
  ### PR checks (decide only)
251
223
 
252
- PR jobs should not operate. Use `command: decide` and comment or gate on `deploy_type`:
224
+ PR jobs usually only decide. Use `command: decide` and comment or gate on `deploy_type`. On a `pull_request` event `base` defaults to the merge base with the PR's base branch, so no merge-base step is needed:
253
225
 
254
226
  ```yaml
255
227
  - uses: HansKristoffer/expo-release@v2
@@ -257,15 +229,9 @@ PR jobs should not operate. Use `command: decide` and comment or gate on `deploy
257
229
  with:
258
230
  command: decide
259
231
  profile: pr-preview # or production
260
- base: ${{ steps.merge-base.outputs.sha }}
261
- head: ${{ github.sha }}
262
- env:
263
- APP_VARIANT: staging
264
- SECRETS_ENV: staging
265
- APS_ENVIRONMENT: production
266
232
  ```
267
233
 
268
- Then use `${{ steps.decision.outputs.deploy_type }}` (`none` / `ota` / `native`).
234
+ Then use `${{ steps.decision.outputs.deploy_type }}` (`none` / `ota` / `native`), `reason`, and for `native` the `changed_sources` that make it native. A PR comment built from them tells the author whether the change needs a store build. A PR job that does build can run `command: release` with `auto-submit: 'false'` and read `queued_build_url`.
269
235
 
270
236
  The CLI writes the same JSON to stdout, including `deployType`:
271
237
 
@@ -274,7 +240,7 @@ bun "${{ github.action_path }}/src/cli.ts" decide \
274
240
  --base "$BASE" --head "$HEAD" --profile production
275
241
  ```
276
242
 
277
- `Bun.spawn` inherits process env. Set variant keys on the **action step or the job**, never on decide alone. When `app.config.ts` switches slug or bundle identifier on `APP_VARIANT`, every EAS command (`fingerprint:generate`, `build:list`, `eas build`, `eas update`) resolves a different project from that config.
243
+ Every EAS command inherits the process env, including the profile's eas.json `env`. Variables that are not in the profile go on the **action step or the job**. When `app.config.ts` switches slug or bundle identifier on `APP_VARIANT`, every EAS command (`fingerprint:generate`, `build:list`, `eas build`, `eas update`) resolves a different project from that config, so keep it in the profile's `env`.
278
244
 
279
245
  ### OTA hooks
280
246
 
@@ -292,13 +258,9 @@ Use it with PostHog error tracking: the same `dist` bundle that goes to `eas upd
292
258
  environment: production
293
259
  export-source-maps: 'true'
294
260
  post-update: bunx posthog-cli hermes upload --directory dist
295
- env:
296
- APP_VARIANT: production
297
- SECRETS_ENV: production
298
- APS_ENVIRONMENT: production
299
261
  ```
300
262
 
301
- Override the Expo binary with `EXPO_BIN` (defaults to `bunx expo`). `--dry-run` prints the export command and skips the `.map` check.
263
+ Override the Expo binary with `EXPO_BIN` (defaults to the app's `node_modules/.bin/expo`, else `bunx expo`). `--dry-run` prints the export command and skips the `.map` check.
302
264
 
303
265
  ### Recovery operations
304
266
 
@@ -323,6 +285,41 @@ Override the Expo binary with `EXPO_BIN` (defaults to `bunx expo`). `--dry-run`
323
285
  dry-run: 'true'
324
286
  ```
325
287
 
288
+ ### Doctor
289
+
290
+ `expo-release doctor --profile <profile>` (or `command: doctor`) prints one line per problem with its fix and exits 1 on an error:
291
+
292
+ - `eas.json` exists, has the build profile, the profile has a `channel`, and `submit.<profile>` exists when auto-submit is on
293
+ - an EAS environment is known for Expo SDK 55+
294
+ - eas-cli runs and satisfies `eas.json` `cli.version`; `EXPO_TOKEN` is set in CI
295
+ - the running Bun equals the profile's `bun` (error) and Node its `node` (warning)
296
+ - no credentials are committed (`*.keystore` except `debug.keystore`, `*.jks`, `*.p8`, `*.p12`, `*.mobileprovision`, service-account JSON), nor `.expo/` or `*.log`
297
+ - committed `android/` or `ios/` are not hidden by `.fingerprintignore`; with config plugins, committed native folders get a warning
298
+ - the app config loads and uses `runtimeVersion: { policy: 'fingerprint' }`
299
+
300
+ `decide` and `release` run the same checks, except the app config, before their first EAS call and print the findings as warnings without failing.
301
+
302
+ ### Fingerprint config
303
+
304
+ EAS increments `ios.buildNumber` and `android.versionCode` remotely, so skip `ExpoConfigVersions` and add the app version back: a new store version then needs a native build, not an OTA. With continuous native generation, ignore the native folders so a local prebuild cannot change the fingerprint. `apps/expo/fingerprint.config.js`:
305
+
306
+ ```js
307
+ const { version } = require('./package.json')
308
+
309
+ /** @type {import('@expo/fingerprint').Config} */
310
+ module.exports = {
311
+ sourceSkips: [
312
+ 'ExpoConfigRuntimeVersionIfString',
313
+ 'ExpoConfigVersions',
314
+ 'PackageJsonAndroidAndIosScriptsIfNotContainRun'
315
+ ],
316
+ extraSources: [{ type: 'contents', id: 'appVersion', contents: version }],
317
+ ignorePaths: ['ios/**/*', 'android/**/*']
318
+ }
319
+ ```
320
+
321
+ Do not list `android` or `ios` in `.fingerprintignore` while they are committed: EAS builds the committed tree, and a change there would never queue a native build.
322
+
326
323
  ## Multiple apps (staging and production)
327
324
 
328
325
  Use one job per profile, or a matrix. Each profile needs its own finished native build before OTAs work. One `EXPO_TOKEN` covers multiple projects under the same Expo account; separate accounts or orgs need separate secrets.
@@ -350,10 +347,6 @@ jobs:
350
347
  - uses: HansKristoffer/expo-release@v2
351
348
  with:
352
349
  profile: production
353
- environment: production
354
- channel: production
355
- env:
356
- APP_VARIANT: production
357
350
 
358
351
  staging:
359
352
  if: github.ref == 'refs/heads/develop'
@@ -362,10 +355,6 @@ jobs:
362
355
  - uses: HansKristoffer/expo-release@v2
363
356
  with:
364
357
  profile: staging
365
- environment: staging
366
- channel: staging
367
- env:
368
- APP_VARIANT: staging
369
358
  ```
370
359
 
371
360
  ### One job, matrix
@@ -376,23 +365,15 @@ strategy:
376
365
  matrix:
377
366
  include:
378
367
  - profile: production
379
- environment: production
380
- variant: production
381
368
  - profile: staging
382
- environment: staging
383
- variant: staging
384
369
 
385
370
  steps:
386
371
  - uses: HansKristoffer/expo-release@v2
387
372
  with:
388
- profile: ${{ matrix.profile }}
389
- environment: ${{ matrix.environment }}
390
- channel: ${{ matrix.profile }}
391
- env:
392
- APP_VARIANT: ${{ matrix.variant }}
373
+ profile: ${{ matrix.profile }} # channel, environment, env from eas.json
393
374
  ```
394
375
 
395
- `channel` defaults to `profile`. If two profiles should publish to the same channel, set `channel` explicitly and keep `profile` as the EAS build profile.
376
+ `channel` defaults to the profile's eas.json `channel`, then to the profile name. Set `channel` only to publish somewhere the builds do not listen.
396
377
 
397
378
  ## CLI
398
379
 
@@ -423,11 +404,13 @@ expo-release operate \
423
404
  [--pre-update "bun scripts/release/tag-dist.ts"] \
424
405
  [--update-extra-args "--private-key-path key"] \
425
406
  [--post-update "bunx posthog-cli hermes upload --directory dist"]
407
+
408
+ expo-release doctor --profile production [--platform ios] [--auto-submit true]
426
409
  ```
427
410
 
428
411
  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
412
 
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`.
413
+ `release` is decide + operate (same as the action default). `force-native`, `republish`, and `rollback-embedded` skip decide. `decide` prints JSON including `deployType`, `reason`, `nativeBuildPending` and, for `native`, `fingerprintDiff`. When `GITHUB_OUTPUT` is set, decide writes `deploy_type`, `reason`, `native_build_pending`, `changed`, `fingerprint`, `build_id`, `build_url`, `changed_sources`, `compared_build_url`. Operate writes `result`, `queued_build_id`, `queued_build_url`; a failure writes `result=failed`, `failed_stage` and `failure`.
431
414
 
432
415
  ```bash
433
416
  # local (after bun add -D expo-release)
@@ -436,11 +419,11 @@ expo-release decide --base HEAD^ --head HEAD --profile production
436
419
 
437
420
  ## App runtime
438
421
 
439
- `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.
422
+ `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`, `react-native`, `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.
440
423
 
441
424
  ### OTA updates
442
425
 
443
- 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).
426
+ Checks once per launch, downloads a new update and hands you a `reload` for your "Restart" prompt, once per update. It is off in development and wherever expo-updates is disabled (Expo Go). An app that stays open for days should also check on foreground: `recheckOnForeground: 30` checks again when the app becomes active, at most every 30 minutes. A failed check goes to `onError`, by default `console.warn`.
444
427
 
445
428
  ```tsx
446
429
  import { useOTAUpdates } from 'expo-release/runtime'
@@ -454,12 +437,15 @@ function AppContent() {
454
437
  description: 'A new version downloaded. Tap Restart to apply.',
455
438
  actionLabel: 'Restart',
456
439
  onActionPress: reload
457
- })
440
+ }),
441
+ { recheckOnForeground: 30 }
458
442
  )
459
443
  }
460
444
  ```
461
445
 
462
- The callback is read through a ref, so an unstable toast function never repeats the check. `checkForOTAUpdate(onUpdateReady)` runs the same check outside React.
446
+ The callback is read through a ref, so an unstable toast function never repeats the check. `checkForOTAUpdate(onUpdateReady)` runs the same check outside React, for example from an error screen's "Update and restart" button.
447
+
448
+ `getReleaseInfo()` returns `{ updateId, runtimeVersion, channel, createdAt, isEmbeddedLaunch }` for the running release (nulls in Expo Go and for the embedded bundle). Attach it to crash reports and show it on a version screen.
463
449
 
464
450
  ### Push notifications
465
451
 
@@ -492,9 +478,10 @@ export const push = createPushNotifications({
492
478
  | Member | Use |
493
479
  |--------|-----|
494
480
  | `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. |
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. |
481
+ | `enable()` / `disable()` | Settings toggle. `enable` requests permission and registers; `disable` calls `registerDevice(null)` and rejects when it does. Use `disable()` to turn push off (also on sign-out, in a `try`) so the token is not re-sent on the next launch. |
482
+ | `syncToken()` | Registers the current token when permission is granted. Call it after a later sign-in when the user's saved preference is on: `prompt()` asks only once per install, and `disable()` on sign-out removed the token. |
483
+ | `listen({ onReceived, onResponse })` | Subscribes and returns the unsubscribe function. `onResponse` also receives the tap that launched the app; each response is delivered once per app start, however often `listen` runs. 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. |
484
+ | `setAndroidChannel(channel)` | Recreates the Android `default` channel, for example with translated texts once i18n is ready. |
498
485
  | `getPermissionStatus()`, `getPushToken()`, `requestPermissions()`, `hasPrompted()` | Lower-level steps. |
499
486
 
500
487
  `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.
@@ -520,12 +507,16 @@ const onToggle = async (on: boolean) => {
520
507
  ### Error boundary
521
508
 
522
509
  ```tsx
523
- import { ErrorBoundary } from 'expo-release/runtime'
510
+ import { ErrorBoundary, getReleaseInfo } from 'expo-release/runtime'
524
511
 
525
512
  <ErrorBoundary
526
513
  fallback={({ error, reset }) => <ErrorScreen message={error.message} onRetry={reset} />}
527
514
  onError={(error, info) =>
528
- posthog.captureException(error, { $exception_source: 'error_boundary', component_stack: info.componentStack })
515
+ posthog.captureException(error, {
516
+ $exception_source: 'error_boundary',
517
+ component_stack: info.componentStack,
518
+ ...getReleaseInfo()
519
+ })
529
520
  }
530
521
  >
531
522
  <App />
@@ -540,9 +531,9 @@ Composite actions for Bun monorepos, used as `HansKristoffer/expo-release/.githu
540
531
 
541
532
  | Action | Does |
542
533
  |--------|------|
543
- | `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. On a self-hosted runner it keeps `cache-paths` instead (see below). |
544
- | `playwright-browsers` | Restores browsers for the installed Playwright version; installs and saves on a miss. A self-hosted runner skips the cache and keeps `~/.cache/ms-playwright`. |
545
- | `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. The Railway CLI goes into the job's `$RUNNER_TEMP`. |
534
+ | `setup-workspace` | Bun (version from `package.json` `packageManager` by default), optional Node, restore the dependency cache keyed on the OS, architecture and `bun.lock`, always `bun install --frozen-lockfile`, save on a miss with `save-cache: 'true'` (a failed save is a warning). `cache-paths` and `cache-key-files` are inputs. `env` on the `uses:` step reaches `bun install` and its postinstall scripts (a `DATABASE_URL` for a generated client, say). On a self-hosted runner it keeps `cache-paths` instead (see below). |
535
+ | `playwright-browsers` | Restores browsers for the installed Playwright version and architecture; installs and saves on a miss. A self-hosted runner skips the cache and keeps `~/.cache/ms-playwright`. |
536
+ | `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. It first checks every input and each provider's access and names each problem in its own annotation; a failed delete fails the job. The Railway CLI goes into the job's `$RUNNER_TEMP`. |
546
537
  | `ai-changelog` | Plain-language changelog of a pushed range with Claude; outputs `message` (chat) and `markdown`, optionally POSTs the message to a webhook. |
547
538
 
548
539
  ### Warm the caches on main
@@ -582,7 +573,7 @@ Use the same `cache-paths` in every job, since they are part of the cache versio
582
573
 
583
574
  ### Self-hosted runners
584
575
 
585
- A self-hosted runner keeps its workspace between jobs, so these actions keep what a hosted VM would restore. Check out with `clean: false`; `setup-workspace` then removes every untracked file except `cache-paths` and `keep-paths`, skips the cache, and skips `bun install` when the files named by `install-key-paths` and the Bun version are unchanged since that runner's last install. Add the files a postinstall reads:
576
+ A self-hosted runner keeps its workspace between jobs, so these actions keep what a hosted VM would restore. Check out with `clean: false`; `setup-workspace` then removes every untracked file except `cache-paths` and `keep-paths`, skips the cache, and skips `bun install` when the files named by `install-key-paths` and the Bun version are unchanged since that runner's last install and every `cache-paths` entry without a glob still exists. When a checkout without `clean: false` wiped `node_modules`, it says so in a notice. Add the files a postinstall reads:
586
577
 
587
578
  ```yaml
588
579
  - uses: actions/checkout@v5
@@ -603,6 +594,8 @@ A self-hosted runner keeps its workspace between jobs, so these actions keep wha
603
594
  packages/prisma/schema
604
595
  ```
605
596
 
597
+ On a self-hosted runner the release action points `TMPDIR` at the job's `runner.temp` when the job has not set it, so Metro's cache never collides with another user's `/tmp/metro-cache` (`EACCES`).
598
+
606
599
  ### PR preview environments
607
600
 
608
601
  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).
package/action.yml CHANGED
@@ -4,11 +4,11 @@ author: Kristoffer Hansen
4
4
 
5
5
  inputs:
6
6
  command:
7
- description: release (decide + operate), decide only, or operate only
7
+ description: release (decide + operate), decide only, operate only, or doctor
8
8
  required: false
9
9
  default: release
10
10
  profile:
11
- description: EAS build profile (also used as the default update channel)
11
+ description: EAS build profile. Its eas.json channel, environment and env are the defaults.
12
12
  required: false
13
13
  default: production
14
14
  operation:
@@ -20,7 +20,7 @@ inputs:
20
20
  required: false
21
21
  default: none
22
22
  base:
23
- description: Git base SHA for the impact diff. Defaults to github.event.before.
23
+ description: Git base SHA for the impact diff. Defaults to the merge base on a pull request, else github.event.before.
24
24
  required: false
25
25
  default: ''
26
26
  head:
@@ -32,11 +32,11 @@ inputs:
32
32
  required: false
33
33
  default: apps/expo/
34
34
  environment:
35
- description: EAS environment passed to eas update. Required for Expo SDK 55+.
35
+ description: EAS environment passed to eas update. Required for Expo SDK 55+, where it defaults to the eas.json profile environment.
36
36
  required: false
37
37
  default: ''
38
38
  channel:
39
- description: EAS Update channel. Defaults to the profile name.
39
+ description: EAS Update channel. Defaults to the eas.json profile channel, else the profile name.
40
40
  required: false
41
41
  default: ''
42
42
  platform:
@@ -60,7 +60,7 @@ inputs:
60
60
  required: false
61
61
  default: ''
62
62
  update-extra-args:
63
- description: Extra tokens appended to eas update (e.g. --input-dir dist --skip-bundler)
63
+ description: Extra arguments appended to eas update, split like a shell (e.g. --input-dir dist --skip-bundler)
64
64
  required: false
65
65
  default: ''
66
66
  export-source-maps:
@@ -88,6 +88,15 @@ outputs:
88
88
  deploy_type:
89
89
  description: Stable PR-check type (none, ota, native)
90
90
  value: ${{ steps.release.outputs.deploy_type }}
91
+ changed:
92
+ description: Whether the change needs a deploy (deploy_type is not none)
93
+ value: ${{ steps.release.outputs.changed }}
94
+ reason:
95
+ description: Why decide chose deploy_type (no-impact-path, finished-build, build-in-progress, no-build-for-fingerprint)
96
+ value: ${{ steps.release.outputs.reason }}
97
+ changed_sources:
98
+ description: For a native decision, the fingerprint sources changed since the profile's last finished build, one per line
99
+ value: ${{ steps.release.outputs.changed_sources }}
91
100
  native_build_pending:
92
101
  description: Whether a matching native build is still in progress
93
102
  value: ${{ steps.release.outputs.native_build_pending }}
@@ -100,9 +109,21 @@ outputs:
100
109
  build_url:
101
110
  description: Matching EAS build URL, if any
102
111
  value: ${{ steps.release.outputs.build_url }}
112
+ queued_build_id:
113
+ description: The native build operate queued, if any
114
+ value: ${{ steps.release.outputs.queued_build_id }}
115
+ queued_build_url:
116
+ description: Dashboard URL of the native build operate queued, if any
117
+ value: ${{ steps.release.outputs.queued_build_url }}
103
118
  result:
104
- description: Operate result (skipped, ota, native-build, recovery)
119
+ description: Operate result (skipped, ota, native-build, recovery), or failed
105
120
  value: ${{ steps.release.outputs.result }}
121
+ failed_stage:
122
+ description: The step that failed (fingerprint, build list, errored build, native build, ota export, ota update, pre-update, post-update, recovery, doctor)
123
+ value: ${{ steps.release.outputs.failed_stage }}
124
+ failure:
125
+ description: On failure, the last lines the failed command printed and the next step
126
+ value: ${{ steps.release.outputs.failure }}
106
127
 
107
128
  runs:
108
129
  using: composite
@@ -139,7 +160,9 @@ runs:
139
160
  EXPO_RELEASE_MESSAGE: ${{ inputs.message }}
140
161
  EXPO_RELEASE_DRY_RUN: ${{ inputs.dry-run }}
141
162
  GITHUB_EVENT_BEFORE: ${{ github.event.before }}
142
- run: bun "$ACTION_PATH/src/cli.ts"
163
+ # The action's own tsconfig: Bun would otherwise read the app's, and warn
164
+ # about options it does not support (moduleSuffixes).
165
+ run: bun --tsconfig-override "$ACTION_PATH/tsconfig.json" "$ACTION_PATH/src/cli.ts"
143
166
 
144
167
  - name: Publish job summary
145
168
  if: always()
@@ -149,10 +172,17 @@ runs:
149
172
  OPERATION: ${{ inputs.operation }}
150
173
  PROFILE: ${{ inputs.profile }}
151
174
  PLATFORM: ${{ inputs.platform }}
175
+ AUTO_SUBMIT: ${{ inputs.auto-submit }}
152
176
  DEPLOY_TYPE: ${{ steps.release.outputs.deploy_type }}
177
+ REASON: ${{ steps.release.outputs.reason }}
153
178
  RESULT: ${{ steps.release.outputs.result || 'did not run' }}
179
+ FAILED_STAGE: ${{ steps.release.outputs.failed_stage }}
180
+ FAILURE: ${{ steps.release.outputs.failure }}
154
181
  FINGERPRINT: ${{ steps.release.outputs.fingerprint }}
155
182
  MATCHING_BUILD: ${{ steps.release.outputs.build_url || steps.release.outputs.build_id }}
183
+ QUEUED_BUILD: ${{ steps.release.outputs.queued_build_url || steps.release.outputs.queued_build_id }}
184
+ CHANGED_SOURCES: ${{ steps.release.outputs.changed_sources }}
185
+ COMPARED_BUILD: ${{ steps.release.outputs.compared_build_url }}
156
186
  NATIVE_PENDING: ${{ steps.release.outputs.native_build_pending }}
157
187
  run: |
158
188
  {
@@ -160,14 +190,35 @@ runs:
160
190
  echo ""
161
191
  echo "- Command: \`$COMMAND\`"
162
192
  echo "- Operation: \`$OPERATION\`"
163
- echo "- Result: \`$RESULT\`"
193
+ if [[ -n "$FAILED_STAGE" ]]; then
194
+ echo "- Result: \`$RESULT\` at \`$FAILED_STAGE\`"
195
+ else
196
+ echo "- Result: \`$RESULT\`"
197
+ fi
164
198
  if [[ -n "$DEPLOY_TYPE" ]]; then
165
- echo "- Deploy type: \`$DEPLOY_TYPE\`"
199
+ echo "- Deploy type: \`$DEPLOY_TYPE\` (\`${REASON:-unknown}\`)"
166
200
  echo "- Native build pending: \`${NATIVE_PENDING:-false}\`"
167
201
  echo "- Fingerprint: \`${FINGERPRINT:-none}\`"
168
202
  echo "- Matching build: ${MATCHING_BUILD:-none}"
169
203
  fi
170
- if [[ "$COMMAND" == "release" ]]; then
171
- echo "- New native builds are submitted automatically when auto-submit is enabled (TestFlight on iOS, Play on Android)."
204
+ if [[ -n "$QUEUED_BUILD" ]]; then
205
+ echo "- Queued build: $QUEUED_BUILD"
206
+ fi
207
+ if [[ "$RESULT" == "native-build" && "$AUTO_SUBMIT" == "true" ]]; then
208
+ echo "- The new build is submitted when it finishes (TestFlight on iOS, Play on Android)."
209
+ fi
210
+ if [[ -n "$CHANGED_SOURCES" ]]; then
211
+ echo ""
212
+ echo "Fingerprint sources changed since ${COMPARED_BUILD:-the last finished build}:"
213
+ echo ""
214
+ echo '```'
215
+ echo "$CHANGED_SOURCES"
216
+ echo '```'
217
+ fi
218
+ if [[ -n "$FAILURE" ]]; then
219
+ echo ""
220
+ echo '```'
221
+ echo "$FAILURE"
222
+ echo '```'
172
223
  fi
173
224
  } >> "$GITHUB_STEP_SUMMARY"
package/dist/args.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import type { CommandContext } from 'hanzio/cli';
2
2
  import { z } from 'zod';
3
+ import type { BuildProfile } from './eas-json';
3
4
  export declare const DEFAULT_IMPACT_PATHS: readonly ['apps/expo/'];
4
5
  export type ReleasePlatform = 'ios' | 'android';
5
- export type CommandName = 'release' | 'decide' | 'operate';
6
+ export type CommandName = 'release' | 'decide' | 'operate' | 'doctor';
6
7
  export type Operation = 'deploy' | 'force-native' | 'retry-ota' | 'republish' | 'rollback-embedded';
7
8
  export type DeployType = 'none' | 'ota' | 'native';
8
9
  export type ReleaseOptions = {
@@ -73,6 +74,7 @@ export declare const releaseFlags: z.ZodObject<{
73
74
  dryRun: z.ZodPreprocess<z.ZodBoolean, unknown>;
74
75
  }, z.core.$strip>;
75
76
  export type ReleaseFlags = z.output<typeof releaseFlags>;
76
- export declare function toReleaseOptions(command: CommandName, { context: flags }: CommandContext<ReleaseFlags>): ReleaseOptions;
77
+ /** Flags win over the eas.json build profile, which wins over the profile name. */
78
+ export declare function toReleaseOptions(command: CommandName, { context: flags }: Pick<CommandContext<ReleaseFlags>, 'context'>, buildProfile?: BuildProfile | null): ReleaseOptions;
77
79
  export declare function needsDecide(operation: Operation): boolean;
78
80
  export declare function shouldIgnoreExpoImpact(options: Pick<ReleaseOptions, 'ignoreExpoImpact' | 'operation'>): boolean;