From 9ac63a410371f519765ab5bf1d011171eb1be622 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Tue, 30 Jun 2026 21:39:15 -0400 Subject: [PATCH] fix(version): tolerate prerelease dryrun tags in next-env calc The next-env-version calc parsed nextEnvVersion with the strict semver parser, which only accepts an -rc.N suffix. A stray v0.6.0-dryrun.13 value recorded as the latest aborted the whole calculation with "parsing next env version: invalid version format", which blocked a real release until the dryrun tags were pruned by hand. Discovery-side tag lookups already skip such exercise tags, but the calc path did not. Add ParseBase, which extracts the numeric core of a version and discards any pre-release suffix, and use it for nextEnvVersion. Only the base major.minor.patch feeds the calculation, so dropping the suffix is correct and cannot change the result for well-formed inputs. Closes #410 Signed-off-by: Joshua Temple --- internal/version/version.go | 39 +++++++++++++++- internal/version/version_test.go | 79 ++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/internal/version/version.go b/internal/version/version.go index 272b4a1..23c3993 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -34,6 +34,38 @@ const ( // The hotfix segment is only valid nested after an rc segment. var semverRegex = regexp.MustCompile(`^([a-zA-Z]*)(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+)(?:\.hotfix\.(\d+))?)?$`) +// baseVersionRegex matches a semver core (vX.Y.Z) with any optional +// pre-release suffix (for example -rc.4, -dryrun.13, or -beta.1). Only the +// numeric core and prefix are captured; the suffix is intentionally ignored. +var baseVersionRegex = regexp.MustCompile(`^([a-zA-Z]*)(\d+)\.(\d+)\.(\d+)(?:-.+)?$`) + +// ParseBase parses the numeric core (prefix and major.minor.patch) of a version +// string, tolerating and discarding any pre-release suffix such as an -rc.N, +// -dryrun.N, or other exercise tag that the strict Parse rejects. The returned +// Version always has no pre-release or hotfix segment. It errors only when the +// core itself is not a valid vX.Y.Z triple. Version calculations that derive +// their next version solely from a base can use this so a stray suffixed value +// recorded as the latest does not abort the whole calculation. +func ParseBase(s string) (*Version, error) { + matches := baseVersionRegex.FindStringSubmatch(s) + if matches == nil { + return nil, fmt.Errorf("invalid version format: %s", s) + } + + major, _ := strconv.Atoi(matches[2]) + minor, _ := strconv.Atoi(matches[3]) + patch, _ := strconv.Atoi(matches[4]) + + return &Version{ + Major: major, + Minor: minor, + Patch: patch, + PreRelease: -1, + Hotfix: -1, + Prefix: matches[1], + }, nil +} + // Parse parses a version string into a Version struct func Parse(s string) (*Version, error) { matches := semverRegex.FindStringSubmatch(s) @@ -200,8 +232,13 @@ func (c *Calculator) CalculateNext(currentDevVersion, nextEnvVersion string, com Prefix: c.prefix, } } else { + // Only the numeric core of the next env's version feeds the + // calculation (see BaseVersion below), so tolerate any pre-release + // suffix here. A stray -dryrun.N or -rc.N value recorded as the latest + // must not abort the calculation, matching the discovery-side filtering + // that keeps such exercise tags out of tag lookups. var err error - baseVersion, err = Parse(nextEnvVersion) + baseVersion, err = ParseBase(nextEnvVersion) if err != nil { return nil, fmt.Errorf("parsing next env version: %w", err) } diff --git a/internal/version/version_test.go b/internal/version/version_test.go index e259381..7093eae 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -558,6 +558,85 @@ func TestCalculateNext_NextEnvHoldsHotfixVersion(t *testing.T) { assert.Equal(t, -1, got.Hotfix) } +func TestParseBase(t *testing.T) { + tests := []struct { + name string + input string + want string // Version.String() of the parsed base, or "" when wantErr + wantErr bool + }{ + {name: "plain release", input: "v1.2.3", want: "v1.2.3"}, + {name: "rc suffix dropped", input: "v1.2.3-rc.4", want: "v1.2.3"}, + {name: "hotfix suffix dropped", input: "v1.4.0-rc.2.hotfix.1", want: "v1.4.0"}, + {name: "dryrun suffix dropped", input: "v0.6.0-dryrun.13", want: "v0.6.0"}, + {name: "opaque suffix dropped", input: "v2.0.0-beta.1", want: "v2.0.0"}, + {name: "no prefix", input: "3.4.5-dryrun.1", want: "3.4.5"}, + {name: "not a triple", input: "vnightly", wantErr: true}, + {name: "empty", input: "", wantErr: true}, + {name: "two segments", input: "v1.2-dryrun.1", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseBase(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got.String()) + assert.Equal(t, -1, got.PreRelease) + assert.Equal(t, -1, got.Hotfix) + }) + } +} + +func TestCalculateNext_NextEnvHoldsDryrunVersion(t *testing.T) { + calc := NewCalculator("v") + + commits := []changelog.ConventionalCommit{ + {Type: "fix", Description: "bug fix"}, + } + + tests := []struct { + name string + currentDevVersion string + nextEnvVersion string + want string + }{ + { + // A dry-run vector leaves a v0.6.0-dryrun.13 value in state; a + // later real release must still compute the next version from the + // v0.6.0 base rather than aborting the whole calculation. + name: "dryrun latest, fresh release", + currentDevVersion: "", + nextEnvVersion: "v0.6.0-dryrun.13", + want: "v0.6.1-rc.0", + }, + { + name: "dryrun latest, just promoted", + currentDevVersion: "v0.6.0-dryrun.13", + nextEnvVersion: "v0.6.0-dryrun.13", + want: "v0.6.1-rc.0", + }, + { + name: "opaque prerelease suffix tolerated", + currentDevVersion: "", + nextEnvVersion: "v1.2.3-beta.4", + want: "v1.2.4-rc.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := calc.CalculateNext(tt.currentDevVersion, tt.nextEnvVersion, commits) + require.NoError(t, err) + assert.Equal(t, tt.want, got.String()) + assert.Equal(t, -1, got.Hotfix) + }) + } +} + func TestStripRC_HotfixVersion(t *testing.T) { got, err := StripRC("v1.4.0-rc.2.hotfix.1") require.NoError(t, err)