Production access came through on 2026-08-01. Until now a `vX.Y.Z` tag could only reach `alpha` and someone had to promote it by hand in the Console; it now goes to `production` at 100% (`completed`). Canary is unchanged on `internal`, and its run-number versionCodes always outrank production, so testers keep getting the newer build. A tag therefore reaches real users with no further click. What keeps that honest: the tag is only pushed once every platform is green, and Play reviews each production release before it ships. Ramping instead is `--status inProgress --user-fraction 0.2` on the upload step. Play's "What's new" gets its own file, docs/releases/whatsnew/vX.Y.Z.txt — the vX.Y.Z.md body is ~34 KB against a 500-char cap, so it cannot be reused. Only tags have one; canary is a moving target and Play carrying the previous text over is fine for internal testers. Same freeze rule as the notes: once the tag exists, the file describes what that versionCode shipped. android-promote.yml is the lever for everything that is not a fresh tag — promote a tested build, halt a rollout, or roll production back onto an older versionCode. It is separate from android.yml because promotion must not rebuild, and an `if:` on all ten build steps is worse than one small workflow. dry_run defaults to true, so a mis-typed versionCode validates and deletes the edit instead of publishing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Release notes
One file per stable release: docs/releases/vX.Y.Z.md. Its contents become the Gitea release
body verbatim and are the source of the Discord #releases announcement.
Why this exists
Releases used to be created by CI with an empty body; the notes were pasted in by hand afterward. That left a window where the release — and anything announcing it — carried no notes. Now the notes are authored before the tag is pushed, as part of the version bump, so the release is born complete and the announcement always has something to say.
The flow
- Write the notes. Add
docs/releases/vX.Y.Z.mdin the same commit (or PR) as the version bump. CopyTEMPLATE.mdand fill it in. This file is the single source of truth for the body. - Tag & push.
git tag -a vX.Y.Z … && git push origin vX.Y.Zfans out to the build workflows. Whichever one wins the create race seeds the release body from this file (scripts/ci/gitea-release.sh→ensure_release, and its PowerShell twin). The release page shows the notes immediately. - Wait for green. Let every platform's CI finish and go green.
- Announce. Dispatch the
announceworkflow (.gitea/workflows/announce.yml) with the tag. It re-asserts this file over the live release (so any late edit wins) and posts an embed to Discord#releases. Pressing "go" is the quality gate — a half-built release is never announced. Stable-only; a-rctag is refused unlessallow_prerelease=true.
Editing the notes after the tag is fine: update this file, then re-run step 4 (or PATCH the body via the API) — the announce step always re-syncs from the file, so the file stays authoritative even across a tag re-point.
Canary / -rc builds have no file here on purpose: they get no curated body and are not
announced.
Google Play "What's new": whatsnew/vX.Y.Z.txt
Play shows its own release notes on the Play Store listing and in the Play Store app, and caps
them at 500 characters per language — the vX.Y.Z.md body is two orders of magnitude too
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.
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.
Voice & format
Write for the people who USE Punktfunk to stream their games and desktops — not for the people who
build it. A non-engineer should finish knowing what's new and whether it affects them; an engineer
should never be confused or forced to decode internals. (See any recent vX.Y.Z.md for the target.)
- Lead with the benefit. Each entry = what the user can now do, what now works, or what stopped going wrong — in their words. Implementation is not the story.
- No internal vocabulary in the body. No protocol/message names, code type names, hex codes or hardware IDs, crate/component names, or API symbols. Translate any essential detail to plain language. Name things users recognize (iPad, Apple Pencil, Steam Deck, Android TV, the Windows sign-in screen) — not subsystems.
- Group as New / Improved / Fixed, each a bold one-line lead-in + a tight plain explanation.
Skimmable. The lead-in text before the first
##is what the Discord announcement shows, so make it a real, plain-language summary. - Be specific and honest — no vague "various improvements"; a reader should know exactly what changed.
- Compatibility line up top, in plain terms: can they update one side at a time? does their existing setup keep working? No version numbers in the lead.
- All protocol / ABI / driver / embedder detail goes in ONE
## Under the hood (for developers)section at the very bottom — the only place internal names and version numbers belong, clearly optional. The old dense engineering style survives only there.
The short annotated-tag message stays separate and short (a headline + a paragraph); it is the tag object's message, not this file.