feat(release): guard empty notes and recommend next tag
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -12,20 +12,31 @@ 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 <version> --date <YYYY-MM-DD> [--root <dir>]")
|
||||
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 <version> --date <YYYY-MM-DD> [--root <dir>] | --recommend [--root <dir>]")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if err := releaseprep.Prepare(absRoot, *version, *date); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "prepare release: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
updated := text[:afterHeader] + "\n" + newSection + text[nextSectionStart:]
|
||||
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
|
||||
return fmt.Errorf("write changelog: %w", err)
|
||||
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)
|
||||
}
|
||||
|
||||
return nil
|
||||
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 semver{major: major, minor: minor, patch: patch}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user