Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/release-note-must-say.txt
Original file line number Diff line number Diff line change
@@ -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
57 changes: 57 additions & 0 deletions .github/scripts/note_says.sh
Original file line number Diff line number Diff line change
@@ -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 note> [file with the list]
set -euo pipefail

note="${1:?usage: note_says.sh <note file> [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"
8 changes: 8 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
20 changes: 12 additions & 8 deletions .github/workflows/verify-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
132 changes: 132 additions & 0 deletions internal/guard/releasenote_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading