gosick #1
@@ -1,44 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"git.hrafn.xyz/aether/gosick/internal/releaseprep"
|
|
||||||
)
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
package releaseprep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"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 == "" {
|
|
||||||
return fmt.Errorf("version must not be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
releaseDate = strings.TrimSpace(releaseDate)
|
|
||||||
if releaseDate == "" {
|
|
||||||
return fmt.Errorf("release date must not be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := updateVersionFile(rootDir, normalizedVersion); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := updateChangelog(rootDir, normalizedVersion, releaseDate); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
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, "### Breaking"), 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)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read version file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
updated := versionPattern.ReplaceAllString(string(contents), fmt.Sprintf(`const String = %q`, version))
|
|
||||||
if updated == string(contents) {
|
|
||||||
return fmt.Errorf("version constant not found in %s", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
|
|
||||||
return fmt.Errorf("write version file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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 "", "", 0, 0, fmt.Errorf("read changelog: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
text := string(contents)
|
|
||||||
unreleasedHeader := "## [Unreleased]\n"
|
|
||||||
start := strings.Index(text, unreleasedHeader)
|
|
||||||
if start == -1 {
|
|
||||||
return "", "", 0, 0, fmt.Errorf("unreleased section not found in changelog")
|
|
||||||
}
|
|
||||||
|
|
||||||
afterHeader := start + len(unreleasedHeader)
|
|
||||||
nextSectionRelative := strings.Index(text[afterHeader:], "\n## [")
|
|
||||||
if nextSectionRelative == -1 {
|
|
||||||
nextSectionRelative = len(text[afterHeader:])
|
|
||||||
}
|
|
||||||
nextSectionStart := afterHeader + nextSectionRelative
|
|
||||||
unreleasedBody := strings.TrimLeft(text[afterHeader:nextSectionStart], "\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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
package releaseprep_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.hrafn.xyz/aether/gosick/internal/releaseprep"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"github.com/stretchr/testify/suite"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PrepareSuite struct {
|
|
||||||
suite.Suite
|
|
||||||
rootDir string
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPrepareSuite(t *testing.T) {
|
|
||||||
suite.Run(t, new(PrepareSuite))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PrepareSuite) SetupTest() {
|
|
||||||
s.rootDir = s.T().TempDir()
|
|
||||||
versionDir := filepath.Join(s.rootDir, "internal", "homesick", "version")
|
|
||||||
require.NoError(s.T(), os.MkdirAll(versionDir, 0o755))
|
|
||||||
|
|
||||||
require.NoError(s.T(), os.WriteFile(
|
|
||||||
filepath.Join(versionDir, "version.go"),
|
|
||||||
[]byte("package version\n\nconst String = \"1.1.6\"\n"),
|
|
||||||
0o644,
|
|
||||||
))
|
|
||||||
|
|
||||||
require.NoError(s.T(), os.WriteFile(
|
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
|
||||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n"),
|
|
||||||
0o644,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PrepareSuite) TestPrepare_UpdatesVersionAndPromotesUnreleasedNotes() {
|
|
||||||
err := releaseprep.Prepare(s.rootDir, "v1.1.7", "2026-03-20")
|
|
||||||
|
|
||||||
require.NoError(s.T(), err)
|
|
||||||
|
|
||||||
versionBytes, err := os.ReadFile(filepath.Join(s.rootDir, "internal", "homesick", "version", "version.go"))
|
|
||||||
require.NoError(s.T(), err)
|
|
||||||
require.Equal(s.T(), "package version\n\nconst String = \"1.1.7\"\n", string(versionBytes))
|
|
||||||
|
|
||||||
changelogBytes, err := os.ReadFile(filepath.Join(s.rootDir, "changelog.md"))
|
|
||||||
require.NoError(s.T(), err)
|
|
||||||
require.Equal(s.T(), "# Changelog\n\n## [Unreleased]\n\n## [1.1.7] - 2026-03-20\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n", string(changelogBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() {
|
|
||||||
require.NoError(s.T(), os.WriteFile(
|
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
|
||||||
[]byte("# Changelog\n\n## [1.1.6] - 2017-12-20\n"),
|
|
||||||
0o644,
|
|
||||||
))
|
|
||||||
|
|
||||||
err := releaseprep.Prepare(s.rootDir, "1.1.7", "2026-03-20")
|
|
||||||
|
|
||||||
require.ErrorContains(s.T(), err, "unreleased section")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionIsEmpty() {
|
|
||||||
require.NoError(s.T(), os.WriteFile(
|
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
|
||||||
[]byte("# Changelog\n\n## [Unreleased]\n\n## [1.1.6] - 2017-12-20\n"),
|
|
||||||
0o644,
|
|
||||||
))
|
|
||||||
|
|
||||||
err := releaseprep.Prepare(s.rootDir, "1.1.7", "2026-03-20")
|
|
||||||
|
|
||||||
require.ErrorContains(s.T(), err, "unreleased section is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PrepareSuite) TestRecommendedTag_UsesMinorBumpWhenAddedEntriesExist() {
|
|
||||||
tag, err := releaseprep.RecommendedTag(s.rootDir)
|
|
||||||
|
|
||||||
require.NoError(s.T(), err)
|
|
||||||
require.Equal(s.T(), "v1.2.0", tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PrepareSuite) TestRecommendedTag_UsesPatchBumpForFixOnlyChanges() {
|
|
||||||
require.NoError(s.T(), os.WriteFile(
|
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
|
||||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- Patch note.\n\n## [1.1.6] - 2017-12-20\n"),
|
|
||||||
0o644,
|
|
||||||
))
|
|
||||||
|
|
||||||
tag, err := releaseprep.RecommendedTag(s.rootDir)
|
|
||||||
|
|
||||||
require.NoError(s.T(), err)
|
|
||||||
require.Equal(s.T(), "v1.1.7", tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenRemovedEntriesExist() {
|
|
||||||
require.NoError(s.T(), os.WriteFile(
|
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
|
||||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Removed\n\n- Breaking removal.\n\n## [1.1.6] - 2017-12-20\n"),
|
|
||||||
0o644,
|
|
||||||
))
|
|
||||||
|
|
||||||
tag, err := releaseprep.RecommendedTag(s.rootDir)
|
|
||||||
|
|
||||||
require.NoError(s.T(), err)
|
|
||||||
require.Equal(s.T(), "v2.0.0", tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenBreakingEntriesExist() {
|
|
||||||
require.NoError(s.T(), os.WriteFile(
|
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
|
||||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n- Changed API contract.\n\n### Changed\n\n- Updated defaults.\n\n## [1.1.6] - 2017-12-20\n"),
|
|
||||||
0o644,
|
|
||||||
))
|
|
||||||
|
|
||||||
tag, err := releaseprep.RecommendedTag(s.rootDir)
|
|
||||||
|
|
||||||
require.NoError(s.T(), err)
|
|
||||||
require.Equal(s.T(), "v2.0.0", tag)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user