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,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
|
||||
}
|
||||
Reference in New Issue
Block a user