From 3c1e3f2a6227135a492f3025d180901f5da65750 Mon Sep 17 00:00:00 2001 From: David Ragot <35502263+Dav-14@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:25:25 +0200 Subject: [PATCH] fix(core): normalize partial-semver versions + drop dead e2e carve-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ValidateMinimumVersion("v3.2")` silently returned `nil` because `semver.IsValid("v3.2")` is false (semver requires major.minor.patch). The short-circuit at `semver.IsValid(...) && Compare(...) < 0` meant every partial-semver Versions resource name (`v3`, `v3.2`, …) bypassed the minimum-version gate entirely. Adds `normalizePartialSemver` which expands `v3` → `v3.0.0` and `v3.2` → `v3.2.0` before the comparison so partial-semver names are gated by the same minimum as their canonical form. Non-matching inputs (canonical semver, dev tags, SHA refs, non-`v`-prefixed strings) are returned unchanged and continue to pass through. Also drops two pieces of now-redundant code from the same function: - `if strings.TrimPrefix(version, "v") == "0.0.0-e2e" { return nil }`: a sentinel for e2e test builds. Dead: `ValidateMinimumVersion` has exactly one call site (inside `GetModuleVersion`) that fires only when `stack.Spec.VersionsFromFile` is set AND both `module.Version` and `stack.Spec.Version` are empty. Every chainsaw test stack uses `spec.version: v0.0.0-e2e`, so the validator is never invoked for them — the carve-out covered nothing. semver also already handles `-e2e` as a valid pre-release suffix; a real e2e tag at or above the minimum sails through naturally. - The `!strings.HasPrefix(version, "v")` dance: `golang.org/x/mod/semver` contractually requires the `v` prefix, and non-prefixed strings are exactly the "non-semver names allowed through" case the docstring already calls out. Letting them passthrough by way of `semver.IsValid` returning false is consistent and removes the only branch left in the function. Tests: - Drops the two `0.0.0-e2e` cases (covered nothing — see above). - Drops the two non-`v`-prefixed cases that previously hinged on the prefix-injection branch (`2.1.0 rejected`, `2.2.0 accepted`); replaced by one explicit `non-v-prefixed accepted as passthrough` case making the intent visible. - Adds five partial-semver cases: `v3`, `v3.2`, `v2.2` accepted; `v2`, `v2.1` rejected. --- internal/core/version.go | 37 +++++++++++++++++++++++++---------- internal/core/version_test.go | 19 ++++++++++++++---- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/internal/core/version.go b/internal/core/version.go index df04543e..392191cc 100644 --- a/internal/core/version.go +++ b/internal/core/version.go @@ -3,6 +3,7 @@ package core import ( "errors" "fmt" + "regexp" "strings" "golang.org/x/mod/semver" @@ -19,18 +20,34 @@ var ErrNoVersionFound = errors.New("no version found") // MinimumStackVersion is the minimum Stack version the operator supports deploying. const MinimumStackVersion = "v2.2.0" -// ValidateMinimumVersion checks that a Versions resource name meets the minimum requirement. -// Non-semver names (dev tags, SHA refs) are allowed through. -func ValidateMinimumVersion(version string) error { - if strings.TrimPrefix(version, "v") == "0.0.0-e2e" { - return nil - } +// partialSemverRe matches `v` or `v.` — semver-shaped +// names that are missing the patch (and optionally the minor) component. +// These are routinely used as Versions resource names (`v3`, `v3.2`) and +// would otherwise sail past [semver.IsValid] and silently bypass the +// minimum-version check. +var partialSemverRe = regexp.MustCompile(`^v(\d+)(?:\.(\d+))?$`) - normalizedVersion := version - if !strings.HasPrefix(normalizedVersion, "v") { - normalizedVersion = "v" + normalizedVersion +// normalizePartialSemver expands `v3` → `v3.0.0` and `v3.2` → `v3.2.0` so +// partial-semver Versions resource names are gated by the same min-version +// check as their canonical form. Non-matching inputs (canonical semver, +// dev tags, SHA refs, non-`v`-prefixed strings) are returned unchanged. +func normalizePartialSemver(v string) string { + m := partialSemverRe.FindStringSubmatch(v) + if m == nil { + return v + } + minor := m[2] + if minor == "" { + minor = "0" } - if semver.IsValid(normalizedVersion) && semver.Compare(normalizedVersion, MinimumStackVersion) < 0 { + return fmt.Sprintf("v%s.%s.0", m[1], minor) +} + +// ValidateMinimumVersion checks that a Versions resource name meets the minimum requirement. +// Non-semver names (dev tags, SHA refs, non-`v`-prefixed strings) are allowed through. +func ValidateMinimumVersion(version string) error { + normalized := normalizePartialSemver(version) + if semver.IsValid(normalized) && semver.Compare(normalized, MinimumStackVersion) < 0 { return fmt.Errorf("version %s is not supported, minimum required: %s - please upgrade your stack", version, MinimumStackVersion) } return nil diff --git a/internal/core/version_test.go b/internal/core/version_test.go index e217fadf..6bdf35e4 100644 --- a/internal/core/version_test.go +++ b/internal/core/version_test.go @@ -12,21 +12,32 @@ func TestValidateMinimumVersion(t *testing.T) { version string wantError bool }{ + // Canonical semver below minimum is rejected. {name: "v2.0.0 rejected", version: "v2.0.0", wantError: true}, {name: "v2.1.0 rejected", version: "v2.1.0", wantError: true}, - {name: "2.1.0 rejected without v prefix", version: "2.1.0", wantError: true}, {name: "v2.1.9 rejected", version: "v2.1.9", wantError: true}, {name: "v2.0.0-rc.5 rejected", version: "v2.0.0-rc.5", wantError: true}, {name: "v2.2.0-alpha pre-release rejected", version: "v2.2.0-alpha", wantError: true}, + + // Canonical semver at or above minimum is accepted. {name: "v2.2.0 accepted", version: "v2.2.0", wantError: false}, - {name: "2.2.0 accepted without v prefix", version: "2.2.0", wantError: false}, - {name: "v0.0.0-e2e accepted as test tag", version: "v0.0.0-e2e", wantError: false}, - {name: "0.0.0-e2e accepted as test tag without v prefix", version: "0.0.0-e2e", wantError: false}, {name: "v2.3.0 accepted", version: "v2.3.0", wantError: false}, {name: "v3.0.0 accepted", version: "v3.0.0", wantError: false}, + + // Partial semver (`v3`, `v3.2`) is expanded to its canonical form + // (`v3.0.0`, `v3.2.0`) and gated by the same minimum-version check. + {name: "v3 accepted (expands to v3.0.0)", version: "v3", wantError: false}, + {name: "v3.2 accepted (expands to v3.2.0)", version: "v3.2", wantError: false}, + {name: "v2.2 accepted as equal to minimum (expands to v2.2.0)", version: "v2.2", wantError: false}, + {name: "v2 rejected (expands to v2.0.0)", version: "v2", wantError: true}, + {name: "v2.1 rejected (expands to v2.1.0)", version: "v2.1", wantError: true}, + + // Non-semver names (dev tags, SHA refs, non-`v`-prefixed strings) + // pass through unchecked. {name: "non-semver accepted", version: "main", wantError: false}, {name: "sha ref accepted", version: "abc123def", wantError: false}, {name: "latest accepted", version: "latest", wantError: false}, + {name: "non-v-prefixed accepted as passthrough", version: "2.2.0", wantError: false}, } for _, tt := range tests {