// Semver targets and counter iterations (release versioning spec, // 2026-08-08). A TARGET is a bare MAJOR.MINOR.PATCH ("0.5.0"); an ITERATION // is a shipped build under a target ("0.5.0-3"). Both are ordinary version // strings to everything else in this package — ValidVersion accepts them, // pointers and manifests carry them unchanged. This file only teaches the // package to read the structure back OUT of them. package release import ( "strconv" "strings" ) // SemVer is a parsed MAJOR.MINOR.PATCH. No prerelease/build fields: the // iteration counter is this package's only prerelease shape, and // SplitIteration owns it. type SemVer struct{ Major, Minor, Patch int } // ParseSemVer parses a strict bare semver: three dot-separated decimal // numbers, no leading zeros, nothing else. Refusals are (zero, false) — // version strings outside this shape are legal everywhere, they just are // not targets. func ParseSemVer(s string) (SemVer, bool) { parts := strings.Split(s, ".") if len(parts) != 3 { return SemVer{}, false } var n [3]int for i, p := range parts { v, ok := parseCounter(p, true) if !ok { return SemVer{}, false } n[i] = v } return SemVer{n[0], n[1], n[2]}, true } // parseCounter parses a plain decimal with no sign and no leading zeros. // zeroOK distinguishes semver components (0 is fine) from iteration // counters (they start at 1). func parseCounter(s string, zeroOK bool) (int, bool) { if s == "" || len(s) > 9 { return 0, false } for _, c := range s { if c < '0' || c > '9' { return 0, false } } if len(s) > 1 && s[0] == '0' { return 0, false } v, err := strconv.Atoi(s) if err != nil || (!zeroOK && v == 0) { return 0, false } return v, true } func (v SemVer) String() string { return strconv.Itoa(v.Major) + "." + strconv.Itoa(v.Minor) + "." + strconv.Itoa(v.Patch) } // Compare is < 0 / 0 / > 0, semver precedence (major, then minor, then patch). func (v SemVer) Compare(o SemVer) int { switch { case v.Major != o.Major: return v.Major - o.Major case v.Minor != o.Minor: return v.Minor - o.Minor default: return v.Patch - o.Patch } } // NextPatch is where the target auto-advances after a tag is cut. func (v SemVer) NextPatch() SemVer { return SemVer{v.Major, v.Minor, v.Patch + 1} } // SplitIteration reads "-" back out of an iteration version: // "0.5.0-3" -> (0.5.0, 3, true). Counters start at 1 and carry no leading // zeros, so every iteration version has exactly one spelling. Anything // else — a sha, a bare target, "dev" — is (zero, 0, false), which callers // read as "not an iteration; no tag business here". func SplitIteration(version string) (SemVer, int, bool) { base, cnt, found := strings.Cut(version, "-") if !found || strings.Contains(cnt, "-") { return SemVer{}, 0, false } t, ok := ParseSemVer(base) if !ok { return SemVer{}, 0, false } n, ok := parseCounter(cnt, false) if !ok { return SemVer{}, 0, false } return t, n, true } // IterationVersion is SplitIteration's inverse: the version string minted // for iteration n under a target. func IterationVersion(t SemVer, n int) string { return t.String() + "-" + strconv.Itoa(n) }