From d5c53df6b5626cf336f556ae58ec820db7352185 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 30 Jul 2026 13:59:43 +0200 Subject: [PATCH] Publish the release bundle to Google Play (#84) (#100) Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/100 --- .gitea/workflows/release.yaml | 155 +++++++++++++++++++++++++++++++++- .gitignore | 11 +++ Gemfile | 10 +++ docs/RELEASING.md | 64 ++++++++++++++ fastlane/Appfile | 7 ++ fastlane/Fastfile | 80 ++++++++++++++++++ 6 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 Gemfile create mode 100644 fastlane/Appfile create mode 100644 fastlane/Fastfile diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 17a4efd..cc0c0d7 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -1,4 +1,4 @@ -name: Release — F-Droid repo + Gitea/Codeberg release +name: Release — F-Droid repo + Gitea/Codeberg release + Play # A release is cut by merging a release branch into main with a bumped # versionName (see docs/RELEASING.md). This workflow reads that versionName and, @@ -9,6 +9,12 @@ name: Release — F-Droid repo + Gitea/Codeberg release # trigger. Ordinary merges (no version bump) fall through `detect` and do # nothing. # +# A trailing `play` job then uploads the App Bundle to Google Play. It is last +# and separate because Play is the only channel that can reject a good build for +# reasons the pipeline can't see, and that must not endanger a release which has +# already shipped to F-Droid and Codeberg. It skips cleanly until the +# PLAY_SERVICE_ACCOUNT_JSON secret exists. +# # A manual workflow_dispatch (from a branch) runs the re-sign-only recovery # path: it re-signs the existing F-Droid index with the repo key and re-uploads, # without building an APK or creating a release. Used for key rotation / repo @@ -203,6 +209,33 @@ jobs: if: env.IS_RELEASE == 'true' run: ./gradlew assembleRelease + # Play takes an App Bundle, not the APK, so it is a second artifact from + # the same source and the same signing config — not a repackage of the + # APK. The release key signs it, but Play only ever treats that key as the + # *upload* key: Play App Signing re-signs with Google's own key before + # delivery. A Play install and an F-Droid install therefore carry + # different signatures and cannot update each other. That divergence is a + # deliberate, documented choice (docs/RELEASING.md), not an accident. + # + # Nothing here touches the F-Droid path: the AAB is never copied into the + # repo, never attached to a release, and its build cannot change the APK + # that was already produced above. + # + # AGP embeds the R8 mapping in the bundle's BUNDLE-METADATA, so Play gets + # deobfuscated stacktraces without a separate mapping upload. + - name: Build release AAB + if: env.IS_RELEASE == 'true' + run: ./gradlew bundleRelease + + - name: Hand the AAB to the Play job + if: env.IS_RELEASE == 'true' + uses: actions/upload-artifact@v4 + with: + name: release-aab-${{ needs.detect.outputs.version }} + path: app/build/outputs/bundle/release/app-release.aab + if-no-files-found: error + retention-days: 14 + - name: Setup F-Droid Server Tools run: | SUDO="" @@ -477,3 +510,123 @@ jobs: "$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n" done echo "Published $TAG to Codeberg." + + # Google Play channel. + # + # A separate job, on purpose, running only AFTER the F-Droid publish and both + # forge releases have completed. Play is the one channel that can reject a + # perfectly good build for reasons outside the pipeline (listing rules, policy + # review, API outage, a track that needs manual promotion). Isolating it means + # such a rejection surfaces as one red job next to a release that already + # shipped everywhere else, instead of failing the workflow that publishes it. + # + # Not a `container:` job even though a fastlane image exists: act_runner does + # not provide node inside custom job containers, so JavaScript actions + # (checkout, download-artifact) can't run there. The Renovate job gets away + # with a container because its only step is a shell command. Ruby is installed + # the same way sshpass, jq and fdroidserver are in the job above. + play: + needs: [detect, release] + # workflow_dispatch is the F-Droid re-sign recovery path — it must never + # touch Play, so gate on a real release only. + if: needs.detect.outputs.is_release == 'true' + runs-on: docker + env: + VERSION: ${{ needs.detect.outputs.version }} + VERSION_CODE: ${{ needs.detect.outputs.version_code }} + # Where the bundle lands. `internal` by default so a release reaches + # testers rather than the public, and promotion to production stays a + # deliberate human action in the Play Console — the same posture as + # holding UI releases for on-device review. Override with the PLAY_TRACK + # repo variable once the flow is trusted. + PLAY_TRACK: ${{ vars.PLAY_TRACK || 'internal' }} + PLAY_RELEASE_STATUS: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }} + # Set PLAY_DRY_RUN=true to validate the edit against the API and discard + # it instead of committing — used to rehearse the first upload. + PLAY_DRY_RUN: ${{ vars.PLAY_DRY_RUN || 'false' }} + BUNDLE_PATH: vendor/bundle + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Skip cleanly (not fatally) when Play isn't configured yet, so the rest + # of the release pipeline keeps working during setup — same contract as + # the Codeberg mirror step. + - name: Write the Play service-account key + id: key + env: + PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + run: | + set -euo pipefail + if [ -z "${PLAY_SERVICE_ACCOUNT_JSON:-}" ]; then + echo "PLAY_SERVICE_ACCOUNT_JSON not set — skipping the Play upload." + echo "configured=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json + # Fail here, with a clear message, rather than inside fastlane: a + # mangled multi-line secret is the likeliest setup mistake. + python3 -c "import json,sys; d=json.load(open('play-service-account.json')); sys.exit(0 if d.get('type')=='service_account' else 1)" \ + || { echo "PLAY_SERVICE_ACCOUNT_JSON is not a valid service-account JSON." >&2; exit 1; } + echo "configured=true" >> "$GITHUB_OUTPUT" + + - name: Download the AAB + if: steps.key.outputs.configured == 'true' + uses: actions/download-artifact@v4 + with: + name: release-aab-${{ needs.detect.outputs.version }} + path: dist + + - name: Install Ruby + if: steps.key.outputs.configured == 'true' + run: | + set -euo pipefail + SUDO="" + if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi + $SUDO apt-get update + # ruby-dev + build-essential: several of fastlane's dependencies build + # native extensions. + $SUDO apt-get install -y ruby-full ruby-dev build-essential + ruby -v + + # Only the first release pays the full gem build; afterwards this restores. + - name: Cache bundled gems + if: steps.key.outputs.configured == 'true' + uses: actions/cache@v4 + with: + path: vendor/bundle + key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile') }} + restore-keys: | + ${{ runner.os }}-gems- + + - name: Install fastlane + if: steps.key.outputs.configured == 'true' + run: | + set -euo pipefail + gem install bundler --no-document + bundle config set --local path vendor/bundle + bundle install --jobs 4 + bundle exec fastlane --version + + - name: Upload to Play + if: steps.key.outputs.configured == 'true' + env: + SUPPLY_JSON_KEY: play-service-account.json + # supply is chatty on a TTY-less runner otherwise. + FASTLANE_SKIP_UPDATE_CHECK: '1' + FASTLANE_HIDE_CHANGELOG: '1' + run: | + set -euo pipefail + test -f "dist/app-release.aab" + bundle exec fastlane deploy \ + aab:"dist/app-release.aab" \ + track:"$PLAY_TRACK" \ + release_status:"$PLAY_RELEASE_STATUS" \ + dry_run:"$PLAY_DRY_RUN" + echo "Uploaded $VERSION (code $VERSION_CODE) to the '$PLAY_TRACK' track." + + # The workspace is reused between runs on a self-hosted runner, so the + # credential must not outlive the job. + - name: Shred the service-account key + if: always() + run: shred -u play-service-account.json 2>/dev/null || rm -f play-service-account.json diff --git a/.gitignore b/.gitignore index 3f3546f..06a7a2d 100644 --- a/.gitignore +++ b/.gitignore @@ -61,5 +61,16 @@ Thumbs.db # KSP .ksp/ +# Google Play Developer API service-account key. Reconstructed in CI from the +# PLAY_SERVICE_ACCOUNT_JSON secret and shredded afterwards — never committed. +/play-service-account.json + +# fastlane (Play uploader only — see fastlane/Fastfile) +/fastlane/report.xml +/fastlane/README.md +/vendor/bundle/ +/.bundle/ +Gemfile.lock + # Claude Code /CLAUDE.md diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..e12874e --- /dev/null +++ b/Gemfile @@ -0,0 +1,10 @@ +source "https://rubygems.org" + +# fastlane is used ONLY to upload the release bundle to Google Play +# (see fastlane/Fastfile). It is not part of the build or the signing path, so +# it never runs on a PR — only in release.yaml's `play` job. +# +# Pinned exactly; Renovate's bundler manager keeps it bumped. No Gemfile.lock is +# committed on purpose: this resolves an uploader's transitive deps, not the +# app's, and none of it affects the reproducible release build. +gem "fastlane", "2.237.0" diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 4426944..43a9b52 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -87,6 +87,9 @@ release work when a merge actually cuts a release: mirror the release to **Codeberg** with the signed APK + a SHA-256 checksum (both best-effort). Ordinary merges with no version bump fall through `detect` and do nothing. +- **`play` job** (same workflow, after `release`) — uploads the App Bundle to + Google Play. Runs last and separately so a Play rejection can't endanger a + release that already shipped; skips cleanly until Play is configured. ### Codeberg direct-download channel @@ -102,6 +105,58 @@ unset. One-time setup: the repo's **Releases** unit must be enabled and a `CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope) added to Gitea Actions. +### Google Play channel + +Play is a third channel alongside F-Droid and the Codeberg download, and it is +the only one that gets a different artifact and a different signature. + +**Artifact.** Play takes an **App Bundle** (`bundleRelease`), not the APK. It is +a second output of the same source and the same signing config — never a +repackage of the published APK, which stays untouched so the F-Droid +reproducibility guarantee is unaffected. `dependenciesInfo` stays disabled for +the bundle too; Play's "app dependencies" report is optional and re-enabling it +would break reproducibility. + +**Signature — read this before assuming an update path exists.** Play App +Signing is mandatory for new apps, and Google generates and holds the app +signing key. The release keystore in CI is registered only as the **upload +key**: Play verifies uploads with it, then re-signs with Google's key before +delivery. Consequences, accepted deliberately: + +- A Play install and an F-Droid install have **different signatures** and + **cannot update each other**. Switching channels requires uninstall + + reinstall, which loses nothing (all data lives in the system calendar + provider) but must be stated wherever both channels are advertised. +- Losing the upload key is **recoverable** — request an upload-key reset in the + Play Console. Losing the app key still is not, for F-Droid. + +**Build and signing are not fastlane's job.** Gradle does both, exactly as +before. fastlane appears only as the Play Developer API client (`supply`), +because the store listing already lives in `fastlane/metadata/android/` — the +same tree the official F-Droid repo harvests. One metadata source, two stores. + +**What gets uploaded per release:** the AAB, plus the per-version "What's New" +from `fastlane/metadata/android/en-US/changelogs/.txt` (generated +from `CHANGELOG.md` by `scripts/sync_changelog_to_fastlane.sh`). Listing text is +**not** touched — an accidental overwrite of a live listing triggers a Play +policy review. Sync it deliberately with `bundle exec fastlane listing`. + +**Screenshots and graphics are skipped**, because the committed assets satisfy +F-Droid but not Play: + +| Asset | Committed | Play requires | +| --- | --- | --- | +| `phoneScreenshots/*.png` | 1280×2856, 32-bit RGBA | long edge ≤ 2× short edge (so ≤ 2560), 24-bit PNG, no alpha | +| `icon.png` | 512×512, 24-bit RGB | 512×512, 32-bit PNG | +| `featureGraphic.png` | *missing* | required, exactly 1024×500 | + +Until those are fixed, Play's graphics are managed by hand in the console. Then +pass `images:true` to the `listing` lane. + +**Track.** Uploads go to `internal` by default; promotion to production stays a +manual action in the Play Console, matching the rule that UI releases wait for +on-device review. Override with the `PLAY_TRACK` repo variable. + ### Manual re-sign / recovery A manual `workflow_dispatch` of the release workflow runs a **re-sign-only** @@ -153,6 +208,15 @@ Two consequences worth remembering: | `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | Upload target for the F-Droid repo. | | `GITHUB_TOKEN` | Provided by Gitea Actions; used to create the release + attach assets. | | `CODEBERG_RELEASE_TOKEN` | Codeberg access token (`write:repository` scope) — creates the mirrored Codeberg release + uploads the APK/checksum. Best-effort; if unset the Codeberg step skips. | +| `PLAY_SERVICE_ACCOUNT_JSON` | Google Cloud service-account key (full JSON) with Play Console access — uploads the AAB. If unset, the `play` job skips cleanly. | + +### Variables (Gitea → repo Settings → Actions → Variables) + +| Variable | Default | Purpose | +| --- | --- | --- | +| `PLAY_TRACK` | `internal` | Play track the bundle is uploaded to. | +| `PLAY_RELEASE_STATUS` | `completed` | `completed`, `draft`, `inProgress` or `halted`. | +| `PLAY_DRY_RUN` | `false` | `true` validates the Play edit against the API and discards it — use to rehearse. | The two keys are independent: the **app key** signs APKs; the **repo key** signs the index (its fingerprint is what users pin). Neither key nor the diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 0000000..b0c010b --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,7 @@ +# fastlane is used in this repo ONLY as a Google Play Developer API client +# (see fastlane/Fastfile). It never builds and never signs. + +# Reconstructed in CI from the PLAY_SERVICE_ACCOUNT_JSON secret; gitignored. +json_key_file(ENV["SUPPLY_JSON_KEY"] || "play-service-account.json") + +package_name("de.jeanlucmakiola.calendula") diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..ab0256d --- /dev/null +++ b/fastlane/Fastfile @@ -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/.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