Skip to content

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:

LineMeans
the checkboxthe action, in the place it is done
Unlockswhat starts working — usually a job that skips today
Flipthe repo constant to change in the same or a follow-up PR (repo constants), or
Proofthe command or screen that shows it took

Groups are ordered by how early you need them: merge gatestagingstore releaseoptionaltemplate 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 init offers it)
  • [ ] re-run it after any change to REQUIRED_CHECKS or LABELS in scripts/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:check prints in 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: branch main + tag v*, uat: branch main. Without the v* tag policy a tag-triggered release deployment is refused before the reviewer sees it.
  • Flip:
  • Proof: bun run repo:settings:check no longer reports environments.<name>: ….
  • [ ] expo.dev → account → project → GitHub → install the app and link the repository (the Expo user needs a linked GitHub account)
  • Unlocks: every pull_request / push trigger in .eas/workflows/ (e2e.yml, preview-web.yml, deploy-staging.yml), the github-comment jobs, and EAS runs reporting back as PR checks. No EXPO_TOKEN is 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 to REQUIRED_CHECKS in scripts/repo-settings.js, run bun run repo:settings:apply
  • Unlocks: the native lane actually gates merges instead of being informational.
  • Flip: — (optionally tier iOS down with IOS_MODE in e2e.yml)
  • Proof: bun run repo:settings:check is 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

sh
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 / notify jobs of deploy-staging.yml, promote.yml, release.yml and rollout.yml — the install links designers and testers use.
  • Flip: — (the job exits 0 with SLACK_WEBHOOK_URL is not set … skipping while unset)
  • Proof: bun run eas env:list --environment preview --format long shows the name (never the value); the next push to main posts.

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 the pr-<number> PR previews.
  • Flip: HOSTINGenabled in deploy-staging.yml, preview-web.yml and promote.yml (constant + matching input default) in one PR.
  • Proof: the alias resolves; a PR labelled web-preview gets a comment with its pr-<n> URL.

iOS ad hoc credentials (staging, uat)

  • [ ] register at least one device (bun run devices:add), then bun run eas credentials -p ios for each profile
  • Unlocks: installable iOS staging builds (the QR / install page testers use), and the uat build when a promotion needs one.
  • Flip: IOS_BUILDSenabled in deploy-staging.yml and promote.yml; promote.yml also takes -F ios_builds=enabled per run.
  • Proof: bun run eas credentials -p ios shows 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):
sh
# 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_TOKEN as a GitHub repository secret too, if bun run sentry:sourcemaps ever runs from GitHub Actions rather than EAS
  • Unlocks: symbolicated stack traces. src/lib/sentry.ts is a no-op until the DSN exists.
  • Flip: set upload_sentry_sourcemaps: true on the update job of deploy-staging.yml so 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:set command (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:pull then bun run env:check.
  • More: Environments and secrets → Variables.

Store release

GitHub repository secrets

  • [ ] EXPO_TOKEN — an EAS robot / personal access token; .github/workflows/release.yml starts the EAS release with it
  • [ ] RELEASE_PLEASE_TOKENnot the built-in GITHUB_TOKEN: events it creates trigger no other workflow, so the release PR would have no required checks and the tag would never start release.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 as RELEASE_PLEASE_APP_ID / RELEASE_PLEASE_APP_PRIVATE_KEY and add an actions/create-github-app-token step 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.Z tag, and the EAS release run.
  • Flip:
  • Proof: gh secret list; release-please opens chore(main): release x.y.z on the next push to main.
  • 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 iosproduction: distribution certificate + App Store provisioning profile
  • [ ] App Store Connect API key (.p8) stored on EAS, so eas submit runs with EXPO_TOKEN only
  • [ ] App Store Connect app record created, ascAppId added to submit.production.ios in eas.json (a PR)
  • Unlocks: the build_ios + testflight_ios jobs of .eas/workflows/release.yml.
  • Flip: IOS_RELEASEenabled in .eas/workflows/release.yml.
  • Proof: bun run eas credentials -p ios lists the key; a v* tag run reaches TestFlight.
  • More: iOS runbook.

App Store Connect: the Internal TestFlight group

  • [ ] create an internal group named exactly Internal (TESTFLIGHT_GROUP in .eas/workflows/release.yml), without automatic distribution
  • Unlocks: the testflight_ios job's upload target; "What to Test" is set to Release <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_submit job of .eas/workflows/release.yml (internal track).
  • Flip: PLAY_SUBMITenabled in .eas/workflows/release.yml.
  • Proof: bun run eas credentials -p android shows 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 the production Environment on the GitHub Release run
  • 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 EAS Release run 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_ME in both cloud_<p> jobs of .eas/workflows/e2e-cloud.yml
  • Unlocks: the e2e:cloud label running the same flows on real devices.
  • Flip: MAESTRO_CLOUDenabled in e2e-cloud.yml (three if: 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: FLASHLIGHTenabled in .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 / android limits in observe-budget.json from 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 resolve and approve in promote.yml).
  • Flip: — (--strict on observe:check turns skips into failures)
  • Proof: bun run observe:check --days 7 reports OK rows rather than SKIP / 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-fail from the after_maestro_tests hook 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.json is in the repo; the app is what opens PRs.
  • Unlocks: dependency PRs (chore(deps), auto-merge on per scripts/repo-settings.js). With no app installed the config is inert and the repo silently drifts behind.
  • Flip:
  • Proof: gh pr list --author app/renovate is non-empty; bunx expo install --check stops 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.yml publishing the VitePress site on every push to main.
  • Flip:
  • Proof: bun run repo:settings:check reports no pages.* 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.

LaneBlocked onWhat the first run must confirmRecord in
E2E (native) (e2e.yml)Expo GitHub AppBoth maestro_<p> jobs start, the build id resolves through after.*, the PR comment shows flow counts, the a11y / Flashlight hooks executenative-e2e.md
e2e:ios label runExpo GitHub AppA labelled run arrives as pull_request and the comment guard tolerates itnative-e2e.md → Tiered mode
Preview web (preview-web.yml)EAS Hosting dev-domainThe pr-<n> alias deploys and the PR comment landsrelease-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-byterelease-ladder.md → UAT and production
Release (release.yml, both halves)the first release + Apple / PlayThe tag reaches the reviewer, the fingerprint short-circuit behaves, TestFlight / Play internal receive the buildrelease-ladder.md → Store release
Rollout (rollout.yml)a production rollout groupresolve refuses the wrong group and the update-rollout job ramps the right onerelease-ladder.md → Staged rollouts
Backport (backport.yml)a store release taggit in a custom job can fetch and cherry-pick, bun install --frozen-lockfile works on an old tree, the update's runtime version matchesADR-0008
E2E (Maestro Cloud) (e2e-cloud.yml)a Maestro Cloud planJob env reaches the flows as ${MAESTRO_APP_ID}, flows: .maestro is accepted, whether a PR check appearsADR-0006
Observe checka staging soak with real launchesObserve reports data at all, then re-base the budgetobserve.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 protectionDocs is in REQUIRED_CHECKS but not in live branch protection.
  • bun run repo:settings:apply --only environments — both environments' deployment branch policies are still protected_branches, so a v* tag cannot deploy release.yml.
  • bun run repo:settings:apply --only labelsweb-preview is 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 --check reports 11 Expo packages a patch behind.
  • The next production promotion needs a store release tag first: expo-dev-client moved 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_URL has ever been set.
  • Decide whether Bash(bunx --package renovate:*) stays in .claude/settings.json — it has no call site in the repo.