Script the release-notes copy-paste ritual - #53
Merged
Merged
Conversation
Every release page follows the same convention: only the newest one carries the full wrapper - description, screenshot, feature list, wiki link - and the release it replaces is stripped back to its first line with its changes folded into a collapsed <details> block. Done by hand that means copying the previous body, swapping the Changes, publishing, then going back to edit the old release. The half that gets forgotten is the demotion, and nobody notices until two releases both look like the current one. The wrapper is not stored in the script. It is read from the release being superseded and carried forward, which is exactly what the copy-paste does - so editing the wording on the latest release changes it for the next one, and the script never needs to know what a release page says. Same reason it reuses the previous heading's wording rather than imposing one: the exporter says "Changes" and the Android app says "What Changed since Last Release", and a tool should not rename either. Versions come from the source - VERSION in the wizard, versionName in the Gradle build - so the tag cannot disagree with what the app reports about itself. Releases are created as drafts, because the release workflow triggers on published: nothing builds until someone clicks the button. Demoting a published release asks first unless --yes. Two things found by running it rather than reading it: - subprocess defaults to cp1252 on Windows and the release bodies contain emoji, so reading a release died in a background thread and gh appeared to return nothing. Encoding is explicit now. - the two kinds of release space their sections differently - one has a blank line after the rule above the changes heading and one does not - so the preamble is preserved verbatim instead of being rebuilt from a guess. 15 tests, including a round trip: putting a release's own changes back through the builder must reproduce it exactly. Anything else means the script is quietly reformatting a page somebody wrote by hand, a little more on every release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 17:59 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 17:59 — with
GitHub Actions
Inactive
Demotion defaulted to the newest published release. But it runs *after* the new release goes out, so by then the newest is the one that must keep its wrapper - running it would have stripped the screenshot and feature list off the release published moments earlier. Found by running it: with 1.0.5 already published, `demote --kind exporter` offered to collapse 1.0.5. Now it picks the second newest, the one actually superseded, and refuses when only one release exists rather than guessing. Both tags are printed before the confirmation, so a wrong target is visible before answering rather than after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 18:11 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 18:11 — with
GitHub Actions
Inactive
Two claims went stale. The JVM unit tests said "almost nothing here - a green test run says very little". That was accurate this morning and is not now: the stream compositions and decision logic behind the map were extracted out of MapsActivity precisely so they could be tested there, and it is the fastest suite in the project. The scripts/ suite was described as string tooling. It now also covers the release-tag version check and the release-notes script, which is worth naming because each of the three guards something else - and a bug in a guard reports green while protecting nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 18:20 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 18:20 — with
GitHub Actions
Inactive
Running `draft --kind android` today computed android-app-v1.0.4 - the tag already released - because versionName in the Gradle build has not been bumped. The script happily offered to create a draft on top of a published release, with notes describing changes that release does not contain. Reading the version from the source is what makes the tag trustworthy, and it is also what makes forgetting to bump it produce a plausible-looking wrong answer rather than an error. Now it stops, and says which file to bump for that kind of release. Drafts count as taken too: a half-prepared release still occupies the tag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 18:33 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 18:33 — with
GitHub Actions
Inactive
Rebases onto the rename, and removes the duplication the rename exposed: release_notes.py had its own copy of where each version lives, including a second regex for versionName in the Gradle build. Two readers of the same fact can drift, and the failure here would be a release page describing one version while the build inside it reports another - the exact thing release_version.py exists to prevent. It also now owns both tag prefixes, so there is one place that decides what a release is called. What is left in this script's KINDS is only what a release *page* needs: the title, and which file to tell someone to bump. Tests assert the two modules agree rather than duplicating the knowledge again: that the versions come from release_version's readers, that both tag prefixes match what their workflow filters on - build-release.yml included, which was never covered - and that neither module knows a kind the other does not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 20:06 — with
GitHub Actions
Inactive
parawanderer
temporarily deployed
to
Android Build
August 9, 2026 20:06 — with
GitHub Actions
Inactive
This branch was previously deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
scripts/release_notes.pydoes the release-page convention that is currently done by hand.The convention
Only the newest release carries the full wrapper — description, screenshot, feature list, wiki link. The release it replaces gets stripped back to its first line, with its changes folded into a collapsed
<details><summary>Summary</summary>block.By hand that is: copy the previous body, swap the Changes section, publish, then go back and edit the old release. The half that gets forgotten is the demotion, and nobody notices until two releases both look like the current one.
How it works
python scripts/release_notes.py draft --kind exporter --changes-file notes.md --dry-run python scripts/release_notes.py draft --kind exporter --changes-file notes.md # publish from the UI, then: python scripts/release_notes.py demote --kind exporterThe wrapper is never stored in the script. It is read from the release being superseded and carried forward — which is what the copy-paste does anyway. Editing the wording on the latest release is enough to change it for the next one, and the script never needs to know what a release page says.
For the same reason it reuses the previous heading's wording rather than imposing one: the exporter says
### Changes, the Android app says### What Changed since Last Release, and a tool should not rename either.--kind androidworks on the app releases.Versions come from the source —
VERSIONin the wizard,versionNameinapp/build.gradle.kts— so the tag cannot disagree with what the app reports about itself, and it composes with the check added in #49 that fails a release whose tag and source disagree.Releases are created as drafts:
macos-exporter-python.ymltriggers onpublished, so nothing builds until someone clicks the button. Demoting a published release prints the result and asks first unless--yes.Two bugs it had, found by running it
subprocessdefaults to cp1252 on Windows and the release bodies contain emoji (❓,👉), so reading a release died in a background thread andghappeared to have returned nothing at all. Encoding is explicit now.Tests
15, covering the parsing, which is the fragile part: it works by finding a heading in prose a human wrote and may reword.
The one worth pointing at is a round trip — putting a release's own changes back through the builder must reproduce that release exactly. Anything else means the script is quietly reformatting a page somebody wrote by hand, a little more on every release. There is also a check that the tag prefix matches what the release workflow filters on, since a typo there would create a release that silently never builds anything.
Verified
macos-exporter-v1.0.4and reproduce the ritual exactly, including the collapsed form matching howv1.0.3was demoted by handpytest scripts/testgreen (58), flake8 and pyright clean🤖 Generated with Claude Code