diff --git a/cmd/releaseprep/main.go b/cmd/releaseprep/main.go deleted file mode 100644 index 8d9d047..0000000 --- a/cmd/releaseprep/main.go +++ /dev/null @@ -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 --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) - } -} diff --git a/internal/releaseprep/releaseprep.go b/internal/releaseprep/releaseprep.go deleted file mode 100644 index 3177170..0000000 --- a/internal/releaseprep/releaseprep.go +++ /dev/null @@ -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 -} diff --git a/internal/releaseprep/releaseprep_test.go b/internal/releaseprep/releaseprep_test.go deleted file mode 100644 index 9c1b8c9..0000000 --- a/internal/releaseprep/releaseprep_test.go +++ /dev/null @@ -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) -}