ci(mobile): build the release APK on the runner, and make it report the tag it ships under - #87
Merged
Merged
Conversation
… hosted EAS v0.1.0-beta.4 shipped without its APK. The release job submitted the build to hosted EAS and blocked on the result; the build sat 3h09m in the free-tier queue, the job hit its 45-minute cap and was cancelled, and the download/attach steps never ran. The finished artifact was on expo.dev the whole time. Raising the cap only widens the window — the queue is unbounded and not ours to control. `eas build --local` runs the same recipe on the runner: no queue, no EAS build quota, and the artifact lands on disk where the upload step can reach it. Signing is unchanged, since credentials still come from the EAS server, so installed apps keep upgrading in place. Also: - A verify step fails the release if the APK is missing or has lost the networkSecurityConfig attribute. That's the #79 user-CA/cleartext policy — a native config that, if the plugin ever stops applying, breaks self-hosted servers in a way only a device test would otherwise catch. - A dry_run_release dispatch input rehearses the whole release build and leaves the APK as a workflow artifact without touching any Release, so this path can be exercised without cutting a tag. - Gradle caches keyed on the lockfile; the APK is named from the app version rather than the git ref, which is a branch name (with slashes) on a dry run. The tester path is unchanged: still a hosted EAS build with --no-wait, which is what Expo's own CI guidance recommends when you don't need the artifact in hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JxKyZy6vR6xKu7Sp2MrEwn
…ionCode to EAS v0.1.0-beta.4 shipped an APK whose versionName was 0.3.0 — mobile carried its own semver, independent of the tag the release was cut from. The release page and the phone's App Info disagreed about what version this was. The tag is now the version, on every platform (#77 already unified the namespace). CI stamps expo.version from the tag before building and fails the job if the built APK's versionName doesn't match, so the two can't drift again. The asset is named from the same value. This mirrors comparable self-hosted projects: Immich tags v3.1.0 and its mobile app reports 3.1.0. versionCode moves to EAS (appVersionSource: remote), the equivalent of Immich's separate build counter. It was set to `local` with autoIncrement, which bumps app.json inside the build and never commits it — Expo's docs name this exact failure ("difficult to coordinate when building on CI") and have recommended `remote` since EAS CLI 12.0.0. The last build bumped 3→4 in the build environment only, so the next release would have bumped 3→4 again and collided with the versionCode already published; Android treats a duplicate as not-an-upgrade. android.versionCode is dropped from app.json — under remote it's ignored, and the CLI recommends removing it. Requires one manual step before the next release, since the project has no remote version configured yet ("No remote versions are configured for this project"): cd mobile && npx eas-cli build:version:set -p android # set to 4 Without it the first remote build initialises from app.json and re-issues a versionCode that's already public. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JxKyZy6vR6xKu7Sp2MrEwn
…interactive command
`eas build:version:set` is prompt-only — it has no flag to pass a value and refuses
a piped stdin ("Input is required, but stdin is not readable"), so it can't be run
from CI or a non-TTY shell.
EAS initializes the remote counter from the app config the first time it builds
under `appVersionSource: remote`, and this project has no remote version configured
yet. So android.versionCode goes back in as an explicit seed, set to 4 — the highest
already published — which puts the first remote build at 5 rather than re-issuing a
versionCode that's already public.
The EAS CLI advises removing the field under remote source, and it is genuinely
ignored once the remote counter exists. Leaving it costs nothing and documents where
the counter started; RELEASING.md explains it's a seed, not the source of truth.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JxKyZy6vR6xKu7Sp2MrEwn
… silently ignoring them The release job is Android/preview by definition, so dry_run_release ignores the platform and profile inputs — picking ios with the box ticked still built an Android preview APK, with nothing in the form or the log saying so. GitHub Actions can't hide inputs that don't apply to the selected path, so the fix is to stop the form implying otherwise: both descriptions now name the limitation, and a mismatch emits a ::warning on the run. Deliberately not a hard failure. The values can't produce a wrong artifact here, and losing a 20-minute build over an irrelevant dropdown would be worse than the confusion it prevents. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JxKyZy6vR6xKu7Sp2MrEwn
…terpolating them into shells Review follow-ups on the release path: - The `v*` trigger matches anything, so a tag like `vnext` would stamp expo.version="next", sail past the versionName check (which compares against the same derived string and so can never disagree), and publish lyftr-vnext.apk. The tag must now look like a version or the job fails before building. - Tag text was expanded straight into `run:` scripts. A tag containing quotes or $( ) would execute in a job holding contents:write and EXPO_TOKEN. Exploiting it needs push access, but the values now come through `env:` instead, which costs nothing. - aapt2 was selected with a lexicographic `sort`, which picks 19.x over a future 9.x and would silently hand the verify step an ancient binary whose dump output can fail both greps on a good APK. `sort -V`, plus explicit errors when ANDROID_HOME is unset or no aapt2 is found. - The Gradle cache key included app.json, which RELEASING.md has you bump before every tag — so the release build, the run that most needs the cache, was guaranteed to miss and then save a fresh multi-hundred-MB entry. Keyed on the lockfile alone; the app version has no bearing on the dependency graph. RELEASING.md also updated: the versionCode seed is spent. The remote counter was initialized by the second dry run and stands at 5, which the docs previously described as still pending. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JxKyZy6vR6xKu7Sp2MrEwn
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
v0.1.0-beta.4shipped with no APK attached, and the APK that did exist reported a different version than the tag. Both are fixed here, and both paths were verified end to end on this branch before opening.Why the release had no APK
The release job submitted the build to hosted EAS and blocked on the result. The build spent 3h09m in the free-tier queue; the job hit its 45-minute cap and was cancelled, so the download and attach steps never ran. The finished artifact sat on expo.dev the whole time.
Raising the cap only widens the window — the queue is unbounded and not ours to control. So the release build now runs on the runner with
eas build --local: same recipe, no queue, no EAS build quota, and the artifact lands on disk where the upload step can reach it. Credentials still come from the EAS server, so signing is unchanged.This is what comparable self-hosted projects do. The tester path is untouched — still hosted EAS with
--no-wait, which is Expo's own CI guidance when you don't need the artifact in hand.Why the version didn't match
Mobile carried its own semver (
0.3.0) independent of the tag the release was cut from, so the release page and the phone's App Info disagreed. The tag is now the version on every platform — #77 already unified the namespace. CI stampsexpo.versionfrom the tag before building, names the asset from the same value, and fails the job if the built APK's versionName doesn't match.Same split Immich uses: tag
v3.1.0→ mobile reports3.1.0, with a separate monotonic build counter (+3057). Ours isappVersionSource: remote, which is the EAS equivalent.That setting also fixes a live bug. It was
localwithautoIncrement, which bumpsapp.jsoninside the build and never commits it — Expo's docs name this exact failure ("difficult to coordinate when building on CI"). The last build bumped 3→4 in the build environment only, so the next release would have bumped 3→4 again and re-issued a versionCode that is already public. Android treats a duplicate as not-an-upgrade.eas build:version:setcan't seed the counter from CI — it has no value flag and refuses piped stdin.android.versionCodeis therefore left inapp.jsonas an explicit seed of4, the highest already published; EAS initializes the remote counter from the app config on the first remote build. It's inert afterwards.Also
networkSecurityConfigattribute — the Accept Self-Signed Certs #79 user-CA/cleartext policy, native config that would otherwise only fail on a device.dry_run_releasedispatch input rehearses the whole release build and leaves the APK as a workflow artifact without touching any Release.RELEASING.mddocuments the version split.Verification
Two dry runs on this branch, both green:
Bundled 28128ms node_modules/expo-router/entry.js (3574 modules)Using Keystore from configuration: Build Credentials nwKfixtSaV— same key as the hosted build4(old local autoIncrement, reproducing the collision)Incremented versionCode from 4 to 5local.lyftr.app versionCode='4' versionName='0.3.0'versionCode='5' versionName='0.3.0'lyftr-mobile-v0.3.0.apklyftr-v0.3.0.apkbuild:version:getnow returns Android versionCode - 5, so the counter is live server-side and the seed is spent.versionNamereads0.3.0in both because a dry run has no tag and falls back toapp.json; a real tag takes the tag.Worth knowing: the local Gradle build fails in this monorepo on
node_modules/expo-router/entry.jsresolution (hoistednode_modules), but EAS's local recipe resolves it correctly — that was the open risk and run 1 closed it.Notes
6, not5. Harmless, but numbers will have gaps.expo-updatesisn't installed, so thepreviewprofile's declaredchanneldoes nothing — there's no OTA path and every mobile change needs a full build. Out of scope here.v0.1.0-beta.4was attached manually in the meantime, so that release is no longer assetless.🤖 Generated with Claude Code
https://claude.ai/code/session_01JxKyZy6vR6xKu7Sp2MrEwn