Skip to main content

Mobile release pipeline + CI secrets via 1Password

How a Flutter app builds, signs, and uploads itself to the Play Console internal track and TestFlight from one GitHub Actions pipeline — with every signing/store secret pulled at run time from a single 1Password vault, nothing sensitive in the repo. This is the mobile counterpart to uniform-ci.md: same "one shared pattern, thin per-repo wiring" philosophy.

Reference implementation: leaders-app is the first adopter. Per-repo specifics (the workflow, the /release skill, docs/deployment.md) live in that repo; this runbook is the shared pattern other mobile apps (next: consumer-mobile) reuse. The platform-wide extraction (a workflow_call reusable in bewith-dev/.github) is the planned end state — see Sharing across apps.

Secrets via 1Password (the reusable half)

The decision that makes this scale: one place to manage every key. Instead of pasting N secrets into each repo's GitHub settings, all secrets live in a 1Password vault and the workflow reads them at run time.

  • One GitHub secret org-wide: OP_SERVICE_ACCOUNT_TOKEN — a 1Password Service Account token scoped to read the secrets vault.
  • Everything else (signing keystore, Play service account, App Store Connect API key, Slack bot token) lives in the MCP-Exposed 1Password vault and is fetched per run with op read (files via --out-file).
  • Rotating a key is a one-place edit in 1Password; no repo changes, no N GitHub secrets to chase.

leaders-app vault contract (MCP-Exposed):

1Password item / fieldUse
Android Signing/keystore + store_password / key_password / key_aliassign the AAB (android/key.properties assembled in CI; storeFile constant)
Play Store Service Account/jsonfastlane supply auth to Play
App Store Connect API/key_id + issuer_id + AuthKey_*.p8TestFlight upload and iOS signing
Slack bot token/credentialrelease notifications

A small composite action (.github/actions/op-secrets in the app repo) installs the op CLI, masks the string secrets, materializes the file secrets into $RUNNER_TEMP, assembles android/key.properties, and exports the env the fastlane lanes read. A matching cleanup step wipes everything if: always().

Security note (read before adding more apps)

1Password service-account access is per-vault, not per-item: any token that can read MCP-Exposed can read everything in it, including the non-rotatable Android upload keystore. This is acceptable while only the CI service account reads the vault. Before broadening read access (more MCP connectors) or piling in unrelated keys, isolate the signing material into a dedicated CI-only vault (e.g. release-signing) read solely by the release pipeline. Isolation is done by vaults, and you need one service account per trust boundary, not per app.

The pipeline (the per-repo half)

GitHub Actions, manual workflow_dispatch, jobs prepare → android + ios → notify:

  • Android (ubuntu): flutter build appbundlefastlane supply to the Play internal track (release_status: completed).
  • iOS (macos): flutter build ios --config-onlypod installfastlane build_app + upload_to_testflight (internal).
  • iOS signing is cloud-managed: xcodebuild -allowProvisioningUpdates with the App Store Connect API key creates the distribution cert + provisioning profile on demand. No .p12 / profile in the repo or CI. (Xcode Cloud is retired — GitHub Actions is the single iOS CI.)
  • Versioning needs no input: versionName from pubspec.yaml; the build number is resolved automatically as max(highest Play versionCode, latest TestFlight build) + 1, shared by both platforms — never rejected for a stale code.
  • Notifications: state is posted to a releases Slack channel at each stage (started → Play internal → TestFlight → ✅/❌), best-effort.

Production promotion, AI-generated release notes, and a beta flavor are later, gated phases — not part of this baseline.

The /release skill (first repo-specific artifact)

leaders-app ships a repo-local Claude Code skill at .claude/skills/release/ that is the single entry point: it picks the next version (semver bump from commits since the last tag), bumps pubspec.yaml, and triggers the workflow via gh workflow run — so no one has to remember to bump a version. The build number stays automatic.

This is the first artifact that is specific to one repo rather than shared platform content, so it deliberately lives in the app repo, not in bewith-docs .claude/skills/ (which are platform-wide). The shared knowledge is this runbook; the executable skill is repo-local until the reusable workflow lands and a templated skill can be promoted.

Sharing across apps

Following the uniform-ci model, the end state is a workflow_call reusable workflow in bewith-dev/.github that each mobile app calls with a thin caller (secrets: inherit for OP_SERVICE_ACCOUNT_TOKEN). leaders-app proves the pattern first (POC); consumer-mobile is the next adopter. Pin the reusable workflow by @v1 and move the tag on release — never @main.

Adoption checklist for a new mobile app

  1. Add the app's signing keystore + store credentials to the secrets vault as the items above (or app-scoped equivalents).
  2. Grant the Play service account release permission on the app; register the bundle id in App Store Connect.
  3. Copy fastlane/, ios/ExportOptions.plist, and the .github/actions/op-secrets
    • slack-notify composites; add the caller workflow.
  4. Confirm OP_SERVICE_ACCOUNT_TOKEN (org secret) can read the vault.
  5. First run validates iOS cloud signing on a real macOS runner.

See also