diff --git a/.github/release-note-must-say.txt b/.github/release-note-must-say.txt new file mode 100644 index 0000000..26b3ab3 --- /dev/null +++ b/.github/release-note-must-say.txt @@ -0,0 +1,20 @@ +# What a release note has to say, in one place. +# +# A person landing on a release page has to be told how to check what they +# downloaded. These are the substrings that have to appear in the note, and +# they are here rather than written into a workflow because they were written +# into two things at once: the workflow that generates the note and the +# workflow that checks the published page. Two copies of one fact agree until +# the day one of them is edited, and this project has measured that day twice. +# +# The predicate URI is the sharpest example. Change it in the generator alone +# and the check goes red on a correct page. Change it in the check alone and a +# page telling people the wrong command passes. +# +# One substring per line. Blank lines and lines starting with # are ignored. +# Matched literally, not as a pattern, because a URI is full of characters a +# pattern would read as syntax. + +gh attestation verify +--predicate-type https://spdx.dev/Document/v2.3 +verify-SHA256SUMS.txt diff --git a/.github/scripts/note_says.sh b/.github/scripts/note_says.sh new file mode 100644 index 0000000..0dde315 --- /dev/null +++ b/.github/scripts/note_says.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# +# Does this release note say what a release note has to say. +# +# Used twice and that is the point of it being a file: the workflow that +# GENERATES the note checks its own output with this, and the workflow that +# reads the PUBLISHED page checks that with the same list. Before this, the +# list lived inline in the second one and the first one simply wrote the +# sentences out - two copies of one fact with nothing comparing them. +# +# Reports every missing line rather than the first. A person reads a release +# page once, and a check that names one problem per run turns a list into a +# queue. +# +# Usage: note_says.sh [file with the list] +set -euo pipefail + +note="${1:?usage: note_says.sh [list file]}" +list="${2:-.github/release-note-must-say.txt}" + +test -f "$note" || { + echo "note_says: there is no note at $note, so nothing was checked" + exit 1 +} + +# An empty or missing list would let this pass on any page at all, which is +# the shape of a gate that never reads its own answer. +test -s "$list" || { + echo "note_says: $list is missing or empty, so this would pass on any note" + exit 1 +} + +missing=0 +asked=0 +while IFS= read -r promised || [ -n "$promised" ]; do + case "$promised" in + '' | '#'*) continue ;; + esac + asked=$((asked + 1)) + if ! grep -qF -- "$promised" "$note"; then + echo " the note never mentions: $promised" + missing=$((missing + 1)) + fi +done < "$list" + +if [ "$asked" = "0" ]; then + echo "note_says: $list names nothing, so this checked nothing" + exit 1 +fi + +if [ "$missing" != "0" ]; then + echo "note_says: $missing of $asked things a release note has to say are not in $note." + echo "A person reading that page is not told how to check what they downloaded." + exit 1 +fi + +echo "note_says: the note says all $asked things it has to" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff51e24..4f64887 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -500,6 +500,14 @@ jobs: } > notes.md cat notes.md + # The note this just wrote has to say what a release note has to say, + # checked against the same file verify-release.yml reads after the + # release is published. A generator that quietly stops emitting one of + # those sentences would produce a draft that looks finished and a gate + # that goes red hours later, on the published page, where fixing it is + # a public edit rather than a build failure. + bash .github/scripts/note_says.sh notes.md + - name: open the draft, with nothing in it yet env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/verify-release.yml b/.github/workflows/verify-release.yml index 0f728ac..74a31a6 100644 --- a/.github/workflows/verify-release.yml +++ b/.github/workflows/verify-release.yml @@ -298,17 +298,21 @@ jobs: # and two written copies of one instruction drift. This is the # comparison that stops them: what the page tells a person to type has # to be what was just run. + # + # What has to be said lives in .github/release-note-must-say.txt, and + # release.yml checks its generated note against the same file. The list + # used to be written out here while the generator wrote the sentences + # out separately - two copies of one fact, which is how a check ends up + # red on a correct page after somebody edits the wording in one of them. run: | set -euo pipefail gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json body --jq .body > notes.md - for promised in "gh attestation verify" "--predicate-type https://spdx.dev/Document/v2.3" "verify-SHA256SUMS.txt"; do - grep -qF -- "$promised" notes.md || { - echo "the release notes never mention: $promised" - echo "This job just ran it, so the page and the check disagree about what a person should do." - exit 1 - } - done - echo "the notes and this job say the same thing" + bash .github/scripts/note_says.sh notes.md || { + echo "This job just ran those, so the page and the check disagree about" + echo "what a person should do. Either the page lost them, or the list in" + echo ".github/release-note-must-say.txt no longer describes this release." + exit 1 + } - name: a full release has to be the one people are offered id: latest diff --git a/internal/guard/releasenote_test.go b/internal/guard/releasenote_test.go new file mode 100644 index 0000000..886f47c --- /dev/null +++ b/internal/guard/releasenote_test.go @@ -0,0 +1,132 @@ +package guard + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// What this defends. What a release note has to say is decided in one place, +// and both halves that care about it read that place. +// +// Why it needed a guard. Two workflows had an opinion about the same sentences. +// release.yml wrote them into the note it generates, and verify-release.yml +// held the published page to a list written out inside itself. Nothing compared +// the two. Change the predicate URI in the generator alone and the check goes +// red on a correct page - somebody then debugs the release page. Change it in +// the check alone and a page telling people the wrong command sails through. +// This is the shape closed for archive settings earlier the same day, O171, +// found again a few hours later in a different corner. +// +// Why the wiring rather than the wording. A person edits the published note by +// hand, by the owner's decision of 2026-09-02, so what the note SAYS is theirs. +// What is not theirs is whether anything still looks: deleting the call from +// verify-release.yml removes the check with nothing going red, which is the +// silent kind this project writes guards for. +// +// What this does NOT check, and it is checked another way. That note_says.sh +// behaves. Running it from here would mean running bash from a Go test on a +// machine with three of them, where the one a lookup finds is not the one +// CreateProcess starts - measured 2026-09-02 and written down. It was run by +// hand instead, three ways: a real published note missing all three sentences +// fails and names all three, a note carrying them passes, and an empty list is +// refused rather than passing on any note at all. + +const ( + releaseNoteList = "release-note-must-say.txt" + releaseNoteScript = "note_says.sh" +) + +// requiredOfAReleaseNote reads the list the way the script does. +func requiredOfAReleaseNote(t *testing.T) []string { + t.Helper() + + body, err := os.ReadFile(filepath.Join(repoRoot(t), ".github", releaseNoteList)) + if err != nil { + t.Skipf("no %s here: %v", releaseNoteList, err) + } + + var out []string + for _, line := range strings.Split(string(body), "\n") { + line = strings.TrimRight(line, "\r") + if line == "" || strings.HasPrefix(line, "#") { + continue + } + out = append(out, line) + } + return out +} + +// One list, and both workflows read it. +func TestWhatAReleaseNoteHasToSayIsDecidedInOnePlace(t *testing.T) { + required := requiredOfAReleaseNote(t) + + // An empty list would let every check built on it pass on any page, which + // is the gate that never reads its own answer. + if len(required) == 0 { + t.Fatalf("%s names nothing, so every check reading it passes on any release note", + releaseNoteList) + } + + script := filepath.Join(repoRoot(t), ".github", "scripts", releaseNoteScript) + if _, err := os.Stat(script); err != nil { + t.Fatalf("%s is missing, so the workflows that call it fail at the worst moment "+ + "- one of them runs on a tag: %v", releaseNoteScript, err) + } + + // Both halves. The one that WRITES the note and the one that reads the + // PUBLISHED page. Either alone leaves the other free to drift. + for _, wf := range []string{"release.yml", "verify-release.yml"} { + text := workflowText(t, wf) + if !strings.Contains(text, releaseNoteScript) { + t.Errorf("%s never calls %s, so nothing holds it to %s.\n"+ + "release.yml has to check the note it generates and verify-release.yml has "+ + "to check the note that got published. A missing call here is a check that "+ + "stopped happening with nothing going red.", + wf, releaseNoteScript, releaseNoteList) + } + } +} + +// The generator says everything the list asks for. +// +// Caught here rather than at release time on purpose. release.yml checks its +// own note when it runs, which is a tag - so a line added to the list and never +// taught to the generator would break the build at the one moment nobody wants +// a surprise. This asks the same question on a pull request. +func TestTheGeneratedReleaseNoteSaysEverythingTheListAsksFor(t *testing.T) { + required := requiredOfAReleaseNote(t) + if len(required) == 0 { + t.Fatalf("%s names nothing, so this proved nothing", releaseNoteList) + } + + text := workflowText(t, "release.yml") + + // The block that builds the note, rather than the whole file. A substring + // found somewhere else in the workflow - in the step that computes the + // checksums, say - would answer yes to a question about the note. + const closes = "} > notes.md" + end := strings.Index(text, closes) + if end < 0 { + t.Fatalf("release.yml no longer ends a block with %q, so this guard cannot find "+ + "the note it generates. Teach it the new shape rather than deleting it.", closes) + } + const opens = "\n {\n" + start := strings.LastIndex(text[:end], opens) + if start < 0 { + t.Fatalf("release.yml has %q with no block opening before it, so this guard cannot "+ + "tell where the note starts", closes) + } + block := text[start:end] + + for _, promised := range required { + if !strings.Contains(block, promised) { + t.Errorf("%s asks a release note to say %q and the note release.yml generates "+ + "never says it.\n"+ + "The workflow would catch this itself - on a tag, while building a release. "+ + "Either teach the generator to say it, or take the line out of the list.", + releaseNoteList, promised) + } + } +}