Skip to content

Build the release page from the tag's CHANGELOG section - #54

Merged
aldegad merged 3 commits into
mainfrom
feat/release-page-gate
Sep 22, 2026
Merged

aldegad merged 3 commits into
mainfrom
feat/release-page-gate

Conversation

@aldegad

@aldegad aldegad commented Sep 22, 2026

Copy link
Copy Markdown
Owner

A vX.Y.Z tag can be pushed, changelogged and announced while the release page is never created — v2.5.3 was, and the Releases list kept showing v2.5.2 as Latest. This makes the page a consequence of the tag instead of something to remember.

What it adds

  • .github/workflows/release.yml — push: tags: v* builds the wheel, the sdist and SHA256SUMS from the tag's commit and publishes the release page. workflow_dispatch runs the same decision with --dry-run against a branch, so the body and the asset list can be read before a tag exists.
  • scripts/release_notes.py — the body is the ## vX.Y.Z section of CHANGELOG.md. No section, an empty section, or an explicit docs/assets/*.gif that is not in the checkout all fail before anything is published. Link targets become raw URLs, because a release body is not rendered relative to the repository; a path written in a sentence stays a path.
  • scripts/release_publish.py — three outcomes and no fourth: released is a no-op, missing is a create, unreadable is a stop. Only gh's own release not found counts as missing, so a 503 cannot be answered by creating a page, and re-running a tag changes nothing.
  • tests/release/ — 28 tests over the section rules, the asset rules and the three outcomes against a stub gh. One of them renders this repository's own current version, so a release commit whose CHANGELOG section is missing or names an uncommitted clip fails CI while the tag can still be fixed.
  • docs/release.md — the procedure, indexed in docs/README.md.

Two parser fixes from review

Both were reachable only from a CHANGELOG this repository does not have yet, and both cost one character plus one test.

  • _heading_lines says "a ## … inside a fenced block is code, not a section" but toggled on backticks alone, so a ~~~ block's ## vX.Y.Z line cut the section short. A fence is now closed only by the character that opened it.
  • version_of accepts a suffixed tag, so v1.0.0-rc1 is legal, but the heading guard (?![\w.]) did not refuse - — the tag v1.0.0 matched a ## v1.0.0-rc1 heading and would have published the release candidate's notes as the release's. The guard is (?![\w.-]), and the "what does exist" hint carried the same gap, which made the new failure contradict itself ("no ## v1.0.0 section. Newest sections: v1.0.0").

The rule worth knowing

The workflow attaches a showcase GIF only when the CHANGELOG section names it as docs/assets/<name>.gif and that file is committed here. A clip produced outside the repository — v2.5.4's — cannot be attached by the gate at all; that case stays a manual gh release upload after the page exists. docs/release.md says so.

Verification

  • Full suite on this branch: 1898 passed, 1 skipped (250s).
  • actionlint 1.7.12 on release.yml and ci.yml: no findings.
  • Write-path idempotency run against a stub gh with a disk-backed release store: second run reports "already has a release page … body and assets left untouched", store diff 0.
  • Reverting either parser fix fails its new test.
  • No tag was created and no release was published by this branch.

v2.5.3 was tagged, changelogged and announced without anyone running
gh release create, so the Releases list kept showing v2.5.2 as Latest.
The answer is a gate rather than a reminder: pushing vX.Y.Z now renders
the release body from the `## vX.Y.Z` section of CHANGELOG.md, builds the
wheel, the sdist and SHA256SUMS, and publishes the page.

release_notes.py owns the body. No section for the tag, an empty section,
or an explicit docs/assets/*.gif that is not in the checkout all fail
before anything is published. Link targets become raw URLs because a
release body is not rendered relative to the repository; a path written
in a sentence stays a path. A bare <name>.gif is attached when
docs/assets holds it, which is how the CHANGELOG names continuation
clips: the v2.5.3 section writes one full path and four bare names, and
all five of those clips are on that release page.

release_publish.py owns the decision and has three outcomes — released is
a no-op, missing is a create, unreadable is a stop. Only gh's own
"release not found" counts as missing, so a 503 cannot be answered by
creating a page, and re-running a tag changes nothing.

The workflow is a wrapper around that script so the decision can be run
outside Actions: workflow_dispatch runs it with --dry-run against a
branch, and only a tag push creates anything.

tests/release covers the section rules, the asset rules and the three
outcomes against a stub gh. One test renders this repository's own
current version, so a release commit whose CHANGELOG section is missing
or names an uncommitted clip fails CI while the tag can still be fixed.
… its rc

Two holes found by reviewing the notes renderer against its own stated
contract, both reachable only from a CHANGELOG this repository does not
have yet — which is exactly when they are cheap to close.

_heading_lines documents "a `## …` inside a fenced block is code, not a
section", but it toggled on backticks alone. Markdown fences both ways,
and quoted markdown is written with `~~~` precisely so the backticks
inside it stay readable — the CHANGELOG entry most likely to contain a
`## vX.Y.Z` line was the one spelling the rule did not cover. A fence is
now closed only by the character that opened it, so a ``` line inside a
`~~~` block is content and vice versa.

version_of accepts `vX.Y.Z` with a suffix, so v1.0.0-rc1 is a legal tag;
the heading guard `(?![\w.])` did not refuse `-`, so the tag v1.0.0
matched the `## v1.0.0-rc1` heading and a release candidate's notes would
have been published as the release's. The guard is `(?![\w.-])` now. The
"what does exist" hint had the same character missing from its own
pattern, which made the new failure contradict itself: it refused
`## v1.0.0` and then reported "Newest sections: v1.0.0".

One test each. Reverting either fix fails its test.
The release procedure lived only in an untracked local file, so the one
rule that is not obvious from the workflow had nowhere to be read: the
gate attaches a showcase GIF only when the CHANGELOG section names it as
docs/assets/<name>.gif *and* that file is committed here. A clip produced
outside the repository — v2.5.4's, for one — cannot be attached by the
workflow at all, so that case is an explicit gh release upload after the
page exists, not a silently missing clip.

The rest is the chain a release is: release commit, PR to main, tag push,
the workflow builds the page, and `gh release view` is what says it is
done. Also written down: an existing page is left untouched, what makes
the job fail instead of publishing something wrong, and that gh answers
"release not found" for a repository it cannot see, so that sentence is
not proof a release is missing.
@aldegad
aldegad merged commit 476943f into main Sep 22, 2026
1 check passed
@aldegad
aldegad deleted the feat/release-page-gate branch September 22, 2026 02:34
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