Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9647b71
Document the develop-first branching model in AGENTS.md
EarMaster Aug 4, 2026
b216667
feat: implement the Android client through phase 7
EarMaster Aug 4, 2026
3f2727b
chore(release): manage Play Store metadata in fastlane layout
EarMaster Aug 4, 2026
d641a96
fix: correct the protocol assumptions the first box run exposed
EarMaster Aug 4, 2026
a42f524
feat(favourites): make favouriting explicit and add item menus
EarMaster Aug 4, 2026
3493a31
fix(library): let back go up a folder instead of leaving the library
EarMaster Aug 4, 2026
ff3c29d
fix(boxes): make a second box reachable
EarMaster Aug 4, 2026
9099111
feat(favourites): hand out the play link for a favourite
EarMaster Aug 4, 2026
a831041
feat(library): add search over the cached library
EarMaster Aug 4, 2026
cd663eb
feat(player): add a sleep timer that stops the player, not the box
EarMaster Aug 4, 2026
0e9d4a5
test(screenshots): render the UI to golden images on every PR
EarMaster Aug 4, 2026
164c57a
feat(store): generate the Play listing and website screenshots from t…
EarMaster Aug 5, 2026
9b8dcd9
feat(player): put the cover beside the controls on a wide screen
EarMaster Aug 5, 2026
b2c7758
ci(screenshots): run the metadata check through bash
EarMaster Aug 5, 2026
ab43245
docs(screenshots): correct the claim that goldens must be recorded on CI
EarMaster Aug 5, 2026
34e9634
chore(tools): check the scripts in executable and run them by path
EarMaster Aug 5, 2026
f1c5985
chore(release): bump version to 0.8.0
EarMaster Aug 5, 2026
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
42 changes: 31 additions & 11 deletions .claude/commands/release.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
---
description: Bump the app version (major/minor/patch or explicit semver), update CHANGELOG.md and What's New files, and commit the release.
description: Bump the app version (major/minor/patch or explicit semver), update CHANGELOG.md and the per-locale Play Store release notes, and commit the release.
allowed-tools: Bash(git status:*), Bash(git pull:*), Bash(git diff:*), Bash(git add:*), Bash(git commit:*), Bash(git push:*), Read, Edit, Bash, AskUserQuestion, TodoWrite, TodoRead
model: haiku
---

You are preparing a new release for the Coil Android app. Your job is to determine the new version number, update `app/build.gradle.kts`, restructure `CHANGELOG.md`, generate What's New files, and commit the result.
You are preparing a new release for the Coil Android app. Your job is to determine the new version number, update `app/build.gradle.kts`, restructure `CHANGELOG.md`, generate the per-locale Play Store release notes under `fastlane/metadata/android/`, verify their character limits, and commit the result.

## Step 0 — Check readiness

Expand Down Expand Up @@ -56,31 +56,51 @@ Single question, two options: "Proceed" and "Cancel / change". If the user cance

## Step 4 — Apply changes

**4a. Create What's New files**
**4a. Create release-note (What's New) files**

Create `docs/whatsnew/X.Y.Z-{LOCALE}` files for Coil's launch locales:
Release notes live in the fastlane metadata tree, one file per locale, named after the **new versionCode** — not the versionName:

```
fastlane/metadata/android/{LOCALE}/changelogs/{NEW_VERSION_CODE}.txt
```

So bumping to versionCode 7 means creating `fastlane/metadata/android/en-US/changelogs/7.txt` and its four siblings. Getting this wrong is the most likely mistake in this step: a file named `0.2.0.txt` or one using the *old* versionCode will simply never be picked up, and the release ships with no notes.

**All five launch locales are mandatory.** There is no English fallback for store text — a locale with no file gets a blank What's New in the Play Store for that language. `google-play.yml` fails the deploy if any locale is missing a file for the versionCode being released, so an incomplete set blocks the release rather than shipping quietly:

- `en-US` (source language — write this one first, the others are translations of it)
- `de-DE`
- `fr-FR`
- `es-ES`
- `nl-NL`

Each file should contain a short user-facing summary of the release (**max 300 characters** to leave margin for translations, which can expand the text). Base the content on the `## [Unreleased]` section of `CHANGELOG.md`, but write it in plain language for end users — not a technical log. Do not copy changelog bullet points verbatim.
Each file contains a short user-facing summary of the release. Aim for **max 300 characters in the English draft** to leave headroom for translation expansion; the hard limit is 500 per locale (see the check below). Base the content on the `## [Unreleased]` section of `CHANGELOG.md`, but write it in plain language for end users — not a technical log. Do not copy changelog bullet points verbatim.

Only include changes that are visible or relevant to the user of the app itself. Exclude anything related to CI/CD workflows, GitHub Actions, GitHub Pages, the website, internal tooling, or other infrastructure — users don't see these.

**Translation quality:** per `AGENTS.md` and `README.md`, unreviewed machine translation is not acceptable for anything a user reads. Draft each locale's text yourself, but flag clearly to the user that the `de-DE`/`fr-FR`/`es-ES`/`nl-NL` drafts need a fluent-speaker review pass before the release actually ships — same standard as `res/values-<locale>/strings.xml`.
**Translation quality:** per `AGENTS.md` and `README.md`, unreviewed machine translation is not acceptable for anything a user reads. Draft each locale's text yourself, but flag clearly to the user that the `de-DE`/`fr-FR`/`es-ES`/`nl-NL` drafts need a fluent-speaker review pass before the release actually ships — same standard as `res/values-<locale>/strings.xml`. Store text deserves *more* care than in-app strings, not less: in-app strings fall back to English when missing, store text does not.

**4b. Verify the character limits**

Google Play's 500-character What's New limit is per locale and counted in characters. Translation length varies a lot by language (Romance/Germanic expand, CJK compress), so an in-limit English draft guarantees nothing about the rest.

Run the validator with the **new** versionCode:

```
tools/check_store_metadata.sh {NEW_VERSION_CODE}
```

(Invoke it through `bash` — the repo is developed on Windows with `core.filemode=false`, so the script's executable bit is not recorded in git.)

**Translation note:** Keep English concise (max 300 chars) as a starting point, but the 300/500 numbers are guidance for the *English draft* only, not a guarantee for the rest. Translation length varies a lot by language (Romance/Germanic languages tend to expand, CJK languages tend to compress) — a translation that started from an in-limit English draft can still end up over the limit.
It checks all five locales for a present, non-empty, in-limit file and exits non-zero listing every problem. If a locale is over, re-trim *that locale's* text (summarize or combine points) and re-run until it passes. Do not skip this because the English source was short, and do not hand-count instead: `wc -m` counts bytes rather than characters unless the shell locale is UTF-8, so on Windows it reports 4 for `für` and makes accented locales look over-limit. The script counts correctly and is the only measurement to trust here.

**Mandatory per-locale check — do this for every locale, not just English:** after writing (or translating) each `docs/whatsnew/X.Y.Z-{LOCALE}` file, check that specific file's character count (e.g. `wc -m`, not `wc -c`, since `wc -c` undercounts multi-byte UTF-8 content). No locale file may exceed 500 characters — Google Play's hard "what's new" limit is per-locale, not per-release. If any file is over, re-trim that locale's text (summarize/combine bullets) and re-check — do not assume it's fine because the English source was short.
Treat a non-zero exit as blocking — the same check runs in CI and will fail the deploy.

**4b. Update `app/build.gradle.kts`**
**4c. Update `app/build.gradle.kts`**

Replace the `versionCode` and `versionName` lines with the new values. Use Edit — do not rewrite the whole file.

**4c. Update `CHANGELOG.md`**
**4d. Update `CHANGELOG.md`**

Get today's date via: `date +%Y-%m-%d`

Expand All @@ -98,7 +118,7 @@ This preserves an empty Unreleased section for future work and stamps the releas

Run:
```
git add app/build.gradle.kts CHANGELOG.md docs/whatsnew/
git add app/build.gradle.kts CHANGELOG.md fastlane/metadata/android/
git commit -m "chore(release): bump version to X.Y.Z"
```

Expand Down
8 changes: 8 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# The wrapper script must stay LF whatever a contributor's core.autocrlf is set to: CRLF
# line endings make it unrunnable on the Linux CI runner.
gradlew text eol=lf
*.bat text eol=crlf
*.jar binary
# Golden screenshots. Marking them binary keeps Git from trying to diff or convert them, and
# keeps a merge conflict in one from producing an unopenable file.
*.png binary
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,41 @@ jobs:
path: app/build/reports/tests/
retention-days: 14

screenshots:
name: Screenshots
needs: detect
if: needs.detect.outputs.ready == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17

- uses: gradle/actions/setup-gradle@v3

# Compares every screen against the committed golden. Goldens recorded on a developer's
# machine have been checked against this runner and came out byte-identical — Robolectric
# brings its own fonts and Skia — so a locally recorded golden is expected to pass here.
#
# The filter excludes StoreAssetTest. Those images are products, not baselines — they are
# flattened after capture and so never match byte for byte, and a UI change should fail
# one job rather than two.
- name: Verify screenshots
run: ./gradlew :app:verifyRoborazziDebug --tests '*ScreenshotTest'

# The actual/expected/diff triptych for anything that moved — the point of the job is to
# be able to see what changed, not just that something did.
- name: Upload screenshot diffs
if: failure()
uses: actions/upload-artifact@v4
with:
name: screenshot-diffs
path: app/build/outputs/roborazzi/
retention-days: 14

lint:
name: Lint
needs: detect
Expand Down
39 changes: 36 additions & 3 deletions .github/workflows/google-play.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,43 @@ jobs:
contents: read

steps:
# Check out the tag being deployed, not the default branch: the versionCode below and the
# release notes keyed by it must come from the same commit the AAB was built from.
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.event.release.tag_name }}

- name: Resolve tag and version
id: resolve
run: |
TAG="${{ inputs.tag || github.event.release.tag_name }}"
VERSION="${TAG#v}"
CODE=$(sed -n 's/.*versionCode[[:space:]]*=[[:space:]]*\([0-9][0-9]*\).*/\1/p' app/build.gradle.kts | head -1)
NAME=$(sed -n 's/.*versionName[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' app/build.gradle.kts | head -1)

if [ -z "$CODE" ]; then
echo "::error::Could not read versionCode from app/build.gradle.kts"
exit 1
fi

# Guards against deploying a tag whose version bump never landed — that would pick
# the previous release's notes and upload them under the new version.
if [ "$NAME" != "$VERSION" ]; then
echo "::error::Tag $TAG implies versionName $VERSION but app/build.gradle.kts says $NAME"
exit 1
fi

echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "code=$CODE" >> "$GITHUB_OUTPUT"
echo "Deploying $TAG — versionName $NAME, versionCode $CODE"

# Fails the deploy if any locale is missing release notes for this versionCode, or if any
# locale's text is over Play's per-locale character limit.
# Invoked via `bash` deliberately: the repo is developed on Windows with core.filemode=false,
# so the script's executable bit is not recorded and `./tools/...` would fail here.
- name: Validate store metadata
run: tools/check_store_metadata.sh "${{ steps.resolve.outputs.code }}"

- name: Download AAB and mapping from GitHub Release
env:
Expand All @@ -56,14 +84,19 @@ jobs:
--pattern "mapping.txt" \
--dir release-assets

# Translate fastlane's layout (<locale>/changelogs/<versionCode>.txt) into the flat
# whatsnew-<locale> naming upload-google-play expects. The validate step above already
# guaranteed every launch locale has a file here.
- name: Prepare what's new
run: |
VERSION="${{ steps.resolve.outputs.version }}"
CODE="${{ steps.resolve.outputs.code }}"
mkdir -p whatsnew
for FILE in docs/whatsnew/${VERSION}-*; do
for DIR in fastlane/metadata/android/*/; do
LOCALE=$(basename "$DIR")
FILE="${DIR}changelogs/${CODE}.txt"
if [ -f "$FILE" ]; then
LOCALE=$(basename "$FILE" | sed "s/^${VERSION}-//")
cp "$FILE" "whatsnew/whatsnew-${LOCALE}"
echo "release notes: $LOCALE"
fi
done

Expand Down
81 changes: 81 additions & 0 deletions .github/workflows/screenshots.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: Record screenshots

# Re-recording is how a visual change is *accepted*, which is a decision rather than something
# that should happen on every push. There are therefore two deliberate ways to ask for it
# (a local `recordRoborazziDebug` is equally valid — the files come out the same):
#
# 1. `workflow_dispatch` — pick the branch in the Actions tab. GitHub only offers this once
# the workflow exists on the default branch, which for this repo means after `screenshots.yml`
# has reached `main` at a release.
# 2. A push to `develop` whose commit message contains `[record-screenshots]`. That works
# today, needs no default-branch registration, and keeps the opt-in explicit — put the
# marker in the commit that changes the UI.
#
# Either way the run commits the regenerated files back to the branch it ran on. It cannot be
# used on `main`: that branch takes pull requests only, so the push would be rejected.
on:
workflow_dispatch:
push:
branches: [develop]

permissions:
contents: write

jobs:
record:
name: Record and commit
if: >-
github.event_name == 'workflow_dispatch' ||
contains(github.event.head_commit.message, '[record-screenshots]')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17

- uses: gradle/actions/setup-gradle@v3

# Recording here rather than locally is about who is doing it, not about rendering: the
# two have been compared and produce identical files. This is the route for a change
# someone wants accepted without a local Android toolchain, and it keeps the baseline and
# the check in the same place by default.
- name: Record goldens
run: ./gradlew :app:recordRoborazziDebug --tests '*ScreenshotTest'

# Same run, same renderer, so the store listing and the website never show an older UI
# than the goldens do. `recordRoborazziDebug` is finalised by `flattenStoreAssets`, which
# strips the alpha channel Play rejects and copies the phone set into the Pages site.
- name: Record store and website assets
run: ./gradlew :app:recordRoborazziDebug --tests '*StoreAssetTest'

# The images are listing metadata, so they answer to the same validator as the listing
# text: format, alpha, dimensions, aspect ratio and count.
- name: Validate store images
run: tools/check_store_metadata.sh

- name: Commit updated screenshots
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add app/src/testDebug/screenshots fastlane/metadata/android docs/pages/assets
if git diff --cached --quiet; then
echo "Screenshots unchanged — nothing to commit."
exit 0
fi
git commit -m "test(screenshots): re-record screenshots on the CI runner"
git push

# Uploaded whether or not anything changed, so the run itself is reviewable: the images
# are the result here, and a commit hash does not show you what the app looks like.
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v4
with:
name: screenshots
path: |
app/src/testDebug/screenshots/
fastlane/metadata/android/en-US/images/
retention-days: 14
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
.gradle/
build/
.idea/
.kotlin/
local.properties
*.iml
*.jks
*.keystore

# JVM crash dumps
hs_err_pid*.log
replay_pid*.log
Loading
Loading