feat(checks): split an unreadable version heading out of heading-form #14

Merged
mikluko merged 6 commits from mikluko/octant/1721-unreadable-version into main 2026-09-06 19:36:46 +00:00
mikluko commented 2026-09-05 16:22:19 +00:00 (Migrated from github.com)

Bottom of a three-PR stack. The release ceremony is the top one, so merging this alone cuts no tag.

The defect

A ## heading whose version the parser cannot read was skipped rather than kept, so the entry beneath it became the newest and every output described that one instead. heading-form was the only thing reporting it, and heading-form is configurable off.

Measured on main before the fix, with off: heading-form:

## [9.9]            valid=true
## [9.8.0] - date   version=9.8.0, already-tagged=false, notes= the 9.8.0 body

Nothing fires, valid is true, and a workflow reading those outputs cuts a tag for the wrong release and publishes the previous release's notes under it.

The loud case was loud only by luck. With the skipped heading above an entry that happens to sit below the reference tag, version-behind-tag fires — reporting a true thing about the wrong entry, for a reason unrelated to the fault. Move the tag and it goes silent.

There is a path leading straight into it. A repository that tries a two-component heading finds it rejected and reaches for off: heading-form as the obvious next move, which is exactly the configuration that turns a loud rejection into a wrong release.

The fix

heading-form was doing two jobs and only one of them is a policy. Its name, description and configurability all say it is about heading shape, which is a style question a repository may reasonably differ on. It was also the only thing standing between an unparseable version heading and a silently shifted version, which is not a style question at all.

D1629 settled that a check catching a defect defaults to error and a check encoding a policy defaults to off. A check that is both cannot honour that, and switching off the policy switched off the defect. Same split undated-entry / undated-release needed, for the same reason.

unreadable-version, default error, fires where a ## heading is neither [Unreleased] nor a heading whose version parses. heading-form keeps the shape question it is named for. Sixteen checks.

version now reports nothing for such a document, which is the existing "names no version" case reached by construction rather than by a second branch: Latest() stops at the first non-[Unreleased] entry and reports false where its version cannot be read. So version and notes are empty, already-tagged and prerelease false, latest-tag still the reference tag — a fact about the repository rather than the document — and valid false.

A consuming workflow already guards on [ -n "$VERSION" ], so this lands on the existing no-op path rather than a new failure mode.

After:

valid=false   version=   notes=   already-tagged=false
+ unreadable-version, with heading-form off

Two knock-ons, both intended

version-behind-tag stops firing on such a document: there is nothing to compare, and its firing was incidental to begin with. And an undated entry below an unreadable heading takes heading-form rather than undated-entry, since it is no longer the newest.

Verification

TestOutputsDescribeNoEntryUnderAnUnreadableHeading is the demonstration: it was written before the fix and fails on main with version="9.8.0", valid="true" and the 9.8.0 body in notes. Beside it, TestAnUnreadableHeadingBelowTheNewestEntry, TestLintUnreadableVersionIsSeparateFromHeadingForm (on by default, still fires with heading-form off, [Unreleased] silent), TestLatestStopsAtAnUnreadableHeading, and a TestLintFindings row. Mutation-checked both directions.

go test ./... green, go vet ./... and gofmt -l clean, gen-readme idempotent, -list-checks reports sixteen.

Release

The entry is under ## [Unreleased]. No version heading and no action.yaml bump — those are the ceremony at the top of the stack, so the release is armed once, when the whole stack lands, and its notes cover everything in it.

action.yaml is touched only in the version, notes and prerelease output descriptions, which claimed the value came from "the newest versioned entry" and would otherwise ship false.

AI assistance

Written by Claude Code (Opus 5) under an octant worker contract; the defect was measured independently before and after.

Octant B1721.

**Bottom of a three-PR stack.** The release ceremony is the top one, so merging this alone cuts no tag. ## The defect A `##` heading whose version the parser cannot read was **skipped rather than kept**, so the entry beneath it became the newest and every output described that one instead. `heading-form` was the only thing reporting it, and `heading-form` is configurable off. Measured on `main` before the fix, with `off: heading-form`: ``` ## [9.9] valid=true ## [9.8.0] - date version=9.8.0, already-tagged=false, notes= the 9.8.0 body ``` Nothing fires, `valid` is **true**, and a workflow reading those outputs cuts a tag for the wrong release and publishes the previous release's notes under it. The loud case was loud only by luck. With the skipped heading above an entry that happens to sit below the reference tag, `version-behind-tag` fires — reporting a true thing about the wrong entry, for a reason unrelated to the fault. Move the tag and it goes silent. **There is a path leading straight into it.** A repository that tries a two-component heading finds it rejected and reaches for `off: heading-form` as the obvious next move, which is exactly the configuration that turns a loud rejection into a wrong release. ## The fix **`heading-form` was doing two jobs and only one of them is a policy.** Its name, description and configurability all say it is about heading *shape*, which is a style question a repository may reasonably differ on. It was also the only thing standing between an unparseable version heading and a silently shifted `version`, which is not a style question at all. D1629 settled that a check catching a defect defaults to error and a check encoding a policy defaults to off. A check that is both cannot honour that, and switching off the policy switched off the defect. Same split `undated-entry` / `undated-release` needed, for the same reason. **`unreadable-version`**, default `error`, fires where a `##` heading is neither `[Unreleased]` nor a heading whose version parses. `heading-form` keeps the shape question it is named for. Sixteen checks. **`version` now reports nothing** for such a document, which is the existing "names no version" case reached by construction rather than by a second branch: `Latest()` stops at the first non-`[Unreleased]` entry and reports false where its version cannot be read. So `version` and `notes` are empty, `already-tagged` and `prerelease` false, `latest-tag` still the reference tag — a fact about the repository rather than the document — and `valid` false. A consuming workflow already guards on `[ -n "$VERSION" ]`, so this lands on the existing no-op path rather than a new failure mode. After: ``` valid=false version= notes= already-tagged=false + unreadable-version, with heading-form off ``` ## Two knock-ons, both intended `version-behind-tag` stops firing on such a document: there is nothing to compare, and its firing was incidental to begin with. And an undated entry *below* an unreadable heading takes `heading-form` rather than `undated-entry`, since it is no longer the newest. ## Verification `TestOutputsDescribeNoEntryUnderAnUnreadableHeading` is the demonstration: it was written before the fix and fails on `main` with `version="9.8.0"`, `valid="true"` and the 9.8.0 body in `notes`. Beside it, `TestAnUnreadableHeadingBelowTheNewestEntry`, `TestLintUnreadableVersionIsSeparateFromHeadingForm` (on by default, still fires with `heading-form` off, `[Unreleased]` silent), `TestLatestStopsAtAnUnreadableHeading`, and a `TestLintFindings` row. Mutation-checked both directions. `go test ./...` green, `go vet ./...` and `gofmt -l` clean, `gen-readme` idempotent, `-list-checks` reports sixteen. ## Release The entry is under `## [Unreleased]`. **No version heading and no `action.yaml` bump** — those are the ceremony at the top of the stack, so the release is armed once, when the whole stack lands, and its notes cover everything in it. `action.yaml` is touched only in the `version`, `notes` and `prerelease` output descriptions, which claimed the value came from "the newest versioned entry" and would otherwise ship false. ## AI assistance Written by Claude Code (Opus 5) under an octant worker contract; the defect was measured independently before and after. Octant B1721.
mikluko commented 2026-09-06 14:28:34 +00:00 (Migrated from github.com)

This branch now carries more than the title says. Octant T1723 landed here via #17 and two commits after it, and the branch was rebased onto main (it had gone CONFLICTING when #18 landed under it).

Four commits, CLEAN and green:

7596292  feat(checks): split an unreadable version heading out of heading-form   the original #14
74ecc72  docs(changelog): record the check split and the output it fixes         the original #14
b87462c  feat(semver): a strict Semantic Versioning 2.0.0 parser
993373e  feat(checks): read a version heading as strict SemVer, naming the rule a rejection hit
fa96c0b  feat(semver): ordering by sections 10 and 11, and a tag namespace of its own
7a3aa05  refactor: drop golang.org/x/mod/semver

golang.org/x/mod/semver is out of the module. What counts as a version, and how two are ordered, are both this repository's own now, pinned against the specification's suggested regex as a differential oracle — 54 valid forms and 42 invalid ones, plus three pinned departures where the uint64 bound is narrower than the spec.

Two verdicts change for a consumer:

heading before after
[v1.2.3] a version no version — the leading v is a tag's, not a heading's
[1.2.3+build.1] no version a version — build metadata is valid SemVer

The one thing worth a reviewer's attention: the dependency was load-bearing in internal/git in a way nothing recorded. Tag.Version() read the moving v1 tag — the Actions convention, and this repo ships one — only because x/mod accepts vMAJOR shorthand. Read strictly, v1 names no version and drops out of reference-tag selection, silently, with every existing test still green. ParseTag now makes the tag namespace's laxness an explicit contract and TestVersionsReadsTheMovingMajorTag pins it.

The title and description still describe the original unreadable-version split only; renaming them is yours, not mine.

This branch now carries more than the title says. Octant [T1723](http://127.0.0.1:9876/T1723) landed here via #17 and two commits after it, and the branch was rebased onto `main` (it had gone `CONFLICTING` when #18 landed under it). Four commits, `CLEAN` and green: ``` 7596292 feat(checks): split an unreadable version heading out of heading-form the original #14 74ecc72 docs(changelog): record the check split and the output it fixes the original #14 b87462c feat(semver): a strict Semantic Versioning 2.0.0 parser 993373e feat(checks): read a version heading as strict SemVer, naming the rule a rejection hit fa96c0b feat(semver): ordering by sections 10 and 11, and a tag namespace of its own 7a3aa05 refactor: drop golang.org/x/mod/semver ``` **`golang.org/x/mod/semver` is out of the module.** What counts as a version, and how two are ordered, are both this repository's own now, pinned against the specification's suggested regex as a differential oracle — 54 valid forms and 42 invalid ones, plus three pinned *departures* where the `uint64` bound is narrower than the spec. Two verdicts change for a consumer: | heading | before | after | |---|---|---| | `[v1.2.3]` | a version | no version — the leading `v` is a tag's, not a heading's | | `[1.2.3+build.1]` | no version | a version — build metadata is valid SemVer | **The one thing worth a reviewer's attention:** the dependency was load-bearing in `internal/git` in a way nothing recorded. `Tag.Version()` read the moving `v1` tag — the Actions convention, and this repo ships one — *only* because `x/mod` accepts `vMAJOR` shorthand. Read strictly, `v1` names no version and drops out of reference-tag selection, silently, with every existing test still green. `ParseTag` now makes the tag namespace's laxness an explicit contract and `TestVersionsReadsTheMovingMajorTag` pins it. The title and description still describe the original `unreadable-version` split only; renaming them is yours, not mine.
Sign in to join this conversation.
No description provided.