compat: SemVer version windows between app and server
server-release / image (push) Successful in 14s
server-test / test (push) Successful in 30s
server-release / release (push) Successful in 30s

Both sides now declare what they will talk to, and enforce it. Two axes kept
deliberately separate, because conflating them is the trap:

  protocol_version  — CAN these builds talk. The correctness axis. Below 1.0.0
                      the minor is the breaking axis, per SemVer §4.
  release window    — MAY they, per policy. [min, max), advertised in the
                      profile, overridable by the operator.

The server refuses out-of-window apps with 426 and a body naming both versions
and the accepted range; the app checks the profile in both directions before a
run rather than discovering mid-measurement that it will be refused.

Three rules that shape the rest:

  - GET /v1/profile is never gated. It is where a refused client learns which
    version it needs; gating it leaves the user with a network error instead of
    an answer, which is precisely the confusion this exists to remove.
  - An unparseable or absent version is "unknown", and is allowed. Development
    builds report "dev", and a client too old to send the header cannot be
    identified anyway.
  - Bounds sit at breaking boundaries, not at releases, so shipping a patch
    never requires editing a range. The app's server minimum is 0.4.2 for a
    stated reason: earlier multi-homed servers mis-addressed granted sends and
    the client measured 100% downstream loss that never happened.

The app's versionCode is now derived from its SemVer instead of being a second
number someone has to remember to bump.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 11:36:34 +02:00
co-authored by Claude Fable 5
parent 277e33da75
commit 0c5b021b63
18 changed files with 1213 additions and 47 deletions
+200
View File
@@ -0,0 +1,200 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package compat decides whether two Echolot builds should talk to each other.
//
// Versions are SemVer. What is actually being checked, though, is not "is this release recent"
// but "does this peer speak a wire protocol and schema I understand" — the version is a proxy for
// that, and the proxy only holds because we bump the breaking axis when the contract changes.
// So the bounds here are set at *breaking boundaries*, not at every release: a patch bump must
// never strand a fleet, and the range must not need editing to ship a bugfix.
//
// The one rule that shapes the rest: refusing a peer must still tell it why. A client that cannot
// reach the profile endpoint cannot learn what version it should be, so it has nothing to show its
// user but a network error. GET /v1/profile is therefore always reachable, whatever the range says.
package compat
import (
"fmt"
"strconv"
"strings"
)
// Version is a parsed SemVer. Build metadata is discarded (it is explicitly not part of
// precedence); pre-release is kept and compared, because "1.0.0-rc1" must sort below "1.0.0".
type Version struct {
Major, Minor, Patch int
Pre string
}
// Parse accepts "1.2.3", "v1.2.3" and our namespaced release tags ("server-v1.2.3"), because
// those are the forms that actually reach this code: a tag, a --version output, and an HTTP
// header written by three different pieces of software.
func Parse(s string) (Version, bool) {
s = strings.TrimSpace(s)
// Strip a tag prefix ending in "v" ("v1.2.3", "server-v1.2.3"). Guarded on the prefix having
// no digits so a pre-release identifier that happens to contain a "v" is left alone.
if i := strings.LastIndexByte(s, 'v'); i >= 0 && i+1 < len(s) &&
s[i+1] >= '0' && s[i+1] <= '9' && !strings.ContainsAny(s[:i], "0123456789") {
s = s[i+1:]
}
if plus := strings.IndexByte(s, '+'); plus >= 0 {
s = s[:plus]
}
var pre string
if dash := strings.IndexByte(s, '-'); dash >= 0 {
pre, s = s[dash+1:], s[:dash]
}
parts := strings.Split(s, ".")
if len(parts) != 3 {
return Version{}, false
}
out := Version{Pre: pre}
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return Version{}, false
}
switch i {
case 0:
out.Major = n
case 1:
out.Minor = n
case 2:
out.Patch = n
}
}
return out, true
}
func (v Version) String() string {
s := fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
if v.Pre != "" {
s += "-" + v.Pre
}
return s
}
// Compare returns -1, 0 or 1. A pre-release sorts below the same version without one (SemVer §11);
// two pre-releases compare lexically, which is close enough for the identifiers we use.
func (v Version) Compare(o Version) int {
for _, d := range []int{v.Major - o.Major, v.Minor - o.Minor, v.Patch - o.Patch} {
if d != 0 {
return sign(d)
}
}
switch {
case v.Pre == o.Pre:
return 0
case v.Pre == "":
return 1
case o.Pre == "":
return -1
case v.Pre < o.Pre:
return -1
}
return 1
}
func (v Version) Less(o Version) bool { return v.Compare(o) < 0 }
// NextBreaking is the first version that may break compatibility with v.
//
// Below 1.0.0 the minor is the breaking axis (SemVer §4: anything may change in 0.y), so 0.4.2's
// next break is 0.5.0, not 1.0.0. Getting this wrong in the permissive direction would let a
// 0.5 server accept a 0.4 app that cannot speak to it.
func (v Version) NextBreaking() Version {
if v.Major == 0 {
return Version{Major: 0, Minor: v.Minor + 1}
}
return Version{Major: v.Major + 1}
}
// Range is [Min, Max): minimum inclusive, maximum exclusive. An unset Max means unbounded.
//
// Exclusive on the upper end because the useful bound is always "the version that broke it",
// and writing that literally ("< 1.0.0") is unambiguous in a way that "<= 0.999.999" is not.
type Range struct {
Min Version
Max Version
HasMax bool
}
func (r Range) Contains(v Version) bool {
if v.Less(r.Min) {
return false
}
if r.HasMax && !v.Less(r.Max) {
return false
}
return true
}
func (r Range) String() string {
if !r.HasMax {
return ">= " + r.Min.String()
}
return ">= " + r.Min.String() + ", < " + r.Max.String()
}
// ParseRange builds a range from two strings; an empty max means unbounded. A malformed bound is
// an error rather than a silently ignored one: a typo in an operator's config must not quietly
// turn a restriction off.
func ParseRange(min, max string) (Range, error) {
lo, ok := Parse(min)
if !ok {
return Range{}, fmt.Errorf("bad minimum version %q", min)
}
if strings.TrimSpace(max) == "" {
return Range{Min: lo}, nil
}
hi, ok := Parse(max)
if !ok {
return Range{}, fmt.Errorf("bad maximum version %q", max)
}
if hi.Less(lo) || hi.Compare(lo) == 0 {
return Range{}, fmt.Errorf("maximum %s is not above minimum %s", max, min)
}
return Range{Min: lo, Max: hi, HasMax: true}, nil
}
// Verdict is the outcome of a compatibility check.
type Verdict int
const (
OK Verdict = iota
TooOld
TooNew
// Unknown means the peer did not say, or said something unparseable — a development build,
// or a client too old to send its version at all.
Unknown
)
// Check reports whether peer falls in r, and explains the answer in words meant for a person.
// The message is deliberately actionable: it names both versions and what to do about it, since
// it is the only thing the user on the other end will see.
func Check(peer string, r Range, peerName string) (Verdict, string) {
v, ok := Parse(peer)
if !ok {
return Unknown, fmt.Sprintf("%s did not report a usable version (%q); proceeding without a compatibility check", peerName, peer)
}
switch {
case v.Less(r.Min):
return TooOld, fmt.Sprintf("%s %s is older than this build supports (needs %s). Update the %s.",
peerName, v, r, peerName)
case r.HasMax && !v.Less(r.Max):
return TooNew, fmt.Sprintf("%s %s is newer than this build supports (accepts %s). Update this side, or point at a %s within range.",
peerName, v, r, peerName)
}
return OK, ""
}
func sign(d int) int {
if d < 0 {
return -1
}
if d > 0 {
return 1
}
return 0
}
+156
View File
@@ -0,0 +1,156 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package compat
import "testing"
func TestParseAcceptsTheFormsThatActuallyReachUs(t *testing.T) {
cases := map[string]Version{
"1.2.3": {Major: 1, Minor: 2, Patch: 3},
"v1.2.3": {Major: 1, Minor: 2, Patch: 3},
"server-v0.4.2": {Minor: 4, Patch: 2},
" 0.2.0 ": {Minor: 2},
"1.0.0-rc1": {Major: 1, Pre: "rc1"},
"1.0.0+build.7": {Major: 1},
"1.0.0-rc1+meta": {Major: 1, Pre: "rc1"},
}
for in, want := range cases {
got, ok := Parse(in)
if !ok || got != want {
t.Errorf("Parse(%q) = %v,%v; want %v", in, got, ok, want)
}
}
// A pre-release identifier containing a "v" must not be mistaken for a tag prefix.
if got, ok := Parse("1.2.3-rcv1"); !ok || got.Pre != "rcv1" || got.Major != 1 {
t.Errorf("Parse(1.2.3-rcv1) = %v,%v", got, ok)
}
for _, bad := range []string{"", "dev", "1.2", "1.2.3.4", "x.y.z", "-1.0.0", "1.2.beta"} {
if _, ok := Parse(bad); ok {
t.Errorf("Parse(%q) should have failed", bad)
}
}
}
func TestCompareOrdersPreReleasesBelowTheirRelease(t *testing.T) {
lt := func(a, b string) {
t.Helper()
x, _ := Parse(a)
y, _ := Parse(b)
if x.Compare(y) != -1 || y.Compare(x) != 1 {
t.Errorf("expected %s < %s", a, b)
}
}
lt("0.9.9", "1.0.0")
lt("1.0.0", "1.0.1")
lt("1.0.0", "1.1.0")
lt("1.0.0-rc1", "1.0.0")
lt("1.0.0-rc1", "1.0.0-rc2")
a, _ := Parse("1.2.3")
b, _ := Parse("v1.2.3")
if a.Compare(b) != 0 {
t.Error("the same version written two ways must compare equal")
}
}
// Below 1.0.0 the minor is the breaking axis. Treating 1.0.0 as the next break for a 0.x build
// would let a 0.5 server accept a 0.4 client it cannot actually talk to.
func TestNextBreakingUsesTheMinorBelowOne(t *testing.T) {
cases := map[string]string{
"0.4.2": "0.5.0",
"0.0.9": "0.1.0",
"1.2.3": "2.0.0",
"2.0.0": "3.0.0",
}
for in, want := range cases {
v, _ := Parse(in)
if got := v.NextBreaking().String(); got != want {
t.Errorf("NextBreaking(%s) = %s, want %s", in, got, want)
}
}
}
func TestRangeIsMinInclusiveMaxExclusive(t *testing.T) {
r, err := ParseRange("0.2.0", "1.0.0")
if err != nil {
t.Fatal(err)
}
in := []string{"0.2.0", "0.2.1", "0.9.9", "1.0.0-rc1"}
out := []string{"0.1.9", "1.0.0", "1.0.1", "2.0.0"}
for _, s := range in {
v, _ := Parse(s)
if !r.Contains(v) {
t.Errorf("%s should be inside %s", s, r)
}
}
for _, s := range out {
v, _ := Parse(s)
if r.Contains(v) {
t.Errorf("%s should be outside %s", s, r)
}
}
}
func TestUnboundedRangeHasNoCeiling(t *testing.T) {
r, err := ParseRange("0.2.0", "")
if err != nil {
t.Fatal(err)
}
v, _ := Parse("99.0.0")
if !r.Contains(v) {
t.Error("an empty maximum must mean unbounded")
}
}
// A typo in an operator's config must not silently disable the restriction it was meant to set.
func TestMalformedBoundsAreErrorsNotSilentPermissiveness(t *testing.T) {
for _, c := range [][2]string{
{"nonsense", "1.0.0"},
{"0.2.0", "nonsense"},
{"1.0.0", "0.9.0"}, // max below min
{"1.0.0", "1.0.0"}, // empty window: nothing could ever satisfy it
} {
if _, err := ParseRange(c[0], c[1]); err == nil {
t.Errorf("ParseRange(%q, %q) should have failed", c[0], c[1])
}
}
}
func TestCheckExplainsItself(t *testing.T) {
r, _ := ParseRange("0.2.0", "1.0.0")
if v, msg := Check("0.5.0", r, "app"); v != OK || msg != "" {
t.Errorf("in-range check should pass silently: %v %q", v, msg)
}
v, msg := Check("0.1.0", r, "app")
if v != TooOld {
t.Fatalf("want TooOld, got %v", v)
}
for _, want := range []string{"0.1.0", "0.2.0", "Update"} {
if !contains(msg, want) {
t.Errorf("the refusal must name %q so the user can act on it: %q", want, msg)
}
}
if v, _ := Check("1.4.0", r, "app"); v != TooNew {
t.Errorf("want TooNew, got %v", v)
}
// A development build reports "dev". Locking developers out of their own server would be a
// poor trade for a check that exists to prevent confusing failures.
if v, msg := Check("dev", r, "server"); v != Unknown || msg == "" {
t.Errorf("unparseable version should be Unknown with an explanation, got %v %q", v, msg)
}
}
func contains(h, n string) bool {
return len(h) >= len(n) && (h == n || len(n) == 0 || indexOf(h, n) >= 0)
}
func indexOf(h, n string) int {
for i := 0; i+len(n) <= len(h); i++ {
if h[i:i+len(n)] == n {
return i
}
}
return -1
}