diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf86b09..a1bfd90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. @@ -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 @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef13ea3..3b38bf1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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" @@ -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 @@ -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" @@ -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 @@ -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" @@ -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: | @@ -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 }} @@ -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 @@ -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" @@ -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: diff --git a/AGENTS.md b/AGENTS.md index d4e4bd6..bbe5c61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 2504081..fe0bc00 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: diff --git a/docs/runbook.md b/docs/runbook.md index 39b8283..bdb2b1f 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -5,9 +5,9 @@ your own machine, how the production backend is stood up once, and what every release after that does. Commands that change something in a Cloudflare, Google, Resend, GitHub or Play -account are in the production sections, and only there. Nothing in the -repository runs them for you, apart from an explicitly dispatched **Release** -workflow with **deploy** checked. Pushes to `main` build artifacts only. +account are in the production sections. After CI passes, pushes to `main` +deploy the web/backend; an app version change also submits to Play production. +A manual **Release** run deploys only when **deploy** is checked. --- @@ -312,11 +312,19 @@ ANDROID_FCM_API_KEY ANDROID_FCM_APP_ID WEB_FCM_API_KEY WEB_FCM_APP_ID ``` -plus the Play ones (`PLAY_*`, `GCP_WORKLOAD_IDENTITY_PROVIDER`, and -`FIREBASE_PROJECT_ID`, which the Play step authenticates against). +plus `PLAY_DEPLOY_SERVICE_ACCOUNT`, `GCP_WORKLOAD_IDENTITY_PROVIDER`, and +`FIREBASE_PROJECT_ID`, which the Play step authenticates against. Production's +track and full rollout status are fixed in the Fastlane production lane; +`PLAY_TRACK` and `PLAY_RELEASE_STATUS` only configure manual testing lanes. +Production update priority comes from the Cider version compared with the +last submitted app release tag: major **5**, minor **2**, patch **0**. CI passes +`PLAY_APP_VERSION` and `PLAY_PREVIOUS_VERSION` to Fastlane, which computes the +priority. Automatic and manual production runs use the same rule; there is +no manual priority input. `PLAY_UPDATE_PRIORITY` only configures testing lanes. `dart run tool/verify_config.dart` checks the shape of every value. -From here on, a push to `main` releases. +From here on, a push to `main` deploys the web/backend. A changed app version +also submits an Android release to Play production. --- @@ -324,29 +332,70 @@ From here on, a push to `main` releases. A push to `main` runs `.github/workflows/release.yml`: -1. CI, the same jobs as on a pull request. +1. CI, the same jobs as on a pull request: app changes require a Cider version + increase and finalized release notes. On main, the comparison is against + the previous push; on PRs, it is against the PR base. 2. The resource-id check, before anything slow. -3. The build number, and Play metadata: release notes from `CHANGELOG.md` - (see *Versions*) plus the committed icon, feature graphic and screenshots. -4. The web bundle and the signed Android bundle, both carrying that number, - uploaded as artifacts alongside the prepared Play metadata. +3. The build number and complete web bundle, uploaded as an artifact. +4. When the app version has no `v` release tag, a signed Android bundle + and Play metadata: release notes from `CHANGELOG.md` (see *Versions*) plus artwork. + Android and web share the build number on these releases. 5. `wrangler d1 migrations apply opensplit --remote`: whatever is pending. 6. `wrangler deploy`: the script and the bundle, together, as one version. -7. A wait for `/api/health`, then Play closed testing, with the notes and listing - artwork. Fastlane skips unchanged images by checksum and preserves listing - text. The listing artwork is shared across tracks. -8. A `v` tag, the first time a build of that version ships. +7. A wait for `/api/health`, then, for a pending app version, a Play production + submission with status `completed`, the notes and artwork. This requests full + rollout subject to Google review and managed publishing. Fastlane skips + unchanged images by checksum and preserves listing text. +8. A `v` tag after an app release submission. This records that Play + accepted the upload; it does not mean review is complete. Web-only deployments + do not create app version tags. + +The tag is the release-completion marker. If a newer web-only merge supersedes +the version-bump build, or an upload fails, that app version stays pending for +the next successful run. Otherwise an ordinary merge could lose the requested +Android release. Once tagged, subsequent merges skip Android until the next bump. + +The previous version is the nearest release tag on main's first-parent history, +not the previous merge or the newest changelog section. A pending major followed +by a backend-only merge or an unsubmitted patch still gets major priority. A +patch after a successfully submitted major gets priority 0. The existing +`v4.1.0` testing release tag is the baseline for the first new production release. + +Every app-changing PR, including cosmetic fixes and app dependency updates, +needs a version bump and finalized notes. Its merge deploys the web/backend +and submits Android from the same commit. Website, backend-only, documentation, +tests and release tooling need no app bump; they skip Android unless a previous +app release is still pending. A backend change that needs a new client must +include that client change and its app version bump. + +`tool/check_app_release.dart` runs inside the required **Analyse and test** job. +It treats `lib/`, `assets/`, `android/`, `web/`, the generated API package's +`lib/`, and `service_worker/src/` as app inputs. App dependency manifests and +lockfiles, code generation/localization/icon/splash configuration, and service +worker build configuration also count. Dependency manifests are deliberately +conservative: even a dev-dependency-only edit to an app pubspec requires a bump. +Tests, store listing artwork, release scripts/workflows and public site files +are exempt. The check uses Cider to read the versions and changelog, requires +a newer stable version, and validates the released section through the existing +Play note formatter. Published changelog history is not audited. + +The current-main check skips superseded commits when merges overlap. A green +upload means Play accepted the submission, not that review has finished or +users have installed it. Additional submissions during review can delay approval. D1 migrations are applied before the Worker that needs them goes live. The Durable Objects migrate themselves, lazily, the first time each group is -opened after a deploy. A breaking API requires a coordinated pause that covers -Play review and installation, because old clients remain installed after the -backend changes. Arrange any required [storage reset](resetting-the-backend.md) -before merging a release that needs one. Normal schema changes use incremental -migrations; an initial schema must not be reapplied over existing databases. +opened after a deploy. Keep backend/schema changes compatible with installed +clients throughout Play review, rollout and delayed installation. A breaking +API needs an explicit transition plan before merge; an urgent update priority +does not provide compatibility. Normal schema changes use incremental migrations; +an initial schema or testing-era storage reset must not replace production data. A manual run (*Actions → Release → Run workflow*) with **deploy** unchecked -builds the artifacts without deploying anything. +builds web and Android artifacts without deploying anything. Checking **deploy** +deploys the web/backend and submits Android only if the app version is still +untagged. +Its production update priority is also derived from the version bump. --- @@ -355,7 +404,10 @@ builds the artifacts without deploying anything. Dependabot opens one weekly `routine-dependencies` pull request across the Flutter app, generated API package, Worker, service worker, GitHub Actions and Fastlane. Minor and patch updates share the full CI matrix and one merge to -`main`, which triggers one release. Grouping does not enable automatic merging. +`main`, which deploys the web/backend. A batch that changes app dependency +manifests or lockfiles needs a Cider bump and release, and also submits Android. +Backend-only or release-tool-only updates need no app bump. Grouping does not +enable automatic merging. The configuration is in [`.github/dependabot.yml`](../.github/dependabot.yml); see GitHub's [multi-ecosystem grouping documentation](https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/secure-your-dependencies/configuring-multi-ecosystem-updates). @@ -386,46 +438,73 @@ A build has two numbers, and only one of them is written down. [cider](https://pub.dev/packages/cider), which keeps `CHANGELOG.md` in step. - **The build number**, `1701`: what Play orders builds by, Android's `versionCode`. It is never written down. The release workflow works it out - and gives the same number to the web bundle and the Android bundle, so About - reads "Version 1.2.0 (1701)" on both, and a bug report names one build. + and gives the same number to web and Android on app releases. A web-only + deployment advances the web build number while Android keeps its last release. cider is a dev dependency, so `dart run cider` is the version `pubspec.lock` pins, the same everywhere. ### In every pull request -A change someone using the app would notice gets a line under *Unreleased*: +Every app-changing PR, including cosmetic fixes, gets an entry under *Unreleased*: ```sh dart run cider log fixed 'Totals no longer round twice.' # or added, changed, deprecated, removed, security ``` -Write it for a tester rather than for a reviewer: the lines become the build's -notes in Play. Refactors, tests and tooling get none. +Write it for a user: the lines become the release's notes in Play. Backend-only, +documentation, standalone website, tests and release tooling changes need no app +changelog entry or version bump. ### Cutting a version -When what is under *Unreleased* deserves a version of its own, and at the -latest before a build goes beyond closed testing: +Bump and finalize the version in that same app-changing PR: ```sh dart run cider bump minor # or patch, or major dart run cider release # Unreleased becomes this version's section, dated today +dart run tool/store_artifacts.dart ``` -Both files go in one pull request. A bump without the release leaves -`pubspec.yaml` naming a version the changelog does not have, and -`test/tool/release_notes_test.dart` fails the pull request for it. +The artwork provenance includes `pubspec.yaml`, so regenerate after every +version bump. This refreshes its manifest and any date-dependent screenshot data. -Which part to bump: patch when a version only fixes things, minor when it adds -anything, and major when it leaves older clients behind, such as a server change -they can no longer talk to. Send a breaking release with update priority 4 so -eligible clients request an immediate update when Play makes it available. -Priority does not replace the pause or verify that everyone has installed it. +Both files go in the PR. CI compares app inputs with the PR base and requires +a higher stable app version, a released section with nonempty Play-compatible +notes, and no remaining Unreleased entries. If another app PR merges first, +update your branch, bump beyond main's new version, and finalize the combined +notes again. Main requires branches to be up to date before merging. -Merges between versions keep the version's name and differ by build number; -"1.2.0 (1701)" and "1.2.0 (1804)" are two builds of 1.2.0. +To run the same check locally after committing your changes: + +```sh +dart run tool/check_app_release.dart origin/main +``` + +The release workflow reads the version with Cider and checks for its release +tag before building Android. Semantic bump size remains a review decision. + +Which part to bump: patch for fixes, minor for features, and major for breaking +changes. Fastlane maps the bump relative to the last submitted version to: + +| Bump | Play priority | App behavior when Play permits the flow | +|---|---|---| +| Major | 5 | Request the immediate update flow | +| Minor | 2 | Offer a dismissible flexible update | +| Patch | 0 | Keep the dismissible flexible update | + +Users can cancel the immediate flow; this policy does not add a client lockout. +Play reports the highest priority across releases skipped by an installed +client, so someone skipping a major can still see an immediate update when the +latest version is a patch. Priority does not bypass review, force installation, +or replace a compatibility plan for breaking backend changes. +See [Google's update-priority rules](https://developer.android.com/guide/playcore/in-app-updates/kotlin-java#check-priority) +for the behavior across skipped releases. + +Merges without a version bump keep the app version name. An app release or +manual artifact build receives its own build number; CI does not create +version-changing commits or push to `main`. ### What a release does with them @@ -449,8 +528,8 @@ screenshots byte-for-byte. The workflow passes that directory as `bundle exec ruby test/tool/fastlane_test.rb` to exercise the upload configuration locally without contacting Play. -**Tags.** The first build of each version tags its commit `v1.2.0`, and later -builds leave the tag where it was. The compare links at the foot of +**Tags.** The first successful submission of each version tags its commit +`v1.2.0`, and retries leave the tag where it was. The compare links at the foot of `CHANGELOG.md` point at those tags. `v1.0.0` marks the first build that reached Play. @@ -546,8 +625,23 @@ Anything else means links open in a browser instead of the app, silently. The release service account needs the app-level [Manage store presence](https://support.google.com/googleplay/android-developer/answer/9844686) permission as well as access to release on testing tracks, so Fastlane can -update the listing artwork. The upload does not change listing text or promote -the bundle to production. Play review and availability still apply. +update the listing artwork. For automatic production submissions, also grant +**Release to production, exclude devices, and use Play App Signing** under +**Play Console → Users and permissions → CI service account → App permissions → +OpenSplit**. Use the account named by `PLAY_DEPLOY_SERVICE_ACCOUNT`. + +Complete outstanding production setup and country selection before merging the +switch to production. In **Publishing overview**, turn **Managed publishing** +off for approved changes to publish automatically. Review pending changes first: +merging the production workflow can initiate the public launch. First production +releases have no percentage rollout; this workflow also requests 100% for updates. +Review or setup failures stay visible instead of silently saving an unsubmitted +release. The upload does not change listing text. + +The `closed` and `internal` Fastlane lanes remain available for manual testing; +their track/status overrides cannot redirect the production lane. Testing uploads +can still change the shared store artwork. Halting production stops further +distribution but leaves installed copies in place; fixes need a higher build code. Verify the feature graphic under **Grow users → Store presence → Main store listing → Graphics** after uploading and publishing. Its presence there is diff --git a/fastlane/Fastfile b/fastlane/Fastfile index ebae918..1aba342 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -1,61 +1,75 @@ default_platform(:android) platform :android do - # Which Play track a build goes to. - # - # "internal" is the one this project shipped to first: instant availability, - # no review, up to 100 testers. Closed testing is the next rung — it is - # reviewed, it is what Play's own progression asks you to graduate to, and it - # is where the app is now. - # # Google's *default* closed track is still called "alpha" in the Publishing # API, whatever the Play Console calls it on screen. A track created by hand # carries whatever name it was given, so this is configurable rather than # hardcoded: set PLAY_TRACK to that name and nothing else changes. DEFAULT_TRACK = "alpha".freeze - # Tracks you can reach from here. Naming them is what turns a typo into a - # refusal before an upload rather than an HTTP 400 after one — and keeps - # "production" out of reach of a workflow that is not meant to publish. - KNOWN_TRACKS = %w[internal alpha beta].freeze + # Testing lanes cannot publish to production through an environment override. + TEST_TRACKS = %w[internal alpha beta].freeze - # How badly a release wants to be installed, on Play's 0-5 scale. - # - # This is a property of the release, not of the app, which is why it is set - # here and not in Dart: the same build is urgent for somebody on a client the - # server can no longer talk to and ordinary for somebody already current. - # - # 0 is the default and means "take it when you like" -- the app downloads it - # in the background and offers a restart. 4 and 5 are the two Google - # documents as warranting a blocking flow, and AppUpdateService treats - # anything from 4 up as immediate. - # - # Reach for 4 only when a stale client is actively harmful. For this app that - # means a sync change the server no longer accepts, where the symptom is an - # expense that looks saved and silently is not. - # - # It can only be set through the Publishing API -- there is no field for it in - # the Play Console -- and it applies to the release it is uploaded with. It - # cannot be added to a release after the fact, so a build that needed it and - # did not get it needs another build. + # Production derives Play priority from the Cider version bump: major 5, + # minor 2, patch 0. Testing lanes retain an explicit environment override. + # The installed app requests Play's immediate flow at priority 4 or above; + # lower priorities, including patches, keep the dismissible flexible flow. + # Play can report a higher priority when a user skips an intervening release. + # Priority is set on upload and cannot be changed after rollout. It cannot + # force installation or replace backend compatibility with older clients. DEFAULT_PRIORITY = 0 desc "Distribute the signed CI bundle to Google Play closed testers" lane :closed do - priority = Integer(ENV.fetch("PLAY_UPDATE_PRIORITY", DEFAULT_PRIORITY)) - unless (0..5).cover?(priority) - UI.user_error!("PLAY_UPDATE_PRIORITY must be between 0 and 5") + track = ENV.fetch("PLAY_TRACK", DEFAULT_TRACK) + unless TEST_TRACKS.include?(track) + UI.user_error!("PLAY_TRACK must be one of #{TEST_TRACKS.join(', ')}") end + distribute(track: track, status: ENV.fetch("PLAY_RELEASE_STATUS", "completed")) + end - track = ENV.fetch("PLAY_TRACK", DEFAULT_TRACK) - unless KNOWN_TRACKS.include?(track) - UI.user_error!( - "PLAY_TRACK must be one of #{KNOWN_TRACKS.join(', ')}, or the lane " \ - "needs teaching about a custom track named #{track.inspect}" - ) + desc "Submit the signed CI bundle for full Google Play production release" + lane :production do + distribute(track: "production", status: "completed", priority: production_priority) + end + + private_lane :production_priority do + previous, current = %w[PLAY_PREVIOUS_VERSION PLAY_APP_VERSION].map do |name| + value = ENV.fetch(name, "") + unless value.match?(/\A\d+\.\d+\.\d+\z/) + UI.user_error!("#{name} must be a stable x.y.z version") + end + Gem::Version.new(value) + end + unless current > previous + UI.user_error!("PLAY_APP_VERSION must be newer than PLAY_PREVIOUS_VERSION") + end + priority = if current.segments[0] > previous.segments[0] + 5 + elsif current.segments[1] > previous.segments[1] + 2 + else + 0 + end + UI.message("Cider #{previous} -> #{current}: Play update priority #{priority}") + priority + end + + desc "Distribute the signed CI bundle to Google Play internal testers" + lane :internal do + distribute(track: "internal", status: ENV.fetch("PLAY_RELEASE_STATUS", "completed")) + end + + private_lane :distribute do |options| + priority = options.fetch(:priority) do + Integer(ENV.fetch("PLAY_UPDATE_PRIORITY", DEFAULT_PRIORITY), exception: false) + end + unless priority && (0..5).cover?(priority) + UI.user_error!("PLAY_UPDATE_PRIORITY must be between 0 and 5") end - status = ENV.fetch("PLAY_RELEASE_STATUS", "completed") + track = options.fetch(:track) + status = options.fetch(:status) unless %w[completed draft].include?(status) UI.user_error!("PLAY_RELEASE_STATUS must be completed or draft") end @@ -109,13 +123,4 @@ platform :android do rescue_changes_not_sent_for_review: false ) end - - # Kept so a build can still be put in front of the internal testers without - # editing this file — useful for a smoke test that should not spend a closed - # testing review. - desc "Distribute the signed CI bundle to Google Play internal testers" - lane :internal do - ENV["PLAY_TRACK"] = "internal" - closed - end end diff --git a/site/store/artifacts.json b/site/store/artifacts.json index 4f94702..6ae2493 100644 --- a/site/store/artifacts.json +++ b/site/store/artifacts.json @@ -1,7 +1,7 @@ { "version": 1, "inputs": { - ".github/workflows/ci.yml": "2e72f8aa1d99d3a27eee1a71a881c229c27e5ca299fcf3a029e088c26ad2ae1a", + ".github/workflows/ci.yml": "84836b704aee490b3f02bd1d329b607a903140b961bc843f02befe90bf4eed85", "assets/brand/lockup-horizontal.svg": "0d0867f40d2351996ff7a808751d7b6e44e65323e0b4894bff6f6647f032ba15", "assets/brand/lockup-vertical.svg": "c32df2988057fc53cd5502a1823f6ce06b41e64e724c09d3f6cf140f1353d9fd", "assets/brand/mark-1024-dark.png": "7e19e1c337a842a697b0128163e817e231653c9ae0df56f93d241258d36aed45", @@ -360,11 +360,11 @@ }, "outputs": { "site/store/screenshot-1-groups.png": "5a423c4ee53d798e8cc5c3d6c88c7c0463537b9496a5c3af209131e9a0adaa75", - "site/store/screenshot-2-expenses.png": "bf13ae85570f7b0fc498097d714aa8a4ac61a49c663cd4143708fd596f453365", + "site/store/screenshot-2-expenses.png": "0865049e8f6453ee82c5dcd2674c863210c5d7dc3a7ac81044395f7e7c1dab19", "site/store/screenshot-3-balances.png": "cf8b93928e508999a1996c9bf1a763aee59ec7b1539540cb57406d6359945915", "site/store/screenshot-4-add-expense.png": "d4ca87fb76b9cffbf8fcbd53d4064b669802f5e55d1a099aeaf4447f9ae43027", "site/store/screenshot-5-insights.png": "876fe78c5ce4952a58b27fc3171b3fed13fc0341fa0d0234413f27fc61ca30a9", - "site/store/web-app.png": "932618a787d758163203b922fc193157432f6b033f7a3d6b5f0879b768181925", + "site/store/web-app.png": "f480ff75b808f800ece8b8d0adabe7104b4efc37d1a44978d8b6c87f1902f5f2", "site/store/og-card.png": "b0a268e14bcdf8d3466d0bcb030ea9580994afa9b7737cd4e70347557a8e62e5", "site/store/feature-graphic.jpg": "0bd2040b384175626d695087aabc4771962201c3c5fe51a06ba7d0a570185a0d", "site/email/wordmark.png": "8c43232387a3464261b7b86957495f972a1b4540d122d0cbfb33e5b18ad50b4c" diff --git a/site/store/screenshot-2-expenses.png b/site/store/screenshot-2-expenses.png index 9328a9f..9cbc982 100644 Binary files a/site/store/screenshot-2-expenses.png and b/site/store/screenshot-2-expenses.png differ diff --git a/site/store/web-app.png b/site/store/web-app.png index bc79c87..425b859 100644 Binary files a/site/store/web-app.png and b/site/store/web-app.png differ diff --git a/test/tool/check_app_release_test.dart b/test/tool/check_app_release_test.dart new file mode 100644 index 0000000..373d365 --- /dev/null +++ b/test/tool/check_app_release_test.dart @@ -0,0 +1,196 @@ +import 'dart:io'; + +import 'package:test/test.dart'; + +import '../../tool/check_app_release.dart'; + +void main() { + late Directory repository; + late String base; + + Future git(List arguments) async { + final result = await Process.run('git', [ + '-c', + 'user.name=Release check test', + '-c', + 'user.email=release-check@example.invalid', + '-c', + 'commit.gpgsign=false', + ...arguments, + ], workingDirectory: repository.path); + expect(result.exitCode, 0, reason: '${result.stderr}'); + return '${result.stdout}'.trim(); + } + + Future write(String path, String contents) async { + final file = File('${repository.path}/$path'); + await file.parent.create(recursive: true); + await file.writeAsString(contents); + } + + Future commit() async { + await git(['add', '--all']); + await git(['commit', '--quiet', '-m', 'Fixture change']); + } + + Future version(String value, {String? changelog}) async { + await write('pubspec.yaml', 'name: fixture\nversion: $value\n'); + if (changelog != null) await write('CHANGELOG.md', changelog); + await commit(); + } + + Future check() => + checkAppRelease(base, workingDirectory: repository.path); + + String notes(String value) => + '# Changelog\n\n## $value - 2026-10-30\n' + '### Fixed\n- Clearer group names.\n'; + + setUp(() async { + repository = await Directory.systemTemp.createTemp('app-release-test-'); + await git(['init', '--quiet', '--initial-branch=main']); + await write('lib/app with spaces.dart', '// Original app.\n'); + await version('1.2.3', changelog: notes('1.2.3')); + base = await git(['rev-parse', 'HEAD']); + }); + + tearDown(() async => repository.delete(recursive: true)); + + test('backend, website, docs, tests and tooling need no app bump', () async { + for (final path in [ + 'server/src/index.ts', + 'server/package.json', + 'site/index.html', + 'site/store/artifacts.json', + 'README.md', + 'docs/openapi.json', + 'test/app_test.dart', + 'tool/build_web.dart', + '.github/workflows/release.yml', + 'fastlane/Fastfile', + 'packages/opensplit_api/README.md', + 'service_worker/test/push.test.ts', + ]) { + await write(path, 'Updated.\n'); + } + await commit(); + + expect(await check(), contains('No app changes')); + }); + + for (final path in [ + 'lib/presentation/theme.dart', + 'assets/brand/mark-mono.svg', + 'android/app/src/main/AndroidManifest.xml', + 'web/index.html', + 'packages/opensplit_api/lib/api.dart', + 'packages/opensplit_api/pubspec.yaml', + 'service_worker/src/sw.ts', + 'service_worker/pnpm-lock.yaml', + 'pubspec.lock', + 'flutter_native_splash.yaml', + ]) { + test('$path requires an app bump', () async { + await write(path, 'Updated.\n'); + await commit(); + + await expectLater(check(), throwsA(isA())); + }); + } + + test('patch, minor and major bumps pass with finalized notes', () async { + for (final next in ['1.2.4', '1.10.0', '2.0.0']) { + await git(['reset', '--hard', base]); + await version(next, changelog: notes(next)); + + expect(await check(), 'App release: 1.2.3 -> $next.'); + expect(await git(['status', '--porcelain']), isEmpty); + } + }); + + test('unchanged, lower, prerelease and build-only versions fail', () async { + for (final next in ['1.2.3', '1.2.2', '1.2.4-beta.1', '1.2.3+2']) { + await git(['reset', '--hard', base]); + await write('lib/new.dart', '// Changed app.\n'); + await version(next, changelog: notes(next)); + + await expectLater(check(), throwsA(isA())); + } + }); + + test('a bump without cider release fails', () async { + await version('1.2.4'); + + await expectLater( + check(), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('1.2.4 not found'), + ), + ), + ); + }); + + test('a released section with no notes fails', () async { + await version('1.2.4', changelog: '# Changelog\n## 1.2.4 - 2026-10-30\n'); + + await expectLater( + check(), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('no entries'), + ), + ), + ); + }); + + test('unfinalized notes fail even if the version has older notes', () async { + await version( + '1.2.4', + changelog: + '# Changelog\n## Unreleased\n### Fixed\n- Pending app change.\n\n' + '${notes('1.2.4').replaceFirst('# Changelog\n', '')}', + ); + + await expectLater( + check(), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Finalize Unreleased'), + ), + ), + ); + }); + + test('moving an app file into docs still requires a release', () async { + await Directory('${repository.path}/docs').create(); + await File( + '${repository.path}/lib/app with spaces.dart', + ).rename('${repository.path}/docs/app with spaces.dart'); + await commit(); + + await expectLater(check(), throwsA(isA())); + }); + + test('a bump already merged into main cannot cover another PR', () async { + await version('1.2.4', changelog: notes('1.2.4')); + base = await git(['rev-parse', 'HEAD']); + await write('lib/another.dart', '// Another app change.\n'); + await commit(); + + await expectLater(check(), throwsA(isA())); + }); + + test('an unavailable base fails instead of skipping validation', () async { + await expectLater( + checkAppRelease('missing-ref', workingDirectory: repository.path), + throwsA(isA()), + ); + }); +} diff --git a/test/tool/fastlane_test.rb b/test/tool/fastlane_test.rb index 79ec583..b7b748f 100644 --- a/test/tool/fastlane_test.rb +++ b/test/tool/fastlane_test.rb @@ -3,6 +3,8 @@ require "tmpdir" require "fileutils" require "fastlane" +require "open3" +require "yaml" Fastlane::Actions.load_default_actions uploads = [] @@ -14,6 +16,23 @@ def assert(condition, message) raise message unless condition end +def run_command(*arguments, **options) + output, status = Open3.capture2e(*arguments, **options) + assert(status.success?, "#{arguments.inspect} failed: #{output}") + output.strip +end + +def refuses_upload(fastfile, uploads, lane, message) + before = uploads.size + begin + fastfile.runner.execute(lane, :android) + raise "Expected #{lane} to refuse #{message}" + rescue FastlaneCore::Interface::FastlaneError, ArgumentError => error + assert(error.message.include?(message), "Unexpected error: #{error.message}") + end + assert(uploads.size == before, "Invalid inputs reached the upload action") +end + repository = File.expand_path("../..", __dir__) original_environment = ENV.to_h temporary = Dir.mktmpdir("opensplit-fastlane-") @@ -43,6 +62,8 @@ def assert(condition, message) ENV["PLAY_TRACK"] = "alpha" ENV["PLAY_RELEASE_STATUS"] = "completed" ENV["PLAY_UPDATE_PRIORITY"] = "0" + ENV["PLAY_PREVIOUS_VERSION"] = "4.1.0" + ENV["PLAY_APP_VERSION"] = "4.1.1" Dir.chdir(temporary) do fastfile = Fastlane::FastFile.new("fastlane/Fastfile") @@ -58,16 +79,136 @@ def assert(condition, message) assert(options[:sync_image_upload], "Unchanged artwork should be skipped") assert(options[:rescue_changes_not_sent_for_review] == false, "Review failures must remain visible") - File.delete(File.join(metadata, "en-US/images/phoneScreenshots/5.png")) - begin - fastfile.runner.execute(:closed, :android) - raise "Missing artwork should refuse the upload" - rescue FastlaneCore::Interface::FastlaneError => error - assert(error.message.include?("PLAY_METADATA_DIR"), "Expected an actionable artwork error") + # Production's track and status cannot drift through old repository vars. + ENV["PLAY_TRACK"] = "alpha" + ENV["PLAY_RELEASE_STATUS"] = "draft" + ENV.delete("PLAY_UPDATE_PRIORITY") + fastfile.runner.execute(:production, :android) + options = uploads.last + assert(options[:track] == "production", "Production lane must publish to production") + assert(options[:release_status] == "completed", "Production must request full rollout") + assert(options[:rollout].nil?, "Production must not leave a staged rollout") + assert(options[:in_app_update_priority] == 0, "Patch releases must have priority 0") + assert(File.realpath(options[:aab]) == File.realpath(bundle), "Production must use the signed CI bundle") + assert(options[:metadata_path] == metadata, "Production must include the prepared notes and artwork") + assert(options[:rescue_changes_not_sent_for_review] == false, "Production review failures must remain visible") + + # Production's priority comes from the version delta, not old hosted vars. + ENV["PLAY_UPDATE_PRIORITY"] = "5" + [ + ["4.1.0", "4.1.1", 0], + ["4.1.0", "4.2.0", 2], + ["4.1.0", "5.0.0", 5], + ["4.1.0", "5.0.1", 5], # A pending major followed by a patch. + ["4.1.0", "4.2.1", 2], # A pending minor followed by a patch. + ["4.9.9", "4.10.0", 2], + ["5.0.0", "5.0.1", 0], + ["4.2.0", "4.2.1", 0] + ].each do |previous, current, priority| + ENV["PLAY_PREVIOUS_VERSION"] = previous + ENV["PLAY_APP_VERSION"] = current + fastfile.runner.execute(:production, :android) + assert(uploads.last[:in_app_update_priority] == priority, + "#{previous} -> #{current} must have priority #{priority}") end - assert(uploads.size == 1, "Incomplete artwork reached the upload action") + ["4.1.0", "4.0.9", "5.0.0-beta.1", "4.1.1+2", "4.2", ""].each do |version| + ENV["PLAY_PREVIOUS_VERSION"] = "4.1.0" + ENV["PLAY_APP_VERSION"] = version + refuses_upload(fastfile, uploads, :production, "PLAY_APP_VERSION") + end + ENV["PLAY_APP_VERSION"] = "4.1.1" + ENV["PLAY_PREVIOUS_VERSION"] = "invalid" + refuses_upload(fastfile, uploads, :production, "PLAY_PREVIOUS_VERSION") + ENV["PLAY_PREVIOUS_VERSION"] = "4.1.0" + ENV["PLAY_UPDATE_PRIORITY"] = "0" + + fastfile.runner.execute(:internal, :android) + assert(uploads.last[:track] == "internal", "Internal lane must remain available") + assert(uploads.last[:release_status] == "draft", "Testing can still prepare drafts") + ENV["PLAY_TRACK"] = "production" + refuses_upload(fastfile, uploads, :closed, "PLAY_TRACK") + ENV["PLAY_TRACK"] = "alpha" + ENV["PLAY_RELEASE_STATUS"] = "inProgress" + refuses_upload(fastfile, uploads, :closed, "PLAY_RELEASE_STATUS") + ENV["PLAY_RELEASE_STATUS"] = "completed" + [-1, 6, "urgent"].each do |priority| + ENV["PLAY_UPDATE_PRIORITY"] = priority.to_s + refuses_upload(fastfile, uploads, :internal, "PLAY_UPDATE_PRIORITY") + end + ENV["PLAY_UPDATE_PRIORITY"] = "0" + + File.delete(File.join(metadata, "en-US/images/phoneScreenshots/5.png")) + refuses_upload(fastfile, uploads, :production, "PLAY_METADATA_DIR") + File.write(File.join(metadata, "en-US/images/phoneScreenshots/5.png"), "test metadata") + File.delete(bundle) + refuses_upload(fastfile, uploads, :production, "signed release AAB") + File.write(bundle, "test bundle") + File.delete(credentials) + refuses_upload(fastfile, uploads, :production, "credentials file") + end + # Exercise the actual workflow step with Git history and tags. Only Cider's + # version command is stubbed; no build, upload or external remote is involved. + workflow = YAML.load_file(File.join(repository, ".github/workflows/release.yml")) + steps = workflow.fetch("jobs").fetch("publish").fetch("steps") + checkout = steps.find { |step| step["uses"] == "actions/checkout@v7" } + assert(checkout.dig("with", "fetch-depth") == 0, "Release tag lookup needs full history") + script = steps.find { |step| step["name"] == "Number this build" }.fetch("run") + history = File.join(temporary, "history") + origin = File.join(temporary, "origin.git") + bin = File.join(temporary, "bin") + FileUtils.mkdir_p([history, bin]) + run_command("git", "init", "--quiet", "--bare", origin) + run_command("git", "init", "--quiet", "--initial-branch=main", history) + git = lambda do |*arguments| + run_command("git", "-c", "user.name=Release test", + "-c", "user.email=release@example.invalid", + "-c", "commit.gpgsign=false", *arguments, chdir: history) + end + git.call("remote", "add", "origin", origin) + commit = lambda do |message| + File.write(File.join(history, "app.txt"), message) + git.call("add", "app.txt") + git.call("commit", "--quiet", "-m", message) + git.call("rev-parse", "HEAD") + end + commit.call("Submitted 4.1.0") + git.call("tag", "v4.1.0") + git.call("push", "--quiet", "--tags", "origin", "HEAD:main") + File.write(File.join(bin, "dart"), <<~SH) + #!/bin/sh + [ "$*" = "run cider version" ] || exit 1 + printf '%s\\n' "$CIDER_TEST_VERSION" + SH + FileUtils.chmod(0o755, File.join(bin, "dart")) + number_build = lambda do |version, event = "push"| + output = File.join(temporary, "build-output") + environment = File.join(temporary, "build-environment") + File.write(output, "") + File.write(environment, "") + run_command({ + "PATH" => "#{bin}:#{ENV.fetch('PATH')}", + "CIDER_TEST_VERSION" => version, + "GITHUB_RUN_NUMBER" => "100", "GITHUB_RUN_ATTEMPT" => "1", + "GITHUB_EVENT_NAME" => event, "GITHUB_OUTPUT" => output, + "GITHUB_ENV" => environment + }, "bash", "-e", "-o", "pipefail", "-c", script, chdir: history) + [output, environment].flat_map { |path| File.readlines(path, chomp: true) } + .to_h { |line| line.split("=", 2) } end - puts "Fastlane artwork upload configuration passed." + assert(number_build.call("4.1.0")["BUILD_ANDROID"] == "false", "Submitted versions skip Android") + assert(number_build.call("4.1.0", "workflow_dispatch")["BUILD_ANDROID"] == "true", + "Manual artifact builds remain available") + major = commit.call("Pending major 5.0.0") + assert(number_build.call("5.0.0")["previous_version"] == "4.1.0", "Major baseline changed") + commit.call("Backend-only merge while the major is pending") + assert(number_build.call("5.0.0")["previous_version"] == "4.1.0", "Backend merge lost major intent") + commit.call("Pending patch 5.0.1") + assert(number_build.call("5.0.1")["previous_version"] == "4.1.0", "Unsubmitted patch lost major intent") + git.call("tag", "v5.0.0", major) + assert(number_build.call("5.0.1")["previous_version"] == "5.0.0", "Submitted major must reset patch baseline") + git.call("tag", "v5.0.1") + assert(number_build.call("5.0.1")["app_release"] == "false", "Submitted patch must not upload again") + puts "Fastlane priority mapping, upload configuration and release tag selection passed." ensure ENV.replace(original_environment) FileUtils.remove_entry(temporary) diff --git a/tool/check_app_release.dart b/tool/check_app_release.dart new file mode 100644 index 0000000..adff3ed --- /dev/null +++ b/tool/check_app_release.dart @@ -0,0 +1,132 @@ +// Checks committed app changes against the PR base or previous main commit. +// Cider owns the version and changelog; this only enforces when to release. + +import 'dart:io'; + +import 'package:cider/cider.dart'; + +import 'release_notes.dart'; + +Future main(List args) async { + if (args.length != 1) { + stderr.writeln('Usage: dart run tool/check_app_release.dart '); + exitCode = 64; + return; + } + try { + stdout.writeln(await checkAppRelease(args.single)); + } catch (error) { + stderr.writeln('App release check failed: $error'); + exitCode = 1; + } +} + +/// Requires a newer, finalized Cider release when app inputs differ from [base]. +/// +/// Compares [base] with HEAD in [workingDirectory], or the current repository. +/// Website, backend, documentation, tests and release tooling are exempt. +Future checkAppRelease(String base, {String? workingDirectory}) async { + Future git(List arguments) async { + final result = await Process.run( + 'git', + arguments, + workingDirectory: workingDirectory, + ); + if (result.exitCode != 0) { + throw ProcessException( + 'git', + arguments, + '${result.stderr}', + result.exitCode, + ); + } + return '${result.stdout}'; + } + + // Without rename detection, moving an app file out of the app still counts + // as deleting an app input. NUL delimiters preserve spaces in filenames. + final paths = (await git([ + 'diff', + '--no-renames', + '--name-only', + '-z', + base, + 'HEAD', + '--', + ])).split('\u0000').where(_isAppInput); + if (paths.isEmpty) return 'No app changes; no Android release required.'; + + final temporary = await Directory.systemTemp.createTemp('opensplit-release-'); + try { + await File( + '${temporary.path}/pubspec.yaml', + ).writeAsString(await git(['show', '$base:pubspec.yaml'])); + final previous = await _cider(temporary.path, ['version']); + final root = workingDirectory ?? Directory.current.path; + final current = await _cider(root, ['version']); + if (!_isNewerStableVersion(previous, current)) { + throw FormatException( + 'App changes require a stable version newer than $previous; ' + 'found $current. Run dart run cider bump patch (or minor/major), ' + 'then dart run cider release.', + ); + } + playReleaseNotes(await _cider(root, ['describe', current, '--only-body'])); + if ((await _cider(root, ['describe', '--only-body'])).isNotEmpty) { + throw const FormatException( + 'Finalize Unreleased with dart run cider release before merging.', + ); + } + return 'App release: $previous -> $current.'; + } finally { + await temporary.delete(recursive: true); + } +} + +bool _isAppInput(String path) => + const [ + 'lib/', + 'assets/', + 'android/', + 'web/', + 'packages/opensplit_api/lib/', + 'service_worker/src/', + ].any(path.startsWith) || + const { + 'pubspec.yaml', + 'pubspec.lock', + 'build.yaml', + 'l10n.yaml', + 'flutter_launcher_icons.yaml', + 'flutter_native_splash.yaml', + 'packages/opensplit_api/pubspec.yaml', + 'packages/opensplit_api/pubspec.lock', + 'packages/opensplit_api/build.yaml', + 'service_worker/build.ts', + 'service_worker/package.json', + 'service_worker/pnpm-lock.yaml', + 'service_worker/pnpm-workspace.yaml', + 'service_worker/tsconfig.json', + 'service_worker/tsconfig.node.json', + }.contains(path); + +bool _isNewerStableVersion(String previous, String current) { + final stable = RegExp(r'^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$'); + if (!stable.hasMatch(previous) || !stable.hasMatch(current)) return false; + final before = previous.split('.').map(int.parse).toList(); + final after = current.split('.').map(int.parse).toList(); + for (var index = 0; index < 3; index++) { + if (after[index] != before[index]) return after[index] > before[index]; + } + return false; +} + +Future _cider(String root, List arguments) async { + final output = BufferChannel(); + final errors = BufferChannel(); + final status = await CiderCli( + console: Console(out: output, err: errors), + ).run(['--project-root', root, ...arguments]); + if (status != 0) throw FormatException(errors.buffer.toString().trim()); + return output.buffer.toString().trim(); +}