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 {