From 92f76fd19f185f540e55ffa1bc652f95d07b1e4b Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 14:33:53 +0000 Subject: [PATCH] chore(go): route release notes through vociferate --- cmd/vociferate/main.go | 13 +++- internal/vociferate/vociferate.go | 51 ++++++++++++++++ publish/action.yml | 90 +++++++++++++++++++++++----- script/download-vociferate-binary.sh | 9 +++ script/resolve-vociferate-runtime.sh | 51 ++++++++++++++++ 5 files changed, 198 insertions(+), 16 deletions(-) create mode 100644 script/download-vociferate-binary.sh create mode 100644 script/resolve-vociferate-runtime.sh diff --git a/cmd/vociferate/main.go b/cmd/vociferate/main.go index e5d2f37..e520d0e 100644 --- a/cmd/vociferate/main.go +++ b/cmd/vociferate/main.go @@ -14,6 +14,7 @@ func main() { date := flag.String("date", "", "release date in YYYY-MM-DD format") recommend := flag.Bool("recommend", false, "print the recommended next release tag based on the changelog") printUnreleased := flag.Bool("print-unreleased", false, "print the current Unreleased changelog body") + printReleaseNotes := flag.Bool("print-release-notes", false, "print the release notes section for --version") root := flag.String("root", ".", "repository root to update") versionFile := flag.String("version-file", "", "path to the version file, relative to --root") versionPattern := flag.String("version-pattern", "", "regexp with one capture group for the version value") @@ -52,8 +53,18 @@ func main() { return } + if *printReleaseNotes { + body, err := vociferate.ReleaseNotes(absRoot, *version, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "print release notes: %v\n", err) + os.Exit(1) + } + fmt.Print(body) + return + } + if *version == "" || *date == "" { - fmt.Fprintln(os.Stderr, "usage: vociferate --version --date [--root ] [--version-file ] [--version-pattern ] [--changelog ] | --recommend [--root ] [--version-file ] [--version-pattern ] [--changelog ] | --print-unreleased [--root ] [--changelog ]") + fmt.Fprintln(os.Stderr, "usage: vociferate --version --date [--root ] [--version-file ] [--version-pattern ] [--changelog ] | --recommend [--root ] [--version-file ] [--version-pattern ] [--changelog ] | --print-unreleased [--root ] [--changelog ] | --print-release-notes --version [--root ] [--changelog ]") os.Exit(2) } diff --git a/internal/vociferate/vociferate.go b/internal/vociferate/vociferate.go index d1d9b62..2b171d9 100644 --- a/internal/vociferate/vociferate.go +++ b/internal/vociferate/vociferate.go @@ -215,6 +215,11 @@ func UnreleasedBody(rootDir string, options Options) (string, error) { return defaultService().UnreleasedBody(rootDir, options) } +// ReleaseNotes returns the release section for a specific semantic version. +func ReleaseNotes(rootDir, version string, options Options) (string, error) { + return defaultService().ReleaseNotes(rootDir, version, options) +} + // RecommendedTag returns the next semantic release tag based on current changelog state. func (s *Service) RecommendedTag(rootDir string, options Options) (string, error) { resolved, err := resolveOptions(options) @@ -281,6 +286,16 @@ func (s *Service) UnreleasedBody(rootDir string, options Options) (string, error return s.readUnreleasedBody(rootDir, resolved.Changelog) } +// ReleaseNotes returns the release section for a specific semantic version. +func (s *Service) ReleaseNotes(rootDir, version string, options Options) (string, error) { + resolved, err := resolveOptions(options) + if err != nil { + return "", err + } + + return s.readReleaseNotes(rootDir, strings.TrimPrefix(strings.TrimSpace(version), "v"), resolved.Changelog) +} + func sectionHasEntries(unreleasedBody, sectionName string) bool { heading := "### " + sectionName sectionStart := strings.Index(unreleasedBody, heading) @@ -471,6 +486,10 @@ func readLatestChangelogVersion(rootDir, changelogPath string) (string, bool, er return defaultService().readLatestChangelogVersion(rootDir, changelogPath) } +func readReleaseNotes(rootDir, version, changelogPath string) (string, error) { + return defaultService().readReleaseNotes(rootDir, version, changelogPath) +} + func (s *Service) readLatestChangelogVersion(rootDir, changelogPath string) (string, bool, error) { path := filepath.Join(rootDir, changelogPath) contents, err := s.fileSystem.ReadFile(path) @@ -485,6 +504,38 @@ func (s *Service) readLatestChangelogVersion(rootDir, changelogPath string) (str return match[1], true, nil } +func (s *Service) readReleaseNotes(rootDir, version, changelogPath string) (string, error) { + if version == "" { + return "", fmt.Errorf("release version must not be empty") + } + + path := filepath.Join(rootDir, changelogPath) + contents, err := s.fileSystem.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read changelog: %w", err) + } + + text := string(contents) + headingExpr := regexp.MustCompile(`(?m)^## \[` + regexp.QuoteMeta(version) + `\](?:\([^\n)]*\))? - `) + headingLoc := headingExpr.FindStringIndex(text) + if headingLoc == nil { + return "", fmt.Errorf("release notes section for %s not found in changelog", version) + } + + nextSectionRelative := strings.Index(text[headingLoc[0]+1:], "\n## [") + sectionEnd := len(text) + if nextSectionRelative != -1 { + sectionEnd = headingLoc[0] + 1 + nextSectionRelative + } + + section := text[headingLoc[0]:sectionEnd] + if !strings.HasSuffix(section, "\n") { + section += "\n" + } + + return section, nil +} + func deriveRepositoryURL(rootDir string) (string, bool) { return defaultService().deriveRepositoryURL(rootDir) } diff --git a/publish/action.yml b/publish/action.yml index 7a73191..2a2b886 100644 --- a/publish/action.yml +++ b/publish/action.yml @@ -36,6 +36,19 @@ outputs: runs: using: composite steps: + - name: Resolve vociferate binary metadata + id: resolve-binary + shell: bash + env: + ACTION_REF: ${{ github.action_ref }} + ACTION_REPOSITORY: ${{ github.action_repository }} + CACHE_TOKEN: ${{ env.VOCIFERATE_CACHE_TOKEN }} + SERVER_URL: ${{ github.server_url }} + RUNNER_ARCH: ${{ runner.arch }} + RUNNER_TEMP: ${{ runner.temp }} + run: | + bash "$GITHUB_ACTION_PATH/../script/resolve-vociferate-runtime.sh" + - name: Resolve release version id: resolve-version shell: bash @@ -63,9 +76,53 @@ runs: echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "version=$normalized" >> "$GITHUB_OUTPUT" - - name: Extract release notes from changelog - id: extract-notes + - name: Setup Go + if: steps.resolve-binary.outputs.use_binary != 'true' + uses: actions/setup-go@v5 + with: + go-version: '1.26.1' + cache: true + cache-dependency-path: ${{ steps.resolve-binary.outputs.source_root }}/go.sum + + - name: Restore cached vociferate binary + id: cache-vociferate + if: steps.resolve-binary.outputs.use_binary == 'true' + uses: actions/cache@v4 + with: + path: ${{ steps.resolve-binary.outputs.cache_dir }} + key: vociferate-${{ steps.resolve-binary.outputs.cache_token }}-linux-${{ runner.arch }} + + - name: Download vociferate binary + if: steps.resolve-binary.outputs.use_binary == 'true' && steps.cache-vociferate.outputs.cache-hit != 'true' shell: bash + env: + TOKEN: ${{ github.token }} + ASSET_URL: ${{ steps.resolve-binary.outputs.asset_url }} + BINARY_PATH: ${{ steps.resolve-binary.outputs.binary_path }} + run: | + bash "${{ steps.resolve-binary.outputs.source_root }}/script/download-vociferate-binary.sh" + + - name: Extract release notes from binary + id: extract-notes-binary + if: steps.resolve-binary.outputs.use_binary == 'true' + shell: bash + env: + VOCIFERATE_BIN: ${{ steps.resolve-binary.outputs.binary_path }} + CHANGELOG: ${{ inputs.changelog != '' && inputs.changelog || 'CHANGELOG.md' }} + RELEASE_VERSION: ${{ steps.resolve-version.outputs.version }} + RUNNER_TEMP: ${{ runner.temp }} + run: | + set -euo pipefail + + notes_file="${RUNNER_TEMP}/release-notes.md" + "$VOCIFERATE_BIN" --print-release-notes --version "$RELEASE_VERSION" --root "$GITHUB_WORKSPACE" --changelog "$CHANGELOG" > "$notes_file" + printf 'notes_file=%s\n' "$notes_file" >> "$GITHUB_OUTPUT" + + - name: Extract release notes from source + id: extract-notes-source + if: steps.resolve-binary.outputs.use_binary != 'true' + shell: bash + working-directory: ${{ steps.resolve-binary.outputs.source_root }} env: CHANGELOG: ${{ inputs.changelog != '' && inputs.changelog || 'CHANGELOG.md' }} RELEASE_VERSION: ${{ steps.resolve-version.outputs.version }} @@ -73,22 +130,25 @@ runs: run: | set -euo pipefail - release_notes="$(awk -v version="$RELEASE_VERSION" ' - $0 ~ "^## \\[" version "\\]" {capture=1} - capture { - if ($0 ~ "^## \\[" && $0 !~ "^## \\[" version "\\]") exit - print - } - ' "$CHANGELOG")" + notes_file="${RUNNER_TEMP}/release-notes.md" + go run ./cmd/vociferate --print-release-notes --version "$RELEASE_VERSION" --root "$GITHUB_WORKSPACE" --changelog "$CHANGELOG" > "$notes_file" + printf 'notes_file=%s\n' "$notes_file" >> "$GITHUB_OUTPUT" - if [[ -z "${release_notes//[[:space:]]/}" ]]; then - echo "Release notes section for ${RELEASE_VERSION} was not found in ${CHANGELOG}" >&2 - exit 1 + - name: Finalize release notes file + id: extract-notes + shell: bash + env: + NOTES_FILE_BINARY: ${{ steps.extract-notes-binary.outputs.notes_file }} + NOTES_FILE_SOURCE: ${{ steps.extract-notes-source.outputs.notes_file }} + run: | + set -euo pipefail + + notes_file="$NOTES_FILE_BINARY" + if [[ -z "$notes_file" ]]; then + notes_file="$NOTES_FILE_SOURCE" fi - notes_file="${RUNNER_TEMP}/release-notes.md" - printf '%s\n' "$release_notes" > "$notes_file" - echo "notes_file=$notes_file" >> "$GITHUB_OUTPUT" + printf 'notes_file=%s\n' "$notes_file" >> "$GITHUB_OUTPUT" - name: Create or update release id: create-release diff --git a/script/download-vociferate-binary.sh b/script/download-vociferate-binary.sh new file mode 100644 index 0000000..7342eae --- /dev/null +++ b/script/download-vociferate-binary.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -euo pipefail + +curl --fail --location \ + -H "Authorization: token ${TOKEN}" \ + -o "$BINARY_PATH" \ + "$ASSET_URL" +chmod +x "$BINARY_PATH" \ No newline at end of file diff --git a/script/resolve-vociferate-runtime.sh b/script/resolve-vociferate-runtime.sh new file mode 100644 index 0000000..74cfd2c --- /dev/null +++ b/script/resolve-vociferate-runtime.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +set -euo pipefail + +case "${RUNNER_ARCH}" in + X64) + arch="amd64" + ;; + ARM64) + arch="arm64" + ;; + *) + echo "Unsupported runner architecture: ${RUNNER_ARCH}" >&2 + exit 1 + ;; +esac + +source_root="${GITHUB_ACTION_PATH}" +if [[ ! -f "${source_root}/go.mod" ]]; then + source_root="$(realpath "${GITHUB_ACTION_PATH}/..")" +fi + +printf 'source_root=%s\n' "$source_root" >> "$GITHUB_OUTPUT" + +if [[ "${ACTION_REF:-}" == v* ]]; then + release_tag="${ACTION_REF}" + normalized_version="${release_tag#v}" + asset_name="vociferate_${normalized_version}_linux_${arch}" + cache_dir="${RUNNER_TEMP}/vociferate/${release_tag}/linux-${arch}" + binary_path="${cache_dir}/vociferate" + asset_url="${SERVER_URL}/aether/vociferate/releases/download/${release_tag}/${asset_name}" + + provided_cache_token="$(printf '%s' "${CACHE_TOKEN:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')" + if [[ -n "$provided_cache_token" ]]; then + cache_token="$provided_cache_token" + else + cache_token="${ACTION_REPOSITORY:-aether/vociferate}-${release_tag}" + fi + + mkdir -p "$cache_dir" + + printf 'use_binary=true\n' >> "$GITHUB_OUTPUT" + printf 'release_tag=%s\n' "$release_tag" >> "$GITHUB_OUTPUT" + printf 'cache_token=%s\n' "$cache_token" >> "$GITHUB_OUTPUT" + printf 'asset_name=%s\n' "$asset_name" >> "$GITHUB_OUTPUT" + printf 'asset_url=%s\n' "$asset_url" >> "$GITHUB_OUTPUT" + printf 'cache_dir=%s\n' "$cache_dir" >> "$GITHUB_OUTPUT" + printf 'binary_path=%s\n' "$binary_path" >> "$GITHUB_OUTPUT" +else + printf 'use_binary=false\n' >> "$GITHUB_OUTPUT" +fi \ No newline at end of file