Files
enricobuehler a2dc011200 release: 0.31.2 — version bump, notes, CHANGELOG, Play notes
10 commits since v0.31.1 (6 non-merge). Cut from origin/main 48eeae75 (#368
merged).

THE NUMBER: a patch, and unlike the last cut the version table does not even
have to argue for it. Nothing versioned moved — WIRE_VERSION 2, C ABI 25 with
include/punktfunk_core.h showing NO diff at all against the v0.31.1 tag (not
even a #define, unlike the last two releases), driver protocol 6 / min 3 with
pf-driver-proto unchanged, gamepad channel 3, plugin index schema 1, host event
schema 1, gamescope +pfhdr8 with no new patch files, SDK 0.1.5 and plugin-kit
0.4.4 both untouched. No `!` commit, no feat, no route added or removed, no
breaking change of any kind. Every non-merge commit is fix/refactor/test.

The cycle has a shape: three of the six non-merge commits are the same class of
fault — the host using the wrong local address — reached from three directions.
The data socket bound 0.0.0.0:0 and let routing pick the video source, which the
client's connected socket then dropped in-kernel (#367). Host::detect() froze the
advertised address at process start, so a cold boot that beat the network pinned
127.0.0.1 for the life of the process and broke both mDNS adverts, the Moonlight
session URL, the WoL mac record and HostInfo together (#366). And the firewall
rules guarding the ports those addresses point at admitted any program on the
machine (#368). The fourth is an Android regression from v0.31.1 (#365); the
remaining two are the refactor and test supporting #366.

api/openapi.json changes in DOCUMENTATION ONLY this time — two description
strings on HostInfo, no route, schema, required field or type — plus the stamp.
Re-stamped here, not regenerated: punktfunk-host does not build on macOS, and
#366 regenerated the document itself on a runner where
openapi_document_is_complete_and_checked_in actually executes. "0.31.1" appears
nowhere in either copy afterwards, and the two copies are byte-identical.

That description change is load-bearing rather than cosmetic, so it is called out
as a behaviour change in the CHANGELOG beside the firewall one: HostInfo.local_ip
was a field snapshotted at detect() and is now a method that re-reads per
request, so a consumer that cached it at startup was caching a value that could
be 127.0.0.1 forever.

The other behaviour change is the externally visible one: Windows service install
now scopes all five fixed-port rules to the listening executable while keeping
their localport=, so 5353 is punktfunk's alone and anything else on the machine
that was reachable on mDNS through our any-program rule needs its own. Fallbacks
are asymmetric on purpose — a fixed-port rule that cannot resolve its exe falls
back to the old wide form (a looser rule still streams), while the data-plane
rule skips (it has no port to fall back to, so a program-less version would not
be looser, it would be open).

Also in this commit, because a cut is when docs freshness bites:
docs-site/content/docs/ports.mdx. Its "Video needs nothing opened" bullet has
been wrong for Windows since v0.31.1 added the data-plane rule — it now says so
and names why (no fixed rule can cover a per-session ephemeral port). And the
Windows line gains a Callout for the 5353 change above, since that is the one
thing on this page a reader may have to act on. Callout shape copied from the
proven usage in plugins.mdx (no `title` prop — node_modules is not installed here
and fumadocs' prop surface could not be verified offline).

Play notes are Android-only per whatsnew/TEMPLATE.txt, which this cycle means the
#365 regression alone. The three host-side fixes are deliberately NOT in there:
updating the app does not fix any of them, so listing them on the store page
would promise something the download does not deliver.

Gates: cargo fmt --all --check clean; cargo metadata --offline ok with the
Cargo.lock diff versions-only (36/36); cargo test -p punktfunk-core --lib 273
passed; the C ABI harness PASSED reporting abi_version=25 (needed `brew install
opus` on this Mac to link — the first run failed on the missing library, not on
the code); cbindgen regenerated include/punktfunk_core.h during that build and it
came out byte-identical to the checked-in file AND to the v0.31.1 tag, which is a
stronger check on the ABI row than diffing it; scripts/ci/check-docs-drift.sh
clean; scripts/ci/check-docs-links.sh clean; the android.yml Play notes gate run
verbatim, 357/500 characters and unique; both openapi copies cmp identical and
stamped 0.31.2; notes voice scan clean (one backticked term in the whole file,
the `punktfunk-host service install` command, and the only technical vocabulary
sits inside `## For developers`).

Not run here, and why: clippy and any punktfunk-host build (does not compile on
macOS — CI covers it), and the Android unit tests (:kit: and :app: were run on
#365 itself; nothing in this commit touches Kotlin).

One judgement call left for the tag: SECURITY.md promises to credit a reporter in
the release notes when the fix is public, and the #368 commit records only "a
user on 2026-08-21" with no name. The notes credit them unnamed. If they want
their name on it, that is a one-line edit to docs/releases/v0.31.2.md before the
tag is pushed.
2026-08-21 20:04:23 +02:00
..

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

  1. Write the notes. Add docs/releases/vX.Y.Z.md in the same commit (or PR) as the version bump. Copy TEMPLATE.md and fill it in. This file is the single source of truth for the body. Docs freshness, while you have the diff in front of you: every user-facing fact the release changes has its docs-site page updated (CONTRIBUTING.md "Where facts live" — docs-drift in CI catches renamed knobs and dead links, not a stale sentence). If an install command, repo URL or port changed, data/platforms.json changed with it — then run bun run sync-platforms in punktfunk-website and commit, because its download page vendors that file and only refreshes when someone does. Same pass for the website itself: does the landing page still describe what this release ships (features, platforms, the blog post the CMS expects per release)?
  2. Tag & push. git tag -a vX.Y.Z … && git push origin vX.Y.Z fans out to the build workflows. Whichever one wins the create race seeds the release body from this file (scripts/ci/gitea-release.shensure_release, and its PowerShell twin). The release page shows the notes immediately.
  3. Wait for green. Let every platform's CI finish and go green.
  4. Announce. Dispatch the announce workflow (.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 -rc tag is refused unless allow_prerelease=true.

If a platform's run never appears, do not re-run the PR run — it cannot publish. A re-run replays the original event (pull_request), and android's publish steps are gated on a push, so they stay skipped no matter how often you press it. Merging two PRs seconds apart can leave the older merge sha with no run at all — Gitea attributes the window's runs to the newer head (2026-08-14: 1e5dca4c lost its run to b5cace3a, 12 s later), which is how an android change reaches main having never been built. Recover it by dispatching android.yml on that ref with publish=true; that is the only manual path reaching the registry and Play, and a plain dispatch stays build-only so a stray click can't ship to testers. Check for the gap by matching your own merge sha in the run list — "CI ran" is not the same as "your commit ran".

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 (Play renders no markdown), one bullet per line, same voice rules as below. Copy whatsnew/TEMPLATE.txt.

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; open-testing users see the previous release's text on a canary, which is cosmetic and cheaper than gating every main push on a notes file.

Same freeze rule as the notes: once the tag exists, this file is the record of what that versionCode shipped.

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.)

  1. 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.
  2. 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.
  3. 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.
  4. Be specific and honest — no vague "various improvements"; a reader should know exactly what changed.
  5. 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.
  6. No protocol / ABI / driver / embedder detail in this file at all. It goes in the root CHANGELOG.md (see below), and the notes carry a single short ## For developers section linking there. Nothing else in vX.Y.Z.md may use an internal name.
  7. Open with a ## TL;DR — three to six bullets naming only what most readers would be sorry to miss, each one line. A large release is exactly where a reader gives up, and the TL;DR is what they read instead of giving up. If something needs the reader to act, it belongs here and in ## Before you update, not buried in ## Fixed.

The technical half: root CHANGELOG.md

Why it is separate. Through v0.24.0 the engineering detail lived in an ## Under the hood (for developers) section at the bottom of each release's notes. That worked while releases were small. It stopped working: v0.25.0 is 300+ commits, and the section had grown long enough to bury the user-facing half it was appended to — the exact failure the voice rules exist to prevent. The two audiences also want different shapes. A user reads one release and wants prose; an embedder wants to diff across releases and see when the ABI moved, which is a table, not a paragraph.

So: vX.Y.Z.md is for people who use Punktfunk, CHANGELOG.md is for people who build against it, and neither has to compromise for the other.

Format. Newest release first, one ## vX.Y.Z section each. Lead with a version table (wire protocol, C ABI, driver protocol, gamepad channel — every row, marked unchanged where it did not move, because "unchanged" is the answer an embedder most often needs). Then breaking changes, then whatever else matters: capability bits, new environment variables, wire additions, workspace members. Internal names are the point here — use them.

Linking. The notes link to the file at the tag, not at main: https://git.unom.io/unom/punktfunk/src/tag/vX.Y.Z/CHANGELOG.md. A release's notes are frozen; a link to main would silently start describing a later release.

Same freeze rule. Add the release's section in the version-bump commit, alongside the notes.

The short annotated-tag message stays separate and short (a headline + a paragraph); it is the tag object's message, not this file.