feat: derive recommended version from changelog; no version file required

- RecommendedTag now reads the current version from the most recent
  released section heading in the changelog (## [x.y.z] - ...) when no
  --version-file flag is given, removing the dependency on a separate
  version file for recommendation.
- When the changelog contains no prior releases, the base version
  defaults to 0.0.0, so the first recommended tag is v1.0.0 (or higher
  depending on unreleased content).
- Prepare creates the release-version file if it does not already exist,
  so new repositories do not need to pre-seed it.
- Add tests covering changelog-based version resolution, first-release
  default, and automatic file creation.
- Update README and changelog unreleased section to document the new
  behaviour.
This commit is contained in:
Micheal Wilkinson
2026-03-20 20:10:13 +00:00
parent c859a3fccb
commit 8c2835fe2e
4 changed files with 88 additions and 4 deletions

View File

@@ -15,6 +15,8 @@ const (
defaultChangelog = "changelog.md"
)
var releasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\] - `)
type Options struct {
VersionFile string
VersionPattern string
@@ -66,9 +68,22 @@ func RecommendedTag(rootDir string, options Options) (string, error) {
return "", err
}
currentVersion, err := readCurrentVersion(rootDir, resolved)
if err != nil {
return "", err
var currentVersion string
if options.VersionFile != "" {
currentVersion, err = readCurrentVersion(rootDir, resolved)
if err != nil {
return "", err
}
} else {
version, found, err := readLatestChangelogVersion(rootDir, resolved.Changelog)
if err != nil {
return "", err
}
if !found {
currentVersion = "0.0.0"
} else {
currentVersion = version
}
}
unreleasedBody, err := readUnreleasedBody(rootDir, resolved.Changelog)
@@ -146,6 +161,9 @@ func updateVersionFile(rootDir, version string, options resolvedOptions) error {
path := filepath.Join(rootDir, options.VersionFile)
contents, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return os.WriteFile(path, []byte(version+"\n"), 0o644)
}
return fmt.Errorf("read version file: %w", err)
}
@@ -244,6 +262,20 @@ func readChangelogState(rootDir, changelogPath string) (string, string, int, int
return unreleasedBody, text, afterHeader, nextSectionStart, path, nil
}
func readLatestChangelogVersion(rootDir, changelogPath string) (string, bool, error) {
path := filepath.Join(rootDir, changelogPath)
contents, err := os.ReadFile(path)
if err != nil {
return "", false, fmt.Errorf("read changelog: %w", err)
}
match := releasedSectionRe.FindStringSubmatch(string(contents))
if match == nil {
return "", false, nil
}
return match[1], true, nil
}
func parseSemver(version string) (semver, error) {
parts := strings.Split(strings.TrimSpace(version), ".")
if len(parts) != 3 {

View File

@@ -155,6 +155,52 @@ func (s *PrepareSuite) TestPrepare_AllowsUnchangedVersionValue() {
require.Equal(s.T(), "1.1.6\n", string(versionBytes))
}
func (s *PrepareSuite) TestRecommendedTag_UsesChangelogVersionWhenNoVersionFileConfigured() {
// The default release-version file is present from SetupTest but should be ignored;
// the current version must be read from the changelog, not the file.
require.NoError(s.T(), os.WriteFile(
filepath.Join(s.rootDir, "release-version"),
[]byte("99.99.99\n"), // deliberately wrong value
0o644,
))
require.NoError(s.T(), os.WriteFile(
filepath.Join(s.rootDir, "changelog.md"),
[]byte("# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- A fix.\n\n## [3.0.0] - 2026-01-01\n\n### Fixed\n\n- Historical.\n"),
0o644,
))
tag, err := vociferate.RecommendedTag(s.rootDir, vociferate.Options{})
require.NoError(s.T(), err)
require.Equal(s.T(), "v3.0.1", tag)
}
func (s *PrepareSuite) TestRecommendedTag_DefaultsToV1WhenNoPriorReleasesInChangelog() {
require.NoError(s.T(), os.Remove(filepath.Join(s.rootDir, "release-version")))
require.NoError(s.T(), os.WriteFile(
filepath.Join(s.rootDir, "changelog.md"),
[]byte("# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n### Added\n\n- First feature.\n"),
0o644,
))
tag, err := vociferate.RecommendedTag(s.rootDir, vociferate.Options{})
require.NoError(s.T(), err)
require.Equal(s.T(), "v1.0.0", tag)
}
func (s *PrepareSuite) TestPrepare_CreatesVersionFileWhenNotPresent() {
require.NoError(s.T(), os.Remove(filepath.Join(s.rootDir, "release-version")))
err := vociferate.Prepare(s.rootDir, "2.0.0", "2026-03-20", vociferate.Options{})
require.NoError(s.T(), err)
versionBytes, readErr := os.ReadFile(filepath.Join(s.rootDir, "release-version"))
require.NoError(s.T(), readErr)
require.Equal(s.T(), "2.0.0\n", string(versionBytes))
}
func (s *PrepareSuite) TestRecommendedTag_UsesCustomVersionFileAndPattern() {
customVersionFile := filepath.Join("custom", "VERSION.txt")
require.NoError(s.T(), os.MkdirAll(filepath.Join(s.rootDir, "custom"), 0o755))