Release the Linyup member app (apps/mobile) — decide OTA vs store build, cut a mobile-v* tag, watch the EAS lanes, and finish a store submission. Use when asked to ship, hotfix, roll back or submit the mobile app, or to explain why a change needs a native build.
Install
npx skillscat add francodgstn/dgstn-linyup/mobile-release Install via the SkillsCat registry.
Mobile release — the member app ships on its own tag
The app (apps/mobile, package @linyup/mobile, Expo slug linyup) releases
on mobile-v* tags, never on the backend's v* tags. The two cadences are
deliberately decoupled (docs/mobile-roadmap-2026-09.md §3): a web rollout is
instant and pinnable, a store build takes days and cannot be un-shipped.
The three lanes (.github/workflows/mobile.yml)
| Trigger | What runs | You do |
|---|---|---|
PR touching apps/mobile/** or packages/shared/** |
lint, jest, tsc, expo config per project (catches config-time throws) |
nothing |
main merge touching the same |
Expo's continuous-deploy-fingerprint action against the preview profile / staging branch: a build with the current fingerprint exists → OTA update; none → a preview build (internal distribution, staging Firebase). It comments the result on the commit. |
install the preview build link on your phone |
mobile-v* tag |
same action, store profile / production branch: OTA when possible, otherwise a store build. It does NOT submit. |
approve the production gate, then eas submit the build CI made |
Where a store binary must come from
From CI. Never from eas build on your laptop.
The fingerprint is computed by whichever machine runs the command, and a dev
machine does not agree with the runner. Same commit, measured:
| iOS | Android | |
|---|---|---|
| GitHub runner (Linux) | b817ef4f… |
a4093b56… |
| Windows checkout | 88d18f6f… |
4e1cc6df… |
.gitattributes sets * text=auto, so a Windows working tree holds CRLF where
the runner holds LF — and @expo/fingerprint reads .gitignore to decide what
to SKIP, so the CRLF copy's patterns stop matching and 172 node_modules
paths get hashed in that CI correctly ignores.
Two consequences, and the second is the one that bites:
- CI will never find a locally-built build, so the lane rebuilds every time
instead of publishing an update. Wasteful, visible, harmless. - An OTA only reaches builds carrying ITS fingerprint. Publish from CI
while the store binary was built locally and the update targets a build
nobody installed — the lane reports success, the dashboard shows the update,
and not one phone changes. Nothing anywhere reports this.
So whichever machine builds the binary that ships must also be the machine that
publishes updates to it. CI is the one that can do both unattended, which is why
it is CI and not the laptop.
Where this stands (2026-09-11). Both binaries in the stores today were built
on the Windows checkout — TestFlight 1.0.1(3) (88d18f6f…) and Play closed
testing version code 3 (4e1cc6df…) — so a CI-published OTA reaches NEITHER.
Until the next store binary comes from a mobile-v* tag, publish updates the way
1.0.1's safe-area fix went out: eas update --branch production --environment production from the machine that built them. The switchover costs one release,
once.
The CI builds already exist. SUBMIT them, do not rebuild. The tagmobile-v1.0.1-ci1 produced iOS 1.0.1(5) (b817ef4f…) and Android
1.0.1(5), version code 5 (a4093b56…) — both finished, both carrying the
runner's fingerprints. They are held only by the App Store review and the Play
fourteen-day clock. When those close:
cd apps/mobile
npx eas-cli submit --profile store --platform ios --latest
npx eas-cli submit --profile store --platform android --latestBuilding a fresh binary instead — from the laptop especially — puts this back
exactly where it started. The whole value of those two artefacts is that CI made
them, so an OTA CI publishes reaches the phone that installed them.
A local eas build is still right for anything you are NOT shipping: apreview build for your own phone, or reproducing a build failure.
OTA or native build? The fingerprint decides — but know the rule
runtimeVersion.policy: 'fingerprint': Expo hashes the native project (deps
with native code, config plugins, app.config.js native fields). A change
that alters the fingerprint cannot ship over the air; the lane builds
instead. JS/TS-only changes, assets, copy, most packages/shared changes → OTA.
Adding/removing a dependency with native code, touching plugins, permissions,
icons/splash, bundle ids → build.
apps/mobile/fingerprint.config.js makes the hash ignore extra and the
version fields (SourceSkips.ExpoConfigExtraSection | ExpoConfigVersions).
Without it the lane's runner — which evaluates app.config.js with noFIREBASE_API_KEY, i.e. the demo project — would never hash the same as the
EAS build carrying the real key, every push would look native, and OTA would
never be chosen. Keep runtime data in extra; never move native config there.npx @expo/fingerprint apps/mobile prints the hash and its sources.
Never bump runtimeVersion by hand; never set updates.url orextra.eas.projectId by hand (owner-set once via eas init).
Retiring a build that can no longer follow the backend is a separate
lever: operator console → Settings → Member app → minimum supported version
(app_settings/mobile). Older builds open on an update-required screen with
the store links. It fails OPEN on a malformed value; the console validates.
Use it rarely — an OTA reaches every build on the same fingerprint without it.
Cutting a release
git checkout main && git pull
# version is ONE source: apps/mobile/package.json — app.config.js reads it
pnpm --filter @linyup/mobile version <major|minor|patch> --no-git-tag-version
git commit -am "chore(mobile): v1.2.0"
git tag -a mobile-v1.2.0 -m "<one line: what members get>"
git push origin main mobile-v1.2.0The tag body is the release note (same convention as the backend's v*
tags). Store build numbers are EAS-managed (appVersionSource: remote,autoIncrement on store).
An update during the Play closed test (before production access)
A mid-test update is a STORE build, not an OTA: Play only sees a new version code on the
closed track, and an EAS Update is invisible to it. Testers who uninstall a
broken build drop the count below twelve and the fourteen days restart, so the
order below puts your own phone before theirs.
# 1. version — the notes live beside the listing assets
pnpm --filter @linyup/mobile version patch --no-git-tag-version
# write apps/mobile/store/release-notes/<version>/{en-US,de-DE,fr-FR,it-IT}.txt
git commit -am "chore(mobile): v1.0.1 — <what testers get>" # PR, merge
# 2. tag — CI builds it. Do NOT `eas build` a store binary from here:
# see "Where a store binary must come from".
git tag -a mobile-v1.0.2 -m "<why>" && git push origin mobile-v1.0.2
# then approve the `production` environment gate and wait for the build
# 3. submit the build CI made (submit.store.android.track = alpha)
cd apps/mobile
npx eas-cli submit --profile store --platform android --latest
# 4. Play Console → Closed testing → the new release: paste the notes per
# language, but install it on YOUR phone from the tester link and sign in
# with the review login before rolling it out to the group.
# 5. Tell the testers what changed and ask for ONE concrete thing
# ("switch the phone to German, book a class, reply with what annoyed you") —
# the production-access questionnaire asks for feedback and what you changed.Backend compatibility — the rule the web never needed
A phone can be several store versions behind. Before merging a backend change:
- callable responses are additive — never remove or rename a field a
shipped app reads (theaccessRuleremoval crashed the appointment modal); - the
listAvailabilityandgetMyBookingspayloads have ONE typed owner inpackages/shared— change the type, and every consumer's typecheck fails
loudly; - if a break is unavoidable, raise
app_settings/mobile.min_supported_version
(step 4 of the roadmap) so old builds are told to update instead of failing
silently.
Hotfix and rollback
- JS-only fix: merge to
main, tagmobile-vX.Y.Z→ OTA in minutes. - Roll back an OTA:
eas update:republish --branch production --group <id>
(EAS keeps every update group; pick the previous one). - Roll back a store build: you can't. Ship a fix forward; use the min-version
gate to stop the broken build from running if it is data-damaging.
Store submission checklist (first time and every native release)
- Build profiles are
development(dev client, staging Firebase),preview
(internal distribution, staging Firebase,stagingchannel) andstore
(store distribution, prod Firebase,productionchannel, auto-incremented
build numbers). Onlystoreis submittable; there is deliberately no
internal "production" profile — it used to exist and produced builds that
looked shippable and were not. - Review login: operator console → Settings → Demo tenant → review access
(fixed OTP forapp.review@example.com, ≤ 60 days) — paste into ASC/Play
"App access" notes (docs/launch/prod-demo-and-store-review.md). - Account deletion is in-app (Profile → Delete my account) — Apple 5.1.1(v).
- Camera purpose string is explicit and the microphone permission is off
(expo-cameraplugin config inapp.config.js). - Push credentials must be uploaded BEFORE the first build carrying
expo-notifications. The plugin is inapp.config.jsand the app registers
a token when permission is already granted (src/push/registerPushToken.ts) —
but delivery needs an FCM V1 service account (Android) and an APNs key (iOS)
in EAS (eas credentials), and neither lives in this repo. Ship without them
and the app happily stores tokens that can never be delivered to; you find out
the day someone wires the first send, not at build time. Nothing prompts and
nothing sends yet, so a build that goes out ahead of the credentials is not
broken — it is just not yet useful. - iPad screenshots are required while
ios.supportsTabletis true. - Privacy policy must cover the app's users (studio contacts); Terms/DPA must
not carry the DRAFT banner.
Secrets and where they live
The one-time staging setup (project, key, token, first build) is a runbook
for a local session: docs/mobile-eas-setup.md. Run 2026-09-03 — the EAS
project is @francodagostino/linyup, f941b285-002a-4bdb-8c42-8c3e5edfab66.
The staging half is COMPLETE and both halves of the lane are proven onmain: the project, the EXPO_TOKEN robot (linyup-eas-robot), the key
in both channels, a finished preview APK, and EAS updates landing on thestaging branch at a runtime version that APK matches. What remains is all
Apple/Google (see the roadmap §7), plus the prod key.
EXPO_TOKEN— GitHub repository secret (the CI's EAS identity).EAS_PROJECT_ID— the project id is now the DEFAULT inapp.config.js
(extra.eas.projectId+updates.urlare live, so OTA is armed). The env
var only redirects a build at another EAS project; an empty value falls
back to the default (which is why the config uses||, not??— the.env.*templates shipEAS_PROJECT_ID=).- First Play submission is manual: Google requires the very first AAB to be
uploaded by hand beforeeas submitcan target a track. The whole store
path — both consoles, the credentials, and the 14-day Play closed-testing
clock that gates going public — isdocs/mobile-store-setup.md. FIREBASE_API_KEY— botheas.json'senvblock per profile and
an EAS environment variable per environment. Not redundancy: theenv
block is the only thing in scope wheneas buildevaluates app.config.js
LOCALLY (eas-cli setsEXPO_NO_DOTENV=1there), while the EAS environment
variable is what the builder gets and whateas update --environment
resolves for the OTA path.developmentandpreviewcarry the staging key
in both. Thestoreprofile TEMPORARILY carries the STAGING key too, so the
Play closed test could start before the prod key was decided — the release
lane refuses amobile-v*tag while itsFIREBASE_PROJECT_IDis anything butlinyup-prod, so this cannot ship by accident. TheproductionEAS
environment still carries nothing. Write the literal value —"${FIREBASE_API_KEY}"is not interpolated and gets baked in as that string.- Apple ASC API key, Play service-account JSON — stored on EAS
(credentialsSource: remote).
Traps recorded
"${FIREBASE_API_KEY}"ineas.jsonbakes the literal into the app: auth
fails at runtime, build succeeds.The opposite trap, which cost the first green run on main: NO key in
eas.jsonenvat all.eas buildevaluates app.config.js locally before
upload, the config's own guard throws,expo config --jsonexits 1 with
EMPTY stderr, and expo-github-action reports only "failed with exit code
1". Nothing anywhere names the cause. An EAS environment variable does not
cover this — it is not in scope for that local read.A green lane does not mean a green build. The action starts builds with
--no-wait, so the job goes green the moment the build is QUEUED. The first
run after the key fix reported success while its build failed four minutes
later, and nothing in GitHub ever says so — the commit comment carries a
link, not an outcome. After any lane run that STARTS a build (rather than
publishing an update), check EAS:eas build:list --platform android --limit 3, or--jsonfor theerrorfield, which is the only place the
reason appears.Gradle failures on EAS can be transient. Two builds of the SAME fingerprint
errored withEAS_BUILD_UNKNOWN_GRADLE_ERRORand a third finished, same
native inputs. Identical fingerprint + different outcome = infrastructure,
not code; re-run before investigating.A stale
packages/shared/diston the EAS builder:eas-build-post-install
builds shared; if that script is removed, Metro fails to resolve@linyup/shared.expo configthrows on an unknownFIREBASE_PROJECT_ID— every profile's
env must name a project theenvironmentsmap inapp.config.jsknows.A CONFIG EDIT COSTS A REBUILD, and the scoping you wrote it with does not
survive. Both config files are hashed WHOLE, so a change aimed at one
platform or one profile invalidates every fingerprint. Two mechanisms, both
measured on 2026-09-03, both of which look like bugs until you know:Edit Hashed as What actually happens app.config.jsone expoConfigsource, no platform filteran ios.*field rebuilds ANDROID — the Android fingerprint's config blob containsios.supportsTableteas.jsonone easBuildfile source, no profile filtera store-onlyenvchange rebuildspreviewThe lane then finds no build matching the new fingerprint and BUILDS instead
of publishing an update. Expected, not a fault.So batch config changes. Three separate config commits on 2026-09-03
produced three Android builds back to back, and because EAS runs them on a
queue they serialised — the third (the one actually wanted) started last. Each
merge also strands the previously installed APK: OTA updates only reach a
build whose fingerprint matches, so a tester on the older APK silently stops
receiving them. One commit, one rebuild, one reinstall.fingerprint.config.jsalready skips the two parts that would otherwise churn
on EVERY push (extraand the version fields). What is left genuinely
describes the native project, so there is nothing further to skip — the answer
is fewer config commits, not moresourceSkips.