diff --git a/README.md b/README.md index 83b8098..c03fa2e 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,11 @@ Defaults: - `version-pattern`: `^\s*([^\r\n]+)\s*$` - `changelog`: `changelog.md` -By default, `vociferate` reads and writes the release version as the sole content of a root-level `release-version` file. Repositories that keep the version inside source code should pass explicit `version-file` and `version-pattern` values. +When no `--version-file` flag is provided, `vociferate` derives the current version from the most recent released section heading in the changelog (`## [x.y.z] - ...`). If no prior releases exist, it defaults to `0.0.0` and recommends `v1.0.0` as the first tag. + +When running `--version`, the `release-version` file is created automatically if it does not exist, so new repositories do not need to pre-seed it. + +Repositories that keep the version inside source code should pass explicit `--version-file` and `--version-pattern` values; in that case the version file is used directly instead of the changelog. ## Testing diff --git a/changelog.md b/changelog.md index 37e7e1d..d5cb758 100644 --- a/changelog.md +++ b/changelog.md @@ -13,6 +13,8 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect ### Changed +- Release version recommendation now reads the current version from the most recent released section in the changelog instead of requiring a separate version file. When no prior releases exist the version defaults to `0.0.0`, yielding `v1.0.0` as the first recommended tag. +- `vociferate prepare` creates the `release-version` file if it does not already exist, removing the need to pre-seed it in new repositories. - Release automation is now split into a prepare workflow that updates and tags `main`, and a tag-driven publish workflow that creates the release from the tagged revision. - The CLI entrypoint, internal package paths, build outputs, and automation references now use the `vociferate` name instead of the earlier `releaseprep` naming. - Configurable version source and parser via `--version-file` and `--version-pattern`. diff --git a/internal/vociferate/vociferate.go b/internal/vociferate/vociferate.go index 446c427..2a5759f 100644 --- a/internal/vociferate/vociferate.go +++ b/internal/vociferate/vociferate.go @@ -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 { diff --git a/internal/vociferate/vociferate_test.go b/internal/vociferate/vociferate_test.go index db94a98..760f39f 100644 --- a/internal/vociferate/vociferate_test.go +++ b/internal/vociferate/vociferate_test.go @@ -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))