From 476ba8feca45cba2b9fff784665fcf12cafe935a Mon Sep 17 00:00:00 2001 From: Torsten Mahr Date: Mon, 31 Aug 2026 14:34:33 +0200 Subject: [PATCH 1/2] feat(standard): require release notes generated from the changelog (R07) R06 accepts "GitHub release or changelog" as evidence, and that disjunction is a hole. A repository in the estate keeps a Keep-a-Changelog file with an Unreleased section and one dated section per release, while its release workflow publishes every release with a hardcoded notes string describing the artifacts rather than the change. Nothing reads the changelog, nothing fails when a tag has no section, and nothing fails when entries are still under Unreleased at tag time. Assessed against R06 as written, it passes: the changelog exists and does describe meaningful changes, while the surface consumers land on says nothing. R07 closes it by provenance rather than by restating the content requirement: the notes published for a tag are generated from the changelog entry for exactly that version, enforced by a gate that fails when the entry is missing, empty, or still held in an unreleased section. R06 is left unchanged. Narrowing it would be a major change under the versioning policy, invalidating every recorded result in the estate, to reach an outcome R07 already reaches as a minor one. The two stay orthogonal: an entry reading "bug fixes" fails R06 and cannot fail R07, because it is faithfully published. Decision 0010 records this. Version is 1.7.0, not 1.5.2 as originally requested: adding a criterion is minor under Versioning And Compatibility, and 1.6.0 was published while this change was in progress. This repository already satisfies R07. The unreleased guard was added to its release workflow in the same change, so the gate is complete rather than complete by absence of an unreleased section. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/badges/conformance.svg | 6 +- .github/conformance.yml | 3 +- .github/workflows/release.yml | 15 +++- CHANGELOG.md | 15 ++++ ...0-release-notes-come-from-the-changelog.md | 70 +++++++++++++++++++ docs/decisions/README.md | 1 + docs/repository-quality-standard.md | 25 ++++++- docs/self-assessment.md | 10 ++- standard.yml | 6 +- 9 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 docs/decisions/0010-release-notes-come-from-the-changelog.md diff --git a/.github/badges/conformance.svg b/.github/badges/conformance.svg index f874123..e314d96 100644 --- a/.github/badges/conformance.svg +++ b/.github/badges/conformance.svg @@ -1,10 +1,10 @@ - - trsdn standard: v1.6.0 - Healthy + + trsdn standard: v1.7.0 - Healthy trsdn standard - v1.6.0 - Healthy + v1.7.0 - Healthy diff --git a/.github/conformance.yml b/.github/conformance.yml index d512b34..4739085 100644 --- a/.github/conformance.yml +++ b/.github/conformance.yml @@ -6,7 +6,7 @@ # The badge in .github/badges/ is generated from this file. Change the evidence # first, then this record, then regenerate. Never the other way round. -standard_version: "1.6.0" +standard_version: "1.7.0" assessed_on: "2026-08-31" state: "Healthy" evidence: "docs/self-assessment.md" @@ -56,6 +56,7 @@ criteria: R04: pass R05: na R06: pass + R07: pass I01: na I02: na I03: na diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 000a35b..4b1959d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: Release # A tag publishes a release of the standard. The notes come from CHANGELOG.md so # that the changelog stays the single place a change is described, and the # consistency check guarantees the tag, the document version, and the changelog -# agree before anything is published. +# agree before anything is published. Together these steps are the `R07` gate. on: push: @@ -34,6 +34,19 @@ jobs: exit 1 fi + - name: Reject entries left in an unreleased section + run: | + set -euo pipefail + held="$(awk ' + tolower($0) ~ /^##[ \t]+\[?unreleased\]?/ { capture = 1; next } + capture && /^## / { exit } + capture { print } + ' CHANGELOG.md | tr -d '[:space:]')" + if [ -n "$held" ]; then + echo "release: entries are still held in the unreleased section; promote them into $GITHUB_REF_NAME" >&2 + exit 1 + fi + - name: Extract release notes for this version run: | set -euo pipefail diff --git a/CHANGELOG.md b/CHANGELOG.md index c115ed7..1cfb231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ recorded here. Versions follow the compatibility policy in the [Repository Quality Standard](docs/repository-quality-standard.md). +## 1.7.0 - 2026-08-31 + +- Added `R07`, requiring that published release notes are generated from the + changelog entry for the version being released, gated by automation that fails + the release when the entry is missing, empty, or still held in an unreleased + section. A repository can keep an exemplary changelog and still publish + releases whose notes are fixed boilerplate, because nothing connects the two. + The entries then reach nobody, and the release page is the surface a consumer + actually lands on. +- Left `R06` unchanged. Narrowing it so that boilerplate notes over an unread + changelog could no longer pass would be a major change, and it would buy no + coverage that `R07` does not already provide. The two now divide the work: + `R06` is about content, `R07` about provenance. See + [decision 0010](docs/decisions/0010-release-notes-come-from-the-changelog.md). + ## 1.6.0 - 2026-08-31 - Corrected the reason given for self-hosting images. Both `P08` and `P09` diff --git a/docs/decisions/0010-release-notes-come-from-the-changelog.md b/docs/decisions/0010-release-notes-come-from-the-changelog.md new file mode 100644 index 0000000..dd8ec11 --- /dev/null +++ b/docs/decisions/0010-release-notes-come-from-the-changelog.md @@ -0,0 +1,70 @@ +# 0010 - Release notes are generated from the changelog, and R06 stays as it is + +- Status: Accepted +- Date: 2026-08-31 + +## Context + +`R06` asks that release notes describe meaningful changes and upgrade concerns, +and accepts "GitHub release **or** changelog" as evidence. That disjunction is +the hole. + +A repository in the estate demonstrated it. It keeps a Keep-a-Changelog file +with an `## Unreleased` section and one dated section per release, and its +release workflow publishes every GitHub release with a hardcoded notes string +describing the artifacts rather than the change. The changelog is never read by +anything. Nothing fails when a tag has no changelog section, and nothing fails +when entries are still sitting under `## Unreleased` at tag time — those entries +ship and then appear in no release notes at all. + +Assessed against `R06` as written, that repository passes: a maintained +changelog exists, and it does describe meaningful changes. The criterion was +satisfied by a document nobody consumes while the surface consumers actually +land on said nothing. + +## Decision + +Add `R07`, requiring that the notes published for a tag are generated from the +changelog entry for exactly that version, enforced by a release gate that fails +when the entry is missing, empty, or still held in an unreleased section. + +Leave `R06` unchanged. + +## Consequences + +Sharpening `R06` was the obvious alternative and was rejected for two reasons. + +It buys nothing. Once `R07` holds, boilerplate notes over an unread changelog +cannot pass, because the notes are no longer written separately from the entry. +The combination "exemplary changelog, meaningless release page" is closed by +provenance, not by restating the content requirement more forcefully. + +It is expensive. Narrowing a criterion so that a recorded `Pass` could become a +`Fail` is a major change under +[Versioning And Compatibility](../repository-quality-standard.md#versioning-and-compatibility). +Every recorded result in the estate would be invalidated and every repository +would be due for reassessment — to reach an outcome `R07` already reaches as a +minor change, where a recorded result stays valid for the version it names. + +So the two criteria divide the work and stay orthogonal. `R06` is about content: +a changelog entry reading "bug fixes" still fails it, and `R07` cannot catch +that, because such an entry is faithfully published. `R07` is about provenance: +whatever the entry says, it is what the consumer receives. Neither subsumes the +other, and keeping them separate means an assessment says which of the two +things is wrong. + +The cost is that `R06` still reads permissively in isolation, and a reader who +finds it without `R07` may draw the old conclusion. The prose under the Package +And Release table names the division for that reader, which is the remedy +available without a major bump. + +`R07` is deliberately conditional on the second half. A repository that keeps no +unreleased section has nowhere to strand an entry and satisfies that condition by +construction; requiring a guard against a failure mode the repository cannot have +would be evidence theatre, which +[decision 0005](0005-proportionate-accessibility-and-privacy.md) rules out. + +This repository already satisfies `R07`: its release workflow extracts the +section for the tag from `CHANGELOG.md`, fails when the extraction is empty, and +publishes that text as the notes body. The unreleased guard was added alongside +this decision so the gate is complete rather than complete-by-absence. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 47e5e59..9aac8a5 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -15,3 +15,4 @@ future reader can see why it is the way it is before changing it. | [0007](0007-testing-the-validation-scripts.md) | The validation scripts are tested through their command line, asserting exit code and diagnostic | Accepted | | [0008](0008-internal-links-are-checked-external-links-are-not.md) | Internal links and anchors are checked in CI; external links are deliberately not | Accepted | | [0009](0009-published-sites-and-content-boundaries.md) | Published sites carry the shared design language, and each fact has one home | Accepted | +| [0010](0010-release-notes-come-from-the-changelog.md) | Release notes are generated from the changelog, and `R06` stays as it is | Accepted | diff --git a/docs/repository-quality-standard.md b/docs/repository-quality-standard.md index 227e434..2b40164 100644 --- a/docs/repository-quality-standard.md +++ b/docs/repository-quality-standard.md @@ -1,6 +1,6 @@ # Repository Quality Standard -- Version: 1.6.0 +- Version: 1.7.0 - Last reviewed: 2026-08-31 - Review cadence: every six months, even when nothing changes @@ -192,6 +192,29 @@ assessment backlog. | R04 | Tag, package version, and release title are consistent | Release workflow validation | | R05 | Built artifacts are smoke-tested in a clean environment | CI or release workflow | | R06 | Release notes describe meaningful changes and upgrade concerns | GitHub release or changelog | +| R07 | Release notes are generated from the changelog entry for the version being released, and automation fails the release when that entry is missing, empty, or still held in an unreleased section | Release workflow gate plus a published release whose notes match its changelog entry | + +`R06` and `R07` divide the work. `R06` is about content: notes a reader can act +on. `R07` is about provenance: the notes a consumer actually receives are the +maintained entry for that exact version, and not a second description written at +tag time. + +The gap `R07` closes is specific. A repository can keep an exemplary changelog +and still publish releases whose notes are fixed boilerplate, because nothing +connects the two. The entries then reach nobody — the changelog is read only by +someone who already knows to open it, and the release page, which is the surface +a consumer actually lands on, says nothing. `R06` on its own is satisfiable by a +changelog nobody consumes, which is why `R07` asks for a gate rather than a +habit. + +The gate fails the release when the changelog has no section for the version +being tagged, when that section is empty, or when entries are still held in an +unreleased section that the tagged version did not absorb. A repository that +keeps no unreleased section satisfies the last condition by construction, having +nowhere to strand an entry. A minimal gate extracts the section for the tag, +exits non-zero when the result is empty, and passes that same text to the +release command as the notes body, so the published notes and the maintained +entry cannot disagree. ## Product Identity diff --git a/docs/self-assessment.md b/docs/self-assessment.md index e8a5499..9793553 100644 --- a/docs/self-assessment.md +++ b/docs/self-assessment.md @@ -1,6 +1,6 @@ # Self-Assessment: trsdn/.github -- Standard version: 1.6.0 +- Standard version: 1.7.0 - Assessed on: 2026-08-31 - State: **Healthy** - Record: [`.github/conformance.yml`](../.github/conformance.yml) @@ -153,6 +153,14 @@ environment. - `R02`, `R04` — the versioning policy is documented and a check enforces that the document version, the changelog, and the release tag agree, so they cannot drift apart silently. +- `R07` — passes, and the criterion was written from a failure found elsewhere in + the estate rather than from this repository. `.github/workflows/release.yml` + extracts the changelog section for the tag, exits non-zero when the result is + empty, and publishes that same text as the notes body, so no release can carry + notes that were written separately from the entry. The unreleased guard was + added in the same change: this repository keeps no unreleased section and could + not strand an entry today, but a gate that holds only while a convention holds + is not a gate. - `G01`-`G08` — the agent readiness criteria are satisfied by `AGENTS.md`, a Copilot configuration that defers to it instead of duplicating it, generated paths marked explicitly, and a single documented validation command. diff --git a/standard.yml b/standard.yml index ff74bb6..502c436 100644 --- a/standard.yml +++ b/standard.yml @@ -3,7 +3,7 @@ # Generated by scripts/standard.py from # docs/repository-quality-standard.md. Do not edit by hand. -version: "1.6.0" +version: "1.7.0" last_reviewed: "2026-08-31" prefixes: @@ -198,6 +198,10 @@ criteria: section: "Package And Release Repositories" requirement: "Release notes describe meaningful changes and upgrade concerns" evidence: "GitHub release or changelog" + - id: "R07" + section: "Package And Release Repositories" + requirement: "Release notes are generated from the changelog entry for the version being released, and automation fails the release when that entry is missing, empty, or still held in an unreleased section" + evidence: "Release workflow gate plus a published release whose notes match its changelog entry" - id: "I01" section: "Product Identity" requirement: "The built artifact embeds its product name and exact version" From ac43b827743b8f6736a064db4b825722a220b9e4 Mon Sep 17 00:00:00 2001 From: Torsten Mahr Date: Mon, 31 Aug 2026 15:05:37 +0200 Subject: [PATCH 2/2] docs(standard): publish the R07 reference gate as a template R07 mandates automation but pointed at no implementation, so every repository adopting it would invent the gate again and the estate would end up with as many gates as it has release workflows. The only working example was this repository's own release.yml, which nobody reading the standard would think to open. templates/release-notes/release.yml is the reference gate, linked from the criterion's prose and from the README, the same way templates/AGENTS.md backs G01-G08 and templates/repo-stats/ backs P09. It stays a starting point rather than a mandate: R07 asks for the outcome, and a repository reaching it with a shell script, a release tool, or another CI system passes just as well. PtionsPlus, which motivated the criterion, extracts the same logic into scripts/changelog.sh and passes on its own terms. The extraction accepts both the plain and the Keep-a-Changelog bracketed heading form, escapes dots so a version is not read as a wildcard, and does not match an adjacent version. Verified against six changelog shapes, including 1.2.30 while releasing 1.2.3. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 + README.md | 3 + ...0-release-notes-come-from-the-changelog.md | 9 +++ docs/repository-quality-standard.md | 3 + templates/release-notes/release.yml | 75 +++++++++++++++++++ 5 files changed, 94 insertions(+) create mode 100644 templates/release-notes/release.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cfb231..ea738d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ Versions follow the compatibility policy in the coverage that `R07` does not already provide. The two now divide the work: `R06` is about content, `R07` about provenance. See [decision 0010](docs/decisions/0010-release-notes-come-from-the-changelog.md). +- Published [`templates/release-notes/`](templates/release-notes/) as the + reference gate for `R07`, linked from the criterion's prose. A criterion that + mandates automation has to say where the automation comes from, or every + repository invents it again. ## 1.6.0 - 2026-08-31 diff --git a/README.md b/README.md index 1770e30..82aab14 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,9 @@ repositories. Those controls must be enabled and verified per repository. readiness criteria `G01` to `G08`. - [`templates/repo-stats/`](templates/repo-stats/) — caller workflow and README snippet for the `P09` repository statistics card. +- [`templates/release-notes/`](templates/release-notes/) — reference release + workflow implementing the `R07` gate, which publishes the changelog entry for + the tag as the release notes. ## Contributing diff --git a/docs/decisions/0010-release-notes-come-from-the-changelog.md b/docs/decisions/0010-release-notes-come-from-the-changelog.md index dd8ec11..0dbb5d6 100644 --- a/docs/decisions/0010-release-notes-come-from-the-changelog.md +++ b/docs/decisions/0010-release-notes-come-from-the-changelog.md @@ -68,3 +68,12 @@ This repository already satisfies `R07`: its release workflow extracts the section for the tag from `CHANGELOG.md`, fails when the extraction is empty, and publishes that text as the notes body. The unreleased guard was added alongside this decision so the gate is complete rather than complete-by-absence. + +A criterion that mandates automation has to say where the automation comes from, +or every repository invents it again and the estate ends up with as many gates as +it has release workflows. A reference workflow is therefore published as +[`templates/release-notes/`](../../templates/release-notes/) and linked from the +criterion's prose, the same way `templates/AGENTS.md` backs `G01`-`G08` and +`templates/repo-stats/` backs `P09`. It is a starting point, not a mandate: +`R07` asks for the outcome, and a repository that reaches it with a shell script, +a release tool, or a different CI system passes just as well. diff --git a/docs/repository-quality-standard.md b/docs/repository-quality-standard.md index 2b40164..d7306c6 100644 --- a/docs/repository-quality-standard.md +++ b/docs/repository-quality-standard.md @@ -216,6 +216,9 @@ exits non-zero when the result is empty, and passes that same text to the release command as the notes body, so the published notes and the maintained entry cannot disagree. +A reusable starting point is published as +[`templates/release-notes/`](../templates/release-notes/). + ## Product Identity Apply these requirements to anything a user installs, runs, or downloads: diff --git a/templates/release-notes/release.yml b/templates/release-notes/release.yml new file mode 100644 index 0000000..9737a2c --- /dev/null +++ b/templates/release-notes/release.yml @@ -0,0 +1,75 @@ +name: Release + +# Reference implementation of the `R07` gate: the notes published for a tag are +# the maintained changelog entry for exactly that version, and the release fails +# rather than publishing something written here instead. +# +# Adopt this workflow as it stands, or lift the two gate steps into an existing +# release workflow. Everything project-specific — building, signing, and +# uploading artifacts — belongs between the gate and the publish step. +# +# The extraction accepts both `## 1.2.3 - 2026-01-01` and the +# Keep-a-Changelog `## [1.2.3] - 2026-01-01` heading form. + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + name: Publish release + runs-on: ubuntu-latest + steps: + - name: Check out the tag + uses: actions/checkout@v7 + + # Gate, part one. Nothing ships while it is still described as unreleased. + # Without this, entries stay under `## Unreleased`, ship anyway, and then + # appear in no release notes at all. + # + # A repository that keeps no unreleased section can drop this step: it has + # nowhere to strand an entry. + - name: Reject entries left in an unreleased section + run: | + set -euo pipefail + held="$(awk ' + tolower($0) ~ /^##[ \t]+\[?unreleased\]?/ { capture = 1; next } + capture && /^## / { exit } + capture { print } + ' CHANGELOG.md | tr -d '[:space:]')" + if [ -n "$held" ]; then + echo "release: entries are still held in the unreleased section; promote them into $GITHUB_REF_NAME" >&2 + exit 1 + fi + + # Gate, part two. The notes are extracted, never authored here. A missing + # or empty entry fails the release instead of publishing boilerplate. + - name: Extract release notes for this version + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + awk -v version="$version" ' + BEGIN { gsub(/\./, "\\.", version) } + $0 ~ "^## \\[?" version "\\]?([ \t]|$)" { capture = 1; next } + capture && /^## / { exit } + capture { print } + ' CHANGELOG.md > release-notes.md + if [ ! -s release-notes.md ]; then + echo "release: no changelog entry for $version" >&2 + exit 1 + fi + + # Build, sign, and stage artifacts here. Keep them after the gate, so a + # release that cannot describe itself fails before anything is built. + + - name: Publish + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" \ + --title "$GITHUB_REF_NAME" \ + --notes-file release-notes.md