Major version pinning: semver-stamped releases, halt-and-notify (#817)
* server: major version pinning โ semver-stamped releases, halt-and-notify Releases now carry a human semver (deploy/SERVER_SEMVER, stamped by release-server.sh publish) beside the sha artifact id. A new update_major instance setting (lchat update major <host> <n>|clear, admin settings API) keeps a space on one major: minors/patches follow the channel, a head whose semver crosses the pin is held until the admin raises or clears it. Fail closed: a semver-less release also holds a pinned instance. No per-major LTS heads โ the pin is halt-and-notify, the honest promise for a team not maintaining old-major branches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * deploy: seed SERVER_SEMVER at 0.11.0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
14 files changed,
+474
−14
changelog/2026-07-13-major-version-pinning.md+6 −0db.go+14 −0deploy/SERVER_SEMVER+1 −0deploy/release-server.sh+13 −3docs/instance-router.md+6 −0docs/server-releases.md+27 −0main.go+14 −0majorpin_test.go+218 −0release.go+76 −0updatecheck.go+9 −0updater.go+73 −6updater_test.go+7 −1web/static/admin.js+5 −2web/static/apps/admin.js+5 −2
diff --git a/changelog/2026-07-13-major-version-pinning.md b/changelog/2026-07-13-major-version-pinning.md| new file mode 100644 |
| index 0000000..4db6616 |
| --- /dev/null |
| +++ b/changelog/2026-07-13-major-version-pinning.md |
| @@ -0,0 +1,6 @@ |
| +๐ค Spaces can now stay on a major version. Server releases carry a |
| +human-readable version number alongside their build id, and an admin can pin |
| +their space to a major version: small improvements and fixes keep arriving |
| +automatically, but an upgrade that crosses into a new major version waits |
| +until the admin says go โ no surprise breaking changes overnight. The admin |
| +console shows when an update is waiting on this choice. |
diff --git a/db.go b/db.go| index 9b04a94..6795fed 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -190,6 +190,20 @@ func (db *DB) updatePin() string { |
| } |
| func (db *DB) setUpdatePin(v string) error { return db.setConfig("update_pin", v) } |
| +// updateMajor, when set, keeps this instance on one major version: it follows |
| +// its channel for minors and patches but a release whose semver crosses the |
| +// major is held, never auto-installed (majorHold, release.go). Empty = follow |
| +// everything; a garbled stored value reads as unset โ like updatePin, it must |
| +// not freeze anything silently. |
| +func (db *DB) updateMajor() string { |
| + v, _, _ := db.getConfig("update_major") |
| + if !validMajor(v) { |
| + return "" |
| + } |
| + return v |
| +} |
| +func (db *DB) setUpdateMajor(v string) error { return db.setConfig("update_major", v) } |
| + |
| // charterNextDue is the sidecar-reported earliest next run across this space's |
| // member-bot charters (unix seconds; 0 or absent = none). Charters themselves |
| // are instance-key-encrypted blobs the server can't read (memberbots.go), so |
diff --git a/deploy/SERVER_SEMVER b/deploy/SERVER_SEMVER| new file mode 100644 |
| index 0000000..d9df1bb |
| --- /dev/null |
| +++ b/deploy/SERVER_SEMVER |
| @@ -0,0 +1 @@ |
| +0.11.0 |
diff --git a/deploy/release-server.sh b/deploy/release-server.sh| index 33831cf..8153375 100755 |
| --- a/deploy/release-server.sh |
| +++ b/deploy/release-server.sh |
| @@ -127,15 +127,22 @@ publish) |
| mirror_upload "$TMP/$A" "server-$VER/$A" |
| done |
| + # The human version (deploy/SERVER_SEMVER) rides along as `semver` โ the |
| + # sha stays the artifact id. It's what major pinning (update_major) |
| + # evaluates on the box; a missing file publishes without one, which HOLDS |
| + # any major-pinned instance (fail closed), so keep the file current. |
| + SEMVER="$(tr -d '[:space:]' < deploy/SERVER_SEMVER 2>/dev/null || true)" |
| + [ -n "$SEMVER" ] || echo "release-server: note: no deploy/SERVER_SEMVER โ publishing without a semver (major-pinned instances will hold this release)" >&2 |
| fetch_payload | jq \ |
| --arg ver "$VER" --arg sha "$SHA" --argjson size "$SIZE" --arg notes "$NOTES" \ |
| --arg sha_arm "$SHA_ARM" --argjson size_arm "$SIZE_ARM" \ |
| --arg url "https://github.com/$REPO/releases/download/server-$VER/$BIN" \ |
| --arg url_arm "https://github.com/$REPO/releases/download/server-$VER/$BIN_ARM" \ |
| - --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ |
| - '.channels.edge = {version:$ver, url:$url, sha256:$sha, size:$size, |
| + --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg semver "$SEMVER" \ |
| + '.channels.edge = ({version:$ver, url:$url, sha256:$sha, size:$size, |
| arm64_url:$url_arm, arm64_sha256:$sha_arm, arm64_size:$size_arm, |
| - notes:$notes, published_at:$now}' \ |
| + notes:$notes, published_at:$now} |
| + + (if $semver == "" then {} else {semver:$semver} end))' \ |
| > "$TMP/payload.json" |
| sign_and_upload "$TMP/payload.json" |
| # The installer rides the rolling release too, so its URL is always current. |
| @@ -167,6 +174,9 @@ promote) |
| # releases stay amd64-only). |
| ENTRY="$(jq --arg ver "$VER" '[.channels[] | select(.version == $ver)][0] // empty' "$TMP/payload.json")" |
| if [ -z "$ENTRY" ]; then |
| + # A rebuilt entry can't recover the semver the build was published with โ |
| + # major-pinned instances hold semver-less releases, so say so. |
| + echo "release-server: note: rebuilding the $VER entry from release assets โ it will carry no semver (major-pinned instances will hold it)" >&2 |
| gh release download "server-$VER" -R "$REPO" -p "$BIN.sha256" -O "$TMP/$BIN.sha256" || { |
| echo "release-server: no server-$VER release on $REPO" >&2; exit 1; } |
| SHA="$(cut -d' ' -f1 < "$TMP/$BIN.sha256")" |
diff --git a/docs/instance-router.md b/docs/instance-router.md| index 1627b4b..4e63033 100644 |
| --- a/docs/instance-router.md |
| +++ b/docs/instance-router.md |
| @@ -519,6 +519,12 @@ described in `docs/server-releases.md`: |
| and a pull-back to a previously-seen version applies immediately. Local |
| override: `lchat update run --ignore-bake` (root); design + numbers in |
| `docs/server-releases.md ยง The bake window`. |
| +- **Major version pinning**: `lchat update major <host> <n>` (or the |
| + `update_major` admin setting) keeps an instance on one major โ minors and |
| + patches keep flowing, a head whose `semver` crosses the pin is held |
| + (halt-and-notify; a semver-less release also holds, fail closed). Clear |
| + with `lchat update major <host> clear`. See |
| + `docs/server-releases.md ยง Major version pinning`. |
| - **GC**: after a pass, version-store dirs nothing references (no route's |
| current/previous, no channel head, no live symlink, not the control binary's |
| own build) are pruned, and crashed `dl-*` downloads in `incoming/` are |
diff --git a/docs/server-releases.md b/docs/server-releases.md| index bcb3a08..0bd32e4 100644 |
| --- a/docs/server-releases.md |
| +++ b/docs/server-releases.md |
| @@ -147,6 +147,33 @@ The mechanics (`bake.go`): |
| `lchat update status` as `(held until <time>)` and in the admin consoles as |
| "auto-installs after a N-hour safety hold". |
| +### Major version pinning |
| + |
| +Releases carry two names: the **sha** (`version`, the artifact id everything |
| +deploys by) and a **human semver** (`semver`, stamped at publish time from |
| +`deploy/SERVER_SEMVER` โ bump that file as part of cutting a release; plain |
| +`MAJOR.MINOR.PATCH`, no suffixes). The semver exists for one consumer: an |
| +instance that wants minors and patches but not to be auto-updated across a |
| +breaking major. |
| + |
| +- **Setting the pin**: `update_major` is an instance setting like the version |
| + pin โ `lchat update major <host> <n>` / `lchat update major <host> clear` |
| + on the box, or `update_major` through the admin settings API. |
| +- **Semantics are halt-and-notify, not LTS.** A channel head whose major is |
| + โค the pin installs as normal; one that crosses it is *held* โ the pass says |
| + so, `update status` shows `(held by major pin N)`, and the admin console |
| + explains it on the available line. Nothing auto-installs until the admin |
| + raises or clears the pin. There are no per-major channel heads: we don't |
| + maintain old-major branches, so a pinned instance's updates simply stop at |
| + the boundary (that's the honest promise; per-major heads can be added if an |
| + LTS branch ever actually exists). |
| +- **Fail closed**: a release with no semver at all also holds a major-pinned |
| + instance โ the pin is a promise not to cross a boundary, so an unlabelled |
| + release must never sneak past it. (`release-server.sh` warns when it |
| + publishes without `deploy/SERVER_SEMVER`, and when a `promote` has to |
| + rebuild an entry from release assets, which loses the semver.) |
| +- **Pull-backs always pass**: an older major is never held. |
| + |
| ## The S3 mirror |
| GitHub's asset CDN is the feed's single point of failure โ and it has served |
diff --git a/main.go b/main.go| index f05fa73..fd6f5d0 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -4270,6 +4270,7 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { |
| "update_download": s.db.updateDownload(), |
| "update_channel": s.db.updateChannel(), |
| "update_pin": s.db.updatePin(), |
| + "update_major": s.db.updateMajor(), |
| "update": s.updateInfo(), |
| }) |
| } |
| @@ -4301,6 +4302,9 @@ func (s *Server) setSettings(w http.ResponseWriter, r *http.Request) { |
| // instance entirely. Set by `lchat update pin` / `deploy --pin`; cleared |
| // from the admin UI by picking a channel. |
| UpdatePin *string `json:"update_pin"` |
| + // Stay on one major version ("" clears): minors/patches follow the |
| + // channel, a major bump holds until the admin raises this. |
| + UpdateMajor *string `json:"update_major"` |
| } |
| if json.NewDecoder(r.Body).Decode(&body) != nil { |
| httpError(w, http.StatusBadRequest, "bad request") |
| @@ -4316,6 +4320,10 @@ func (s *Server) setSettings(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusBadRequest, "invalid pin version") |
| return |
| } |
| + if body.UpdateMajor != nil && !validMajor(*body.UpdateMajor) { |
| + httpError(w, http.StatusBadRequest, "invalid major version (a number, or empty to follow everything)") |
| + return |
| + } |
| if body.UserInvites != nil { |
| was := s.db.usersCanInvite() |
| if err := s.db.setUsersCanInvite(*body.UserInvites); err != nil { |
| @@ -4392,6 +4400,12 @@ func (s *Server) setSettings(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| } |
| + if body.UpdateMajor != nil { |
| + if err := s.db.setUpdateMajor(*body.UpdateMajor); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + } |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true, "user_invites": s.db.usersCanInvite()}) |
| } |
diff --git a/majorpin_test.go b/majorpin_test.go| new file mode 100644 |
| index 0000000..1552283 |
| --- /dev/null |
| +++ b/majorpin_test.go |
| @@ -0,0 +1,218 @@ |
| +package main |
| + |
| +import ( |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// Major version pinning (update_major + releaseEntry.Semver): minors and |
| +// patches follow the channel, a head crossing the pinned major is held โ |
| +// halt-and-notify, never a silent auto-upgrade across a breaking boundary. |
| + |
| +// seedSemvers stamps semvers onto feed versions for this test and cleans up. |
| +func seedSemvers(t *testing.T, m map[string]string) { |
| + t.Helper() |
| + for v, sv := range m { |
| + feedSemvers[v] = sv |
| + } |
| + t.Cleanup(func() { |
| + for v := range m { |
| + delete(feedSemvers, v) |
| + } |
| + }) |
| +} |
| + |
| +func TestSemverHelpers(t *testing.T) { |
| + if n, ok := semverMajor("2.14.3"); !ok || n != 2 { |
| + t.Fatalf("semverMajor(2.14.3) = %d, %v", n, ok) |
| + } |
| + for _, bad := range []string{"", "2", "2.1", "v2.1.3", "2.1.3-rc1", "2.1.3.4", "x.y.z"} { |
| + if _, ok := semverMajor(bad); ok { |
| + t.Errorf("semverMajor(%q) accepted", bad) |
| + } |
| + } |
| + for _, good := range []string{"", "0", "1", "12"} { |
| + if !validMajor(good) { |
| + t.Errorf("validMajor(%q) rejected", good) |
| + } |
| + } |
| + for _, bad := range []string{"-1", "1.0", "one", "99999"} { |
| + if validMajor(bad) { |
| + t.Errorf("validMajor(%q) accepted", bad) |
| + } |
| + } |
| +} |
| + |
| +func TestValidateManifestSemver(t *testing.T) { |
| + ok := entryFor("new1234") |
| + ok.Semver = "1.2.3" |
| + m := &releaseManifest{Schema: 1, GeneratedAt: time.Now(), Channels: map[string]releaseEntry{"stable": ok}} |
| + if err := validateManifest(m); err != nil { |
| + t.Fatalf("valid semver rejected: %v", err) |
| + } |
| + bad := entryFor("new1234") |
| + bad.Semver = "not-a-version" |
| + m.Channels["stable"] = bad |
| + if err := validateManifest(m); err == nil { |
| + t.Fatal("garbage semver accepted") |
| + } |
| +} |
| + |
| +func TestMajorHoldSemantics(t *testing.T) { |
| + e := func(sv string) releaseEntry { return releaseEntry{Version: "abc1234", Semver: sv} } |
| + if hold := majorHold("", e("2.0.0")); hold != "" { |
| + t.Fatalf("no pin but held: %s", hold) |
| + } |
| + if hold := majorHold("1", e("1.9.9")); hold != "" { |
| + t.Fatalf("within-major held: %s", hold) |
| + } |
| + if hold := majorHold("2", e("1.0.0")); hold != "" { |
| + t.Fatalf("older major held (pull-backs must pass): %s", hold) |
| + } |
| + if hold := majorHold("1", e("2.0.0")); hold == "" { |
| + t.Fatal("crossing major not held") |
| + } |
| + // Fail closed: a pinned instance never takes an unlabelled release. |
| + if hold := majorHold("1", e("")); hold == "" { |
| + t.Fatal("semver-less release not held under a major pin") |
| + } |
| +} |
| + |
| +func TestUpdatePassMajorPinHoldsCrossing(t *testing.T) { |
| + withTempOpt(t) |
| + zeroBake(t) // bake has its own tests; this one is about the major gate |
| + calls := stubSystemctl(t) |
| + shrink := deployHealthTimeout |
| + deployHealthTimeout = 2 * time.Second |
| + t.Cleanup(func() { deployHealthTimeout = shrink }) |
| + |
| + seedSemvers(t, map[string]string{"v2head99": "2.0.0"}) |
| + _, setChannels := startFeed(t, map[string]string{"v2head99": "v2-binary"}) |
| + setChannels(map[string]string{"stable": "v2head99"}) |
| + |
| + reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + s1 := startFakeInstance(t, "h1.example.com", http.StatusOK, |
| + updatePrefs{AutoUpdate: true, AutoDownload: true, Channel: "stable", Major: "1"}, "") |
| + seedInstance(t, reg, "h1.example.com", s1, "old1111") |
| + |
| + if err := updatePass(reg, "lchat@", false, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if v, _ := liveVersion("h1.example.com"); v != "old1111" { |
| + t.Fatalf("v2 installed across a major-1 pin: live = %q", v) |
| + } |
| + if _, err := os.Stat(versionBinPath("v2head99")); !os.IsNotExist(err) { |
| + t.Fatal("crossing release was downloaded") |
| + } |
| + if len(*calls) != 0 { |
| + t.Fatalf("restarts despite the major hold: %v", *calls) |
| + } |
| +} |
| + |
| +func TestUpdatePassMajorPinFollowsWithinMajor(t *testing.T) { |
| + withTempOpt(t) |
| + zeroBake(t) |
| + stubSystemctl(t) |
| + shrink := deployHealthTimeout |
| + deployHealthTimeout = 2 * time.Second |
| + t.Cleanup(func() { deployHealthTimeout = shrink }) |
| + |
| + seedSemvers(t, map[string]string{"v1patch7": "1.4.2"}) |
| + _, setChannels := startFeed(t, map[string]string{"v1patch7": "patch-binary"}) |
| + setChannels(map[string]string{"stable": "v1patch7"}) |
| + |
| + reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + s1 := startFakeInstance(t, "h1.example.com", http.StatusOK, |
| + updatePrefs{AutoUpdate: true, AutoDownload: true, Channel: "stable", Major: "1"}, "") |
| + seedInstance(t, reg, "h1.example.com", s1, "old1111") |
| + |
| + if err := updatePass(reg, "lchat@", false, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if v, _ := liveVersion("h1.example.com"); v != "v1patch7" { |
| + t.Fatalf("within-major release held: live = %q", v) |
| + } |
| +} |
| + |
| +func TestUpdatePassMajorPinHoldsSemverlessHead(t *testing.T) { |
| + withTempOpt(t) |
| + zeroBake(t) |
| + stubSystemctl(t) |
| + |
| + // No seedSemvers: the head carries no semver at all โ the pin fails closed. |
| + _, setChannels := startFeed(t, map[string]string{"nolabel1": "content"}) |
| + setChannels(map[string]string{"stable": "nolabel1"}) |
| + |
| + reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + s1 := startFakeInstance(t, "h1.example.com", http.StatusOK, |
| + updatePrefs{AutoUpdate: true, AutoDownload: true, Channel: "stable", Major: "1"}, "") |
| + seedInstance(t, reg, "h1.example.com", s1, "old1111") |
| + |
| + if err := updatePass(reg, "lchat@", false, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if v, _ := liveVersion("h1.example.com"); v != "old1111" { |
| + t.Fatalf("semver-less head installed under a major pin: live = %q", v) |
| + } |
| +} |
| + |
| +func TestUpdatePassMajorPinHoldsHibernating(t *testing.T) { |
| + withTempOpt(t) |
| + zeroBake(t) |
| + calls := stubSystemctl(t) |
| + |
| + oldData := instanceDataDir |
| + instanceDataDir = t.TempDir() |
| + t.Cleanup(func() { instanceDataDir = oldData }) |
| + |
| + seedSemvers(t, map[string]string{"v2head99": "2.0.0"}) |
| + _, setChannels := startFeed(t, map[string]string{"v2head99": "v2-binary"}) |
| + setChannels(map[string]string{"stable": "v2head99"}) |
| + |
| + reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + src := filepath.Join(t.TempDir(), "old") |
| + writeFakeBinary(t, src, "old-binary") |
| + if err := stageVersion(src, "old1111"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := pointLive("h1.hib", "old1111"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.upsert(Route{Host: "h1.hib", Kind: "instance", Status: "ready", |
| + Socket: filepath.Join(t.TempDir(), "no.sock"), Hibernate: true, Version: "old1111"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + db, err := openDB(filepath.Join(instanceDataDir, "h1.hib.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := db.setConfig("update_major", "1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + db.sql.Close() |
| + |
| + if err := updatePass(reg, "lchat@", false, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if v, _ := liveVersion("h1.hib"); v != "old1111" { |
| + t.Fatalf("hibernating host repointed across its major pin: live = %q", v) |
| + } |
| + if len(*calls) != 0 { |
| + t.Fatalf("systemctl touched: %v", *calls) |
| + } |
| +} |
diff --git a/release.go b/release.go| index cc42ab0..21b24cb 100644 |
| --- a/release.go |
| +++ b/release.go |
| @@ -11,6 +11,8 @@ import ( |
| "fmt" |
| "net/url" |
| "os" |
| + "regexp" |
| + "strconv" |
| "strings" |
| "time" |
| ) |
| @@ -109,6 +111,12 @@ type releaseEntry struct { |
| ARM64Size int64 `json:"arm64_size,omitempty"` |
| Notes string `json:"notes,omitempty"` |
| PublishedAt string `json:"published_at,omitempty"` |
| + // Semver is the human version stamped at publish time (deploy/SERVER_SEMVER |
| + // via release-server.sh); Version stays the artifact id (the git short sha). |
| + // Optional and additive โ old updaters ignore it โ but it's what major |
| + // pinning (update_major) evaluates, so a semver-less entry HOLDS a |
| + // major-pinned instance rather than moving it (fail closed). |
| + Semver string `json:"semver,omitempty"` |
| } |
| // forArch resolves the binary for a GOARCH: amd64 is the flat fields, arm64 |
| @@ -176,10 +184,78 @@ func validateManifest(m *releaseManifest) error { |
| return fmt.Errorf("channel %s: arm64 sha256 must be %d hex chars", ch, sha256.Size*2) |
| } |
| } |
| + if e.Semver != "" { |
| + if _, ok := semverMajor(e.Semver); !ok { |
| + return fmt.Errorf("channel %s: invalid semver %q", ch, e.Semver) |
| + } |
| + } |
| } |
| return nil |
| } |
| +// semverRe pins the plain MAJOR.MINOR.PATCH shape the release feed stamps โ |
| +// no pre-release/build suffixes until something actually needs them. |
| +var semverRe = regexp.MustCompile(`^(\d{1,4})\.\d{1,4}\.\d{1,4}$`) |
| + |
| +// semverMajor extracts the major from a MAJOR.MINOR.PATCH string. |
| +func semverMajor(s string) (int, bool) { |
| + m := semverRe.FindStringSubmatch(s) |
| + if m == nil { |
| + return 0, false |
| + } |
| + n, err := strconv.Atoi(m[1]) |
| + if err != nil { |
| + return 0, false |
| + } |
| + return n, true |
| +} |
| + |
| +// validMajor accepts what update_major may hold: empty (follow everything) or |
| +// a bare major number ("1"). |
| +func validMajor(v string) bool { |
| + if v == "" { |
| + return true |
| + } |
| + if len(v) > 4 { |
| + return false |
| + } |
| + n, err := strconv.Atoi(v) |
| + return err == nil && n >= 0 |
| +} |
| + |
| +// majorHold returns a human reason when an instance pinned to a major must |
| +// not follow a channel entry, "" when it may. Semantics are halt-and-notify: |
| +// nothing above the pinned major installs, and a feed entry with no semver at |
| +// all also holds โ the pin is a promise not to cross a boundary, so an |
| +// unlabelled release fails closed rather than sneaking past it. Older majors |
| +// pass (a channel pull-back is never blocked). |
| +func majorHold(prefMajor string, e releaseEntry) string { |
| + if prefMajor == "" { |
| + return "" |
| + } |
| + want, err := strconv.Atoi(prefMajor) |
| + if err != nil { |
| + // The pref readers clear garbled values ("a garbled pin must not freeze |
| + // anything silently") โ belt and braces here. |
| + return "" |
| + } |
| + if e.Semver == "" { |
| + return fmt.Sprintf("%s carries no semver but this instance is pinned to major %d โ held (publish with a semver, or clear the major pin)", |
| + e.Version, want) |
| + } |
| + got, ok := semverMajor(e.Semver) |
| + if !ok { |
| + // validateManifest rejects malformed semvers, so this is unreachable โ |
| + // but an unparseable label must hold, never pass, a pinned instance. |
| + return fmt.Sprintf("%s has unparseable semver %q โ held", e.Version, e.Semver) |
| + } |
| + if got > want { |
| + return fmt.Sprintf("v%s (%s) crosses pinned major %d โ held (raise or clear the major pin to take it)", |
| + e.Semver, e.Version, want) |
| + } |
| + return "" |
| +} |
| + |
| // isLoopbackURL allows plain-http binary URLs only on loopback โ local feeds |
| // and tests; anything remote must be https. |
| func isLoopbackURL(u string) bool { |
diff --git a/updatecheck.go b/updatecheck.go| index 06055a9..91ed266 100644 |
| --- a/updatecheck.go |
| +++ b/updatecheck.go |
| @@ -34,6 +34,7 @@ func (s *Server) getUpdatePrefs(w http.ResponseWriter, r *http.Request) { |
| "auto_download": s.db.updateDownload(), |
| "channel": s.db.updateChannel(), |
| "pin": s.db.updatePin(), |
| + "major": s.db.updateMajor(), |
| }) |
| } |
| @@ -78,6 +79,14 @@ func (s *Server) updateInfo() map[string]any { |
| if e, ok := m.Channels[s.db.updateChannel()]; ok { |
| info["available_version"] = e.Version |
| info["available_notes"] = e.Notes |
| + if e.Semver != "" { |
| + info["available_semver"] = e.Semver |
| + } |
| + // A major pin holding this head back is worth explaining in the console |
| + // โ same reason bake_hours below is served. |
| + if hold := majorHold(s.db.updateMajor(), e); hold != "" { |
| + info["major_held"] = true |
| + } |
| // The channel's supply-chain bake window (bake.go), so the console can |
| // explain why a fresh release hasn't auto-installed yet. The instance |
| // can't see the box's bake clock โ only the policy โ so this is an |
diff --git a/updater.go b/updater.go| index 324a39f..a074305 100644 |
| --- a/updater.go |
| +++ b/updater.go |
| @@ -133,6 +133,10 @@ type updatePrefs struct { |
| // Pin, when set, freezes the instance on that exact version โ the updater |
| // skips it entirely (pin beats channel). How a canary declares itself. |
| Pin string `json:"pin"` |
| + // Major, when set, keeps the instance on that major version: minors and |
| + // patches follow the channel, a head whose semver crosses the major is |
| + // held (majorHold, release.go). |
| + Major string `json:"major"` |
| } |
| // fetchUpdatePrefs reads an instance's prefs over its unix socket. A binary |
| @@ -162,6 +166,9 @@ func fetchUpdatePrefs(socket string) (updatePrefs, error) { |
| if !validVersion(p.Pin) { |
| p.Pin = "" // a garbled pin must not freeze anything silently |
| } |
| + if !validMajor(p.Major) { |
| + p.Major = "" // same rule as the pin |
| + } |
| return p, nil |
| } |
| @@ -227,6 +234,11 @@ func hibernatingPrefs(host string) (updatePrefs, error) { |
| } else if ok && validVersion(v) { |
| p.Pin = v |
| } |
| + if v, ok, err := get("update_major"); err != nil { |
| + return p, err |
| + } else if ok && validMajor(v) { |
| + p.Major = v |
| + } |
| return p, nil |
| } |
| @@ -419,6 +431,10 @@ func updatePass(reg *Registry, unitTmpl string, dryRun, ignoreBake bool) error { |
| if cur == e.Version { |
| continue // up to date โ stay quiet on the hourly tick |
| } |
| + if hold := majorHold(prefs.Major, e); hold != "" { |
| + fmt.Printf(" %s: %s\n", rt.Host, hold) |
| + continue |
| + } |
| if hold := bakeHold(bake, prefs.Channel, e.Version, ignoreBake, time.Now()); hold != "" { |
| fmt.Printf(" %s: %s (%s) is %s โ apply now: lchat deploy %s --version %s\n", |
| rt.Host, e.Version, prefs.Channel, hold, rt.Host, e.Version) |
| @@ -490,6 +506,10 @@ func updatePass(reg *Registry, unitTmpl string, dryRun, ignoreBake bool) error { |
| if cur == e.Version { |
| continue // up to date โ stay quiet on the hourly tick |
| } |
| + if hold := majorHold(prefs.Major, e); hold != "" { |
| + fmt.Printf(" %s: %s\n", rt.Host, hold) |
| + continue |
| + } |
| if hold := bakeHold(bake, prefs.Channel, e.Version, ignoreBake, time.Now()); hold != "" { |
| fmt.Printf(" %s: %s (%s) is %s โ apply now: lchat deploy %s --version %s\n", |
| rt.Host, e.Version, prefs.Channel, hold, rt.Host, e.Version) |
| @@ -587,10 +607,10 @@ func gcVersions(reg *Registry, m *releaseManifest, keepExtra []string) { |
| } |
| } |
| -// runUpdate: `lchat update run|status|pin|unpin` โ the auto-updater CLI. |
| +// runUpdate: `lchat update run|status|pin|unpin|major` โ the auto-updater CLI. |
| func runUpdate(args []string) { |
| if len(args) == 0 { |
| - die("update: subcommand required: run | status | pin | unpin") |
| + die("update: subcommand required: run | status | pin | unpin | major") |
| } |
| switch args[0] { |
| case "run": |
| @@ -601,8 +621,10 @@ func runUpdate(args []string) { |
| runUpdatePin(args[1:], true) |
| case "unpin": |
| runUpdatePin(args[1:], false) |
| + case "major": |
| + runUpdateMajor(args[1:]) |
| default: |
| - die("update: unknown subcommand %q (run | status | pin | unpin)", args[0]) |
| + die("update: unknown subcommand %q (run | status | pin | unpin | major)", args[0]) |
| } |
| } |
| @@ -611,6 +633,12 @@ func runUpdate(args []string) { |
| // panel shows and /api/update-prefs reports), not a registry side-channel. The |
| // admin token comes from the instance's EnvironmentFile, like `admin-link`. |
| func pinHost(rt *Route, pin string) error { |
| + return writeHostSetting(rt, "update_pin", pin) |
| +} |
| + |
| +// writeHostSetting posts one settings key into the instance's own admin API โ |
| +// the shared plumbing behind `update pin`/`update major`. |
| +func writeHostSetting(rt *Route, key, value string) error { |
| env, err := readEnvFile(filepath.Join(instanceDataDir, rt.Host+".env")) |
| if err != nil { |
| return err |
| @@ -619,7 +647,7 @@ func pinHost(rt *Route, pin string) error { |
| if tok == "" { |
| return fmt.Errorf("no LCHAT_ADMIN_TOKEN in %s/%s.env", instanceDataDir, rt.Host) |
| } |
| - body, err := json.Marshal(map[string]string{"update_pin": pin}) |
| + body, err := json.Marshal(map[string]string{key: value}) |
| if err != nil { |
| return err |
| } |
| @@ -686,6 +714,43 @@ func runUpdatePin(args []string, pin bool) { |
| } |
| } |
| +// runUpdateMajor: `lchat update major <host> <n>|clear` keeps an instance on |
| +// one major version โ minors/patches keep flowing, a major bump holds until |
| +// the admin raises or clears it. Unlike `pin` there is no default: staying on |
| +// a major is a statement about the future, so the operator says which one. |
| +func runUpdateMajor(args []string) { |
| + fs := flag.NewFlagSet("update major", flag.ExitOnError) |
| + registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path") |
| + _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| + |
| + host := strings.ToLower(strings.TrimSpace(fs.Arg(0))) |
| + if !validHost(host) { |
| + die("update major: invalid host %q", host) |
| + } |
| + major := strings.TrimSpace(fs.Arg(1)) |
| + if major == "clear" { |
| + major = "" |
| + } else if major == "" || !validMajor(major) { |
| + die("update major: pass a major version number, or `clear` to follow everything") |
| + } |
| + reg, err := openRegistry(*registryPath) |
| + if err != nil { |
| + die("update major: open registry: %v", err) |
| + } |
| + rt, err := reg.get(host) |
| + if err != nil || rt == nil || rt.Kind != "instance" { |
| + die("update major: %s is not a managed instance", host) |
| + } |
| + if err := writeHostSetting(rt, "update_major", major); err != nil { |
| + die("update major %s: %v", host, err) |
| + } |
| + if major == "" { |
| + fmt.Printf("cleared %s's major pin (follows its channel fully from the next update pass)\n", host) |
| + } else { |
| + fmt.Printf("%s stays on major %s (minors and patches keep flowing; a release crossing major %s is held until this is raised or cleared)\n", host, major, major) |
| + } |
| +} |
| + |
| func runUpdateRun(args []string) { |
| fs := flag.NewFlagSet("update run", flag.ExitOnError) |
| registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path") |
| @@ -744,9 +809,9 @@ func runUpdateStatus(args []string) { |
| if rt.Kind != "instance" { |
| continue |
| } |
| - channel, auto := "?", "?" |
| + channel, auto, major := "?", "?", "" |
| if prefs, err := statusPrefs(rt); err == nil { |
| - channel = prefs.Channel |
| + channel, major = prefs.Channel, prefs.Major |
| switch { |
| case prefs.Pin != "": |
| auto = "pinned" // pin beats channel; the updater skips this host |
| @@ -771,6 +836,8 @@ func runUpdateStatus(args []string) { |
| switch { |
| case e.Version == cur: |
| avail += " (current)" |
| + case majorHold(major, e) != "": |
| + avail += " (held by major pin " + major + ")" |
| case bakeDelays[channel] > 0: |
| // Status reads the bake clock without ticking it โ only an |
| // update pass observes. An unticked head shows as held too. |
diff --git a/updater_test.go b/updater_test.go| index 584f657..efc3cad 100644 |
| --- a/updater_test.go |
| +++ b/updater_test.go |
| @@ -40,7 +40,7 @@ func startFakeInstance(t *testing.T, host string, prefsCode int, prefs updatePre |
| return |
| } |
| writeJSON(w, http.StatusOK, map[string]any{ |
| - "auto_update": prefs.AutoUpdate, "auto_download": prefs.AutoDownload, "channel": prefs.Channel, "pin": prefs.Pin, |
| + "auto_update": prefs.AutoUpdate, "auto_download": prefs.AutoDownload, "channel": prefs.Channel, "pin": prefs.Pin, "major": prefs.Major, |
| }) |
| }) |
| mux.HandleFunc("/api/version", func(w http.ResponseWriter, r *http.Request) { |
| @@ -61,6 +61,11 @@ func startFakeInstance(t *testing.T, host string, prefsCode int, prefs updatePre |
| return socket |
| } |
| +// feedSemvers, when a test seeds it, stamps a semver onto matching versions |
| +// in every manifest startFeed signs (tests run serially, so a package-level |
| +// map is safe; entries are cleared via t.Cleanup in the tests that set them). |
| +var feedSemvers = map[string]string{} |
| + |
| // startFeed signs entries into a manifest and serves it (plus each version's |
| // binary bytes) from a loopback server, swapping in a test trust root. |
| func startFeed(t *testing.T, binaries map[string]string) (*httptest.Server, func(channels map[string]string)) { |
| @@ -114,6 +119,7 @@ func startFeed(t *testing.T, binaries map[string]string) (*httptest.Server, func |
| SHA256: hex.EncodeToString(sum[:]), |
| ARM64URL: ts.URL + "/arm/" + ver, |
| ARM64SHA256: hex.EncodeToString(armSum[:]), |
| + Semver: feedSemvers[ver], |
| } |
| } |
| w, err := signReleaseManifest(priv, m) |
diff --git a/web/static/admin.js b/web/static/admin.js| index d31b0e7..2af971c 100644 |
| --- a/web/static/admin.js |
| +++ b/web/static/admin.js |
| @@ -126,8 +126,11 @@ async function wireUpdates() { |
| if (s.update_pin) { |
| line += ` โ pinned to ${s.update_pin}; automatic updates are paused (pick a channel to resume)`; |
| } else if (u.available_version && u.available_version !== u.build) { |
| - line += ` โ ${u.available_version} is available on ${s.update_channel}`; |
| - if (u.bake_hours) { |
| + const label = u.available_semver ? `v${u.available_semver} (${u.available_version})` : u.available_version; |
| + line += ` โ ${label} is available on ${s.update_channel}`; |
| + if (u.major_held) { |
| + line += ` (held: it crosses the major version this space stays on โ ${s.update_major || "?"})`; |
| + } else if (u.bake_hours) { |
| // The supply-chain safety hold: new releases wait before auto-installing. |
| line += ` (auto-installs after a ${u.bake_hours}-hour safety hold)`; |
| } |
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js| index c66fc91..f17bef1 100644 |
| --- a/web/static/apps/admin.js |
| +++ b/web/static/apps/admin.js |
| @@ -1212,8 +1212,11 @@ export const app = defineApp({ |
| if (pin) { |
| line += ` โ pinned to ${pin}; automatic updates are paused (pick a channel to resume)`; |
| } else if (u.available_version && u.available_version !== u.build) { |
| - line += ` โ ${u.available_version} is available on ${channel}`; |
| - if (u.bake_hours) { |
| + const label = u.available_semver ? `v${u.available_semver} (${u.available_version})` : u.available_version; |
| + line += ` โ ${label} is available on ${channel}`; |
| + if (u.major_held) { |
| + line += ` (held: it crosses the major version this space stays on)`; |
| + } else if (u.bake_hours) { |
| // The supply-chain safety hold: new releases wait before auto-installing. |
| line += ` (auto-installs after a ${u.bake_hours}-hour safety hold)`; |
| } |