Skip to content

fix(ci): only deploy from main, and never re-release a shipped version #1281

fix(ci): only deploy from main, and never re-release a shipped version

fix(ci): only deploy from main, and never re-release a shipped version #1281

name: Default Workflow
on:
# `push` is intentionally limited to `main` so the workflow doesn't
# double-fire on the `develop → main` release PR (where every push to
# `develop` would otherwise trigger both a `push` run *and* a
# `pull_request` synchronize run). All pre-merge validation happens via
# `pull_request` (against `main` and `develop`). The `push: main` trigger
# only catches post-merge runs on `main`. A direct push to `develop`
# does not run this workflow — prefer PRs into `develop` (see CONTRIBUTING.md).
push:
branches:
- main
pull_request:
branches:
- main
- develop
# Long-lived integration branches, where a multi-PR feature is staged
# before it reaches `develop` as one merge. Without an entry here a PR
# retargeted onto one of these silently gets *no* checks at all — the
# PR still reports mergeable, so the absence is easy to miss.
#
# This is scoped to `feature/**` rather than `**` on purpose: it should
# cover deliberate integration branches without firing a full CI run on
# every PR between two arbitrary topic branches.
#
# Kept after `feature/ai-assisted-meal-logging` merged, rather than
# removed as spent. It is not leftover config: it is what gave #975,
# #978, #980 and #981 any checks at all, and four of the five defects
# that branch surrendered were found after CI passed on exactly those
# PRs. The next long-lived branch will need it before anyone remembers
# to add it back, and the failure mode is silent — a retargeted PR
# still reports mergeable with no checks run.
- 'feature/**'
# Release branches, for the same reason and with the same failure mode.
# `release/2.2.0` is cut from `main`, so a fix that landed on `develop`
# is not in it, and the PR carrying that fix across got exactly one
# check — the Copilot reviewer — while reporting `CLEAN`. A release
# branch is the last place that should merge unverified: everything on
# it is by definition about to become `main`.
- 'release/**'
workflow_dispatch:
jobs:
linux-checks:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Flutter + cache packages
uses: ./.github/actions/setup-flutter-cache
- name: Setup Just
uses: taiki-e/install-action@just
# The envied package obfuscates `.env` values into env.g.dart at compile
# time. CI doesn't have access to the real secrets and the test suite
# never hits Sentry / Supabase / FDC at runtime, so we stub them. Real
# values must come from a developer's local .env when building for
# release.
- name: Generate stub .env for CI
uses: ./.github/actions/write-env-file
with:
sentry_dns: https://stub@sentry.io/0
supabase_project_url: https://stub.supabase.co
supabase_project_anon_key: ci-stub
# `just ci` would also run `dart format --set-exit-if-changed`, but the
# codebase currently has accumulated pre-existing format drift from the
# period when CI was disabled. We run the rest of `just ci` (l10n
# generation, build_runner, analyze, test) and leave the format pass
# for a dedicated follow-up PR.
- name: Install Flutter packages
run: just install
# check_l10n generates, then fails if any locale is missing a key.
# `flutter gen-l10n` alone exits 0 on a missing translation and lets it
# ship as English, and nothing downstream sees the difference.
- name: Generate localizations (fails if a locale is missing keys)
run: just check_l10n
- name: Generate code (env.g.dart, Hive adapters, JSON serializers)
run: just build
- name: Static analysis
run: flutter analyze
- name: Run tests
run: just test
# The iOS pod-install fallback parser is the only piece of the iOS
# CI flow that decides which pods to update when Podfile.lock
# drifts. A regression in its regexes would silently degrade the
# targeted-update path back to a full `pod update`, which is
# exactly the behaviour #369 was filed to avoid. The test runs on
# ubuntu — it stubs out CocoaPods entirely with a shell mock — so
# we don't pay for it on the macOS runners.
- name: Test the iOS pod-install fallback parser
run: .github/scripts/test_pod_install_with_targeted_fallback.sh
# Lightweight guard against the "iOS deps shifted but nobody re-ran
# `pod install`" failure mode. The full `ios-build` job below also
# regenerates Podfile.lock and auto-commits drift back to same-repo
# PRs, but fork PRs run with a read-only token and that auto-commit
# silently no-ops; this job exists to surface a hard failure on the PR
# check so a human notices and refreshes the lockfile locally on a
# Mac. We only run it on `pull_request` events where one of
# pubspec.yaml, pubspec.lock, or ios/Podfile actually changed —
# otherwise the macOS minutes aren't worth burning.
ios-podfile-lock-guard:
runs-on: macos-26
if: github.event_name == 'pull_request'
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
# Need the merge base in scope so the paths-filter step below
# can diff against the PR's target branch.
fetch-depth: 0
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Check for iOS-relevant file changes
id: changes
uses: dorny/paths-filter@v4
with:
base: ${{ github.event.pull_request.base.ref }}
filters: |
ios:
- 'pubspec.yaml'
- 'pubspec.lock'
- 'ios/Podfile'
- name: Setup Flutter + cache packages
if: steps.changes.outputs.ios == 'true'
uses: ./.github/actions/setup-flutter-cache
# Swift Package Manager is enabled by default on Flutter's stable
# channel (see flutter_tools' features.dart), which flips CocoaPods
# over to installing plugins with a Package.swift (image_picker,
# sentry_flutter, ...) via SPM instead. That works for a normal
# build, but `flutter build ios --no-codesign` in ios-build hits an
# Xcode limitation where SPM package products still demand a
# signing identity even with codesigning disabled — and this app
# has no Development Team configured in CI. Pin CocoaPods as the
# sole iOS dependency manager until that's resolved (or until we
# wire up real signing for CI).
- name: Disable Swift Package Manager
if: steps.changes.outputs.ios == 'true'
run: flutter config --no-enable-swift-package-manager
- name: Generate stub .env for CI
if: steps.changes.outputs.ios == 'true'
uses: ./.github/actions/write-env-file
with:
sentry_dns: https://stub@sentry.io/0
supabase_project_url: https://stub.supabase.co
supabase_project_anon_key: ci-stub
- name: Install Flutter packages
if: steps.changes.outputs.ios == 'true'
run: flutter pub get
- name: Pod install (with repo update)
if: steps.changes.outputs.ios == 'true'
run: cd ios && pod install --repo-update
# If `pod install` rewrote Podfile.lock, the working tree now has a
# diff that the PR author hasn't committed. Surface it as a hard
# failure with a hint so the contributor knows what to do next,
# rather than letting a stale lockfile silently ship to main.
- name: Fail if Podfile.lock drifted
if: steps.changes.outputs.ios == 'true'
run: |
if ! git diff --exit-code ios/Podfile.lock; then
echo "::error file=ios/Podfile.lock::Podfile.lock is out of date with pubspec.yaml. Run 'cd ios && pod install' on macOS and commit the result."
exit 1
fi
ios-build:
runs-on: macos-26
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
# Same-repo PRs: check out the head branch so the auto-commit
# step below can push Podfile.lock fixes back to the branch.
# Fork PRs: this job runs with `contents: write` permissions,
# which means GitHub issues a token that's scoped to the base
# repo and can't authenticate against the fork — the auto-commit
# step is already gated to skip for fork PRs, so we just need a
# checkout that works. Use the PR head SHA from the base repo's
# refs/pull/N/head mirror, which the GITHUB_TOKEN can always read.
# Push and workflow_dispatch fall back to github.ref.
ref: >-
${{
github.event.pull_request.head.repo.full_name == github.repository
&& github.event.pull_request.head.ref
|| github.event.pull_request.head.sha
|| github.ref
}}
repository: >-
${{
github.event.pull_request.head.repo.full_name == github.repository
&& github.event.pull_request.head.repo.full_name
|| github.repository
}}
- name: Setup Flutter + cache packages
uses: ./.github/actions/setup-flutter-cache
# See the matching step in ios-podfile-lock-guard: SPM is enabled
# by default on stable, but `flutter build ios --no-codesign`
# below can't satisfy the signing identity SPM package products
# need, and this job has no Development Team configured.
- name: Disable Swift Package Manager
run: flutter config --no-enable-swift-package-manager
- name: Cache CocoaPods
uses: actions/cache@v6
with:
path: ios/Pods
key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-pods-
- name: Generate stub .env for CI
uses: ./.github/actions/write-env-file
with:
sentry_dns: https://stub@sentry.io/0
supabase_project_url: https://stub.supabase.co
supabase_project_anon_key: ci-stub
- name: Install Flutter packages
run: flutter pub get
- name: Generate code
run: dart run build_runner build --delete-conflicting-outputs
# `pod install` honours Podfile.lock as authoritative even with
# --repo-update — CocoaPods will only refresh the spec cache, not
# re-resolve. So if the lockfile pins a transitive subdep version
# that's been bumped in pubspec.yaml (e.g. sentry_flutter 9.19
# now wants Sentry/HybridSDK 8.58 but the lock still pins 8.46),
# `pod install` fails. The script's fallback re-resolves only the
# pod(s) named in the CocoaPods error output (full `pod update`
# only as a last resort), and the auto-commit step below pushes
# the refreshed lockfile back to the PR branch so future runs hit
# the deterministic `install` path. See #369 for the rationale
# behind targeting specific pods rather than re-resolving all.
# The script self-locates the iOS dir, so the caller doesn't need
# to cd first.
- name: Pod install (regenerate lockfile if constraints have shifted)
run: .github/scripts/pod_install_with_targeted_fallback.sh
# Same-repo PRs only — fork PRs run with a read-only token so this
# step would fail to push. Fork contributors get the lockfile via
# the artifact uploaded below. `pod install` above can also touch
# project.pbxproj (it (re)writes the per-flavor Pods-Runner
# xcconfig file references), so both are covered here.
- name: Auto-commit refreshed iOS project files
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository
uses: stefanzweifel/git-auto-commit-action@v7
with:
file_pattern: 'ios/Podfile.lock ios/Runner.xcodeproj/project.pbxproj'
commit_message: 'chore(ios): refresh Podfile.lock and project.pbxproj via CI'
- name: Upload Podfile.lock artifact
uses: actions/upload-artifact@v7
with:
name: ios-podfile-lock
path: ios/Podfile.lock
- name: Build iOS (no codesign)
# --flavor full maps to the `full` Xcode scheme (matches the
# production bundle identifier and display name). The `develop`
# scheme exists for local sideloading alongside a TestFlight /
# App Store install and isn't what CI should validate.
run: flutter build ios --no-codesign --release --flavor full
# Two calls on two runners, not two attempts on one. See the header of
# .github/workflows/ios-integration-attempt.yml for why: the hang this suite
# suffers is runner-sticky, so an in-place retry never recovers from it while
# a fresh runner has every time. Neither call fails on its own — each reports
# a verdict, and ios-integration-tests-result below turns the pair into the
# single required check.
ios-integration-tests:
uses: ./.github/workflows/ios-integration-attempt.yml
# Only when the first runner did not pass. An empty output counts as "did not
# pass": that is what a job killed by its own timeout-minutes leaves behind.
ios-integration-tests-retry:
needs: ios-integration-tests
if: ${{ !cancelled() && needs.ios-integration-tests.outputs.passed != 'true' }}
uses: ./.github/workflows/ios-integration-attempt.yml
# The check to require in branch protection — the two jobs above are green
# even when their tests fail, by design, so requiring either of them directly
# would require nothing at all.
ios-integration-tests-result:
needs:
- ios-integration-tests
- ios-integration-tests-retry
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Require one of the two runners to have passed
run: |
first='${{ needs.ios-integration-tests.outputs.passed }}'
second='${{ needs.ios-integration-tests-retry.outputs.passed }}'
echo "first runner: ${first:-<no verdict — the job was killed>}"
echo "second runner: ${second:-<not run>}"
if [ "$first" = 'true' ] || [ "$second" = 'true' ]; then
exit 0
fi
echo "::error::iOS integration tests failed on two independent runners — treat this as a real break, not a flake."
exit 1
android-build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Java (Zulu)
id: setup_java_zulu
uses: actions/setup-java@v5.6.0
continue-on-error: true
with:
distribution: 'zulu'
java-version: '17'
# Azul's download CDN intermittently returns HTTP 520, which fails the
# whole job at the JDK-fetch step. Fall back to Temurin only when the
# Zulu setup failed, so a single vendor's CDN blip no longer reds the
# build. It now takes both vendors being down at once.
- name: Setup Java (Temurin fallback)
if: steps.setup_java_zulu.outcome == 'failure'
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Flutter + cache packages
uses: ./.github/actions/setup-flutter-cache
- name: Cache Gradle
uses: actions/cache@v6
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/*.gradle*', 'android/**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Generate stub .env for CI
uses: ./.github/actions/write-env-file
with:
sentry_dns: https://stub@sentry.io/0
supabase_project_url: https://stub.supabase.co
supabase_project_anon_key: ci-stub
- name: Install Flutter packages
run: flutter pub get
- name: Generate code
run: dart run build_runner build --delete-conflicting-outputs
- name: Build APK (debug)
# --release would need a keystore which CI doesn't have; --debug
# exercises the same Gradle / desugaring / R8 paths sufficiently
# for a "compiles cleanly" gate.
# --flavor full builds the production-equivalent applicationId
# (no .develop suffix); the develop flavor exists for local
# sideloading alongside the Play Store install and isn't what CI
# should validate.
run: flutter build apk --flavor full --debug
android-integration-tests:
# Same as iOS: runs in parallel from the start, gated on neither
# android-build (it rebuilds the app itself) nor a file-discovery job.
runs-on: ubuntu-latest
# Same backstop as iOS, and like iOS the per-attempt bounds below are what
# actually catch a hang. On a cold cache this job pays an AVD seed boot
# plus a test boot, and the "retry once" step adds a third boot + rebuild,
# so 30 was too tight and got cancelled mid-retry (which also skips the
# AVD cache save, keeping the next run cold). 50 covers the retry path.
timeout-minutes: 50
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v7
# The hosted ubuntu runner has ~14 GB free after its preinstalled
# toolchains, and pulling the android-34 system image plus booting the
# emulator can tip it into "No space left on device". That kills the
# emulator before it boots, surfacing downstream as the misleading
# "could not connect to TCP port 5554". Reclaim ~15 GB by dropping the
# large toolchains this job never uses. Deliberately leaves the Flutter
# and Java toolcache and the Android SDK dir untouched.
- name: Free up disk space for the emulator
run: |
df -h /
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/.ghcup \
/usr/local/share/boost /usr/share/swift /opt/hostedtoolcache/CodeQL
sudo docker image prune --all --force || true
df -h /
- name: Setup Java (Zulu)
id: setup_java_zulu
uses: actions/setup-java@v5.6.0
continue-on-error: true
with:
distribution: 'zulu'
java-version: '17'
# Azul's download CDN intermittently returns HTTP 520, which fails the
# whole job at the JDK-fetch step. Fall back to Temurin only when the
# Zulu setup failed, so a single vendor's CDN blip no longer reds the
# build. It now takes both vendors being down at once.
- name: Setup Java (Temurin fallback)
if: steps.setup_java_zulu.outcome == 'failure'
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Flutter + cache packages
uses: ./.github/actions/setup-flutter-cache
- name: Cache Gradle
uses: actions/cache@v6
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/*.gradle*', 'android/**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
# Cache the AVD snapshot so future runs skip the slow first-boot.
# Key includes the API level so a bump invalidates the cache.
- name: Cache AVD
uses: actions/cache@v6
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-android-34
- name: Enable KVM (so the Android emulator runs at native speed)
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Generate stub .env for CI
uses: ./.github/actions/write-env-file
with:
sentry_dns: https://stub@sentry.io/0
supabase_project_url: https://stub.supabase.co
supabase_project_anon_key: ci-stub
- name: Install Flutter packages
run: flutter pub get
- name: Generate code
run: dart run build_runner build --delete-conflicting-outputs
# First emulator-runner pass — only runs when the AVD cache is empty.
# It boots the emulator once to seed the snapshot, then exits. This
# makes the actual test pass below much faster. Only the first
# shard to land on a fresh cache pays this cost; later shards in the
# same run reuse the warmed AVD via the actions/cache restore key.
- name: Generate AVD snapshot (cache miss only)
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
target: google_apis
arch: x86_64
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: false
script: echo "AVD snapshot generated for cache"
# Two steps rather than a script-level loop on purpose: the flake we see
# is the emulator-runner occasionally failing to bring the AVD up before
# the test runs (it died on "could not connect to TCP port 5554"), which
# happens outside the `script`. Retrying the whole step gives the retry a
# fresh emulator boot, not just a re-run of flutter test. Attempt 1 uses
# continue-on-error so a flake doesn't fail the job; attempt 2 runs only
# if attempt 1 failed, and a real break fails both.
- name: Run integration tests (attempt 1)
id: android-integration
continue-on-error: true
# Same bound as iOS, for the same reason. No Android run in the last
# twenty hung, and the emulator-runner already bounds the boot it is
# known to flake on, but `script:` itself is unbounded — so the one
# failure mode that skips the retry is available here too, and this
# closes it for the price of a line.
#
# 20 minutes: the slowest healthy attempt in that sample was 12m4s.
timeout-minutes: 20
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
target: google_apis
arch: x86_64
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
# --flavor full so the integration-test APK matches the
# production-equivalent applicationId rather than the dev sideload
# variant (see android/app/build.gradle). The whole
# integration_test/ directory runs from a single build.
script: flutter test integration_test/ --flavor full --reporter expanded
- name: Run integration tests (retry once)
if: steps.android-integration.outcome == 'failure'
timeout-minutes: 20
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
target: google_apis
arch: x86_64
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: flutter test integration_test/ --flavor full --reporter expanded
# Deploy gate, 0 of 5 — the only one that asks whether there is anything NEW
# to ship. The five below ask whether it is safe to ship it.
#
# This job exists because of what fixing the deploy-chain skip exposed. Until
# a8a78997 the five deploy jobs silently skipped on every push to `main`, so
# it did not matter that they are keyed on nothing but the branch. They fire
# for real now, and `push: main` fires on every merge — docs-only ones
# included. With `pubspec.yaml` still reading `2.2.0+63` the morning after
# 2.2.0 shipped, the next merge would re-run the whole chain against an
# already-published version:
#
# * `ios-deploy` re-uploads build 63. Apple answers ITMS-90189 "Redundant
# Binary Upload" and pilot raises; the lane has no rescue and the step no
# `continue-on-error`, so the job goes RED — a red `main` for a docs
# commit, after ~41 macOS runner minutes.
# * `android-deploy` re-uploads versionCode 63. Whether that dies inside
# supply's `upload_bundles` or limps to the same #942 commit failure
# depends on whether the AAB was finished by hand in the console. Only
# the second matches the #942 grep, so the first is correctly NOT
# tolerated — also red.
# * `github-release` derives `TAG_NAME=v2.2.0`, which already exists, and
# softprops/action-gh-release does not fail on that — it UPDATES the
# release in place. `overwrite_files` defaults to true, so it would
# DELETE and re-upload the IPA, AAB and APK that 2.2.0 shipped and
# regenerate the notes, while the tag stayed put (GitHub's REST docs:
# `target_commitish` is "Unused if the Git tag already exists"). The
# result is a published release whose binaries no longer match its tag.
#
# Today only ordering stops that last one: `ios-deploy` fails first, so
# `github-release` never runs. That is luck, not design. Bump only the build
# number — `2.2.0+64` — and iOS passes, `TAG_NAME` is still `v2.2.0`, and the
# luck runs out on a published release. This job turns the ordering accident
# into a rule.
#
# The ledger it reads is the one `github-release` writes: the `v<version>`
# tag, created by the last step of the last job in the chain. Tag present
# means a release carrying that exact version name ran to completion. No
# tag, nothing shipped it, so ship.
#
# Deliberately does NOT gate the build and test jobs. A push to `main` should
# still be compiled and tested; what it must not do is package and publish.
#
# Two consequences, both chosen rather than overlooked:
#
# * A build-only bump will not deploy. `2.2.0+64` still derives `v2.2.0`,
# so it is refused. That is deliberate — a build-only bump is exactly the
# input that arms the release-overwrite above — and it matches what
# docs/RELEASING.md already asks for: bump `version:` in `pubspec.yaml`,
# both halves. A rebuild that must reach TestFlight needs a new version
# name, not a new build number.
# * The tag records a release that FINISHED. A chain that died after the
# TestFlight upload leaves no tag, so this job says ship again and the
# retry hits ITMS-90189. Nothing local can detect a half-published
# release; bump the build number before retrying one.
#
# It reports rather than fails when there is nothing to ship. A merge that is
# not a release is the normal case, and a red X on `main` for every docs
# commit teaches everyone to stop reading main's status — the exact habit
# that let the deploy jobs skip unnoticed for a whole release cycle.
release-gate:
# The same ref/event test the five gates below carry (see ios-package).
# Repeating it here means a skipped gate — result `skipped`, not
# `success` — is a second, independent reason the deploy chain cannot run
# from a pull request or from a branch other than `main`.
#
# No `!cancelled()` here, unlike the five below, and that is not an
# oversight: this job has no `needs:`, so there is no upstream skip for
# GitHub's implicit `success()` to inherit and nothing to escape.
if: |
github.ref == 'refs/heads/main' &&
(github.event_name == 'push' || github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
# TWO answers, not one, because two different questions were being
# conflated and they have different ledgers and different stakes:
#
# deploy — is there a NEW BUILD to send to the stores? Ledger: the
# build NUMBER. Read by the four packaging and deploy jobs.
# Getting this wrong costs a red run and two store
# rejections; nothing is destroyed.
# publish — is `v<version-name>` still unclaimed? Ledger: that tag.
# Read by `github-release` and by nothing else, because that
# is the only destructive path in this workflow.
#
# The single `ship` output these replace answered only the second
# question and then gated the first on it, which is why a build-only
# bump — 2.2.0+63 -> 2.2.0+64, same version NAME, new binary — was
# refused: both derive `v2.2.0`. A build-only bump is a legitimate
# TestFlight release and must ship.
#
# Both are the literal string 'true' and nothing else, so the empty
# string a skipped or failed job leaves behind reads as "do not ship"
# without any extra handling.
deploy: ${{ steps.decide.outputs.deploy }}
publish: ${{ steps.decide.outputs.publish }}
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Decide what this push may deploy and what it may publish
id: decide
run: |
# Character-for-character the derivation `github-release` uses in its
# "Prepare release tag and asset names" step. The two are deliberately
# independent — passing the name between jobs would let an empty
# output fall through to action-gh-release's documented `tag_name`
# default of `github.ref_name`, i.e. a release tagged `main` — but
# they must stay in step. Edit one, edit the other.
APP_VERSION=$(awk '/^version:/ {print $2}' pubspec.yaml)
if [ -z "$APP_VERSION" ]; then
echo "::error::No 'version:' line in pubspec.yaml. Refusing to guess what this push would ship."
exit 1
fi
VERSION_NO_BUILD="${APP_VERSION%+*}"
BUILD_NUMBER="${APP_VERSION##*+}"
TAG_NAME="v${VERSION_NO_BUILD}"
BUILD_TAG="deployed/${BUILD_NUMBER}"
# The build number is compared numerically below, so it has to BE a
# number. `2.2.0` with no `+` would leave BUILD_NUMBER as the whole
# string and silently mis-answer; say so instead.
case "$BUILD_NUMBER" in
''|*[!0-9]*)
echo "::error::Build number '$BUILD_NUMBER' in version '$APP_VERSION' is not a plain integer. Refusing to guess whether this build has already shipped."
exit 1
;;
esac
# `git ls-remote --exit-code` rather than a REST call, because it
# separates the three answers that matter into distinct exit codes:
# 0 the ref exists, 2 it does not, anything else the question was
# never answered. A 404 body cannot be told apart from a transient
# failure that cheaply, and "could not reach the remote" must never
# read as "no such ref, go ahead".
#
# It queries the REMOTE, which is why `actions/checkout`'s shallow,
# tag-less fetch does not matter here — a local `git tag -l` would
# answer "no such tag" for everything and wave every push through.
#
# The pattern is a full ref path, which matches exactly. That is not
# decoration: `git ls-remote` matches a pattern "against the 'tail'
# of a ref, starting either from the start of the ref or from a slash
# separator", so the bare pattern `v1.3.2` MATCHES the existing
# `refs/tags/v1.3.2+53-build.629` while `refs/tags/v1.3.2` correctly
# exits 2. Always pass the full path.
#
# Sets a variable rather than echoing, so the `exit 1` below exits
# this step. Inside `VAR=$(ref_exists ...)` it would only end the
# command substitution's subshell.
ref_exists() {
set +e
git ls-remote --exit-code --tags origin "$1" >/dev/null 2>&1
ls_status=$?
set -e
case "$ls_status" in
0) REF_EXISTS=yes ;;
2) REF_EXISTS=no ;;
*)
echo "::error::Could not read tags from origin (git ls-remote exit $ls_status) while asking whether refs/tags/$1 exists. Refusing to guess whether this build has already shipped."
exit 1
;;
esac
}
ref_exists "refs/tags/$BUILD_TAG"
BUILD_SHIPPED="$REF_EXISTS"
ref_exists "refs/tags/$TAG_NAME"
TAG_TAKEN="$REF_EXISTS"
# THE LEDGER STARTS EMPTY, and an empty ledger says "never shipped"
# about builds that demonstrably have. The `deployed/<n>` tags are
# written by the deploy jobs below, and those jobs have never run
# under this design, so on the push that LANDS this change —
# pubspec.yaml still at 2.2.0+63, because a workflow fix does not
# bump the version — `deployed/63` does not exist and build 63 would
# read as new. Build 63 is on TestFlight and on the Play internal
# track right now. Re-pushing it means ITMS-90189 "Redundant Binary
# Upload" from Apple and "version code has already been used" from
# Play — and that second string is NOT one the #942 tolerance grep in
# android-deploy matches, so that job goes red rather than
# warning-green. Worse, it would not self-heal: the deploy fails, so
# no marker is written, so the next push repeats it forever.
#
# LAST_BUILD_BEFORE_LEDGER is the seed. It is the highest build
# number that shipped before this ledger existed; anything at or
# below it is already spoken for, tag or no tag. A constant rather
# than a tag pushed by hand before the merge, because a hand-pushed
# seed can be forgotten and forgetting it costs a red `main` and a
# burnt build number, whereas a constant lands atomically with the
# change and gets reviewed with it.
#
# It also closes a second hole this repo has actually fallen into:
# build numbers are NOT monotonic here. `git log -p pubspec.yaml`
# shows `1.3.1+51` and later `1.4.0+51` — the same Play versionCode
# twice — and `1.4.0+51` follows `1.3.2+55`, a regression of four. A
# ledger of marker tags cannot see a regression to a build that
# predates it; this line can.
#
# DO NOT BUMP IT. It records history, not the current build. Every
# build from 64 onward records itself.
LAST_BUILD_BEFORE_LEDGER=63
if [ "$BUILD_SHIPPED" = no ] && [ "$BUILD_NUMBER" -le "$LAST_BUILD_BEFORE_LEDGER" ]; then
BUILD_SHIPPED=yes
echo "::notice::Build $BUILD_NUMBER is at or below the pre-ledger floor ($LAST_BUILD_BEFORE_LEDGER), so it shipped before this ledger existed. Treating it as already deployed."
fi
# Written out rather than `[ "$X" = no ] && DEPLOY=true`. That idiom
# returns 1 on the false branch, and as the last command of a step it
# would fail the job under the `bash -e` GitHub runs `run:` with.
if [ "$BUILD_SHIPPED" = no ]; then DEPLOY=true; else DEPLOY=false; fi
if [ "$TAG_TAKEN" = no ]; then PUBLISH=true; else PUBLISH=false; fi
echo "deploy=$DEPLOY" >> "$GITHUB_OUTPUT"
echo "publish=$PUBLISH" >> "$GITHUB_OUTPUT"
# Four combinations, all reachable, each meaning something different
# to a human. Say which one this is, every time.
if [ "$DEPLOY" = true ] && [ "$PUBLISH" = true ]; then
echo "::notice::Full release: $APP_VERSION deploys to both stores and is published as $TAG_NAME."
{
echo "### Releasing \`$APP_VERSION\` as \`$TAG_NAME\`"
echo
echo "Neither \`$BUILD_TAG\` nor \`$TAG_NAME\` exists, so this push packages,"
echo 'deploys to both stores and cuts the GitHub release.'
echo
echo "Watch \`android-deploy\`'s step summary — the Play upload usually still"
echo 'needs doing by hand (#942).'
} >> "$GITHUB_STEP_SUMMARY"
elif [ "$DEPLOY" = true ]; then
echo "::notice::Build-only bump: $APP_VERSION deploys to both stores. $TAG_NAME is already published, so no GitHub release is cut and github-release is skipped ON PURPOSE."
{
echo "### Deploying build \`$BUILD_NUMBER\`, with no GitHub release"
echo
echo "\`$BUILD_TAG\` does not exist, so build \`$BUILD_NUMBER\` has never gone to"
echo 'the stores and this push ships it to TestFlight and Play. But'
echo "\`$TAG_NAME\` **already exists**, and \`softprops/action-gh-release\` does"
echo 'not fail on an existing tag — it updates the release in place, renames'
echo 'it and appends regenerated notes to the published body. So'
echo '`github-release` is skipped.'
echo
echo '**A skipped `github-release` on this path is the expected result, not the'
echo '#1008 failure it resembles.** A skipped job reports as Success, nothing'
echo 'in this workflow depends on `github-release`, and the run stays green.'
echo
echo '**Where the binaries are:** the `ios-ipa`, `android-aab` and'
echo '`android-apk` artifacts on this run page, kept for 90 days (the'
echo 'repository artifact retention setting). No release assets are published'
echo "or replaced, so the APK attached to \`$TAG_NAME\` still belongs to the"
echo 'build that release is named for. Do not "fix" that by re-running'
echo '`github-release`; that is the destructive path this gate exists to shut.'
} >> "$GITHUB_STEP_SUMMARY"
elif [ "$PUBLISH" = true ]; then
# Build already consumed, version name never published. There is no
# safe automatic answer here and both causes need a human, so fail
# rather than skip: a green run that quietly published nothing is
# exactly the shape of #1008, and this workflow does not get to
# look like that again.
echo "::error::Build $BUILD_NUMBER has already been deployed, but $TAG_NAME was never published. Nothing can ship from this run. See the step summary."
{
echo "### Refusing to run: build \`$BUILD_NUMBER\` is spent and \`$TAG_NAME\` is unpublished"
echo
echo "\`$BUILD_TAG\` exists (or the build is at or below the pre-ledger floor),"
echo "so build \`$BUILD_NUMBER\` has already gone to the stores — a Play"
echo "versionCode cannot be reused. But \`$TAG_NAME\` has no tag, so no GitHub"
echo 'release was ever cut for it.'
echo
echo 'There are two ways to get here, and they need different fixes:'
echo
echo '1. **The version NAME was bumped without the build number.** Bump the'
echo ' build number too, per `docs/RELEASING.md` — "Both halves. Nothing'
echo ' bumps it for you."'
echo '2. **A previous run deployed, then `github-release` failed, and someone'
echo ' used *Re-run all jobs*.** That re-runs this gate, which now sees the'
echo ' build recorded and shuts the whole chain. Use **Re-run failed jobs**'
echo ' on the ORIGINAL run instead — it reuses this job'"'"'s cached outputs'
echo ' and re-runs only what failed. If that run is gone, bump the build'
echo ' number and ship again, or attach the artifacts to a release by hand.'
} >> "$GITHUB_STEP_SUMMARY"
exit 1
else
echo "::notice::Nothing to ship: build $BUILD_NUMBER has already been deployed and $TAG_NAME is already released. The packaging and deploy jobs are skipped."
{
echo '### No release from this push'
echo
echo "\`pubspec.yaml\` is at \`$APP_VERSION\`; build \`$BUILD_NUMBER\` has already"
echo "been deployed and \`$TAG_NAME\` already exists, so this build has shipped"
echo 'and this version has been published. The packaging, deploy and release'
echo 'jobs are skipped; the build and test jobs ran as normal.'
echo
echo 'This is the expected result for a docs fix or any merge that did not'
echo 'bump the version. To ship, bump `version:` in `pubspec.yaml` per'
echo 'docs/RELEASING.md — the build number alone for a TestFlight/Play-only'
echo 'build, or both halves for a published release.'
} >> "$GITHUB_STEP_SUMMARY"
fi
# Deploy gate, 1 of 5 — read this one, the other four point back at it.
#
# Three things have to be true to package a release, and every one of them is
# load-bearing:
#
# 1. the run is on `main` — tested first, and for EVERY event;
# 2. the run is a push or a manual dispatch — a pull request must never
# touch signing material or a store;
# 3. every job in `needs:` actually SUCCEEDED.
#
# (1) is its own conjunct rather than half of (2) on purpose, and that is
# #1011. The guard used to read
#
# ((github.event_name == 'push' && github.ref == 'refs/heads/main') ||
# github.event_name == 'workflow_dispatch') && ...
#
# which pins the ref on the `push` arm ONLY. `workflow_dispatch:` accepts no
# branch filter — GitHub does not offer one — so the dispatch arm accepted
# any branch or tag in the repository, and a manual run from a topic branch
# got the full release: a TestFlight upload, a Play versionCode, and a `v*`
# tag. Not hypothetical: run 26086317616 was a `workflow_dispatch` on
# `integrate-cicd-deployment` and carried ios-package, android-package,
# ios-deploy, android-deploy AND github-release all to success.
#
# We got away with it — that head was later merged, and every tag this repo
# has cut points at a commit reachable from `main`. That was luck. Nothing
# in the old guard required it.
#
# The parentheses around the event test are load-bearing: `&&` binds tighter
# than `||`, so dropping them re-associates into
# `(ref == main && event == push) || (event == dispatch && needs...)`, which
# is the bug written out longhand.
#
# (2) used to be implicit: with no status check function in an `if:`, GitHub
# ANDs a `success()` onto it for free. That free check is what broke the
# 2.2.0 release. `ios-integration-tests-retry` is skipped on every healthy
# run — the first runner passed, so there is nothing to retry — and a skip
# travels the whole dependency chain, not one hop: "a failure or skip applies
# to all jobs in the dependency chain from the point of failure or skip
# onwards". `ios-integration-tests-result` steps out of the way of it with
# its own `if: !cancelled()`, but that exempts that one job; the skip keeps
# going. In push run 33547853809 every job below was green, this guard was
# true, and the entire deploy chain skipped anyway: no v2.2.0 tag, no GitHub
# release, nothing to TestFlight, no AAB — and the run still reported
# `success`, so nothing looked wrong.
#
# `!cancelled()` is the escape: a default status check of `success()` is
# applied unless the expression includes one of the status functions.
# Including one drops that implicit `success()` and with it the inherited
# skip. It is also why the gate then has to be written out by hand, one
# `result == 'success'` per dependency — a bare `!cancelled()` on its own
# would be strictly WORSE than the bug it fixes, because it would push an
# integration suite that failed on two independent runners straight to
# TestFlight and Play.
#
# Rules for anyone editing this:
# * every entry in `needs:` needs a matching line in `if:`. The implicit
# check is gone; an unmirrored dependency is an ungated dependency.
# * `== 'success'` only. `!= 'failure'` is true for `skipped` and
# `cancelled` too, which re-opens exactly this bug.
# * `!cancelled()`, never `always()` — `always()` runs even when the run
# was cancelled, i.e. it would deploy out of a run someone stopped.
# * keep the event test as an AND-conjunct. Dropping it, or ORing it in,
# deploys from pull requests.
ios-package:
if: |
github.ref == 'refs/heads/main' &&
(github.event_name == 'push' || github.event_name == 'workflow_dispatch') &&
!cancelled() &&
needs.release-gate.result == 'success' &&
needs.release-gate.outputs.deploy == 'true' &&
needs.linux-checks.result == 'success' &&
needs.ios-build.result == 'success' &&
needs.ios-integration-tests-result.result == 'success' &&
needs.android-build.result == 'success' &&
needs.android-integration-tests.result == 'success'
needs:
- release-gate
- linux-checks
- ios-build
- ios-integration-tests-result
- android-build
- android-integration-tests
runs-on: macos-26
permissions:
contents: read
env:
LANG: en_US.UTF-8
LC_ALL: en_US.UTF-8
BUNDLE_GEMFILE: ${{ github.workspace }}/ios/Gemfile
MATCH_READONLY: 'true'
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
MATCH_GIT_SSH_PRIVATE_KEY: ${{ secrets.MATCH_GIT_SSH_PRIVATE_KEY }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64: 'true'
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Ruby + Bundler
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Load SSH key for match repository
if: startsWith(env.MATCH_GIT_URL, 'git@') && env.MATCH_GIT_SSH_PRIVATE_KEY != ''
uses: webfactory/ssh-agent@v0.10.0
with:
ssh-private-key: ${{ secrets.MATCH_GIT_SSH_PRIVATE_KEY }}
- name: Install GitHub SSH host keys
if: startsWith(env.MATCH_GIT_URL, 'git@') && env.MATCH_GIT_SSH_PRIVATE_KEY != ''
run: |
mkdir -p ~/.ssh
# Fetch github.com's host keys with ssh-keyscan rather than the
# api.github.com/meta endpoint. That API is unauthenticated and
# rate-limited to 60 requests/hour per IP, and the shared macOS
# runner IP exhausts it — the step fails intermittently with
# "403 rate limit exceeded". ssh-keyscan talks to github.com:22
# directly and has no such limit.
ssh-keyscan -t rsa,ecdsa,ed25519 github.com >> ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
- name: Cache CocoaPods
uses: actions/cache@v6
with:
path: ios/Pods
key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-pods-
- name: Install FVM
run: |
curl -fsSL https://fvm.app/install.sh | bash
echo "$HOME/fvm/bin" >> "$GITHUB_PATH"
- name: Install Flutter version for FVM
run: fvm install 3.44.6
- name: Generate .env from GitHub Secrets
uses: ./.github/actions/write-env-file
env:
SENTRY_DNS: ${{ secrets.SENTRY_DNS }}
SUPABASE_PROJECT_URL: ${{ secrets.SUPABASE_PROJECT_URL }}
SUPABASE_PROJECT_ANON_KEY: ${{ secrets.SUPABASE_PROJECT_ANON_KEY }}
with:
sentry_dns: ${{ env.SENTRY_DNS }}
supabase_project_url: ${{ env.SUPABASE_PROJECT_URL }}
supabase_project_anon_key: ${{ env.SUPABASE_PROJECT_ANON_KEY }}
- name: Install Flutter packages
run: fvm flutter pub get
- name: Generate code
run: fvm dart run build_runner build --delete-conflicting-outputs
# The Podfile's Flutter post-install hook needs the iOS engine
# artifacts (Flutter.xcframework). The setup-flutter-cache action used
# by ios-build precaches them, but this job installs Flutter via FVM
# (for parity with local dev) which does not, so `pod install` fails
# with "Flutter.xcframework must exist ... run flutter precache --ios
# first". Precache the iOS engine before pods.
- name: Precache iOS Flutter engine
run: fvm flutter precache --ios
- name: Install CocoaPods dependencies
working-directory: ios
run: bundle exec pod install
- name: Build signed iOS IPA via Fastlane
working-directory: ios
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_P8: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }}
run: bundle exec fastlane ios build
- name: Upload IPA artifact
uses: actions/upload-artifact@v7
with:
name: ios-ipa
path: build/ios/ipa/*.ipa
if-no-files-found: error
# Deploy gate, 3 of 5. See ios-package. Fixing only the package jobs would not
# help: the skip from `ios-integration-tests-retry` reaches this job too, so
# the packages would go green and this would still silently skip.
ios-deploy:
if: |
github.ref == 'refs/heads/main' &&
(github.event_name == 'push' || github.event_name == 'workflow_dispatch') &&
!cancelled() &&
needs.release-gate.result == 'success' &&
needs.release-gate.outputs.deploy == 'true' &&
needs.ios-package.result == 'success' &&
needs.android-package.result == 'success'
# Deploy only when BOTH platforms packaged successfully (see android-deploy)
# so a one-sided failure never publishes half a release.
needs:
- release-gate
- ios-package
- android-package
# Must run on macOS. The TestFlight upload goes through Apple's iTunes
# Transporter, which needs the macOS toolchain (xcrun/altool); on
# ubuntu it failed with "No such file or directory @ dir_chdir0" as the
# transporter couldn't resolve its working directory. The IPA is built
# in ios-package and handed off as an artifact, so this job only uploads.
runs-on: macos-26
permissions:
# Widened from `read` for the ledger step at the end of this job,
# which pushes one lightweight tag with the GITHUB_TOKEN credentials
# actions/checkout persists. Nothing else in this job writes to the repo.
contents: write
env:
LANG: en_US.UTF-8
LC_ALL: en_US.UTF-8
BUNDLE_GEMFILE: ${{ github.workspace }}/ios/Gemfile
MATCH_READONLY: 'true'
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
MATCH_GIT_SSH_PRIVATE_KEY: ${{ secrets.MATCH_GIT_SSH_PRIVATE_KEY }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64: 'true'
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Ruby + Bundler
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Download iOS IPA artifact
uses: actions/download-artifact@v8
with:
name: ios-ipa
path: build/ios/ipa
- name: Deploy iOS build to TestFlight via Fastlane
working-directory: ios
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_P8: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }}
run: bundle exec fastlane ios beta_from_ipa
# Writes the ledger `release-gate` reads. Last step of the job, and of
# the sibling deploy job too — both push it, deliberately.
#
# LAST, not first: a job that dies before the upload leaves no marker, so
# docs/RELEASING.md's "re-running is the fix" for a `startup_failure`
# still works. A marker written up front would record builds that never
# left the runner.
#
# BOTH deploy jobs push it, rather than one shared job downstream,
# because the tag means "a store has consumed this build number" and
# consumption is per-store. If only the other job pushed and this half
# were the one that failed, a retry would re-upload against a build
# number the other store has already taken. Two writers means the record
# survives either half failing — which is the honest answer, because a
# half-failed deploy genuinely cannot be retried at the same build
# number. Bump the build number and ship again.
#
# The race between the two jobs is a no-op: both tag the same
# `github.sha`, so the loser's push reports "Everything up-to-date" and
# exits 0. A push at a DIFFERENT sha would be rejected, which is why
# there is no `--force` here — that would mean the gate had let an
# already-deployed build through, and that needs a human.
#
# Pushing a tag cannot retrigger this workflow, twice over: `on.push`
# declares only `branches:`, and "if you define only tags/tags-ignore or
# only branches/branches-ignore, the workflow won't run for events
# affecting the undefined Git ref"; and pushes made with the GITHUB_TOKEN
# do not create workflow runs at all. No `[skip ci]` guard is needed.
#
# `deployed/<n>`, not `v<name>+<n>`: `v2.2.0+64` is valid
# semver-with-build-metadata, and GitHub picks the previous tag for
# `generate_release_notes` on a semver-ish heuristic, so it could become
# the comparison base for a later release and silently truncate its
# changelog. `deployed/64` is not semver-shaped and is keyed on the build
# number ALONE — a Play versionCode is globally unique for the app
# forever, so the number is the thing that is spent, independent of which
# version name it shipped under. `git ls-remote --tags origin
# 'refs/tags/deployed/*'` is the whole ledger.
- name: Record that this build number has been deployed
run: |
BUILD_TAG="deployed/$(awk '/^version:/ {print $2}' pubspec.yaml | sed 's/.*+//')"
# Lightweight tag: needs no user.name/user.email on the runner, and
# there is nothing worth annotating. The push uses the credentials
# actions/checkout persists by default, which is the only reason this
# job carries `contents: write`.
git tag -f "$BUILD_TAG"
if git push origin "refs/tags/$BUILD_TAG"; then
echo "::notice::Recorded $BUILD_TAG. No later push will deploy build number ${BUILD_TAG##*/} again."
exit 0
fi
# A rejected push is not automatically a failure — the sibling deploy
# job may simply have won the race. Re-ask the remote instead of
# parsing git's message, and fail only if the marker genuinely is not
# there.
if git ls-remote --exit-code --tags origin "refs/tags/$BUILD_TAG" >/dev/null 2>&1; then
echo "::notice::$BUILD_TAG was already recorded (the sibling deploy job, or a concurrent run, won the race). The build number is spent either way, which is all the ledger claims."
exit 0
fi
echo "::error::Could not record $BUILD_TAG. This build HAS been uploaded but the ledger does not say so, so the next push to main would upload it again. Create the tag by hand before merging anything else: git tag $BUILD_TAG ${{ github.sha }} && git push origin $BUILD_TAG"
exit 1
# Deploy gate, 2 of 5 — identical to ios-package by design: both package jobs
# gate the same release on the same evidence, so they must stay in step. See
# ios-package for why each dependency is checked by result instead of being
# left to GitHub's implicit `success()`.
android-package:
if: |
github.ref == 'refs/heads/main' &&
(github.event_name == 'push' || github.event_name == 'workflow_dispatch') &&
!cancelled() &&
needs.release-gate.result == 'success' &&
needs.release-gate.outputs.deploy == 'true' &&
needs.linux-checks.result == 'success' &&
needs.ios-build.result == 'success' &&
needs.ios-integration-tests-result.result == 'success' &&
needs.android-build.result == 'success' &&
needs.android-integration-tests.result == 'success'
needs:
- release-gate
- linux-checks
- ios-build
- ios-integration-tests-result
- android-build
- android-integration-tests
runs-on: ubuntu-latest
permissions:
contents: read
env:
BUNDLE_GEMFILE: ${{ github.workspace }}/android/Gemfile
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Java (Zulu)
id: setup_java_zulu
uses: actions/setup-java@v5.6.0
continue-on-error: true
with:
distribution: 'zulu'
java-version: '17'
# Azul's download CDN intermittently returns HTTP 520, which fails the
# whole job at the JDK-fetch step. Fall back to Temurin only when the
# Zulu setup failed, so a single vendor's CDN blip no longer reds the
# build. It now takes both vendors being down at once.
- name: Setup Java (Temurin fallback)
if: steps.setup_java_zulu.outcome == 'failure'
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Ruby + Bundler
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Setup Flutter + cache packages
uses: ./.github/actions/setup-flutter-cache
- name: Cache Gradle
uses: actions/cache@v6
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/*.gradle*', 'android/**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Generate .env from GitHub Secrets
uses: ./.github/actions/write-env-file
env:
SENTRY_DNS: ${{ secrets.SENTRY_DNS }}
SUPABASE_PROJECT_URL: ${{ secrets.SUPABASE_PROJECT_URL }}
SUPABASE_PROJECT_ANON_KEY: ${{ secrets.SUPABASE_PROJECT_ANON_KEY }}
with:
sentry_dns: ${{ env.SENTRY_DNS }}
supabase_project_url: ${{ env.SUPABASE_PROJECT_URL }}
supabase_project_anon_key: ${{ env.SUPABASE_PROJECT_ANON_KEY }}
- name: Restore Android signing material
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
: "${ANDROID_KEYSTORE_BASE64:?Missing ANDROID_KEYSTORE_BASE64 secret}"
: "${ANDROID_KEYSTORE_PASSWORD:?Missing ANDROID_KEYSTORE_PASSWORD secret}"
: "${ANDROID_KEY_ALIAS:?Missing ANDROID_KEY_ALIAS secret}"
: "${ANDROID_KEY_PASSWORD:?Missing ANDROID_KEY_PASSWORD secret}"
mkdir -p android/app
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > android/app/upload-keystore.jks
cat > android/key.properties <<EOF
storePassword=$ANDROID_KEYSTORE_PASSWORD
keyPassword=$ANDROID_KEY_PASSWORD
keyAlias=$ANDROID_KEY_ALIAS
storeFile=../app/upload-keystore.jks
EOF
- name: Install Flutter packages
run: flutter pub get
- name: Generate code
run: dart run build_runner build --delete-conflicting-outputs
- name: Build Android App Bundle
# --flavor full is the production-equivalent applicationId
# (no .develop suffix); Play Store distribution requires this
# flavor.
run: flutter build appbundle --release --flavor full
- name: Build Android APK
run: flutter build apk --release --flavor full
- name: Upload Android AAB artifact
# The full flavor's AAB keeps the historic `app-release.aab`
# filename via the rename in android/app/build.gradle so the
# fastlane lane's default path and the GitHub release asset list
# keep working unchanged.
uses: actions/upload-artifact@v7
with:
name: android-aab
path: build/app/outputs/bundle/fullRelease/app-release.aab
if-no-files-found: error
- name: Upload Android APK artifact
# The Gradle outputFileName rename in android/app/build.gradle
# keeps the historic `app-release.apk` filename, but only in
# Gradle's native output directory (apk/<flavor>/<buildType>/).
# Flutter's post-build copy at build/app/outputs/flutter-apk/
# bypasses the rename and writes `app-full-release.apk` there,
# so we point the upload at the Gradle dir where the name matches
# the historic GitHub release asset.
uses: actions/upload-artifact@v7
with:
name: android-apk
path: build/app/outputs/apk/full/release/app-release.apk
if-no-files-found: error
# Deploy gate, 4 of 5. See ios-package. The two `result == 'success'` lines
# are also what now enforces the both-platforms rule described below — it is
# stated outright here rather than left to an implicit check that a future
# `needs:` edit could quietly widen.
android-deploy:
if: |
github.ref == 'refs/heads/main' &&
(github.event_name == 'push' || github.event_name == 'workflow_dispatch') &&
!cancelled() &&
needs.release-gate.result == 'success' &&
needs.release-gate.outputs.deploy == 'true' &&
needs.android-package.result == 'success' &&
needs.ios-package.result == 'success'
# Deploy only when BOTH platforms packaged successfully. A half-success
# (e.g. iOS packaging fails) would otherwise still upload the Android AAB,
# consuming a Play versionCode and forcing a build-number bump on the
# retry. Gating both deploys on both package jobs keeps a release atomic.
needs:
- release-gate
- android-package
- ios-package
runs-on: ubuntu-latest
permissions:
# Widened from `read` for the ledger step at the end of this job,
# which pushes one lightweight tag with the GITHUB_TOKEN credentials
# actions/checkout persists. Nothing else in this job writes to the repo.
contents: write
env:
BUNDLE_GEMFILE: ${{ github.workspace }}/android/Gemfile
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Ruby + Bundler
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Download Android AAB artifact
uses: actions/download-artifact@v8
with:
name: android-aab
path: release-assets/android
# Tolerates exactly one upstream defect, and nothing else.
#
# Since 2.1.0 the bundle declares `android.permission.health.*`, and the
# Play Publishing API rejects health-permission bundles at
# `EditService.Validate`/`Commit` with "You must let us know whether your
# app includes any health features" — **regardless of the declaration**,
# which is complete and was re-verified across four console surfaces
# (#942). Uploading the same bundle by hand through the console asks no
# health question at all and succeeds. It is a known, open, unowned
# defect: fastlane#22204 was closed unfixed, fastlane#27960 reopened it,
# and expo/eas-cli#3275 reports it from a different toolchain, which
# rules out fastlane's request construction as the sole cause.
#
# So the attempt stays. Deleting the step would go silently green and
# nobody would notice the day Google fixes it; `continue-on-error` would
# swallow real failures too. Matching the one error string keeps every
# other failure — bad credentials, a consumed versionCode, a rejected
# bundle — loud, and lets this heal itself with no further change.
#
# Note what a failing upload also costs, learned the hard way in #959:
# the API never reaches the point where Play returns its release
# warnings, so a minSdk bump that dropped 1,399 device models went
# unseen for eight days. The manual upload is where those warnings
# appear, which is why the summary below insists on reading them.
- name: Upload Android App Bundle to Google Play Internal Testing via Fastlane
working-directory: android
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
ANDROID_AAB_PATH: ${{ github.workspace }}/release-assets/android/app-release.aab
run: |
set +e
log="$RUNNER_TEMP/play-upload.log"
bundle exec fastlane android internal 2>&1 | tee "$log"
status=${PIPESTATUS[0]}
set -e
[ "$status" -eq 0 ] && exit 0
if ! grep -qF \
'You must let us know whether your app includes any health features' \
"$log"; then
echo "::error::Play upload failed for a reason other than the known health-declaration defect (#942). Not tolerated."
exit "$status"
fi
echo "::warning::Play rejected the API upload with the known health-declaration defect (#942). The Android bundle needs uploading by hand; iOS is unaffected."
{
echo '### Android upload needs doing by hand'
echo
echo 'The Play Publishing API rejected this bundle with:'
echo
echo '> Google Api Error: Invalid request - You must let us know whether your app includes any health features.'
echo
echo 'This is [#942](https://github.com/simonoppowa/OpenNutriTracker/issues/942):'
echo 'a known upstream defect, not a missing declaration. The declaration is'
echo 'complete, and the console asks no health question when the same bundle is'
echo 'uploaded by hand.'
echo
echo '**To finish the release:**'
echo
echo '1. Download the `android-aab` artifact from this run, or take the AAB'
echo ' (a full release also attaches it to the GitHub release; a build-only'
echo ' bump creates none, so the run artifact is the only copy).'
echo '2. Play Console → Testen und veröffentlichen → Interner Test →'
echo ' **Neuen Release erstellen**, and drop the AAB in.'
echo '3. **Read the warnings on the review step before publishing.** This is the'
echo ' only place Play reports them, and a failing API upload never gets far'
echo ' enough to return them — which is how the minSdk regression in'
echo ' [#959](https://github.com/simonoppowa/OpenNutriTracker/issues/959) went'
echo ' unnoticed for eight days.'
echo
echo 'This step will start passing on its own once Google fixes the API; nothing'
echo 'here needs changing when that happens.'
} >> "$GITHUB_STEP_SUMMARY"
# Writes the ledger `release-gate` reads. Last step of the job, and of
# the sibling deploy job too — both push it, deliberately.
#
# LAST, not first: a job that dies before the upload leaves no marker, so
# docs/RELEASING.md's "re-running is the fix" for a `startup_failure`
# still works. A marker written up front would record builds that never
# left the runner.
#
# BOTH deploy jobs push it, rather than one shared job downstream,
# because the tag means "a store has consumed this build number" and
# consumption is per-store. If only the other job pushed and this half
# were the one that failed, a retry would re-upload against a build
# number the other store has already taken. Two writers means the record
# survives either half failing — which is the honest answer, because a
# half-failed deploy genuinely cannot be retried at the same build
# number. Bump the build number and ship again.
#
# The race between the two jobs is a no-op: both tag the same
# `github.sha`, so the loser's push reports "Everything up-to-date" and
# exits 0. A push at a DIFFERENT sha would be rejected, which is why
# there is no `--force` here — that would mean the gate had let an
# already-deployed build through, and that needs a human.
#
# Pushing a tag cannot retrigger this workflow, twice over: `on.push`
# declares only `branches:`, and "if you define only tags/tags-ignore or
# only branches/branches-ignore, the workflow won't run for events
# affecting the undefined Git ref"; and pushes made with the GITHUB_TOKEN
# do not create workflow runs at all. No `[skip ci]` guard is needed.
#
# `deployed/<n>`, not `v<name>+<n>`: `v2.2.0+64` is valid
# semver-with-build-metadata, and GitHub picks the previous tag for
# `generate_release_notes` on a semver-ish heuristic, so it could become
# the comparison base for a later release and silently truncate its
# changelog. `deployed/64` is not semver-shaped and is keyed on the build
# number ALONE — a Play versionCode is globally unique for the app
# forever, so the number is the thing that is spent, independent of which
# version name it shipped under. `git ls-remote --tags origin
# 'refs/tags/deployed/*'` is the whole ledger.
- name: Record that this build number has been deployed
run: |
BUILD_TAG="deployed/$(awk '/^version:/ {print $2}' pubspec.yaml | sed 's/.*+//')"
# Lightweight tag: needs no user.name/user.email on the runner, and
# there is nothing worth annotating. The push uses the credentials
# actions/checkout persists by default, which is the only reason this
# job carries `contents: write`.
git tag -f "$BUILD_TAG"
if git push origin "refs/tags/$BUILD_TAG"; then
echo "::notice::Recorded $BUILD_TAG. No later push will deploy build number ${BUILD_TAG##*/} again."
exit 0
fi
# A rejected push is not automatically a failure — the sibling deploy
# job may simply have won the race. Re-ask the remote instead of
# parsing git's message, and fail only if the marker genuinely is not
# there.
if git ls-remote --exit-code --tags origin "refs/tags/$BUILD_TAG" >/dev/null 2>&1; then
echo "::notice::$BUILD_TAG was already recorded (the sibling deploy job, or a concurrent run, won the race). The build number is spent either way, which is all the ledger claims."
exit 0
fi
echo "::error::Could not record $BUILD_TAG. This build HAS been uploaded but the ledger does not say so, so the next push to main would upload it again. Create the tag by hand before merging anything else: git tag $BUILD_TAG ${{ github.sha }} && git push origin $BUILD_TAG"
exit 1
# Deploy gate, 5 of 5, and it needs the same treatment rather than inheriting
# a fix from its parents — the skip propagates past them to here. The two
# result checks also preserve behaviour this job already got right: in run
# 33243607505 android-deploy FAILED and this job correctly skipped, so no tag
# and no release was cut for a half-published build. A bare `!cancelled()`
# here would have tagged and published it.
github-release:
if: |
github.ref == 'refs/heads/main' &&
(github.event_name == 'push' || github.event_name == 'workflow_dispatch') &&
!cancelled() &&
needs.release-gate.result == 'success' &&
needs.release-gate.outputs.publish == 'true' &&
needs.ios-deploy.result == 'success' &&
needs.android-deploy.result == 'success'
needs:
- release-gate
- ios-deploy
- android-deploy
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Download iOS IPA artifact
uses: actions/download-artifact@v8
with:
name: ios-ipa
path: release-assets/ios
- name: Download Android AAB artifact
uses: actions/download-artifact@v8
with:
name: android-aab
path: release-assets/android
- name: Download Android APK artifact
uses: actions/download-artifact@v8
with:
name: android-apk
path: release-assets/android
- name: Prepare release tag and asset names
run: |
APP_VERSION=$(awk '/^version:/ {print $2}' pubspec.yaml)
VERSION_NO_BUILD="${APP_VERSION%+*}"
BUILD_NUMBER="${APP_VERSION##*+}"
TAG_NAME="v${VERSION_NO_BUILD}"
RELEASE_NAME="Release v${VERSION_NO_BUILD} (build ${BUILD_NUMBER})"
echo "TAG_NAME=$TAG_NAME" >> "$GITHUB_ENV"
echo "RELEASE_NAME=$RELEASE_NAME" >> "$GITHUB_ENV"
IPA_PATH=$(ls -1 release-assets/ios/*.ipa | head -n 1)
cp "$IPA_PATH" release-assets/opennutritracker.ipa
- name: Create GitHub release and upload assets
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ env.TAG_NAME }}
# Belt and braces behind `release-gate`'s `publish` output, which
# should mean this job never runs against an existing tag at all. If
# one ever slips through, the action does NOT fail on an existing
# release — it updates it in place, and `overwrite_files` defaults to
# true, which deletes the published IPA/AAB/APK and re-uploads
# binaries from a different commit while the tag stays where it was.
# GitHub does not version release assets, so that deletion is
# permanent.
#
# Do not read this as a tripwire, though — it is NOT one, and an
# earlier version of this comment claimed otherwise. In
# softprops/action-gh-release v3, `src/github.ts` handles the flag as
#
# if (config.input_overwrite_files === false) {
# console.log(`Asset ${name} already exists and overwrite_files is false...`);
# return null;
# }
#
# — it logs and returns. No throw, and the job stays GREEN. It also
# does not stop the rest of the existing-release path: `updateRelease`
# is still called with `name`, so the published release is RENAMED
# (`Release v2.2.0 (build 63)` becomes `(build 64)` over assets that
# are still build 63), and because `generate_release_notes: true`
# routes through `prepareReleaseMutation`, freshly generated notes are
# APPENDED to the existing body rather than replacing it.
#
# So this flag protects the three published binaries and nothing else.
# `publish` is the only thing that keeps this job away from a
# published release, which is why it is a hard gate and not advice.
overwrite_files: false
name: ${{ env.RELEASE_NAME }}
generate_release_notes: true
files: |
release-assets/opennutritracker.ipa
release-assets/android/app-release.aab
release-assets/android/app-release.apk
# Historical reference — the original single-step `just ci` invocation that
# was used before this workflow was split into linux-checks + ios-build.
# Kept here so reviewers can see how the secrets-templated form would look
# if/when real secrets are configured in repo Settings → Secrets and the
# stub .env approach above is replaced.
#
# - name: Run CI
# run: just ci
# env:
## SENTRY_DNS: ${{ secrets.SENTRY_DNS }}
# SUPABASE_PROJECT_ANON_KEY: ${{ secrets.SUPABASE_PROJECT_ANON_KEY }}
# SUPABASE_PROJECT_URL: ${{ secrets.SUPABASE_PROJECT_URL }}