diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index 93b32437..87635c67 100644 --- a/.gitea/workflows/android.yml +++ b/.gitea/workflows/android.yml @@ -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'" diff --git a/docs/releases/README.md b/docs/releases/README.md index 9f0390f4..b5734346 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -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 diff --git a/docs/releases/whatsnew/TEMPLATE.txt b/docs/releases/whatsnew/TEMPLATE.txt new file mode 100644 index 00000000..66d7d438 --- /dev/null +++ b/docs/releases/whatsnew/TEMPLATE.txt @@ -0,0 +1,27 @@ +• +• +• + +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 "" 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.