Owner checklist
Everything the pipeline needs that only a human with an Apple / Google / Slack / GitHub / Expo login can provide. Nothing here is a code change; nothing here happens by itself. Until an item is done the job that needs it skips itself with a notice and the run stays green, so a fresh project is shippable on day one and gets more of the ladder as the list gets ticked.
This file is per-project state. Tick the boxes as you go and commit it — bun run init copies it into a new project untouched (no rewrites, no reset; KEEP in scripts/init.js), because what the previous owner set up says nothing about your accounts.
Every item carries the same four lines:
| Line | Means |
|---|---|
| the checkbox | the action, in the place it is done |
| Unlocks | what starts working — usually a job that skips today |
| Flip | the repo constant to change in the same or a follow-up PR (repo constants), or — |
| Proof | the command or screen that shows it took |
Groups are ordered by how early you need them: merge gate → staging → store release → optional → template repo upkeep. First real runs still owed tracks the lanes that exist but have never executed — every "Unverified" callout in these docs points there.
Merge gate
What has to be true for a PR to be gated properly on main.
Push branch protection, merge settings and labels
- [ ]
bun run repo:settings:apply(first run after creating the repo;bun run initoffers it) - [ ] re-run it after any change to
REQUIRED_CHECKSorLABELSinscripts/repo-settings.js - Unlocks: every JS-gate job becomes a required check on
main, squash-only merging with auto-merge for Renovate, and the labels the workflows key on (web-preview,e2e:cloud,e2e:ios,fingerprint-drift,flaky-flow,autorelease: *). - Flip: —
- Proof:
bun run repo:settings:checkprintsin sync. A subset:--only protection|repo|environments|labels|pages. - More: JS gate → Changing the required set.
GitHub Environments (uat and production)
- [ ]
bun run repo:settings:apply --only environments, then add yourself as the required reviewer if the API did not (Settings → Environments) - Unlocks: the reviewer prompt on
.github/workflows/release.yml, and its deployment branch policies —production: branchmain+ tagv*,uat: branchmain. Without thev*tag policy a tag-triggered release deployment is refused before the reviewer sees it. - Flip: —
- Proof:
bun run repo:settings:checkno longer reportsenvironments.<name>: ….
Link the Expo GitHub App
- [ ] expo.dev → account → project → GitHub → install the app and link the repository (the Expo user needs a linked GitHub account)
- Unlocks: every
pull_request/pushtrigger in.eas/workflows/(e2e.yml,preview-web.yml,deploy-staging.yml), thegithub-commentjobs, and EAS runs reporting back as PR checks. NoEXPO_TOKENis involved — EAS triggers itself from the webhook. - Flip: —
- Proof: open a PR; the run appears on expo.dev → Workflows and
gh pr checks <n>lists the EAS context. - More: Native E2E → Human prerequisites.
Make E2E (native) a required check
- [ ] after the first PR run: read the exact context string from
gh pr checks <n>, add it verbatim toREQUIRED_CHECKSinscripts/repo-settings.js, runbun run repo:settings:apply - Unlocks: the native lane actually gates merges instead of being informational.
- Flip: — (optionally tier iOS down with
IOS_MODEine2e.yml) - Proof:
bun run repo:settings:checkis clean and the check shows Required on a PR. - Budget: the first run has no cached base build and cuts two paid builds (~15 min each); every JS-only PR after that repacks.
- More: JS gate → How EAS checks appear on the PR.
Staging
Everything deploy-staging.yml needs to do more than publish an OTA update.
Slack release webhook
- [ ] create the channel and an incoming webhook (Build sharing → Create the channel and webhook), then store it on EAS:
bun run eas env:set --scope project --environment preview --environment production \
--name SLACK_WEBHOOK_URL --value https://hooks.slack.com/services/... \
--visibility secret --type string --non-interactive- Unlocks: the
slack/notifyjobs ofdeploy-staging.yml,promote.yml,release.ymlandrollout.yml— the install links designers and testers use. - Flip: — (the job exits 0 with
SLACK_WEBHOOK_URL is not set … skippingwhile unset) - Proof:
bun run eas env:list --environment preview --format longshows the name (never the value); the next push tomainposts.
Claim the EAS Hosting dev-domain
- [ ] one interactive deployment by hand:
bun run export:web && bun run eas deploy --environment preview --export-dir dist-web --dev-domain <slug> --alias staging - Unlocks: web on the ladder —
staging/uat/ production aliases and thepr-<number>PR previews. - Flip:
HOSTING→enabledindeploy-staging.yml,preview-web.ymlandpromote.yml(constant + matching input default) in one PR. - Proof: the alias resolves; a PR labelled
web-previewgets a comment with itspr-<n>URL.
iOS ad hoc credentials (staging, uat)
- [ ] register at least one device (
bun run devices:add), thenbun run eas credentials -p iosfor each profile - Unlocks: installable iOS staging builds (the QR / install page testers use), and the
uatbuild when a promotion needs one. - Flip:
IOS_BUILDS→enabledindeploy-staging.ymlandpromote.yml;promote.ymlalso takes-F ios_builds=enabledper run. - Proof:
bun run eas credentials -p iosshows a distribution certificate + ad hoc profile per application id. - More: iOS runbook, Device onboarding.
Sentry DSN and source maps
- [ ] set the four variables on EAS (the DSN is the only
EXPO_PUBLIC_one — the rest are build-time and must never reach the bundle):
# Runtime DSN — one DSN per environment, or the same one three times.
bun run eas env:set --scope project --environment development --environment preview --environment production \
--name EXPO_PUBLIC_SENTRY_DSN --value https://<key>@o<org>.ingest.sentry.io/<project> \
--visibility plaintext --type string --non-interactive
# Build-time source-map upload (NOT EXPO_PUBLIC_).
bun run eas env:set --scope project --environment development --environment preview --environment production \
--name SENTRY_ORG --value <sentry-org-slug> --visibility plaintext --type string --non-interactive
bun run eas env:set --scope project --environment development --environment preview --environment production \
--name SENTRY_PROJECT --value <sentry-project-slug> --visibility plaintext --type string --non-interactive
bun run eas env:set --scope project --environment development --environment preview --environment production \
--name SENTRY_AUTH_TOKEN --value <token> --visibility secret --type string --non-interactive- [ ] add
SENTRY_AUTH_TOKENas a GitHub repository secret too, ifbun run sentry:sourcemapsever runs from GitHub Actions rather than EAS - Unlocks: symbolicated stack traces.
src/lib/sentry.tsis a no-op until the DSN exists. - Flip: set
upload_sentry_sourcemaps: trueon theupdatejob ofdeploy-staging.ymlso a broken upload fails the run instead of shipping unsymbolicated errors. - Proof:
bun run eas env:list --environment production --format long; a test error appears in Sentry with real frames.
Real values behind EXPO_PUBLIC_API_URL
- [ ] point each environment at its backend with the same
env:setcommand (it creates or updates in place); document any new key in.env.example - Unlocks: staging / UAT / production talking to something other than the schema default.
- Flip: —
- Proof:
bun run env:pullthenbun run env:check. - More: Environments and secrets → Variables.
Store release
GitHub repository secrets
- [ ]
EXPO_TOKEN— an EAS robot / personal access token;.github/workflows/release.ymlstarts the EAS release with it - [ ]
RELEASE_PLEASE_TOKEN— not the built-inGITHUB_TOKEN: events it creates trigger no other workflow, so the release PR would have no required checks and the tag would never startrelease.yml. Either a GitHub App on the owning account (repository permissions Contents: read & write and Pull requests: read & write; store the App id / private key asRELEASE_PLEASE_APP_ID/RELEASE_PLEASE_APP_PRIVATE_KEYand add anactions/create-github-app-tokenstep before release-please) or a fine-grained PAT scoped to this repository with the same two permissions — set an expiry reminder. - Unlocks: the release PR, the
vX.Y.Ztag, and the EAS release run. - Flip: —
- Proof:
gh secret list; release-please openschore(main): release x.y.zon the next push tomain. - More: ADR-0002.
Apple: App Store credentials and the ASC API key
- [ ] Apple Developer Program membership active for the team that owns the production bundle id
- [ ]
bun run eas credentials -p ios→production: distribution certificate + App Store provisioning profile - [ ] App Store Connect API key (
.p8) stored on EAS, soeas submitruns withEXPO_TOKENonly - [ ] App Store Connect app record created,
ascAppIdadded tosubmit.production.iosineas.json(a PR) - Unlocks: the
build_ios+testflight_iosjobs of.eas/workflows/release.yml. - Flip:
IOS_RELEASE→enabledin.eas/workflows/release.yml. - Proof:
bun run eas credentials -p ioslists the key; av*tag run reaches TestFlight. - More: iOS runbook.
App Store Connect: the Internal TestFlight group
- [ ] create an internal group named exactly
Internal(TESTFLIGHT_GROUPin.eas/workflows/release.yml), without automatic distribution - Unlocks: the
testflight_iosjob's upload target; "What to Test" is set toRelease <tag>. Renaming the group means changing the constant in the same PR. - Flip: —
- Proof: App Store Connect → TestFlight → Groups shows
Internal; the first release build lands in it. - More: Release ladder → Store release.
Google Play: first AAB and the service-account key
- [ ] create the Play app, upload the first AAB by hand (Play refuses an API upload before one exists), create a service account and upload its JSON key to EAS
- Unlocks: the
play_submitjob of.eas/workflows/release.yml(internal track). - Flip:
PLAY_SUBMIT→enabledin.eas/workflows/release.yml. - Proof:
bun run eas credentials -p androidshows the service account under Service Credentials; a tag run lands on the internal track. - More: Google Play runbook.
Cut the first release
- [ ] merge the open release-please PR (it tags
vX.Y.Z), then approve theproductionEnvironment on the GitHubReleaserun - Unlocks: the first store build — and with it the ability to promote to production after a fingerprint-moving change, and to run a backport at all (both need a store release tag to exist).
- Flip: —
- Proof: the tag exists, the label on the PR flips to
autorelease: tagged, and the EASReleaserun is green (a no-op when the fingerprint is unchanged).
Optional
Nothing here blocks the ladder; each is a plan or a judgement call.
Maestro Cloud (real-device farm)
- [ ] a Maestro Cloud plan → API key + project id
- [ ]
bun run eas env:create --scope project --environment development --name MAESTRO_CLOUD_API_KEY --value <key> --visibility secret --type string --non-interactive - [ ] replace
proj_REPLACE_MEin bothcloud_<p>jobs of.eas/workflows/e2e-cloud.yml - Unlocks: the
e2e:cloudlabel running the same flows on real devices. - Flip:
MAESTRO_CLOUD→enabledine2e-cloud.yml(threeif:literals + the input default). - Proof: label a PR
e2e:cloud; the Maestro Cloud console shows the run. - More: Native E2E → Maestro Cloud, ADR-0006.
Flashlight (Android release-build CPU / RAM / FPS)
- [ ] nothing to buy — decide whether the informational hook is worth the minutes
- Unlocks: a Flashlight report artifact after the Android Maestro step.
- Flip:
FLASHLIGHT→enabledin.eas/workflows/e2e.yml, or dispatch with-F flashlight=enabled. - Proof: the run's artifacts carry the report; locally
bun run perf:flashlight. - More: Performance → Flashlight, ADR-0007.
Re-base the Observe budget on real data
- [ ] after a staging soak with real launches, set
ios/androidlimits inobserve-budget.jsonfrom measured p90s instead of the seeded guesses - Unlocks: a TTI budget that means something — and the option of making it a promotion gate (reviewer rule, or a job between
resolveandapproveinpromote.yml). - Flip: — (
--strictonobserve:checkturns skips into failures) - Proof:
bun run observe:check --days 7reportsOKrows rather thanSKIP/ insufficient data. - More: Observe → Gating on TTI.
Make the a11y audit gating
- [ ] after the first green EAS run shows real reports in the A11y audit artifact, drop
--no-failfrom theafter_maestro_testshook in.eas/workflows/e2e.yml - Unlocks: unlabeled pressables and duplicate labels failing the native lane instead of being informational.
- Flip: — (the flag is the switch)
- Proof: a deliberate missing label turns the job red.
- More: ADR-0005, Testing → Accessibility audit.
Template repo upkeep
Only relevant while this is the template repo, or right after bun run init.
Install the Renovate GitHub App
- [ ] github.com/apps/renovate → install on the repository.
renovate.jsonis in the repo; the app is what opens PRs. - Unlocks: dependency PRs (
chore(deps), auto-merge on perscripts/repo-settings.js). With no app installed the config is inert and the repo silently drifts behind. - Flip: —
- Proof:
gh pr list --author app/renovateis non-empty;bunx expo install --checkstops reporting Expo packages behind.
GitHub Pages source
- [ ]
bun run repo:settings:apply --only pages(sets the Pages source to GitHub Actions) - Unlocks:
.github/workflows/docs.ymlpublishing the VitePress site on every push tomain. - Flip: —
- Proof:
bun run repo:settings:checkreports nopages.*drift; the site loads.
First real runs still owed
These lanes are written, validated (eas workflow:validate) and reviewed, but have never executed against real infrastructure. Every Unverified callout in the docs points here. Record what the first run teaches in the doc or ADR named in the last column — that is how a lane stops being unverified.
| Lane | Blocked on | What the first run must confirm | Record in |
|---|---|---|---|
E2E (native) (e2e.yml) | Expo GitHub App | Both maestro_<p> jobs start, the build id resolves through after.*, the PR comment shows flow counts, the a11y / Flashlight hooks execute | native-e2e.md |
e2e:ios label run | Expo GitHub App | A labelled run arrives as pull_request and the comment guard tolerates it | native-e2e.md → Tiered mode |
Preview web (preview-web.yml) | EAS Hosting dev-domain | The pr-<n> alias deploys and the PR comment lands | release-ladder.md → PR previews |
Promote (promote.yml) | a staging update group (one merge to main after the Expo GitHub App link) | resolve finds the group, the approval page renders, the republish keeps the group byte-for-byte | release-ladder.md → UAT and production |
Release (release.yml, both halves) | the first release + Apple / Play | The tag reaches the reviewer, the fingerprint short-circuit behaves, TestFlight / Play internal receive the build | release-ladder.md → Store release |
Rollout (rollout.yml) | a production rollout group | resolve refuses the wrong group and the update-rollout job ramps the right one | release-ladder.md → Staged rollouts |
Backport (backport.yml) | a store release tag | git in a custom job can fetch and cherry-pick, bun install --frozen-lockfile works on an old tree, the update's runtime version matches | ADR-0008 |
E2E (Maestro Cloud) (e2e-cloud.yml) | a Maestro Cloud plan | Job env reaches the flows as ${MAESTRO_APP_ID}, flows: .maestro is accepted, whether a PR check appears | ADR-0006 |
Observe check | a staging soak with real launches | Observe reports data at all, then re-base the budget | observe.md |
Status in this repo
Delete this section in a project created from the template — it is seandillon1224/expo-boilerplate's own state, not yours (which is why bun run init leaves this file alone rather than rewriting it). Last audited 2026-09-14 (bun run repo:settings:check, gh secret list, gh pr list).
Done: EXPO_TOKEN and RELEASE_PLEASE_TOKEN repository secrets, the uat / production environments (created, policies still drifted), Android keystores for all four application ids, every label except web-preview.
Outstanding:
bun run repo:settings:apply --only protection—Docsis inREQUIRED_CHECKSbut not in live branch protection.bun run repo:settings:apply --only environments— both environments' deployment branch policies are stillprotected_branches, so av*tag cannot deployrelease.yml.bun run repo:settings:apply --only labels—web-previewis missing.- Re-run the checks on the open release-please PR (push an empty commit, or close and reopen it — they predate several required checks), then merge it to cut the first version.
- Install the Renovate GitHub App — never installed; the repo is ~30 packages behind and
bunx expo install --checkreports 11 Expo packages a patch behind. - The next production promotion needs a store release tag first:
expo-dev-clientmoved the native fingerprint after the last one. - Everything in First real runs still owed: no EAS workflow has run against GitHub, no staging update group exists, no
SLACK_WEBHOOK_URLhas ever been set. - Decide whether
Bash(bunx --package renovate:*)stays in.claude/settings.json— it has no call site in the repo.