Everything between "a PR is merged" and "users have it". The procedure is not
written here. It is the family's, and it lives with the workflows in
blinkbitcoin/shared-workflows:
the release runbook
tells the six steps, versions and build numbers, rollback, hotfix and the
verification gates. This page holds only what this app chooses: its callers,
its variables and secrets, its environments and its store listing.
OTA is on its own page, ota.md.
Each .github/workflows/cd-*.yml is one call into a shared pipeline, with the
trigger, concurrency group, permissions, secrets and inputs this app wants
(decision 0026; ci.md
maps each file to its job). The tiers:
| Caller | Runs when | Reaches |
|---|---|---|
cd-internal.yml |
every push to main that is not docs only |
TestFlight internal, Play internal, the vX.Y.Z-build.N pre-release |
cd-release.yml |
every push to main |
keeps the release PR open and drafts its store notes; at a cut release, dispatches beta |
cd-beta.yml |
dispatched at a cut release | TestFlight external group, Play open beta |
cd-production.yml |
dispatched by hand, behind reviewers | App Store, Play, Huawei AppGallery, OTA production |
cd-store-listing.yml |
dispatched by hand | the store listing, outside a release |
Merge the release PR with a squash, and never edit a version by hand (AGENTS.md).
The notes are drafted from store-notes.prompt.md into the release PR body,
reviewed there, and every tier ships that text. Edit the prompt to change the
draft, or the release body after merging, then preview with make store-notes
(TAG=vX.Y.Z reads that release's body, PR=N that PR's). How the draft is
built, truncated per store and falls back to deterministic prose is in
the shared runbook.
Which model drafts the notes is set by repository variables and one secret:
STORE_NOTES_LLM_PROVIDER, STORE_NOTES_LLM_MODEL, STORE_NOTES_LLM_EFFORT,
OPENAI_BASE_URL, then ANTHROPIC_API_KEY or OPENAI_API_KEY. The table of
providers, endpoints, model examples and efforts, and the preview for a local
model, is the shared
Provider recipes.
fastlane/metadata/** is the source of truth for the public store page, and
fastlane/screenshots/<locale>/** and fastlane/metadata/android/<locale>/images/**
are committed with it. cd-store-listing.yml pushes or pulls it, gated on
STORE_METADATA_SYNC_ENABLED. What this app has to decide:
- The copy. The shipped files hold
Replace this text, and the release lanes refuse to run until every one is replaced for your app. - Categories. The template ships no
primary_category.txt, because a guessed category would change a live store page. Create it, with every sub-category file the app uses or none, when you want the sync to own them. app_rating_config.jsonand the category files are outside the placeholder gate: review their values yourself.- Seeding. Run
pull_metadatalocally, review the diff, then commit.
How Apple's edit-version constraint, live mode, screenshots, blank fields and
Play's review of listing edits behave is in the shared
Store listing metadata.
The store-metadata skill walks the files.
Where each value comes from (which console, which page, what it can reach if it
leaks) is in store-accounts.md. The store-credentials
skill's push-to-github.sh --plan prints unchanged, set or missing for
every name without printing a value. Which job reads each name is in the shared
Variables and secrets.
These are the ones this app sets itself. The cd-*.yml callers read them and
hand them to the shared pipelines as inputs (store-uploads-enabled,
huawei-uploads-enabled, ota-enabled, testflight-internal-group and so on):
a pipeline reads no repository variable itself.
Identity, always: IOS_BUNDLE_ID, IOS_SCHEME, ANDROID_PACKAGE. Every
release job sets APP_VARIANT=production, so the bundle id and package name
carry no .dev suffix and match them.
Toggles, each turning on one more tier (all unset is an unsigned build that needs no store account):
| Variable | Turns on | Needs |
|---|---|---|
IOS_SIGNING_ENABLED |
a signed .ipa |
certificates through fastlane match |
ANDROID_SIGNING_ENABLED |
an upload-key-signed .aab |
the upload keystore |
STORE_UPLOADS_ENABLED |
TestFlight and Play uploads (implies both signings) | full store accounts |
HUAWEI_UPLOADS_ENABLED |
the AppGallery upload, on top of STORE_UPLOADS_ENABLED |
the Huawei API client |
STORE_METADATA_SYNC_ENABLED |
the store listing sync | the same store accounts |
OTA_ENABLED |
over-the-air updates, see ota.md | a signing key and an update server |
Needed by a CI build, read and validated by src/config/env.ts:
EXPO_PUBLIC_API_URL (a URL) and EXPO_PUBLIC_APP_NAME. Also
EXPO_PUBLIC_WEB_DOMAIN (empty disables the universal-link entries) and
EXPO_PUBLIC_ALLOW_INSECURE_WEB_STORAGE (the callers substitute false when it
is unset). E2E_IOS=true runs iOS E2E on every push to main.
Store groups and tuning: TESTFLIGHT_INTERNAL_GROUP,
TESTFLIGHT_EXTERNAL_GROUP, PLAY_UPDATE_PRIORITY, BUILD_NUMBER_OFFSET
(raise only), XCODE_VERSION, and ANDROID_UPLOAD_CERT_SHA256 (unset, and the
signature check reports skip). PLAY_RELEASE_STATUS is draft for the first
Play upload and unset (meaning completed) after it: Play refuses a completed
release on an app that has never been published.
Secrets, scoped to the internal, beta and production environments:
MATCH_PASSWORD, MATCH_GIT_URL, MATCH_GIT_BASIC_AUTHORIZATION,
ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8_BASE64,
ANDROID_UPLOAD_KEYSTORE_BASE64, ANDROID_UPLOAD_KEYSTORE_PASSWORD,
ANDROID_UPLOAD_KEY_ALIAS, ANDROID_UPLOAD_KEY_PASSWORD,
PLAY_SERVICE_ACCOUNT_JSON, HUAWEI_CLIENT_ID, HUAWEI_CLIENT_SECRET,
OTA_PUBLISH_TOKEN, the APP_REVIEW_* contact (secrets, not variables, so a
reviewer login stays out of logs) and the LLM key above.
Configure in this order: get the unsigned build green, register the store apps,
add the secrets, turn on signing, rehearse with DRY_RUN=1, and only then set
STORE_UPLOADS_ENABLED. The store-setup skill enforces it.
Settings, then Environments:
| Environment | Protection | Purpose |
|---|---|---|
internal |
none | Scopes the signing and store credentials used by cd-internal |
beta |
none | Scopes the credentials used by cd-beta |
production |
Required reviewers, "prevent self-review" on, deployments limited to the tag pattern v* |
Gates every store job in cd-production.yml and a production OTA hotfix |
github-pages |
GitHub creates it, allowing main only; add a tag policy v* |
Used by the ci-web.yml deploy job, which runs at the release tag |
On a private repository, required reviewers need a Team plan or above.
DRY_RUN=1 makes every store call log its arguments and return canned data, so
a whole promotion walks on a laptop with no credentials. The commands are in
the shared runbook.
Here, both release_production lanes and both push lanes of the listing sync
stop on the shipped Replace this text placeholder copy, by design. make check-release runs the lane unit tests and needs the gems make install puts
in vendor/bundle.