carlos / platform Public

release: semver targets and iteration version parsing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell bc01f9858b9fd9e1deed00858d63aa9ab0a14a42 parent b46a70c
2 files changed, +162 −0
  • internal/release/semver.go +105 −0
  • internal/release/semver_test.go +57 −0
diff --git a/internal/release/semver.go b/internal/release/semver.go
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)
+}
diff --git a/internal/release/semver_test.go b/internal/release/semver_test.go
new file mode 100644
index 000000000..665ec154c
--- /dev/null
+++ b/internal/release/semver_test.go
@@ -0,0 +1,57 @@
+package release
+
+import "testing"
+
+func TestParseSemVer(t *testing.T) {
+ good := map[string]SemVer{
+ "0.5.0": {0, 5, 0},
+ "1.0.0": {1, 0, 0},
+ "10.2.33": {10, 2, 33},
+ }
+ for s, want := range good {
+ got, ok := ParseSemVer(s)
+ if !ok || got != want {
+ t.Errorf("ParseSemVer(%q) = %v, %v; want %v, true", s, got, ok, want)
+ }
+ }
+ bad := []string{"", "0.5", "0.5.0.1", "v0.5.0", "0.05.0", "01.0.0", "0.5.0-3", "0.5.x", "-1.0.0", "0.5.0 "}
+ for _, s := range bad {
+ if _, ok := ParseSemVer(s); ok {
+ t.Errorf("ParseSemVer(%q) accepted; want refused", s)
+ }
+ }
+}
+
+func TestSemVerCompareAndNext(t *testing.T) {
+ if c := (SemVer{0, 5, 0}).Compare(SemVer{0, 5, 1}); c >= 0 {
+ t.Errorf("0.5.0 vs 0.5.1 = %d; want < 0", c)
+ }
+ if c := (SemVer{1, 0, 0}).Compare(SemVer{0, 9, 9}); c <= 0 {
+ t.Errorf("1.0.0 vs 0.9.9 = %d; want > 0", c)
+ }
+ if c := (SemVer{0, 5, 0}).Compare(SemVer{0, 5, 0}); c != 0 {
+ t.Errorf("equal compare = %d; want 0", c)
+ }
+ if got := (SemVer{0, 5, 0}).NextPatch(); got != (SemVer{0, 5, 1}) {
+ t.Errorf("NextPatch = %v; want 0.5.1", got)
+ }
+ if got := (SemVer{0, 5, 0}).String(); got != "0.5.0" {
+ t.Errorf("String = %q; want 0.5.0", got)
+ }
+}
+
+func TestSplitIteration(t *testing.T) {
+ tgt, n, ok := SplitIteration("0.5.0-3")
+ if !ok || tgt != (SemVer{0, 5, 0}) || n != 3 {
+ t.Fatalf("SplitIteration(0.5.0-3) = %v, %d, %v", tgt, n, ok)
+ }
+ if got := IterationVersion(SemVer{0, 5, 0}, 4); got != "0.5.0-4" {
+ t.Errorf("IterationVersion = %q; want 0.5.0-4", got)
+ }
+ bad := []string{"de2766f8", "0.5.0", "0.5.0-0", "0.5.0-03", "0.5.0-x", "0.5.0-3-4", "0.5.0-", ""}
+ for _, s := range bad {
+ if _, _, ok := SplitIteration(s); ok {
+ t.Errorf("SplitIteration(%q) accepted; want refused", s)
+ }
+ }
+}