feat(ci/android): a release tag without Play notes fails before it builds
ci / rust (push) Canceled after 5m55s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
android / android (push) Successful in 6m28s

Play does not show an empty "What's new" when the file is missing — it carries
the PREVIOUS release's text onto the new version. So the store listing ends up
describing a build nobody is getting, and nothing surfaces it except reading
the listing. That is the shape of the v0.22.3 notes announcing a feature the
tag never contained, and a soft warning in a log nobody reads does not prevent
it.

The gate runs FIRST in the job, before the ten-minute build: a miss costs a
second and leaves nothing half-published — no build, no assets on the Gitea
release, nothing on Play. It rejects three things: a missing file, a file
byte-identical to another release's (the same bug reached by copy-paste rather
than omission), and an empty or over-500-char one.

Length is checked here as well as in play-upload.py on purpose. The uploader
stays the last line of defence and is the only check android-promote.yml gets,
but it runs at step 9; this catches an unedited TEMPLATE copy at step 1. It
counts CHARACTERS, not bytes — Play's cap is 500 chars and `•` is three bytes
in UTF-8, so a `wc -c` check would have called the 356-char v0.23.0 notes 365
and can reject a legal file.

whatsnew/TEMPLATE.txt gives the file a starting point and says what the gate
does and does not enforce: it cannot tell whether the prose was ever edited, so
a copy that still reads "<The headline change>" ships exactly as written.

Canary stays exempt — no curated notes, and Play reusing text for internal
testers costs nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 10:34:32 +02:00
co-authored by Claude Opus 5
parent 02a5bdb965
commit e0427a3bb6
3 changed files with 100 additions and 9 deletions
+53 -3
View File
@@ -76,6 +76,56 @@ jobs:
steps:
- uses: actions/checkout@v4
# FIRST, because it costs a second and everything after it costs ten minutes.
#
# A release tag MUST carry its own Play "What's new". If the file is absent Play does not
# show nothing — it carries the PREVIOUS release's text onto this version, so production
# users read notes for a build they are not getting. That is the same defect the v0.22.3
# notes shipped (see docs/releases/README.md), and it is invisible until someone reads the
# store listing. Failing here also means a missing file cannot leave a half-published
# release: nothing is built, nothing is attached to the Gitea release, nothing reaches Play.
#
# Canary is exempt on purpose: it has no curated notes, and Play reusing text for internal
# testers costs nothing.
- name: Play release notes gate (tags only)
if: startsWith(github.ref, 'refs/tags/v')
run: |
NOTES="docs/releases/whatsnew/${GITHUB_REF_NAME}.txt"
if [ ! -f "$NOTES" ]; then
echo "ERROR: $NOTES does not exist."
echo "A production release needs its own Play 'What's new' (<=500 chars, written for"
echo "phone/TV users). Without it Play reuses the previous release's text."
echo "See docs/releases/README.md; copy docs/releases/whatsnew/TEMPLATE.txt."
exit 1
fi
# A verbatim copy of another release's file is the same bug wearing a hat: the store
# listing still describes the wrong build. Cheap to check, and only ever trips on an
# actual copy-paste that was never edited.
for other in docs/releases/whatsnew/*.txt; do
if [ "$other" != "$NOTES" ] && [ "$other" != "docs/releases/whatsnew/TEMPLATE.txt" ]; then
if cmp -s "$NOTES" "$other"; then
echo "ERROR: $NOTES is byte-identical to $other."
echo "Write notes describing THIS release, not the one before it."
exit 1
fi
fi
done
# Length is checked here as well as in play-upload.py. Not redundant: the uploader is
# the last line of defence (and the only one android-promote.yml gets), but it runs at
# step 9 — this catches an unedited TEMPLATE copy at step 1 instead of after the build.
# Must count CHARACTERS, not bytes: Play's cap is 500 chars and `•` is 3 bytes in UTF-8,
# so `wc -c` would reject a file that is comfortably legal.
python3 - "$NOTES" <<'PY'
import sys
path = sys.argv[1]
text = open(path, encoding="utf-8").read().strip()
if not text:
sys.exit(f"ERROR: {path} is empty.")
if len(text) > 500:
sys.exit(f"ERROR: {path} is {len(text)} chars; Play allows 500. Trim it.")
print(f"Play release notes OK: {path} ({len(text)}/500 chars)")
PY
# Everything below the checkout used to be four download steps (JDK, SDK,
# NDK+CMake, cargo-ndk — the flakiest, heaviest part of the job); it is all baked
# into the image now. This guard only re-asserts the Android targets so a
@@ -129,13 +179,13 @@ jobs:
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
# Play's own "What's new" (500-char cap, its own file — the vX.Y.Z.md body is ~34 KB).
# Only a tag has one: canary is a moving target, and for internal testers Play carrying
# the previous text over is fine. See docs/releases/README.md.
# On a tag the gate step above already proved this exists, so the else branch is only
# ever the canary path. See docs/releases/README.md.
NOTES="docs/releases/whatsnew/${GITHUB_REF_NAME}.txt"
if [ -f "$NOTES" ]; then
echo "PLAY_NOTES=$NOTES" >> "$GITHUB_ENV"
else
echo "no Play release notes at $NOTES"
echo "no Play release notes at $NOTES (canary — Play keeps the previous text)"
fi
echo "android version $VN -> Play track '$TRACK'"
+20 -6
View File
@@ -38,13 +38,27 @@ them at **500 characters per language** — the `vX.Y.Z.md` body is two orders o
long, so it gets its own short file: `docs/releases/whatsnew/vX.Y.Z.txt`.
Write it for a **phone/TV user**, not a host operator: only what changed in the Android app is
worth their 500 characters. Plain text, one `•` bullet per line, same voice rules as below.
`clients/android/ci/play-upload.py` refuses to run if the file is over the cap and prints the
actual count, so a too-long file fails the release job instead of reaching Play.
worth their 500 characters. Plain text (Play renders no markdown), one `•` bullet per line, same
voice rules as below. Copy `whatsnew/TEMPLATE.txt`.
Same freeze rule as the notes: once the tag exists, this file describes what that versionCode
shipped. If it is missing, the release still publishes — Play just carries the previous release's
text over, which is worse than a rushed sentence, so write it with the bump.
**A `vX.Y.Z` tag without this file fails the android job before it builds.** This is a hard gate,
not a warning, because the failure it prevents is silent: when the file is missing Play does not
show an empty "What's new" — it **carries the previous release's text onto the new version**, so
the store listing describes a build nobody is getting, and nothing surfaces that but reading the
listing. It is the same shape as the v0.22.3 notes announcing a feature that release never
contained. The gate also rejects a file byte-identical to another release's, which is that bug
reached by copy-paste instead of by omission.
The gate runs first in the job, so a miss costs a second and leaves nothing half-published —
no build, no assets on the Gitea release, nothing on Play. Two more checks sit downstream:
`play-upload.py` refuses text over the 500-char cap (printing the real count) before it uploads,
because the API only rejects oversized notes at commit, by which point the AAB is already on Play.
Canary is exempt: it has no curated notes, and Play reusing text for internal testers costs
nothing.
Same freeze rule as the notes: once the tag exists, this file is the record of what that
versionCode shipped.
## Voice & format
+27
View File
@@ -0,0 +1,27 @@
• <The headline change, in what the user gets — not what was built.>
• <Second thing. Name devices they recognize: a phone, an Android TV, a DualSense.>
• <Third. Stop at what fits; 500 characters is the hard cap, and it is small.>
HOW TO USE THIS FILE
Copy to vX.Y.Z.txt, replace every line, delete this block. It is skipped by the CI gate by
name, so leaving it in place never blocks a release — but a copy that still says "<The
headline change>" will ship to the Play Store exactly as written. Nothing checks your prose.
WHAT BELONGS HERE
Only what changed in the ANDROID APP. Host-side work — Windows installers, gamescope, the web
console — means nothing to someone reading a phone app's store listing, and it is spending
characters you do not have. The full release notes in ../vX.Y.Z.md are where that lives.
RULES THE CI GATE ENFORCES (.gitea/workflows/android.yml)
* The file must exist for a vX.Y.Z tag, or the release fails before it builds. Play does not
show "no notes" when the file is missing — it carries the PREVIOUS release's text onto the
new version, and nobody notices until they read the store listing.
* It must not be byte-identical to another release's file.
* play-upload.py refuses anything over 500 characters and prints the real count.
RULES NOTHING ENFORCES
* Plain text. No markdown — Play renders none of it, so `**bold**` ships as asterisks.
* One `•` bullet per line.
* Same voice as ../README.md: lead with what the user can now do, no internal vocabulary.
* Frozen once the tag exists. Editing it later does not change what already shipped; it only
misleads the next person who reads it as the record of that versionCode.