Skip to content

ci(mobile): build the release APK on the runner, and make it report the tag it ships under - #87

Merged
Cawlumm merged 5 commits into
mainfrom
ci/build-release-apk-on-runner
Aug 6, 2026
Merged

ci(mobile): build the release APK on the runner, and make it report the tag it ships under#87
Cawlumm merged 5 commits into
mainfrom
ci/build-release-apk-on-runner

Conversation

@Cawlumm

@Cawlumm Cawlumm commented Aug 6, 2026

Copy link
Copy Markdown
Owner

v0.1.0-beta.4 shipped 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 stamps expo.version from 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 reports 3.1.0, with a separate monotonic build counter (+3057). Ours is appVersionSource: remote, which is the EAS equivalent.

That setting also fixes a live bug. It was 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"). 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:set can't seed the counter from CI — it has no value flag and refuses piped stdin. android.versionCode is therefore left in app.json as an explicit seed of 4, the highest already published; EAS initializes the remote counter from the app config on the first remote build. It's inert afterwards.

Also

  • A verify step fails the release if the APK is missing or has lost its networkSecurityConfig attribute — the Accept Self-Signed Certs #79 user-CA/cleartext policy, native config that would otherwise only fail on a device.
  • A dry_run_release dispatch input rehearses the whole release build and leaves the APK as a workflow artifact without touching any Release.
  • Gradle cached on the lockfile. RELEASING.md documents the version split.

Verification

Two dry runs on this branch, both green:

run 1 run 2
duration 23m21s (cold cache) 22m20s
monorepo Bundled 28128ms node_modules/expo-router/entry.js (3574 modules)
signing Using Keystore from configuration: Build Credentials nwKfixtSaV — same key as the hosted build
versionCode 4 (old local autoIncrement, reproducing the collision) Incremented versionCode from 4 to 5
APK local.lyftr.app versionCode='4' versionName='0.3.0' versionCode='5' versionName='0.3.0'
asset lyftr-mobile-v0.3.0.apk lyftr-v0.3.0.apk

build:version:get now returns Android versionCode - 5, so the counter is live server-side and the seed is spent. versionName reads 0.3.0 in both because a dry run has no tag and falls back to app.json; a real tag takes the tag.

Worth knowing: the local Gradle build fails in this monorepo on node_modules/expo-router/entry.js resolution (hoisted node_modules), but EAS's local recipe resolves it correctly — that was the open risk and run 1 closed it.

Notes

  • Dry runs increment the shared counter, so the next release will be 6, not 5. Harmless, but numbers will have gaps.
  • expo-updates isn't installed, so the preview profile's declared channel does nothing — there's no OTA path and every mobile change needs a full build. Out of scope here.
  • The APK for v0.1.0-beta.4 was attached manually in the meantime, so that release is no longer assetless.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JxKyZy6vR6xKu7Sp2MrEwn

Cawlumm and others added 5 commits August 5, 2026 20:08
… 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
@Cawlumm
Cawlumm merged commit b49652d into main Aug 6, 2026
20 checks passed
@Cawlumm
Cawlumm deleted the ci/build-release-apk-on-runner branch August 6, 2026 01:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant