compat: SemVer version windows between app and server
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:
co-authored by
Claude Fable 5
parent
277e33da75
commit
0c5b021b63
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user