Skip to content

docs(self-hosting): the app calls functions now, not tables #1254

docs(self-hosting): the app calls functions now, not tables

docs(self-hosting): the app calls functions now, not tables #1254

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/**'
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
ios-package:
if: |
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
github.event_name == 'workflow_dispatch'
needs:
- 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
ios-deploy:
if: |
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
github.event_name == 'workflow_dispatch'
# Deploy only when BOTH platforms packaged successfully (see android-deploy)
# so a one-sided failure never publishes half a release.
needs:
- 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:
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: 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
android-package:
if: |
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
github.event_name == 'workflow_dispatch'
needs:
- 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
android-deploy:
if: |
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
github.event_name == 'workflow_dispatch'
# 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:
- android-package
- ios-package
runs-on: ubuntu-latest
permissions:
contents: read
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 ' attached to the GitHub release this workflow creates.'
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"
github-release:
if: |
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
github.event_name == 'workflow_dispatch'
needs:
- 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 }}
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 }}