Skip to content

Script the release-notes copy-paste ritual - #53

Merged
parawanderer merged 7 commits into
mainfrom
feat/release-notes-script
Aug 9, 2026
Merged

parawanderer merged 7 commits into
mainfrom
feat/release-notes-script

Conversation

@parawanderer

Copy link
Copy Markdown
Owner

scripts/release_notes.py does 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 exporter

The 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 android works on the app releases.

Versions come from the source — VERSION in the wizard, versionName in app/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.yml triggers on published, 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

  • 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 have returned nothing at all. 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. The preamble is preserved verbatim rather than rebuilt from a guess about separators.

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

  • Both commands dry-run against the real macos-exporter-v1.0.4 and reproduce the ritual exactly, including the collapsed form matching how v1.0.3 was demoted by hand
  • pytest scripts/test green (58), flake8 and pyright clean

🤖 Generated with Claude Code

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>
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 and others added 2 commits August 9, 2026 20:19
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>
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 and others added 2 commits August 9, 2026 21:37
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
parawanderer merged commit 5df3ca4 into main Aug 9, 2026
3 checks passed

This branch was previously deployed

1 inactive deployment
Android Build — fd5d0d9e Deployed Aug 9, 2026 by parawanderer via build #42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant