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
+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
}