From 93918f3a393cdcd954b5ba83c1b32aee84b584fc Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 14:59:46 +0000 Subject: [PATCH] feat(release): guard empty notes and recommend next tag --- .gitea/workflows/push-validation.yml | 23 +++++ cmd/releaseprep/main.go | 23 +++-- internal/releaseprep/releaseprep.go | 126 ++++++++++++++++++++++++--- 3 files changed, 153 insertions(+), 19 deletions(-) diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index f069133..4dadd65 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -121,3 +121,26 @@ jobs: - name: Run behavior suite on main pushes if: ${{ github.ref == 'refs/heads/main' }} run: ./script/run-behavior-suite-docker.sh + + - name: Recommend next release tag on main pushes + if: ${{ github.ref == 'refs/heads/main' }} + run: | + set -euo pipefail + + if recommended_tag="$(go run ./cmd/releaseprep --recommend --root . 2>release-recommendation.err)"; then + { + echo + echo '## Release Recommendation' + echo + echo "- Recommended next tag: \\`${recommended_tag}\\`" + } >> "$GITHUB_STEP_SUMMARY" + else + recommendation_error="$(tr '\n' ' ' < release-recommendation.err | sed 's/[[:space:]]\+/ /g' | sed 's/^ //; s/ $//')" + echo "::warning::${recommendation_error}" + { + echo + echo '## Release Recommendation' + echo + echo "- No recommended tag emitted: ${recommendation_error}" + } >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/cmd/releaseprep/main.go b/cmd/releaseprep/main.go index d1c8059..8d9d047 100644 --- a/cmd/releaseprep/main.go +++ b/cmd/releaseprep/main.go @@ -12,22 +12,33 @@ import ( func main() { version := flag.String("version", "", "semantic version to release, with or without leading v") 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") root := flag.String("root", ".", "repository root to update") flag.Parse() - if *version == "" || *date == "" { - fmt.Fprintln(os.Stderr, "usage: releaseprep --version --date [--root ]") - os.Exit(2) - } - absRoot, err := filepath.Abs(*root) if err != nil { fmt.Fprintf(os.Stderr, "resolve root: %v\n", err) os.Exit(1) } + if *recommend { + tag, err := releaseprep.RecommendedTag(absRoot) + if err != nil { + fmt.Fprintf(os.Stderr, "recommend release: %v\n", err) + os.Exit(1) + } + fmt.Println(tag) + return + } + + if *version == "" || *date == "" { + fmt.Fprintln(os.Stderr, "usage: releaseprep --version --date [--root ] | --recommend [--root ]") + os.Exit(2) + } + if err := releaseprep.Prepare(absRoot, *version, *date); err != nil { fmt.Fprintf(os.Stderr, "prepare release: %v\n", err) os.Exit(1) } -} \ No newline at end of file +} diff --git a/internal/releaseprep/releaseprep.go b/internal/releaseprep/releaseprep.go index 5a43a6a..17e5e08 100644 --- a/internal/releaseprep/releaseprep.go +++ b/internal/releaseprep/releaseprep.go @@ -5,11 +5,18 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" ) var versionPattern = regexp.MustCompile(`const String = "[^"]+"`) +type semver struct { + major int + minor int + patch int +} + func Prepare(rootDir, version, releaseDate string) error { normalizedVersion := strings.TrimPrefix(strings.TrimSpace(version), "v") if normalizedVersion == "" { @@ -32,6 +39,37 @@ func Prepare(rootDir, version, releaseDate string) error { return nil } +func RecommendedTag(rootDir string) (string, error) { + currentVersion, err := readCurrentVersion(rootDir) + if err != nil { + return "", err + } + + unreleasedBody, err := readUnreleasedBody(rootDir) + if err != nil { + return "", err + } + + parsed, err := parseSemver(currentVersion) + if err != nil { + return "", err + } + + switch { + case strings.Contains(unreleasedBody, "### Removed"): + parsed.major++ + parsed.minor = 0 + parsed.patch = 0 + case strings.Contains(unreleasedBody, "### Added"): + parsed.minor++ + parsed.patch = 0 + default: + parsed.patch++ + } + + return fmt.Sprintf("v%d.%d.%d", parsed.major, parsed.minor, parsed.patch), nil +} + func updateVersionFile(rootDir, version string) error { path := filepath.Join(rootDir, "internal", "homesick", "version", "version.go") contents, err := os.ReadFile(path) @@ -52,17 +90,70 @@ func updateVersionFile(rootDir, version string) error { } func updateChangelog(rootDir, version, releaseDate string) error { + unreleasedBody, text, afterHeader, nextSectionStart, err := readChangelogState(rootDir) + if err != nil { + return err + } + + if strings.TrimSpace(unreleasedBody) == "" { + return fmt.Errorf("unreleased section is empty") + } + + path := filepath.Join(rootDir, "changelog.md") + newSection := fmt.Sprintf("## [%s] - %s\n", version, releaseDate) + newSection += "\n" + unreleasedBody + if !strings.HasSuffix(newSection, "\n") { + newSection += "\n" + } + + updated := text[:afterHeader] + "\n" + newSection + text[nextSectionStart:] + if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { + return fmt.Errorf("write changelog: %w", err) + } + + return nil +} + +func readCurrentVersion(rootDir string) (string, error) { + path := filepath.Join(rootDir, "internal", "homesick", "version", "version.go") + contents, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read version file: %w", err) + } + + match := versionPattern.FindString(string(contents)) + if match == "" { + return "", fmt.Errorf("version constant not found in %s", path) + } + + return strings.TrimSuffix(strings.TrimPrefix(match, `const String = "`), `"`), nil +} + +func readUnreleasedBody(rootDir string) (string, error) { + unreleasedBody, _, _, _, err := readChangelogState(rootDir) + if err != nil { + return "", err + } + + if strings.TrimSpace(unreleasedBody) == "" { + return "", fmt.Errorf("unreleased section is empty") + } + + return unreleasedBody, nil +} + +func readChangelogState(rootDir string) (string, string, int, int, error) { path := filepath.Join(rootDir, "changelog.md") contents, err := os.ReadFile(path) if err != nil { - return fmt.Errorf("read changelog: %w", err) + return "", "", 0, 0, fmt.Errorf("read changelog: %w", err) } text := string(contents) unreleasedHeader := "## [Unreleased]\n" start := strings.Index(text, unreleasedHeader) if start == -1 { - return fmt.Errorf("unreleased section not found in changelog") + return "", "", 0, 0, fmt.Errorf("unreleased section not found in changelog") } afterHeader := start + len(unreleasedHeader) @@ -73,18 +164,27 @@ func updateChangelog(rootDir, version, releaseDate string) error { nextSectionStart := afterHeader + nextSectionRelative unreleasedBody := strings.TrimLeft(text[afterHeader:nextSectionStart], "\n") - newSection := fmt.Sprintf("## [%s] - %s\n", version, releaseDate) - if strings.TrimSpace(unreleasedBody) != "" { - newSection += "\n" + unreleasedBody - if !strings.HasSuffix(newSection, "\n") { - newSection += "\n" - } + return unreleasedBody, text, afterHeader, nextSectionStart, nil +} + +func parseSemver(version string) (semver, error) { + parts := strings.Split(strings.TrimSpace(version), ".") + if len(parts) != 3 { + return semver{}, fmt.Errorf("version %q is not semantic major.minor.patch", version) } - updated := text[:afterHeader] + "\n" + newSection + text[nextSectionStart:] - if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { - return fmt.Errorf("write changelog: %w", err) + major, err := strconv.Atoi(parts[0]) + if err != nil { + return semver{}, fmt.Errorf("parse major version: %w", err) + } + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return semver{}, fmt.Errorf("parse minor version: %w", err) + } + patch, err := strconv.Atoi(parts[2]) + if err != nil { + return semver{}, fmt.Errorf("parse patch version: %w", err) } - return nil -} \ No newline at end of file + return semver{major: major, minor: minor, patch: patch}, nil +}