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 +266 -10
- package/action.yml +5 -0
- package/dist/args.d.ts +2 -1
- package/dist/cli.js +82 -6
- package/dist/index.js +81 -7
- package/dist/operate.d.ts +2 -0
- package/dist/run.d.ts +4 -0
- package/dist/runtime/error-boundary.d.ts +27 -0
- package/dist/runtime/index.d.ts +3 -0
- package/dist/runtime/index.js +214 -0
- package/dist/runtime/ota.d.ts +19 -0
- package/dist/runtime/push.d.ts +70 -0
- package/dist/source-maps.d.ts +8 -0
- package/package.json +41 -8
- package/src/args.ts +11 -1
- package/src/cli.ts +8 -1
- package/src/operate.ts +19 -1
- package/src/run.ts +33 -4
- package/src/runtime/error-boundary.ts +51 -0
- package/src/runtime/index.ts +19 -0
- package/src/runtime/ota.ts +54 -0
- package/src/runtime/push.ts +243 -0
- package/src/source-maps.ts +61 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
[--
|
|
366
|
-
[--update
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
596
|
+
DEFAULT_IMPACT_PATHS,
|
|
523
597
|
decideRelease,
|
|
524
|
-
|
|
598
|
+
operateRelease
|
|
525
599
|
};
|