Full end-to-end, tag-driven store-release automation for btc-wallet, tracking
issue #178. Mirrors
RealUnitCH/app's pipeline 1:1, adapted from
its Flutter stack to our React Native (BlueWallet-fork) stack.
Setting it up? The one-time provisioning — every secret and exactly where to get it — is in the companion runbook
release-pipeline-setup.md.
Goal: a single Git tag builds, signs, and uploads both the binary and the
store listing (texts, images, screenshots) to App Store Connect / TestFlight and the
Google Play Store — no manual upload steps beyond the final human "Submit/Promote"
click in the consoles. The existing reproducible + attested Android build
(build-release-apk.sh, code-transparency, actions/attest) is preserved; store
delivery is added on top of it.
Bundle/package id is unified on both platforms: swiss.dfx.bitcoin.
All implemented in one PR (#181). "Implemented" = code written + syntax/preflight validated locally; the store-upload lanes are untested end-to-end until the external prerequisites + secrets are provisioned (see below).
| Phase | Scope | Status |
|---|---|---|
| 1 | Versioning generator + auto-tag + release skeleton (guard + version + concurrency) |
✅ done, validated (v9.9.9 test tag) |
| 2 | iOS lane: match + gym + upload_to_testflight + staged deliver |
✅ implemented (needs certs repo + ASC) |
| 3 | Android lane: supply → Play internal, on the attested AAB |
✅ implemented (needs Play service account) |
| 4 | metadata (de-DE/en-US) + check-store-metadata.sh + store-metadata.yml |
✅ scaffolded (real copy + legal URLs are FIXME-, gated by preflight) |
| 5 | Cleanup: remove placeholder custom_lane; remove legacy CircleCI / App Center |
✅ done (both deleted; BlueWallet upstream had already removed them) |
Superseded: the earlier
push: develop→ APK-artifact + TestFlight workflow (PR #180) was replaced by this tag-driven design. Its iOS fastlane scaffolding carries over.
scripts/release-version.sh turns a tag into the version used by both platforms:marketing_version = X.Y.Zversion_code = MAJOR*10000000 + MINOR*100000 + PATCH*1000 + 999 (identical iOS/Android, deterministic)0..99; any suffix (-, +) is rejected (old -beta.N tags can't re-enter)dev tag → dev sentinel (version_code=0); store lanes hard-fail on this so a tagless build is never shippedandroid/app/build.gradle releaseVersionName is the version baseline — the DFX MAJOR.MINOR.PATCH (2.0.5). In CI versionName/versionCode come from the generator; the literal is the local-build fallback and the line auto-tag reads to choose the version.CFBundleShortVersionString + build number set from the generator at build time (phase 2).auto-tag.yml — push to develop → next vX.Y.Z tag. Takes MAJOR.MINOR from build.gradle releaseVersionName (the DFX line) and bumps the patch from the highest existing tag on that line — ignoring the inherited BlueWallet v6.x tags. Pushed via TAG_DEPLOY_KEY so the tag actually triggers the release workflow (a GITHUB_TOKEN push would not).vX.Y.0 tag (MAJOR/MINOR) = production candidate.release.yml — on push: tags: v*:vX.Y.Z shape and routes the lane: PATCH==0 → production candidate (GitHub release, prerelease: false); PATCH>=1 → internal release (prerelease: true).internal); production promotion stays a manual console action.concurrency: store-release, cancel-in-progress: false — serialises store uploads so back-to-back tags can't race the same slot.develop. auto-tag creates the next tag (v2.0.5 → v2.0.6 → …) and release.yml ships it. No edits.releaseVersionName in android/app/build.gradle (e.g. 2.1.0) — in a normal PR, merge to develop; the next auto-tag jumps to v2.1.0, then auto-increments from there. This stays manual on purpose (a merge shouldn't silently decide a release is "a 2.1").vX.Y.0 tag, then click Submit/Promote in App Store Connect / Play Console. Nothing auto-publishes to the public store.master/main is not part of the flow — nothing runs on it; every release goes through develop + tags.ios/fastlane/Fastfile beta lane:
fastlane match (sync_code_signing, type appstore) — certs/profiles in a dedicated private repo (DFXswiss/btc-wallet-certificates); SSH deploy key + MATCH_PASSWORD.gym → signed .ipa (Xcode workspace build of the BlueWallet prod scheme → .env.prd).upload_to_testflight (ASC API key), skip_waiting_for_build_processing: true.deliver (best-effort) stages listing texts + screenshots; Deliverfile: submit_for_review false, automatic_release false (a human clicks Submit). Best-effort because the first App Store version must be created once manually.Appfile (app_identifier "swiss.dfx.bitcoin", apple_id, itc_team_id, team_id Y4QBY6387T).android/fastlane/ (Fastfile + Appfile + credentials.json from a base64 secret) beta lane, on top of the existing signed/attested AAB:
build-release-apk.sh + KEYSTORE_* / TRANSPARENCY_*).upload_to_play_store track internal (Internal Testing) — AAB + changelog, skip_upload_metadata/images/screenshots: true.upload_to_play_store pushing only metadata + images + screenshots (no changelog).Appfile: package_name("swiss.dfx.bitcoin"), json_key_file("./credentials.json") (Play service account).de-DE + en-US (drop unmaintained inherited locales).ios/fastlane/metadata/<locale>/ (name, subtitle, description, keywords, promotional_text, release_notes, URLs, copyright).fastlane/metadata/android/<locale>/ (title, short_description, full_description, changelogs/default.txt).ios/fastlane/screenshots/..., fastlane/metadata/android/<locale>/images/...); automated capture deferred.store-metadata.yml — triggers on **/fastlane/metadata/** changes (+ workflow_dispatch ios/android/both); runs metadata-only lanes (binary uploads skipped) so listing changes ship without a new build.store_metadata lane per platform (metadata-only; Android pinned to a non-production track defensively).scripts/check-store-metadata.sh preflight (gates both the metadata and release workflows): reject unresolved FIXME- placeholders; enforce char limits (iOS name/subtitle 30, keywords 100, description 4000; Android title 50, short 80, full 4000; URLs ≤ 255).| Secret | Platform | Purpose |
|---|---|---|
TAG_DEPLOY_KEY |
tagging | SSH deploy key so auto-tag's push triggers release.yml |
APP_STORE_CONNECT_KEY |
iOS | App Store Connect API key (.p8 contents) |
APP_STORE_CONNECT_KEY_ID |
iOS | ASC API key ID |
APP_STORE_CONNECT_ISSUER_ID |
iOS | ASC API key issuer ID |
MATCH_SSH_KEY |
iOS | deploy key for the private match certs repo |
MATCH_PASSWORD |
iOS | match encryption passphrase |
PLAY_STORE_JSON_BASE64 |
Android | Play Console service-account JSON (base64) |
KEYSTORE_* |
Android | app signing (already present) |
TRANSPARENCY_* |
Android | code-transparency signing (already present) |
SENTRY_AUTH_TOKEN |
both | crash-symbol/sourcemap upload auth (see docs/release-pipeline-setup.md) |
SENTRY_URL, SENTRY_ORG, SENTRY_PROJECT |
both | not secrets, but required alongside the token — same doc |
No secret values live in this repo — only names. Provision them in repo/org settings.
match certs repo (DFXswiss/btc-wallet-certificates) and seed it (fastlane match appstore).deliver).match certs repo: dedicated private repo DFXswiss/btc-wallet-certificates.de-DE + en-US; drop unmaintained inherited locales.internal (Internal Testing) for the automated lane; production promotion manual.gym (Xcode workspace) + gradle/react-native AAB — not flutter build.versionName/versionCode + Xcode build settings — not a Dart-generated file.build_runner / Drift / golden steps.auto-tag on develop produces internal-release tags; vX.Y.0 = production candidate. (phase 1)release workflow on v* builds and uploads iOS (TestFlight) and Android (Play internal) from a single tag.deliver (never auto-submitted).supply, separate from the binary/changelog upload.store-metadata.yml syncs listing-only changes on demand and on metadata changes.check-store-metadata.sh preflight blocks FIXME placeholders + oversize fields, gating both workflows.v9.9.9 test tag → release.yml guard routed it (PATCH=9 → internal) and derived marketing_version=9.9.9, version_code=90909999. Green. Test tag deleted.