Push a v* tag and GitHub Actions builds the app on EAS and delivers it to
TestFlight (iOS) and the Google Play internal track (Android).
Promotion to the public stores is a deliberate manual step.
npm version patch ──► git push --follow-tags ──► .github/workflows/release.yml
│
verify: lint · typecheck · test · tag == app version
│
┌─────────────────────────┴─────────────────────────┐
release (ios) release (android)
eas build -p ios --profile production eas build -p android --profile production
eas submit ──► TestFlight eas submit ──► Play internal track
| File | Role |
|---|---|
eas.json |
Build profiles (development, development-device, preview, production) and the production submit profile |
.github/workflows/release.yml |
Tag / manual release |
.github/workflows/ci.yml |
Lint, typecheck, test on PRs and main |
App identifiers: iOS bundle identifier net.shiryakhat.app, Android package
net.shiryakhat.app, scheme shiryakhat, slug shiryakhat.
Run EAS CLI as npx eas-cli@latest <command> (written as eas below).
eas login, then eas init in the repo. This creates the EAS project and
writes extra.eas.projectId (and owner) into the app config. Commit that.ios.bundleIdentifier and android.package
set to the identifiers above, and version set (see Versioning).EXPO_TOKEN.Build-time environment. Store builds run on EAS servers, not on the GitHub
runner, so EXPO_PUBLIC_* values must live in the EAS production
environment (the production build profile sets "environment": "production"):
eas env:create --environment production --name EXPO_PUBLIC_SUPABASE_URL --value "https://<ref>.supabase.co" --visibility plaintext
eas env:create --environment production --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value "<anon key>" --visibility plaintext
eas env:create --environment production --name EXPO_PUBLIC_COMMUNITY_ENABLED --value "true" --visibility plaintext
Skip this while community features are off.
APPLE_TEAM_ID.net.shiryakhat.app. Its numeric Apple ID (App Information ->
General Information) -> ASC_APP_ID.
(Running eas submit -p ios once interactively can create the record for you.).p8 (only possible once) -> ASC_API_KEY_P8,
Key ID -> ASC_KEY_ID, Issuer ID -> ASC_ISSUER_ID.Create signing credentials once, interactively, so CI never has to prompt:
eas build --platform ios --profile production
Let EAS generate and store the distribution certificate and provisioning profile. CI then reuses them; the API key lets it repair them when they expire.
net.shiryakhat.app and complete the mandatory “App content” forms.GOOGLE_SERVICE_ACCOUNT_KEY_JSON.Create the upload keystore once, interactively, and let EAS store it:
eas build --platform android --profile production
Back the keystore up (eas credentials -p android -> download). With
Play App Signing a lost upload key can be reset by Google, but only via support.
eas submit can create the very first
release on the internal track as long as the app exists in Play Console and
the service account has access. If Google rejects it (historically the
Play API required the first .aab to be uploaded by hand), download the
.aab from the EAS build page and upload it once manually in
Play Console -> Testing -> Internal testing -> Create new release.PLAY_RELEASE_STATUS=draft and roll the release out by hand in Play
Console. Delete the variable after the app’s first approval.Default: signing credentials (iOS certificate + profile, Android keystore) are stored on EAS servers and fetched by the build. Nothing signing-related is in the repo or in GitHub.
Alternative, if you must own the files: put them in the repo root as described
in https://docs.expo.dev/app-signing/local-credentials/ (credentials.json
pointing to the keystore / .p12 / .mobileprovision), set
"credentialsSource": "local" on the production build profile, and have CI
write those files from secrets before eas build. credentials.json and the
key files must be git-ignored. This pipeline does not do that out of the box.
Settings -> Secrets and variables -> Actions.
| Name | What it is | Where to get it |
|---|---|---|
EXPO_TOKEN |
Expo access token used by EAS CLI | expo.dev -> Account settings -> Access tokens |
ASC_API_KEY_P8 |
App Store Connect API private key: the full text of AuthKey_XXXX.p8 (raw PEM, or base64 of the file) |
App Store Connect -> Users and Access -> Integrations -> App Store Connect API |
ASC_KEY_ID |
Key ID of that API key | Same page, “Key ID” column |
ASC_ISSUER_ID |
Issuer ID of the API keys (UUID) | Same page, top of the list |
APPLE_TEAM_ID |
Apple Developer Team ID (10 characters) | developer.apple.com/account -> Membership details |
ASC_APP_ID |
Numeric Apple ID of the app record | App Store Connect -> the app -> App Information -> Apple ID |
GOOGLE_SERVICE_ACCOUNT_KEY_JSON |
Google service account key: full JSON text (or base64 of the file) | Google Cloud Console -> Service Accounts -> Keys |
ASC_APP_ID and APPLE_TEAM_ID are identifiers rather than secrets; they are
kept as secrets only so that no account-specific value lives in the repo.
| Name | Default | Meaning |
|---|---|---|
APPLE_TEAM_TYPE |
INDIVIDUAL |
INDIVIDUAL, COMPANY_OR_ORGANIZATION or IN_HOUSE; passed to EAS as EXPO_APPLE_TEAM_TYPE |
PLAY_RELEASE_STATUS |
unset (completed) |
draft, completed, inProgress or halted; see 1.3 step 6 |
Recommended: protect v* tags (Settings -> Rules -> Rulesets) so only
maintainers can trigger a release.
Two numbers, two owners:
| Example | Source of truth | Changed by | |
|---|---|---|---|
User-facing version (CFBundleShortVersionString / versionName) |
1.2.0 |
version in the app config |
You, per release |
Build number (ios.buildNumber / android.versionCode) |
17 |
EAS servers (cli.appVersionSource: "remote") |
EAS, autoIncrement: true on the production profile |
ios.buildNumber / android.versionCode in the app config; they
are ignored. Every production build gets the next number, so a rebuild of the
same version is always uploadable.eas build:version:get /
eas build:version:set (for example when adopting an app that already has
builds in the stores). eas build:version:sync writes it into local native projects.v<app config version>. In this
repo the two cannot drift: app.config.js sets the app config version from
package.json, so npm version (which also creates the tag) is the single
source of truth. Keep that file when templating this for another app.git switch main && git pull
npm version patch # or minor / major; commits and creates tag vX.Y.Z
git push --follow-tags
Then watch Actions -> Release. When both jobs are green the build is in TestFlight (after Apple’s processing, usually 5-30 minutes) and on the Play internal track.
Manual run (no tag): Actions -> Release -> Run workflow, choose platform
(all / ios / android) and whether to submit. Useful to retry a single
platform; the tag/version check is skipped for manual runs. A failed job of a
tag run can also simply be re-run from the Actions UI.
verify job
npm ci.v + npx expo config version.npm run lint, npm run typecheck, npm test.release job: matrix over ios and android, fail-fast: false, so one
platform can fail without cancelling the other.
expo/expo-github-action), npm ci.$RUNNER_TEMP (outside the checkout, never uploaded to EAS):
.p8, plus EXPO_ASC_* / EXPO_APPLE_TEAM_* for the build and
ASC_API_KEY_* for the submit profile.GOOGLE_SERVICE_ACCOUNT_KEY_PATH.eas build --platform <p> --profile production --non-interactive --json.
The runner waits for the EAS build and fails if it fails. The build id is
taken from the JSON output, so the submit step ships exactly this build.eas submit --platform <p> --profile production --id <build id> --non-interactive.
EAS CLI only substitutes environment variables in ascApiKeyPath,
ascApiKeyId, ascApiKeyIssuerId and serviceAccountKeyPath. ascAppId
(required in non-interactive mode), appleTeamId and the optional Android
releaseStatus are therefore merged into the runner’s copy of eas.json
with jq right before submitting. They never reach git.if: always()).Hardening: permissions: contents: read, persist-credentials: false, actions
pinned to major versions, no $ expression is expanded inside a run:
script (values go through env:), one release per ref via concurrency with
no cancellation.
Cost note: the runner idles while EAS builds. To avoid that, replace the build
and submit steps with a single
eas build --platform <p> --profile production --non-interactive --no-wait --auto-submit
(EAS submits server-side when the build finishes); you lose the pass/fail
signal in GitHub and get it by email / on expo.dev instead.
Nothing is released to the public automatically.
To submit straight to another Play track, change submit.production.android.track
in eas.json (internal, alpha, beta, production).
Not enabled. JS-only fixes could later ship without a store release via EAS Update:
npx expo install expo-updates, then eas update:configure."channel": "production" (and "preview") to the build profiles.runtimeVersion: { "policy": "appVersion" } or "fingerprint"
(the nativeVersion policy is incompatible with the remote version source).eas update --channel production --message "...", e.g. from a
workflow on pushes to a release/* branch.Anything that changes native code or config still requires a tagged store release.
| Symptom | Cause / fix |
|---|---|
Tag 'vX' does not match the app config version |
package.json and app config versions diverged, see Versioning |
Repository secret ... is not set |
Add the secret; secrets are not available to workflows triggered from forks |
| Build fails with “credentials are not set up” / asks for input in non-interactive mode | Run eas build -p <platform> --profile production once locally (1.2 step 4 / 1.3 step 4) |
| iOS: provisioning profile / certificate expired or invalid | The EXPO_ASC_* variables let EAS regenerate them, which needs an API key with enough rights (Admin). Otherwise run eas credentials -p ios locally |
iOS submit: Set ascAppId in the submit profile |
ASC_APP_ID secret missing or empty |
| iOS submit: authentication / 401 errors | Wrong ASC_KEY_ID / ASC_ISSUER_ID, revoked key, or the .p8 was pasted without its BEGIN/END lines |
| iOS submit: “bundle version must be higher” | Remote build number is behind the store: eas build:version:set -p ios |
| Android submit: “Only releases with status draft may be created on draft app” | Set variable PLAY_RELEASE_STATUS=draft (1.3 step 6) |
| Android submit: “package not found” / “caller does not have permission” | App not created in Play Console, first upload still required (1.3 step 5), service account not invited to the app, or permissions not propagated yet (can take hours) |
| Android submit: “version code has already been used” | eas build:version:set -p android to a value above the highest uploaded one |
eas.json rejected by the CLI |
cli.version requires EAS CLI >= 16; the workflow installs latest. Pin eas-version in release.yml for full reproducibility |
| Job times out waiting for the build | EAS queue (free tier); raise timeout-minutes or use the --no-wait --auto-submit variant (section 5) |
Local eas submit fails on the $ASC_API_KEY_PATH placeholders |
Export the same variables locally (ASC_API_KEY_PATH, ASC_API_KEY_ID, ASC_API_KEY_ISSUER_ID, GOOGLE_SERVICE_ACCOUNT_KEY_PATH) before running it |
The workflows contain no app-specific values. Copy and adjust:
eas.json, .github/workflows/release.yml, .github/workflows/ci.yml, this file.package.json has lint, typecheck and test scripts (drop the pipeline job from ci.yml if there is no such sub-project).slug, scheme, version, ios.bundleIdentifier, android.package.eas init (new projectId), commit the app config change.eas build --profile production per platform to create credentials.APPLE_TEAM_TYPE / PLAY_RELEASE_STATUS variables if needed.production environment (eas env:create).v* tags.npm version patch && git push --follow-tags.