Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
# The app release check compares the PR base (or previous main)
# against this checkout, including removals and renamed files.
fetch-depth: 0
# Before setup-node, whose pnpm cache needs pnpm on the path. Both
# versions come from server/package.json: pnpm's from `packageManager`,
# Node's from `devEngines.runtime`, which is what pnpm runs locally too.
Expand All @@ -40,6 +43,11 @@ jobs:
flutter-version: "3.47.4"
cache: true
- run: flutter pub get --enforce-lockfile
- name: Require a Cider release for app changes
if: github.event_name == 'pull_request' || github.event_name == 'push'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
run: dart run tool/check_app_release.dart "$BASE_SHA"
- run: flutter gen-l10n
- run: dart run build_runner build
- name: Regenerate committed database contracts
Expand Down Expand Up @@ -243,12 +251,12 @@ jobs:
# The web bundle only. Android used to be compiled here too, as a release
# APK, and it was by a wide margin the slowest thing in CI — a whole
# Gradle and NDK toolchain, stood up to prove something release proves
# again minutes later. Release calls this workflow and then builds the
# signed bundle from the same commit, and it deploys nothing if that
# fails, so main is never published from a tree Android cannot build.
# again minutes later. For an untagged app version, Release calls this
# workflow and builds the signed bundle from the same commit before
# deploying. Other merges deploy the web/backend without building Android.
#
# This job exists for pull requests, where release does not run and this
# is the only compile check there is. That is also exactly what dropping
# the APK costs: an Android-only break now surfaces on the push to main
# rather than on the pull request that introduced it.
# the APK costs: an Android-only break surfaces when the next app release
# is built rather than on the pull request that introduced it.
- run: dart run tool/build_web.dart --config=env/ci.example.json
94 changes: 59 additions & 35 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,9 @@ on:
workflow_dispatch:
inputs:
deploy:
description: Deploy to Cloudflare and Play (uncheck for first-upload artifacts only)
description: Deploy web/backend and a pending app version (uncheck for artifacts only)
type: boolean
default: false
update_priority:
description: >-
Play in-app update priority, 0-5. The app can offer an immediate
update at 4 or above when Play makes it available and permits the
flow. Use 4 for breaking releases; priority alone does not block
clients or protect data during review or installation.
type: choice
default: "0"
options: ["0", "1", "2", "3", "4", "5"]

permissions:
contents: read
Expand Down Expand Up @@ -48,6 +39,7 @@ jobs:
id-token: write
outputs:
version: ${{ steps.build.outputs.version }}
app_release: ${{ steps.build.outputs.app_release }}
env:
BUNDLE_FROZEN: "true"
FASTLANE_SKIP_UPDATE_CHECK: "true"
Expand All @@ -56,6 +48,9 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
# Find the last submitted app release along main's history, even
# after intervening backend-only merges or failed submissions.
fetch-depth: 0
- uses: pnpm/action-setup@v6
with:
package_json_file: server/package.json
Expand All @@ -66,13 +61,6 @@ jobs:
cache-dependency-path: |
server/pnpm-lock.yaml
service_worker/pnpm-lock.yaml
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: "17"
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.47.4"
Expand Down Expand Up @@ -102,8 +90,8 @@ jobs:
WEB_FCM_API_KEY, WEB_FCM_APP_ID
}' > env/app.json
- run: flutter pub get --enforce-lockfile
# One number for the whole release, given to the web bundle and the
# Android bundle alike, so About shows the same "1.2.0 (1701)" on both.
# One number per deployment. An app release gives the same number to
# web and Android; other merges only advance the web build number.
# The version name is pubspec.yaml's, set with cider; see "Versions" in
# docs/runbook.md.
- name: Number this build
Expand All @@ -123,10 +111,40 @@ jobs:
echo "number=$number" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Building $version ($number)."
# A tag records a successful Play submission. Keep a bumped version
# pending across superseding web-only merges and failed uploads.
git fetch origin --tags
app_release=true
if git show-ref --verify --quiet "refs/tags/v$version"; then
app_release=false
fi
if [[ "$app_release" == 'true' ]]; then
previous_tag="$(git describe --first-parent --tags --abbrev=0 \
--match 'v[0-9]*.[0-9]*.[0-9]*' HEAD)"
echo "previous_version=${previous_tag#v}" >> "$GITHUB_OUTPUT"
echo "Previous submitted app version: ${previous_tag#v}"
fi
echo "app_release=$app_release" >> "$GITHUB_OUTPUT"
build_android=$app_release
if [[ "$GITHUB_EVENT_NAME" == 'workflow_dispatch' ]]; then
build_android=true
fi
echo "BUILD_ANDROID=$build_android" >> "$GITHUB_ENV"
echo "Android release pending: $app_release"
- uses: ruby/setup-ruby@v1
if: env.BUILD_ANDROID == 'true'
with:
bundler-cache: true
- uses: actions/setup-java@v6
if: env.BUILD_ANDROID == 'true'
with:
distribution: temurin
java-version: "17"
# Before the slow build, so a changelog Play would refuse fails in
# seconds. Package the notes and artwork into the runner's temporary
# directory, leaving the checkout and generated screenshots untouched.
- name: Prepare the Play listing and release notes
if: env.BUILD_ANDROID == 'true'
run: |
dart run tool/play_metadata.dart --output="$RUNNER_TEMP/play-metadata"
cat "$RUNNER_TEMP/play-metadata/en-US/changelogs/default.txt"
Expand All @@ -136,6 +154,7 @@ jobs:
BUILD_NUMBER: ${{ steps.build.outputs.number }}
run: dart run tool/build_web.dart --build-number="$BUILD_NUMBER"
- name: Install Android upload key
if: env.BUILD_ANDROID == 'true'
env:
ANDROID_UPLOAD_KEYSTORE_BASE64: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_BASE64 }}
run: |
Expand All @@ -144,6 +163,7 @@ jobs:
| base64 --decode > "$RUNNER_TEMP/opensplit-upload.jks"
test -s "$RUNNER_TEMP/opensplit-upload.jks"
- name: Build signed Android bundle
if: env.BUILD_ANDROID == 'true'
env:
ANDROID_UPLOAD_KEYSTORE_PATH: ${{ runner.temp }}/opensplit-upload.jks
ANDROID_UPLOAD_STORE_PASSWORD: ${{ secrets.ANDROID_UPLOAD_STORE_PASSWORD }}
Expand All @@ -161,14 +181,21 @@ jobs:
- uses: actions/upload-artifact@v7
with:
name: opensplit-${{ github.sha }}-${{ github.run_attempt }}
path: build/web
if-no-files-found: error
retention-days: 30
- uses: actions/upload-artifact@v7
if: env.BUILD_ANDROID == 'true'
with:
name: opensplit-android-${{ github.sha }}-${{ github.run_attempt }}
path: |
build/web
build/app/outputs/bundle/release/app-release.aab
build/app/outputs/mapping/release
build/debug-info/android
if-no-files-found: error
retention-days: 30
- uses: actions/upload-artifact@v7
if: env.BUILD_ANDROID == 'true'
with:
name: opensplit-play-metadata-${{ github.sha }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/play-metadata
Expand Down Expand Up @@ -232,29 +259,26 @@ jobs:
echo "The deployed Worker did not answer /api/health." >&2
exit 1
- name: Authenticate to Google Play (keyless)
if: github.event_name == 'push' || inputs.deploy
if: steps.build.outputs.app_release == 'true' && (github.event_name == 'push' || inputs.deploy)
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ vars.PLAY_DEPLOY_SERVICE_ACCOUNT }}
project_id: ${{ vars.FIREBASE_PROJECT_ID }}
- name: Distribute to Play closed testing
if: github.event_name == 'push' || inputs.deploy
- name: Submit to Play production
if: steps.build.outputs.app_release == 'true' && (github.event_name == 'push' || inputs.deploy)
env:
PLAY_RELEASE_STATUS: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }}
# Google's default closed track is still called "alpha" in the
# Publishing API. Set the PLAY_TRACK repository variable if this
# project's closed track was created by hand and carries its own name.
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'alpha' }}
# Play's 0-5 in-app update priority, set per release and only
# settable through the Publishing API. The installed app chooses the
# update flow; priority alone cannot force installation or block use.
PLAY_UPDATE_PRIORITY: ${{ inputs.update_priority || vars.PLAY_UPDATE_PRIORITY || '0' }}
# Fastlane derives priority from the Cider version relative to the
# last submitted tag: major 5, minor 2, patch 0. This also preserves
# urgency across failed uploads and superseding backend-only merges.
PLAY_APP_VERSION: ${{ steps.build.outputs.version }}
PLAY_PREVIOUS_VERSION: ${{ steps.build.outputs.previous_version }}
# Written above, from CHANGELOG.md and the committed artwork.
# en-US is the store listing's language; images affect the listing
# across tracks, even though the bundle goes to closed testing.
# across tracks. The production lane fixes the destination and full
# rollout status; Play review and managed publishing still apply.
PLAY_METADATA_DIR: ${{ runner.temp }}/play-metadata
run: bundle exec fastlane android closed
run: bundle exec fastlane android production
- name: Remove temporary upload key
if: always()
run: rm -f -- "${RUNNER_TEMP:?}/opensplit-upload.jks"
Expand All @@ -264,7 +288,7 @@ jobs:
tag:
name: Tag the version
needs: publish
if: github.event_name == 'push' || inputs.deploy
if: (github.event_name == 'push' || inputs.deploy) && needs.publish.outputs.app_release == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
Expand Down
36 changes: 36 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,42 @@ practices. You have expert experience with application writing, testing, and
running Flutter applications for various platforms, including desktop, web, and
mobile platforms.

## OpenSplit release workflow

Read the runbook's [release lifecycle](docs/runbook.md#every-release) and
[versioning instructions](docs/runbook.md#versions) when preparing a PR.

* Every app-changing PR, including cosmetic fixes, app assets, native
configuration, generated API client changes and app dependency updates,
must include a Cider version bump and finalized release notes in that PR.
App dependency manifests and lockfiles count, including dev dependencies.
* As part of app-changing work, prepare the release with:

```sh
dart run cider log fixed 'Describe the user-visible change.'
dart run cider bump patch # minor for features
dart run cider release
dart run tool/store_artifacts.dart
```

* Commit the version, changelog and regenerated artwork with the app changes.
CI requires a higher stable version than the PR base and no remaining
Unreleased entries. If another app PR merges first, update the branch and
bump beyond main's new version. After committing, the local check is
`dart run tool/check_app_release.dart origin/main`.
* Backend-only, public website, docs, tests and release tooling changes need
no app version bump. If backend work also changes the generated Flutter
API client, follow the app release steps above.
* Merges to main automatically deploy the Worker and web bundle. Pending app
versions also submit Android to Play production. CI assigns build numbers
and creates release tags; Play review and publishing settings still apply.
Keep backend/schema changes compatible with installed older clients.
* Production Play priority is derived from the Cider version relative to the
last submitted release tag: major 5, minor 2, patch 0. Major updates request
Play's immediate flow; minor and patch updates keep a dismissible prompt.
This does not add a client lockout or guarantee installation. Manual
production runs follow the same version-based rule.

## Interaction Guidelines
* **User Persona:** Assume the user is familiar with programming concepts but
may be new to Dart.
Expand Down
26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,11 +225,19 @@ Configuration is injected at build time, from a file rather than a dozen
`--dart-define` flags on one command line — which is how a release ends up
built against the wrong backend.

A change someone using the app would notice also gets a line in
[CHANGELOG.md](CHANGELOG.md), which becomes the testers' notes in Play:
`dart run cider log fixed 'What changed, for a tester.'`. How versions are cut
and how builds are numbered is under *Versions* in
[docs/runbook.md](docs/runbook.md).
Every app-changing PR, including cosmetic fixes and app dependency updates,
needs a [CHANGELOG.md](CHANGELOG.md) entry with
`dart run cider log fixed 'What changed.'`, a version bump with
`dart run cider bump patch` (or minor/major), and `dart run cider release`.
CI checks the bump against the PR's base. Merging deploys the web/backend and
submits Android to Play production. Backend-only, website, documentation and
tooling PRs need no app bump and deploy without an Android submission, unless
an earlier app version is still awaiting a successful submission. See *Versions*
in [docs/runbook.md](docs/runbook.md).

Production Play priority follows the Cider bump from the last submitted release:
major 5 (immediate update flow), minor 2 and patch 0 (dismissible updates).
Play availability and eligibility still apply; users can cancel the update.

```bash
cp env/app.example.json env/app.json # gitignored; fill it in
Expand Down Expand Up @@ -306,9 +314,11 @@ package, and only `tool/build_web.dart` may produce a deployable web directory.
It verifies the configuration, builds the client, and then has Workbox precache
the finished release, bundling Firebase's public identifiers into the worker. CI uses structurally valid inert identifiers to prove
the release build. After every CI gate passes, pushes to `main` build with real
production variables, deploy the Worker and web bundle together, and distribute
a signed AAB and listing artwork to Play closed testing. Release reruns the same
CI checks before publishing. A manual **Release** run with **deploy unchecked**
production variables and deploy the Worker and web bundle together. When the
app version has no release tag yet, the workflow builds a signed AAB and submits
it with listing artwork to Play production for full rollout after Google approval.
Release reruns the same CI checks before deploying. A manual **Release** run
with **deploy unchecked**
creates artifacts only, for first-upload bootstrap.

Every integration is off unless configured, and hidden rather than shown broken:
Expand Down
Loading