ci(release): publish the release bundle to Google Play

Adds Play as a third channel alongside the F-Droid repo and the Codeberg
download. It gets its own artifact: bundleRelease produces an AAB from
the same source and signing config as the published APK, which is left
untouched so the F-Droid reproducibility guarantee is unaffected.

The upload runs as a separate trailing job rather than more steps in
'release'. Play is the only channel that can reject a good build for
reasons the pipeline cannot see — listing rules, policy review, a track
needing manual promotion — and that must surface as one red job beside a
release that already shipped, not as a failure of the workflow that
shipped it. It also skips cleanly until PLAY_SERVICE_ACCOUNT_JSON
exists, so the pipeline keeps working during Play Console setup.

fastlane is scoped deliberately to 'supply'. Build and signing stay on
Gradle: interposing fastlane there would add a layer able to inject
flags into the one build a third party verifies byte-for-byte. What
fastlane buys is that fastlane/metadata/android — already the source
F-Droid harvests — is exactly what supply consumes, so 'What's New'
comes from CHANGELOG.md via the existing sync script for both stores.

Listing text is not pushed per release (an accidental overwrite of a
live listing is a policy-review event, not a revert), and images are
skipped because the committed assets satisfy F-Droid but not Play: the
screenshots are 1280x2856 with alpha where Play caps the long edge at 2x
the short edge and wants 24-bit, and no 1024x500 featureGraphic exists.

The job runs without a container image despite fastlane publishing one:
act_runner provides no node inside custom job containers, so checkout
and download-artifact cannot run there.

Uploads default to the internal track — promotion to production stays a
human action, matching the rule that UI releases wait for on-device
review.

Note that Play App Signing means Play installs and F-Droid installs
carry different signatures and cannot update each other; the release key
is registered only as the upload key. Documented in docs/RELEASING.md.
This commit is contained in:
2026-07-29 16:42:00 +02:00
parent 2f4e05899a
commit 3fdaa9270f
6 changed files with 326 additions and 1 deletions

80
fastlane/Fastfile Normal file
View File

@@ -0,0 +1,80 @@
# Google Play publishing only.
#
# Building and signing are deliberately NOT fastlane's job: release.yaml drives
# Gradle directly so the release build stays F-Droid-reproducible (`vcsInfo`,
# `dependenciesInfo` and the AGP metadata block are disabled in
# app/build.gradle.kts and guarded by scripts/check_reproducible_release.sh).
# Interposing fastlane there would add a layer that can inject Gradle flags into
# the one build whose byte-for-byte output is verified by a third party.
#
# What fastlane IS here for is `supply` — the Play Developer API client — because
# the store listing already lives in fastlane/metadata/android, which the
# official F-Droid repo harvests from the tagged source tree. One metadata tree,
# two stores.
default_platform(:android)
platform :android do
desc "Upload an already-built, already-signed AAB to Play"
lane :deploy do |options|
aab = options[:aab] || "app/build/outputs/bundle/release/app-release.aab"
UI.user_error!("AAB not found at #{aab}") unless File.exist?(aab)
supply(
aab: aab,
track: options[:track] || "internal",
release_status: options[:release_status] || "completed",
# The APK belongs to F-Droid and the Codeberg download; Play only ever
# gets the bundle. Never let the uploader reach for the other artifact.
skip_upload_apk: true,
# Listing text is synced deliberately via the `listing` lane, not on every
# release. Overwriting a live listing by accident triggers a Play policy
# review, which is not a quick revert.
skip_upload_metadata: true,
# "What's New" DOES track every release.
# scripts/sync_changelog_to_fastlane.sh writes
# fastlane/metadata/android/en-US/changelogs/<versionCode>.txt from
# CHANGELOG.md, so Play shows exactly the text F-Droid does.
skip_upload_changelogs: false,
# See the `listing` lane for why the committed images can't go to Play yet.
skip_upload_images: true,
skip_upload_screenshots: true,
# Dry run: validate the edit against the API and roll it back instead of
# committing it. Used for the first end-to-end rehearsal.
validate_only: options[:dry_run].to_s == "true",
)
end
desc "Sync the store listing text (and, once the assets qualify, the images)"
lane :listing do |options|
# Deliberate and manual: `bundle exec fastlane listing`. Not wired into the
# release pipeline.
#
# Images stay off by default because the committed assets do not satisfy
# Play today, even though F-Droid accepts all of them:
#
# * phoneScreenshots are 1280x2856. Play caps the long edge at twice the
# short edge (2560 here), so every screenshot is rejected.
# * those PNGs are 32-bit RGBA. Play wants 24-bit PNG, no alpha.
# * icon.png is 512x512 but 24-bit RGB. Play's icon wants 32-bit PNG.
# * there is no featureGraphic.png. Play requires one, exactly 1024x500.
#
# Until that is fixed, the Play listing's graphics are managed by hand in the
# console. Pass images:true once the assets qualify.
upload_images = options[:images].to_s == "true"
supply(
skip_upload_aab: true,
skip_upload_apk: true,
skip_upload_metadata: false,
skip_upload_changelogs: true,
skip_upload_images: !upload_images,
skip_upload_screenshots: !upload_images,
)
end
end