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:
@@ -48,7 +48,11 @@ Defaults:
|
|||||||
- `version-pattern`: `^\s*([^\r\n]+)\s*$`
|
- `version-pattern`: `^\s*([^\r\n]+)\s*$`
|
||||||
- `changelog`: `changelog.md`
|
- `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
|
## Testing
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
|
|||||||
|
|
||||||
### Changed
|
### 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.
|
- 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.
|
- 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`.
|
- Configurable version source and parser via `--version-file` and `--version-pattern`.
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const (
|
|||||||
defaultChangelog = "changelog.md"
|
defaultChangelog = "changelog.md"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var releasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\] - `)
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
VersionFile string
|
VersionFile string
|
||||||
VersionPattern string
|
VersionPattern string
|
||||||
@@ -66,9 +68,22 @@ func RecommendedTag(rootDir string, options Options) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
currentVersion, err := readCurrentVersion(rootDir, resolved)
|
var currentVersion string
|
||||||
if err != nil {
|
if options.VersionFile != "" {
|
||||||
return "", err
|
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)
|
unreleasedBody, err := readUnreleasedBody(rootDir, resolved.Changelog)
|
||||||
@@ -146,6 +161,9 @@ func updateVersionFile(rootDir, version string, options resolvedOptions) error {
|
|||||||
path := filepath.Join(rootDir, options.VersionFile)
|
path := filepath.Join(rootDir, options.VersionFile)
|
||||||
contents, err := os.ReadFile(path)
|
contents, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return os.WriteFile(path, []byte(version+"\n"), 0o644)
|
||||||
|
}
|
||||||
return fmt.Errorf("read version file: %w", err)
|
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
|
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) {
|
func parseSemver(version string) (semver, error) {
|
||||||
parts := strings.Split(strings.TrimSpace(version), ".")
|
parts := strings.Split(strings.TrimSpace(version), ".")
|
||||||
if len(parts) != 3 {
|
if len(parts) != 3 {
|
||||||
|
|||||||
@@ -155,6 +155,52 @@ func (s *PrepareSuite) TestPrepare_AllowsUnchangedVersionValue() {
|
|||||||
require.Equal(s.T(), "1.1.6\n", string(versionBytes))
|
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() {
|
func (s *PrepareSuite) TestRecommendedTag_UsesCustomVersionFileAndPattern() {
|
||||||
customVersionFile := filepath.Join("custom", "VERSION.txt")
|
customVersionFile := filepath.Join("custom", "VERSION.txt")
|
||||||
require.NoError(s.T(), os.MkdirAll(filepath.Join(s.rootDir, "custom"), 0o755))
|
require.NoError(s.T(), os.MkdirAll(filepath.Join(s.rootDir, "custom"), 0o755))
|
||||||
|
|||||||
Reference in New Issue
Block a user