eleven / messenger Public

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>
Paul Campbell b9da4bf8b62d0d0782087202e809352eafd66220 parent a23dbe2
14 files changed, +474 −14
  • changelog/2026-07-13-major-version-pinning.md +6 −0
  • db.go +14 −0
  • deploy/SERVER_SEMVER +1 −0
  • deploy/release-server.sh +13 −3
  • docs/instance-router.md +6 −0
  • docs/server-releases.md +27 −0
  • main.go +14 −0
  • majorpin_test.go +218 −0
  • release.go +76 −0
  • updatecheck.go +9 −0
  • updater.go +73 −6
  • updater_test.go +7 −1
  • web/static/admin.js +5 −2
  • web/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)`;
}