| new file mode 100644 |
| index 000000000..ddb70366e |
| --- /dev/null |
| +++ b/internal/release/semver.go |
| @@ -0,0 +1,105 @@ |
| +// 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 "<target>-<n>" 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) |
| +} |