From 9b834693373250a69640ca15af6ab0170b03f560 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 18 Aug 2026 20:27:28 +0200 Subject: [PATCH 1/4] feat(release): generate the GitHub release body from the changelog --- .github/workflows/release.yml | 5 +- Taskfile.yml | 1 - cmd/release/main.go | 11 ++++- cmd/release/notes.go | 49 +++++++++++++++++++ cmd/release/notes_test.go | 75 ++++++++++++++++++++++++++++++ website/src/next/docs/releasing.md | 4 ++ 6 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 cmd/release/notes.go create mode 100644 cmd/release/notes_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b96e514b00..4d8bb2e8bf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,12 +46,15 @@ jobs: run: pnpm install working-directory: website + - name: Generate release notes + run: go run ./cmd/release --notes > "${RUNNER_TEMP}/release-notes.md" + - name: Run GoReleaser uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 with: distribution: goreleaser-pro version: latest - args: release --clean --draft + args: release --clean --release-notes=${{ runner.temp }}/release-notes.md env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} GH_GORELEASER_TOKEN: ${{secrets.GH_GORELEASER_TOKEN}} diff --git a/Taskfile.yml b/Taskfile.yml index da8b2b44f8..791db097f9 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -217,7 +217,6 @@ tasks: Please wait for the CI to finish and then do the following: - - Copy the changelog for v{{.VERSION}} to the GitHub release - Update and push the snapcraft manifest in https://github.com/go-task/snap/blob/main/snap/snapcraft.yaml preconditions: - sh: test $(git rev-parse --abbrev-ref HEAD) = "main" diff --git a/cmd/release/main.go b/cmd/release/main.go index 9448f03b83..362128a014 100644 --- a/cmd/release/main.go +++ b/cmd/release/main.go @@ -43,20 +43,29 @@ var changelogReleaseRegex = regexp.MustCompile(`## Unreleased`) // Flags var ( versionFlag bool + notesFlag bool ) func init() { pflag.BoolVarP(&versionFlag, "version", "v", false, "resolved version number") + pflag.BoolVar(¬esFlag, "notes", false, "changelog section of the current version, for the GitHub release body") pflag.Parse() } func main() { - if err := release(); err != nil { + if err := run(); err != nil { fmt.Println(err) os.Exit(1) } } +func run() error { + if notesFlag { + return notes(os.Stdout) + } + return release() +} + func release() error { if len(pflag.Args()) != 1 { return errors.New("error: expected version number") diff --git a/cmd/release/notes.go b/cmd/release/notes.go new file mode 100644 index 0000000000..80aa3cbdc6 --- /dev/null +++ b/cmd/release/notes.go @@ -0,0 +1,49 @@ +package main + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/Masterminds/semver/v3" +) + +// notes writes the changelog entries of the version being released, for the +// release pipeline to use as the body of its GitHub release. +func notes(w io.Writer) error { + version, err := getVersion(versionFile) + if err != nil { + return err + } + + b, err := os.ReadFile(changelogSource) + if err != nil { + return err + } + + _, err = fmt.Fprintln(w, changelogSection(string(b), version)) + return err +} + +// changelogSection returns the body of the "## v - " section, +// heading excluded: the GitHub release already displays the version and date. +// The section is empty for a release without changelog entries. +func changelogSection(changelog string, version *semver.Version) string { + heading := fmt.Sprintf("## v%s ", version) + var section []string + var found bool + for line := range strings.SplitSeq(changelog, "\n") { + if strings.HasPrefix(line, "## ") { + if found { + break + } + found = strings.HasPrefix(line, heading) + continue + } + if found { + section = append(section, line) + } + } + return strings.TrimSpace(strings.Join(section, "\n")) +} diff --git a/cmd/release/notes_test.go b/cmd/release/notes_test.go new file mode 100644 index 0000000000..3b6cb55aa1 --- /dev/null +++ b/cmd/release/notes_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/assert" +) + +func TestChangelogSection(t *testing.T) { + t.Parallel() + + const changelog = `# Changelog + +## v3.53.1 - 2026-08-18 + +### 🚀 Features + +- Something new (#1 by @someone). + +### 🐛 Fixes + +- Something fixed (#2 by @someone). + +## v3.53.10 - 2026-08-17 + +- An entry of a version whose heading starts with "## v3.53.1". + +## v3.5.0 - 2026-01-01 + +- An older entry. +` + + tests := []struct { + name string + version string + expected string + }{ + { + name: "section with sub-headings", + version: "3.53.1", + expected: `### 🚀 Features + +- Something new (#1 by @someone). + +### 🐛 Fixes + +- Something fixed (#2 by @someone).`, + }, + { + name: "last section of the file", + version: "3.5.0", + expected: "- An older entry.", + }, + { + // A patch release may ship without changelog entries. + name: "missing section", + version: "3.53.2", + expected: "", + }, + { + name: "heading of another version starts with this one", + version: "3.53.10", + expected: `- An entry of a version whose heading starts with "## v3.53.1".`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + version := semver.MustParse(test.version) + assert.Equal(t, test.expected, changelogSection(changelog, version)) + }) + } +} diff --git a/website/src/next/docs/releasing.md b/website/src/next/docs/releasing.md index ac9452ff69..376935bb49 100644 --- a/website/src/next/docs/releasing.md +++ b/website/src/next/docs/releasing.md @@ -16,6 +16,10 @@ of the Taskfile. artifacts automatically when a new Git tag is pushed to `main` branch (raw executables and DEB and RPM packages). +The body of the GitHub release is the section of the `CHANGELOG.md` matching the +version being released, extracted by `go run ./cmd/release --notes`. A version +without changelog entries is released with an empty body. + Raw executables can also be reproduced and verified locally by checking out a specific tag and calling `goreleaser build`, using the Go version defined in the above GitHub Actions. From 573737a429125598971432f60d3f41b45f8b984d Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Wed, 19 Aug 2026 13:07:56 +0200 Subject: [PATCH 2/4] style(release): drop the comments on the notes helpers --- cmd/release/notes.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cmd/release/notes.go b/cmd/release/notes.go index 80aa3cbdc6..cf8d160b20 100644 --- a/cmd/release/notes.go +++ b/cmd/release/notes.go @@ -9,8 +9,6 @@ import ( "github.com/Masterminds/semver/v3" ) -// notes writes the changelog entries of the version being released, for the -// release pipeline to use as the body of its GitHub release. func notes(w io.Writer) error { version, err := getVersion(versionFile) if err != nil { @@ -26,9 +24,6 @@ func notes(w io.Writer) error { return err } -// changelogSection returns the body of the "## v - " section, -// heading excluded: the GitHub release already displays the version and date. -// The section is empty for a release without changelog entries. func changelogSection(changelog string, version *semver.Version) string { heading := fmt.Sprintf("## v%s ", version) var section []string From 0ccf547f45261c185bd35567d92a15d1da793c05 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Wed, 19 Aug 2026 17:37:38 +0200 Subject: [PATCH 3/4] ci(release): fall back to /tmp when RUNNER_TEMP is unset --- .github/workflows/release.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d8bb2e8bf..22ba469398 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,14 +47,18 @@ jobs: working-directory: website - name: Generate release notes - run: go run ./cmd/release --notes > "${RUNNER_TEMP}/release-notes.md" + id: notes + run: | + path="${RUNNER_TEMP:-/tmp}/release-notes.md" + go run ./cmd/release --notes > "$path" + echo "path=$path" >> "$GITHUB_OUTPUT" - name: Run GoReleaser uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 with: distribution: goreleaser-pro version: latest - args: release --clean --release-notes=${{ runner.temp }}/release-notes.md + args: release --clean --release-notes=${{ steps.notes.outputs.path }} env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} GH_GORELEASER_TOKEN: ${{secrets.GH_GORELEASER_TOKEN}} From 5d91dc36048493dc6c68f3b5ea95ac08479658da Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Wed, 19 Aug 2026 17:45:08 +0200 Subject: [PATCH 4/4] ci(release): drop the RUNNER_TEMP fallback --- .github/workflows/release.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 22ba469398..4d8bb2e8bf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,18 +47,14 @@ jobs: working-directory: website - name: Generate release notes - id: notes - run: | - path="${RUNNER_TEMP:-/tmp}/release-notes.md" - go run ./cmd/release --notes > "$path" - echo "path=$path" >> "$GITHUB_OUTPUT" + run: go run ./cmd/release --notes > "${RUNNER_TEMP}/release-notes.md" - name: Run GoReleaser uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 with: distribution: goreleaser-pro version: latest - args: release --clean --release-notes=${{ steps.notes.outputs.path }} + args: release --clean --release-notes=${{ runner.temp }}/release-notes.md env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} GH_GORELEASER_TOKEN: ${{secrets.GH_GORELEASER_TOKEN}}