Release Guide

This document covers the full release lifecycle: branching, PRs, versioning, CI secrets, and deploying staging and production builds to the App Store and Play Store.


Branch Strategy

main          ← production-ready code; triggers releases
next          ← pre-release / RC staging area
feature/*     ← new features, branched from main
fix/*         ← bug fixes, branched from main
chore/*       ← non-functional changes (deps, CI, docs)

Rules

  • main and next are protected. Direct pushes are blocked.
  • All changes enter via a Pull Request targeting main (or next for pre-releases).
  • PRs require the Quality Gate CI check to pass before merging.
  • Commit messages must follow Conventional Commits — semantic-release reads these to determine the next version.

Commit message format

<type>(<scope>): <short description>

feat(app): add lightning invoice QR scanner
fix(sdk): handle relay disconnect gracefully
chore(ci): update Flutter version to 3.41.1
docs: add release guide
Prefix Version bump
feat: minor (0.x.0)
fix:, refactor:, perf: patch (0.0.x)
feat!: / BREAKING CHANGE: footer major (x.0.0)
chore:, docs:, test:, ci: no release

Pull Request Workflow

  1. Branch from main: git checkout -b feat/my-feature
  2. Push and open a PR against main.
  3. CI runs automatically:
    • Change detection determines which test suites are relevant.
    • Only affected suites run (hostr_sdk, app).
    • The Quality Gate job is the required status check.
  4. Merge via Squash and Merge to keep main history linear.

Versioning

Version is managed by semantic-release based on commit history since the last tag. The version in app/pubspec.yaml is updated automatically as part of the release step (via .releaserc.yml prepareCmd).

Build numbers

Both Android (AAB) and iOS (IPA) use github.run_number as the build number. This is:

  • Monotonically increasing across all workflow runs in the repo.
  • Identical for Android and iOS builds triggered in the same workflow run, ensuring consistent build numbers across both stores.
  • Never requires an external API call to calculate.

The semantic version string (x.y.z) is kept in sync via pubspec.yaml. The build number and version string together uniquely identify every build.


Environments

There are two deployed environments, each with their own backend, app build, and CI secrets.

  Staging Production
Backend staging.hostr.network hostr.network
Flutter entrypoint lib/main_staging.dart lib/main_production.dart
Dart env Env.staging Env.prod
Play Store track internal production (manual promotion)
TestFlight staging group production group
GitHub Environment staging production
TLS Let's Encrypt via acme-companion Let's Encrypt via acme-companion

When each build is triggered

Event Staging build Production build
Merge to main Automatically
GitHub Release published by semantic-release
workflow_dispatch with track: internal Optionally
workflow_dispatch with track: production Optionally

A production build is always preceded by a passing staging build (same release version).


CI Pipeline Overview

PR / push
    │
    ▼
[changes]          ← dorny/paths-filter: detect app / sdk / escrow changes
    │
    ├── [test_sdk]  (only if hostr_sdk/** or models/** changed)
    └── [test_app]  (only if app/** or hostr_sdk/** or models/** changed)
                │
                ▼
         [quality_gate]   ← skipped jobs count as passing; failed/cancelled = block
                │
                ▼ (main branch, push only)
          [release]       ← semantic-release: bump version, create GitHub Release
                │
                ▼ (GitHub Release published event)
     ┌──────────┴──────────┐
[build_android_staging] [build_ios_staging]
[build_android_prod]    [build_ios_prod]

Required CI Secrets

Secrets are scoped to GitHub Environments (staging and production). Go to: Settings → Environments → <environment> → Environment secrets

Shared (both environments)

These are identical values in both environments unless you use separate developer accounts per environment.

Secret How to obtain
APP_STORE_CONNECT_ISSUER_ID App Store Connect → Users & Access → Integrations → Keys
APP_STORE_CONNECT_KEY_ID Same page — the key ID shown next to your API key
APP_STORE_CONNECT_PRIVATE_KEY Contents of the .p8 file downloaded when creating the key

Android (per environment)

Secret How to obtain
ANDROID_KEYSTORE_BASE64 base64 -i upload-keystore.jks \| pbcopy
ANDROID_KEYSTORE_PASSWORD Password used when creating the keystore
ANDROID_KEY_ALIAS Alias used when creating the keystore
ANDROID_KEY_PASSWORD Key password (often same as keystore password)
GOOGLE_PLAY_CREDENTIALS Service account JSON — paste the contents of the .json file

⚠️ Use separate upload keystores for staging and production. If a staging keystore is compromised, it cannot be used to push to the production track.

iOS (per environment)

Secret How to obtain
IOS_DISTRIBUTION_CERT_BASE64 base64 -i Certificates.p12 \| pbcopy
IOS_DISTRIBUTION_CERT_PASSWORD Password set when exporting the .p12 from Keychain
IOS_PROVISIONING_PROFILE_BASE64 base64 -i hostr_appstore.mobileprovision \| pbcopy

Staging and production share the same bundle ID (com.sudonym.hostr), the same App Store Distribution certificate, and the same provisioning profile. They differ only in the Dart entrypoint (main_staging.dart vs main_production.dart), which sets the backend URL. Use separate TestFlight groups (e.g. "Internal — Staging" vs "Internal — Production") to control who receives which build. The CI workflow overrides signing settings in Release.xcconfig at build time — locally, the project defaults to Automatic signing for development.

Cloud Deploy (per environment)

Secret How to obtain
GCP_SERVICE_ACCOUNT_KEY GCP Console → IAM → Service Accounts → create key (JSON)

Repository/Environment variable required for Terraform remote state:

Variable Description
TF_STATE_BUCKET GCS bucket name created by infrastructure/project bootstrap.

Deployment is executed by the Infrastructure Deploy workflow (.github/workflows/infra_deploy.yaml), which applies Terraform and then resets the compose VM to trigger the startup deploy script.


Preparing a Release Manually

In normal operation, semantic-release handles this automatically. If you need to cut a release manually:

# Ensure you are on main with a clean working tree
git checkout main && git pull

# Dry-run to preview what semantic-release would do
npx semantic-release --dry-run

# Trigger CI to do the real release by pushing a conventional commit
git commit --allow-empty -m "chore(release): trigger release"
git push

Promoting a Staging Build to Production

After validating the staging build on internal TestFlight / Play internal track:

  1. Go to Actions → Build & Deploy → Run workflow.
  2. Select track: production.
  3. The workflow builds with lib/main_production.dart (pointing to hostr.network) and uploads directly to the production track.

Alternatively, on Android you can promote within the Play Console without a rebuild (the same AAB from the internal track is promoted). On iOS, promote the same TestFlight build to App Store review.

🔒 The production GitHub Environment requires manual approval before the job runs. Configure this under Settings → Environments → production → Required reviewers.


Adding Secrets to the VM (Cloud Services)

Runtime secrets are stored in Google Secret Manager and fetched onto the VM at deploy time. Required keys are:

  • ESCROW_PRIVATE_KEY
  • BLOSSOM_DASHBOARD_PASSWORD

Non-sensitive runtime values (DOMAIN, LETSENCRYPT_EMAIL, RPC_URL, ESCROW_CONTRACT_ADDRESS) are sourced from .env.staging / .env.prod.

See .doc/infrastructure/README.md for the hostr-fetch-secrets flow and example commands.