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
5 changes: 3 additions & 2 deletions .github/workflows/play-listing.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Keeps the Google Play store listing the same as fastlane/metadata/android/en-US/, which
# F-Droid reads too: its title and descriptions, icon, feature graphic and phone screenshots
# F-Droid reads too: its title and descriptions, icon and feature graphic
# (scripts/play_listing.py). Only what differs is changed, so the listing only goes to review
# when it has changed. The release notes aren't part of it: releases send those.
# when it has changed. The phone screenshots and release notes aren't part of it: each
# release takes its own screenshots and sends them, with its notes (release.yml).
#
# On pull requests it only checks the listing against Play's limits. On master it updates Play,
# signed in as dev.yml and release.yml are (Workload Identity Federation, no keys), once the
Expand Down
99 changes: 89 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
# Commits that change something users notice carry a "Release-note:" trailer: one line for
# users. Every Monday this collects the notes since the last release (scripts/plan_release.sh);
# with none, it does nothing, so refactoring, tests and CI never make a release. With some, it:
# - makes a release commit on top of master, setting the version and writing the notes as
# the fastlane changelog, and pushes it as the tag v<version>. Nothing is pushed to master,
# which keeps its -dev version. F-Droid builds the tag.
# - takes the store screenshots: the app on an emulator against a real Clementine
# (store-screenshots.yml)
# - makes a release commit on top of master, setting the version, writing the notes as the
# fastlane changelog and adding the screenshots, and pushes it as the tag v<version>.
# Nothing is pushed to master, which keeps its -dev version. F-Droid builds the tag.
# - publishes a GitHub release with every note and the APK, signed with the release key in
# Cloud KMS
# - uploads the Play bundle to the closed and open testing tracks (PLAY_RELEASE_TRACK,
# default alpha,beta), with the notes as its release notes
# default alpha,beta), with the notes as its release notes, and sends Play the store
# listing with the new screenshots (scripts/play_listing.py)
#
# Run it by hand (Actions → release → Run workflow) to release now rather than on Monday, or,
# with a tag, to publish an existing release again after a failure. Run by hand, it releases
Expand All @@ -29,11 +32,6 @@ on:
description: "Publish this existing release again (such as v13.1), instead of a new one"
required: false

# With dev.yml: one Google Play upload at a time.
concurrency:
group: play
cancel-in-progress: false

permissions:
contents: write # for the tag and the GitHub release
id-token: write # for the OIDC token Workload Identity Federation exchanges
Expand All @@ -52,10 +50,51 @@ env:
KMS_SIGNER_SHA256: 843d553610ebca4a4beee8fc9a66c0c9c7575bc614bc69ea3646eb656974798a

jobs:
# Whether there's a release to make, so the screenshots are only taken for one. The release
# job plans it again, in full.
plan:
if: github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
release: ${{ steps.plan.outputs.release }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Plan the release
id: plan
env:
TAG: ${{ inputs.tag }}
ALWAYS: ${{ github.event_name == 'workflow_dispatch' && '1' || '' }}
run: |
# Publishing a release again: it has its screenshots.
if [ -n "$TAG" ]; then
echo "release=false" >> "$GITHUB_OUTPUT"
else
scripts/plan_release.sh | grep '^release=' | tee -a "$GITHUB_OUTPUT"
fi

screenshots:
needs: plan
if: needs.plan.outputs.release == 'true'
uses: ./.github/workflows/store-screenshots.yml
# What store-screenshots.yml asks for; it only comments on pull requests.
permissions:
contents: read
pull-requests: write

release:
needs: [plan, screenshots]
# Also without screenshots: when they failed, the release keeps the last release's.
# Workload Identity Federation admits master alone.
if: github.ref == 'refs/heads/master'
if: ${{ !cancelled() && github.ref == 'refs/heads/master' }}
runs-on: ubuntu-latest
# With dev.yml and play-listing.yml: one Google Play upload at a time.
concurrency:
group: play
cancel-in-progress: false
steps:
- uses: actions/checkout@v5
with:
Expand Down Expand Up @@ -117,6 +156,40 @@ jobs:
echo "::warning::Releases aren't set up yet, so nothing was released (see RELEASING.md)."
fi

- name: Download the screenshots
if: >
steps.setup.outputs.cloud == 'true' && steps.plan.outputs.again != 'true'
&& needs.screenshots.result == 'success'
uses: actions/download-artifact@v8
with:
name: store-screenshots
path: ${{ runner.temp }}/taken

- uses: actions/setup-python@v7
if: steps.setup.outputs.cloud == 'true'
with:
python-version: "3.13"
- run: pip install google-api-python-client google-auth pillow
if: steps.setup.outputs.cloud == 'true'

# From the last release's, so a screen that looks the same stays the same file, and when
# the screenshots failed, the release keeps them.
- name: The store screenshots
if: steps.setup.outputs.cloud == 'true' && steps.plan.outputs.again != 'true'
env:
LAST: ${{ steps.plan.outputs.last }}
TAKEN: ${{ needs.screenshots.result == 'success' && format('{0}/taken/screenshots', runner.temp) || '' }}
run: |
shots=fastlane/metadata/android/en-US/images/phoneScreenshots
if [ -n "$LAST" ] && git cat-file -e "$LAST:$shots" 2> /dev/null; then
git checkout "$LAST" -- "$shots"
fi
if [ -n "$TAKEN" ]; then
scripts/store_screenshots.py "$TAKEN"
else
echo "::warning::The screenshots weren't taken, so this release keeps ${LAST:-no release}'s."
fi

- name: Make the release commit
if: steps.setup.outputs.cloud == 'true' && steps.plan.outputs.again != 'true'
env:
Expand All @@ -130,6 +203,7 @@ jobs:
mkdir -p "$(dirname "$changelog")"
cp "$NOTES/changelog.txt" "$changelog"
git add app/build.gradle.kts "$changelog"
git add -A fastlane/metadata/android/en-US/images
# Who makes the release commit, and the tag after it.
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
Expand Down Expand Up @@ -238,3 +312,8 @@ jobs:
tracks: ${{ vars.PLAY_RELEASE_TRACK || 'alpha,beta' }}
status: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }}
whatsNewDirectory: ${{ runner.temp }}/whatsnew

# The listing as released: its text and images, with this release's screenshots.
- name: Send the store listing to Google Play
if: steps.setup.outputs.cloud == 'true'
run: scripts/play_listing.py
6 changes: 4 additions & 2 deletions .github/workflows/store-screenshots.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# Takes the Google Play screenshots: the app on an emulator, connected to a real headless
# Clementine (clementine-it) playing the showcase library. The screenshots are uploaded as
# an artifact; scripts/store_graphics.py turns them into the store listing images.
# an artifact. Each release runs this (release.yml) and puts them in its store listing, with
# scripts/store_screenshots.py.
#
# Also checks the media session against that Clementine (MediaSessionDeviceTest): Android
# sees the session and its song, and the system media controls drive Clementine.
#
# On pull requests, the screenshots are also posted as a comment next to the store listing's
# On pull requests, the screenshots are also posted as a comment next to the latest release's
# (scripts/post_screenshots.sh). They're hosted in a Cloudflare R2 bucket, which charges
# nothing for bandwidth, with a token that can only read and write that bucket:
# secrets R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY;
Expand All @@ -14,6 +15,7 @@
name: store-screenshots
on:
workflow_dispatch:
workflow_call:
pull_request:
paths:
- .github/workflows/store-screenshots.yml
Expand Down
43 changes: 27 additions & 16 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,19 @@ rebase merges, each commit keeps its trailer on `master`.
(`scripts/plan_release.sh`; run it to see what the next release would be). With none, it does
nothing. With some, it releases:

- It makes a **release commit** on top of `master` that sets the version and writes the notes
as the fastlane changelog, and pushes it as the tag `v<version>`. Nothing is pushed to
`master`, which keeps its `-dev` version.
- It takes the **store screenshots**: the app on an emulator, against a real Clementine
(`.github/workflows/store-screenshots.yml`).
- It makes a **release commit** on top of `master` that sets the version, writes the notes
as the fastlane changelog and adds the screenshots, and pushes it as the tag `v<version>`.
Nothing is pushed to `master`, which keeps its `-dev` version.
- **F-Droid** builds that tag, reading the version and changelog from it.
- A **GitHub release** gets every note, and the APK, signed with the release key in Cloud KMS.
- **Google Play** gets the bundle on the closed and open testing tracks (`alpha,beta`, or the
repository variable `PLAY_RELEASE_TRACK`, which takes one track or several separated by
commas), with the notes as its release notes: at most 500 characters, so a long list ends
with "And more fixes and improvements." Closed testing also gets every development build,
so there a release is soon followed by newer builds; open testing gets only releases.
so there a release is soon followed by newer builds; open testing gets only releases. Play's
store listing gets the release's screenshots.

**Versions.** Releases are named after `master`'s `versionName` without `-dev`: 13, then
13.1, 13.2 and so on. For a major version, change `master` to `14-dev`. Version codes come
Expand Down Expand Up @@ -182,18 +185,26 @@ New personal developer accounts must also run a closed test with at least 12 tes
## Store listing

`fastlane/metadata/android/en-US/` is the store listing for both stores: `title.txt`,
`short_description.txt`, `full_description.txt`, and `images/` (the icon, the feature graphic
and the phone screenshots). F-Droid reads it from each release tag.

Google Play gets it from `.github/workflows/play-listing.yml`, whenever it changes on `master`,
or when the workflow is run by hand (`scripts/play_listing.py`). It goes to the app's default
language in Play Console, whichever that is. Only what differs from Play's listing is changed, so the listing goes to review only when it has changed. On pull requests,
the workflow checks the listing against Play's limits instead: text lengths, image sizes, and
two to eight screenshots.

To refresh the images: run the *store-screenshots* workflow (on pull requests that change the
UI, it runs by itself), download its `store-screenshots` artifact, run
`scripts/store_graphics.py <its screenshots dir>`, and open a pull request with the result.
`short_description.txt`, `full_description.txt`, and `images/` (the icon and the feature
graphic, which `scripts/store_graphics.py` draws). F-Droid reads it from each release tag.

The **phone screenshots** are each release's own, so they're only on release tags, not on
`master`: every release takes them, and puts them in its release commit
(`scripts/store_screenshots.py`), so the listing always shows the app as released. They're
the main screens in the dark theme, which most people use, then the player in the light
theme. A screen that looks the same as in the last release stays the same file. If taking
them fails, the release goes ahead with the last release's, with a warning.

Google Play gets the text, icon and feature graphic from `.github/workflows/play-listing.yml`,
whenever they change on `master` or when the workflow is run by hand, and the whole listing,
screenshots too, from each release (`scripts/play_listing.py`). It goes to the app's default
language in Play Console, whichever that is. Only what differs from Play's listing is
changed, so the listing goes to review only when it has changed. On pull requests, the
workflow checks the listing against Play's limits instead: text lengths and image sizes.

Pull requests that change the UI get the *store-screenshots* workflow's screenshots as a
comment, next to the latest release's, so a change to how the listing will look is seen in
review.

**One-time setup:** in Play Console, *Users and permissions*, give
`android-play-release@clementine-data.iam.gserviceaccount.com` *Edit store listing, pricing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ public void takeScreenshots() {
screenshot("4_search");

// The same screens in the dark theme: the store listing takes the numbered ones, and the
// light theme's player after them (scripts/store_graphics.py). The activity is recreated
// light theme's player after them (scripts/store_screenshots.py). The activity is recreated
// in the dark, keeping where it was.
shell("cmd uimode night yes");
SystemClock.sleep(SETTLE_MILLIS);
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
19 changes: 12 additions & 7 deletions scripts/play_listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
The listing goes to the app's default language on Play, whichever that is (it's set in Play
Console); fastlane/ has the one listing, in en-US.

The phone screenshots are each release's own (release.yml takes them), so they're in the
listing only on release tags: without them, Play's are left as they are.

Only what differs from Play's listing is changed: text as it is, images by their SHA-256, so
merging something else doesn't send the listing for review again. With nothing to change,
the edit is thrown away.
Expand Down Expand Up @@ -53,15 +56,17 @@ def text():

def images():
"""Each image type's files, in the listing's order."""
screenshots = sorted(IMAGES.glob("phoneScreenshots/*.png"), key=lambda p: int(p.stem))
if not MIN_SCREENSHOTS <= len(screenshots) <= MAX_SCREENSHOTS:
sys.exit(f"{len(screenshots)} phone screenshots; Play takes {MIN_SCREENSHOTS} to "
f"{MAX_SCREENSHOTS}")
return {
files = {
"icon": [IMAGES / "icon.png"],
"featureGraphic": [IMAGES / "featureGraphic.png"],
"phoneScreenshots": screenshots,
}
screenshots = sorted(IMAGES.glob("phoneScreenshots/*.png"), key=lambda p: int(p.stem))
if screenshots:
if not MIN_SCREENSHOTS <= len(screenshots) <= MAX_SCREENSHOTS:
sys.exit(f"{len(screenshots)} phone screenshots; Play takes {MIN_SCREENSHOTS} to "
f"{MAX_SCREENSHOTS}")
files["phoneScreenshots"] = screenshots
return files


def check_image(kind, path):
Expand Down Expand Up @@ -157,7 +162,7 @@ def main():
for path in paths:
check_image(kind, path)
print(f"Listing: {', '.join(f'{f} {len(v)} characters' for f, v in fields.items())}; "
f"{len(files['phoneScreenshots'])} phone screenshots")
f"{len(files.get('phoneScreenshots', [])) or 'no'} phone screenshots")

if not args.check:
publish(fields, files)
Expand Down
27 changes: 17 additions & 10 deletions scripts/post_screenshots.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Uploads a store-screenshots run's screenshots to the R2 bucket and posts them on the pull
# request, next to the store listing's current screenshots from master. Run by
# request, next to the latest release's store screenshots. Run by
# .github/workflows/store-screenshots.yml; one comment per pull request, updated in place.
#
# Usage: post_screenshots.sh <screenshots dir> <pull request number>
Expand All @@ -9,7 +9,7 @@
# (the bucket-scoped R2 token), GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT,
# GITHUB_SERVER_URL, GH_TOKEN, and the AWS and GitHub CLIs.
set -euo pipefail
# Name order as scripts/store_graphics.py sorts them (by code point).
# Name order as scripts/store_screenshots.py sorts them (by code point).
export LC_ALL=C

dir=$1
Expand All @@ -34,22 +34,29 @@ AWS_RESPONSE_CHECKSUM_VALIDATION=when_required \
--endpoint-url "https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com" --only-show-errors
base="${R2_PUBLIC_URL%/}/$prefix"

# The store screenshots on master, in the order scripts/store_graphics.py numbers them: the
# numbered screens in the dark theme, then the light theme's player. Compared only when this
# run took all of them: after a failure the numbers don't line up.
# The latest release's store screenshots (each release takes its own, in release.yml), in the
# order scripts/store_screenshots.py numbers them: the numbered screens in the dark theme, then
# the light theme's player. Compared only when this run took all of them: after a failure the
# numbers don't line up.
listing=fastlane/metadata/android/en-US/images/phoneScreenshots
numbered=("$dir"/dark_[0-9]_*.png "$dir"/1_*.png)
listed=(fastlane/metadata/android/en-US/images/phoneScreenshots/*.png)
compare=$([ ${#numbered[@]} -eq ${#listed[@]} ] && echo yes || echo no)
store="https://raw.githubusercontent.com/$GITHUB_REPOSITORY/master/fastlane/metadata/android/en-US/images/phoneScreenshots"
release=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name 2> /dev/null || true)
listed=0
if [ -n "$release" ]; then
listed=$(gh api "repos/$GITHUB_REPOSITORY/contents/$listing?ref=$release" --jq length \
2> /dev/null || echo 0)
fi
compare=$([ "$listed" -gt 0 ] && [ ${#numbered[@]} -eq "$listed" ] && echo yes || echo no)
store="https://raw.githubusercontent.com/$GITHUB_REPOSITORY/$release/$listing"
run="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"

{
echo "$marker"
echo "### Store screenshots"
echo
echo "From [run $GITHUB_RUN_ID]($run), against clementine-it. Left: the store listing on master, which shows the dark theme. Right: this pull request, dark and light."
echo "From [run $GITHUB_RUN_ID]($run), against clementine-it. Left: the store listing of the latest release${release:+, $release}, which shows the dark theme. Right: this pull request, dark and light."
echo
echo "| Screen | master | This PR, dark | This PR |"
echo "| Screen | ${release:-Released} | This PR, dark | This PR |"
echo "| --- | --- | --- | --- |"
# The store's screens first (numbered), then the others the run took, which the store
# listing doesn't show (such as the settings).
Expand Down
Loading
Loading