server+web: release-sighting alerts + major-pin console control (#823)
The detection half of the supply-chain bake window: each instance's 6-hourly feed check now pushes the space's admins when its channel head changes โ out-of-band from the signing key, so a rogue publish can't be quiet. Copy varies by state (safety hold / updates-off / major-held); edge sightings stay silent (its riders are the operators publishing). Baseline-then-diff watermark like the changelog announcer; payload is public feed data, the changelog banner's plaintext class. Both admin consoles gain the 'Stay on major version' input so hosted admins (no box access) can actually set update_major.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
10 files changed,
+304
−2
changelog/2026-07-13-release-heads-up.md+7 −0docs/server-releases.md+9 −0releasealert.go+110 −0releasealert_test.go+96 −0updatecheck.go+3 −0web/pages/admin.html+6 −0web/static/admin.js+14 −0web/static/apps/admin.js+41 −1web/static/shell.js+3 −1web/static/sw.js+15 −0
diff --git a/changelog/2026-07-13-release-heads-up.md b/changelog/2026-07-13-release-heads-up.md| new file mode 100644 |
| index 0000000..63ce322 |
| --- /dev/null |
| +++ b/changelog/2026-07-13-release-heads-up.md |
| @@ -0,0 +1,7 @@ |
| +๐ค Admins get a heads-up when a server update is on the way. When a new |
| +release appears on your space's update channel, the admins now get a |
| +notification before anything installs โ while the safety hold is still |
| +counting down โ so a release nobody was expecting gets noticed in time to act. |
| +The admin console's Updates section also gains the "stay on major version" |
| +control: leave it empty to follow every release, or set a number and bigger |
| +jumps wait for your say-so. |
diff --git a/docs/server-releases.md b/docs/server-releases.md| index 0bd32e4..f938f33 100644 |
| --- a/docs/server-releases.md |
| +++ b/docs/server-releases.md |
| @@ -146,6 +146,15 @@ The mechanics (`bake.go`): |
| `lchat deploy <host> --version <ver>`. A held release shows in |
| `lchat update status` as `(held until <time>)` and in the admin consoles as |
| "auto-installs after a N-hour safety hold". |
| +- **Detection inside the window** is what makes the hold worth anything: each |
| + instance's own 6-hourly feed check pushes the space's admins when its |
| + channel head *changes* (`releasealert.go`) โ the alert fires on observation |
| + of the feed, out-of-band from the signing key, so a rogue publish can't be |
| + quiet. edge sightings stay silent (its riders are the operators doing the |
| + publishing; the post-install changelog banner covers arrival). |
| + Belt-and-braces for operators: also GitHub-watch the releases repo |
| + (Watch โ Custom โ Releases), so a publish you didn't make lands in your |
| + inbox through a channel the release key never touches. |
| ### Major version pinning |
diff --git a/releasealert.go b/releasealert.go| new file mode 100644 |
| index 0000000..ee002b9 |
| --- /dev/null |
| +++ b/releasealert.go |
| @@ -0,0 +1,110 @@ |
| +package main |
| + |
| +// ๐ค Release-sighting alerts โ the human-detection half of the supply-chain |
| +// bake window (docs/server-releases.md ยง The bake window). The bake buys time |
| +// between a release appearing and it installing; this is what makes that time |
| +// useful: when the instance's own 6-hourly feed check sees its channel head |
| +// CHANGE, it pushes the space's admins. The alert is out-of-band from the |
| +// signing key โ it fires on observation of the feed, not on the publisher's |
| +// cooperation โ so an attacker who can sign releases cannot publish quietly. |
| +// |
| +// Payload safety: channel names, versions, semvers, and hold lengths are |
| +// public feed data (branding-class, exactly the changelog banner's argument |
| +// in announce.go) โ never user content, never a key. |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "log" |
| +) |
| + |
| +// releaseSeenKey is the per-channel last-sighted-head watermark, config-kept |
| +// like changelog_announced: first sighting on a channel baselines silently (a |
| +// fresh space isn't greeted with old news), later sightings announce the diff. |
| +const releaseSeenKey = "release_seen_heads" |
| + |
| +// releaseAlertText builds the admin notification for a newly-sighted channel |
| +// head. ok=false when the sighting shouldn't alert: edge (no hold โ its |
| +// riders are the operators doing the publishing, and the post-install |
| +// changelog banner covers arrival). |
| +func releaseAlertText(group, channel string, e releaseEntry, autoUpdate bool, major string) (title, body string, ok bool) { |
| + label := e.Version |
| + if e.Semver != "" { |
| + label = "v" + e.Semver + " (" + e.Version + ")" |
| + } |
| + switch { |
| + case majorHold(major, e) != "": |
| + body = fmt.Sprintf("%s appeared on the %s channel, but it crosses the major version this space stays on โ nothing installs until you raise that in the admin console.", label, channel) |
| + case bakeHours(channel) == 0: |
| + return "", "", false |
| + case !autoUpdate: |
| + body = fmt.Sprintf("%s appeared on the %s channel. Automatic updates are off for this space, so it's yours to apply from the admin console.", label, channel) |
| + default: |
| + body = fmt.Sprintf("%s appeared on the %s channel. It installs automatically after the safety hold (about %d hours) โ check the admin console if you weren't expecting a release.", label, channel, bakeHours(channel)) |
| + } |
| + return "Server update on the way โ " + group, body, true |
| +} |
| + |
| +// noteReleaseSightings runs after every verified feed fetch (checkForUpdate). |
| +// Returns (alerted, sent) for tests โ whether this sighting warranted an |
| +// alert, and how many push sends succeeded. |
| +func (s *Server) noteReleaseSightings(m *releaseManifest) (alerted bool, sent int) { |
| + ch := s.db.updateChannel() |
| + e, ok := m.Channels[ch] |
| + if !ok { |
| + return false, 0 |
| + } |
| + raw, had, _ := s.db.getConfig(releaseSeenKey) |
| + seen := map[string]string{} |
| + if had { |
| + if err := json.Unmarshal([]byte(raw), &seen); err != nil { |
| + // A corrupt watermark re-baselines rather than announcing a backlog. |
| + log.Printf("release-alert: bad %s config (%v) โ re-baselining", releaseSeenKey, err) |
| + seen = map[string]string{} |
| + } |
| + } |
| + last := seen[ch] |
| + if last == e.Version { |
| + return false, 0 |
| + } |
| + // Advance the watermark before pushing (at-most-once, like the changelog): |
| + // a crash mid-announce must not replay the alert on every later check. |
| + seen[ch] = e.Version |
| + if b, err := json.Marshal(seen); err == nil { |
| + if err := s.db.setConfig(releaseSeenKey, string(b)); err != nil { |
| + log.Printf("release-alert: save watermark: %v", err) |
| + } |
| + } |
| + if last == "" { |
| + return false, 0 // first sighting on this channel โ baseline silently |
| + } |
| + if e.Version == buildVersion { |
| + // The head is what we're already running (we just updated to it, or a |
| + // pull-back landed on us) โ nothing is "on the way". |
| + return false, 0 |
| + } |
| + title, body, ok := releaseAlertText(s.db.getGroupName(), ch, e, s.db.updateAuto(), s.db.updateMajor()) |
| + if !ok { |
| + return false, 0 |
| + } |
| + admins, err := s.db.adminUserIDs() |
| + if err != nil || len(admins) == 0 { |
| + log.Printf("release-alert: %s head moved to %s, no admins to notify (err=%v)", ch, e.Version, err) |
| + return true, 0 |
| + } |
| + recipients := make(map[string]bool, len(admins)) |
| + for _, id := range admins { |
| + recipients[id] = true |
| + } |
| + payload, _ := json.Marshal(map[string]any{ |
| + "release": true, |
| + "title": title, |
| + "body": body, |
| + }) |
| + // skipActive=false: like the changelog banner, this is the one signal an |
| + // admin gets with the app closed โ and rare enough to always show. |
| + sent = s.sendToRecipients(payload, "release-alert", "", false, recipients) |
| + sent += s.apnsAnnounce(title, body, recipients) |
| + log.Printf("release-alert: %s head %s announced to %d admin subscription(s)", ch, e.Version, sent) |
| + return true, sent |
| +} |
diff --git a/releasealert_test.go b/releasealert_test.go| new file mode 100644 |
| index 0000000..d4d448b |
| --- /dev/null |
| +++ b/releasealert_test.go |
| @@ -0,0 +1,96 @@ |
| +package main |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestReleaseAlertText(t *testing.T) { |
| + e := releaseEntry{Version: "abc1234", Semver: "0.12.0"} |
| + |
| + // stable + auto-update on: the safety-hold variant, semver-labelled. |
| + title, body, ok := releaseAlertText("Familia", "stable", e, true, "") |
| + if !ok { |
| + t.Fatal("stable sighting should alert") |
| + } |
| + if !strings.Contains(title, "Familia") { |
| + t.Fatalf("title misses the space name: %q", title) |
| + } |
| + if !strings.Contains(body, "v0.12.0 (abc1234)") || !strings.Contains(body, "72 hours") { |
| + t.Fatalf("hold body = %q", body) |
| + } |
| + |
| + // Automatic updates off: no "installs automatically" promise. |
| + _, body, ok = releaseAlertText("Familia", "stable", e, false, "") |
| + if !ok || strings.Contains(body, "installs automatically") || !strings.Contains(body, "Automatic updates are off") { |
| + t.Fatalf("updates-off body = %q (ok=%v)", body, ok) |
| + } |
| + |
| + // A major hold wins over the bake copy โ the admin's action is different. |
| + held := releaseEntry{Version: "abc1234", Semver: "1.0.0"} |
| + _, body, ok = releaseAlertText("Familia", "stable", held, true, "0") |
| + if !ok || !strings.Contains(body, "major version") { |
| + t.Fatalf("major-held body = %q (ok=%v)", body, ok) |
| + } |
| + |
| + // edge never alerts: its riders are the operators doing the publishing. |
| + if _, _, ok = releaseAlertText("Familia", "edge", e, true, ""); ok { |
| + t.Fatal("edge sighting alerted") |
| + } |
| + // ...unless a major pin holds even edge โ that waits on the admin. |
| + if _, _, ok = releaseAlertText("Familia", "edge", held, true, "0"); !ok { |
| + t.Fatal("major-held edge sighting should alert") |
| + } |
| +} |
| + |
| +func TestNoteReleaseSightings(t *testing.T) { |
| + _, s := newTestServer(t) |
| + stable := func(ver, sv string) *releaseManifest { |
| + return &releaseManifest{Channels: map[string]releaseEntry{"stable": {Version: ver, Semver: sv}}} |
| + } |
| + |
| + // First sighting on the channel baselines silently. |
| + if alerted, _ := s.noteReleaseSightings(stable("aaa1111", "")); alerted { |
| + t.Fatal("first sighting should baseline, not alert") |
| + } |
| + // The same head again is quiet. |
| + if alerted, _ := s.noteReleaseSightings(stable("aaa1111", "")); alerted { |
| + t.Fatal("unchanged head alerted") |
| + } |
| + // The head moving is the alert (sent stays 0 โ no push subscriptions here). |
| + alerted, sent := s.noteReleaseSightings(stable("bbb2222", "0.12.0")) |
| + if !alerted || sent != 0 { |
| + t.Fatalf("moved head: alerted=%v sent=%d", alerted, sent) |
| + } |
| + // At-most-once: the watermark advanced, so the same move doesn't replay. |
| + if alerted, _ := s.noteReleaseSightings(stable("bbb2222", "0.12.0")); alerted { |
| + t.Fatal("alert replayed for the same head") |
| + } |
| + // A head we're already running (a ship landed, or a pull-back onto us) is |
| + // not "on the way" โ buildVersion is "dev" under test. |
| + if alerted, _ := s.noteReleaseSightings(stable(buildVersion, "")); alerted { |
| + t.Fatal("alerted for the build we already run") |
| + } |
| + // A corrupt watermark re-baselines rather than announcing a backlog. |
| + if err := s.db.setConfig(releaseSeenKey, "not-json"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if alerted, _ := s.noteReleaseSightings(stable("ccc3333", "")); alerted { |
| + t.Fatal("corrupt watermark should re-baseline silently") |
| + } |
| + if alerted, _ := s.noteReleaseSightings(stable("ddd4444", "")); !alerted { |
| + t.Fatal("post-re-baseline move should alert") |
| + } |
| + |
| + // An edge-riding space stays quiet through head moves. |
| + if err := s.db.setUpdateChannel("edge"); err != nil { |
| + t.Fatal(err) |
| + } |
| + edge := func(ver string) *releaseManifest { |
| + return &releaseManifest{Channels: map[string]releaseEntry{"edge": {Version: ver}}} |
| + } |
| + s.noteReleaseSightings(edge("eee5555")) // baseline the edge channel |
| + if alerted, _ := s.noteReleaseSightings(edge("fff6666")); alerted { |
| + t.Fatal("edge head move alerted") |
| + } |
| +} |
diff --git a/updatecheck.go b/updatecheck.go| index 91ed266..e589cf1 100644 |
| --- a/updatecheck.go |
| +++ b/updatecheck.go |
| @@ -62,6 +62,9 @@ func (s *Server) checkForUpdate() error { |
| s.upd.mu.Lock() |
| s.upd.manifest, s.upd.checkedAt = m, time.Now() |
| s.upd.mu.Unlock() |
| + // A changed channel head alerts the space's admins (releasealert.go) โ |
| + // the detection half of the supply-chain bake window. |
| + s.noteReleaseSightings(m) |
| return nil |
| } |
diff --git a/web/pages/admin.html b/web/pages/admin.html| index be4d3fd..3ecdb9c 100644 |
| --- a/web/pages/admin.html |
| +++ b/web/pages/admin.html |
| @@ -130,6 +130,12 @@ |
| <option value="beta">Beta โ near-stable, newer features</option> |
| <option value="edge">Edge โ the bleeding edge</option> |
| </select> |
| + <label class="field-label" for="updateMajor">Stay on major version</label> |
| + <input id="updateMajor" inputmode="numeric" placeholder="any" style="display:block;width:100%;margin:0 0 4px" /> |
| + <p class="sub" style="margin:0 0 8px"> |
| + Leave empty to follow every release. With a number set, small updates keep |
| + arriving but a release crossing that major version waits for your say-so. |
| + </p> |
| <p id="updateFeedback" class="sub" style="margin:0" aria-live="polite"></p> |
| </section> |
diff --git a/web/static/admin.js b/web/static/admin.js| index 2af971c..484bede 100644 |
| --- a/web/static/admin.js |
| +++ b/web/static/admin.js |
| @@ -109,6 +109,7 @@ async function wireUpdates() { |
| const auto = document.getElementById("updateAuto"); |
| const dl = document.getElementById("updateDownload"); |
| const chan = document.getElementById("updateChannel"); |
| + const major = document.getElementById("updateMajor"); |
| const status = document.getElementById("updateStatus"); |
| const feedback = document.getElementById("updateFeedback"); |
| @@ -161,11 +162,13 @@ async function wireUpdates() { |
| }; |
| let prevChannel = "stable"; |
| + let prevMajor = ""; |
| try { |
| const s = await (await api("GET", "/api/admin/settings")).json(); |
| auto.checked = !!s.update_auto; |
| dl.checked = !!s.update_download; |
| chan.value = prevChannel = s.update_channel || "stable"; |
| + major.value = prevMajor = s.update_major || ""; |
| showPinned(s.update_pin); |
| renderStatus(s); |
| } catch {} |
| @@ -195,6 +198,17 @@ async function wireUpdates() { |
| showPinned(""); |
| } |
| }; |
| + major.onchange = async () => { |
| + const v = major.value.trim(); |
| + if (v !== "" && !/^\d{1,4}$/.test(v)) { |
| + flash("Major version must be a number โ or empty to follow everything.", true); |
| + major.value = prevMajor; |
| + return; |
| + } |
| + if (await save({ update_major: v }, () => (major.value = prevMajor))) { |
| + prevMajor = major.value = v; |
| + } |
| + }; |
| } |
| // ---- this space (name + optional administrator identity) ---------------- |
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js| index f17bef1..6858618 100644 |
| --- a/web/static/apps/admin.js |
| +++ b/web/static/apps/admin.js |
| @@ -1204,6 +1204,26 @@ export const app = defineApp({ |
| } |
| chanRow.append(chanLabel, chanSelect); |
| updSec.appendChild(chanRow); |
| + // The major pin (update_major): follow the channel for minors/patches, |
| + // wait at a major-version boundary until the admin raises this. |
| + const majorRow = document.createElement("div"); |
| + majorRow.className = "admin-row"; |
| + const majorLabel = document.createElement("label"); |
| + majorLabel.textContent = "Stay on major version"; |
| + majorLabel.htmlFor = "adm-update-major"; |
| + const majorInput = document.createElement("input"); |
| + majorInput.type = "text"; |
| + majorInput.id = "adm-update-major"; |
| + majorInput.inputMode = "numeric"; |
| + majorInput.placeholder = "any"; |
| + majorInput.style.width = "72px"; |
| + majorRow.append(majorLabel, majorInput); |
| + updSec.appendChild(majorRow); |
| + const majorHint = document.createElement("p"); |
| + majorHint.style.cssText = "font-size:12px;color:var(--muted);margin:4px 0 12px"; |
| + majorHint.textContent = |
| + "Leave empty to follow every release. With a number set, small updates keep arriving but a release crossing that major version waits for your say-so."; |
| + updSec.appendChild(majorHint); |
| const updStatus = statusLine(updSec); |
| const paintUpdateInfo = (update, channel, pin) => { |
| @@ -1246,7 +1266,7 @@ export const app = defineApp({ |
| } |
| }; |
| - const updState = { update: {}, channel: "stable", pin: "" }; |
| + const updState = { update: {}, channel: "stable", pin: "", major: "" }; |
| const saveUpdatePref = async (patch, revert, saved) => { |
| if (await bridge.setUpdatePrefs(patch)) { |
| flash(updStatus, saved); |
| @@ -1256,6 +1276,24 @@ export const app = defineApp({ |
| flash(updStatus, "Couldn't save โ try again."); |
| return false; |
| }; |
| + majorInput.onchange = async () => { |
| + const v = majorInput.value.trim(); |
| + if (v !== "" && !/^\d{1,4}$/.test(v)) { |
| + flash(updStatus, "Major version must be a number โ or empty to follow everything."); |
| + majorInput.value = updState.major; |
| + return; |
| + } |
| + const prev = updState.major; |
| + const ok = await saveUpdatePref( |
| + { update_major: v }, |
| + () => (majorInput.value = prev), |
| + v ? `Staying on major version ${v} โ bigger jumps wait for you.` : "Following every release.", |
| + ); |
| + if (ok) { |
| + updState.major = v; |
| + majorInput.value = v; |
| + } |
| + }; |
| autoCheck.onchange = () => |
| saveUpdatePref( |
| { update_auto: autoCheck.checked }, |
| @@ -1357,7 +1395,9 @@ export const app = defineApp({ |
| updState.channel = info.updateChannel || "stable"; |
| updState.update = info.update || {}; |
| updState.pin = info.updatePin || ""; |
| + updState.major = info.updateMajor || ""; |
| chanSelect.value = updState.channel; |
| + majorInput.value = updState.major; |
| showPinned(updState.pin); |
| paintUpdateInfo(updState.update, updState.channel, updState.pin); |
| photoState.alt = info.avatarAlt || ""; |
diff --git a/web/static/shell.js b/web/static/shell.js| index 5cd6111..501cc29 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -5925,6 +5925,7 @@ setSetupBridge({ |
| updateDownload: !!s.update_download, |
| updateChannel: s.update_channel || "stable", |
| updatePin: s.update_pin || "", |
| + updateMajor: s.update_major || "", |
| update: s.update || {}, |
| }; |
| } catch { |
| @@ -5942,7 +5943,8 @@ setSetupBridge({ |
| } |
| }, |
| // Auto-update prefs โ a partial update; pass only the changed field |
| - // (update_auto / update_download / update_channel / update_pin). |
| + // (update_auto / update_download / update_channel / update_pin / |
| + // update_major). |
| setUpdatePrefs: async (patch) => { |
| try { |
| return (await fetch("/api/admin/settings", { method: "POST", headers: adminAuth(), body: JSON.stringify(patch) })).ok; |
diff --git a/web/static/sw.js b/web/static/sw.js| index 78f8970..8ba68d3 100644 |
| --- a/web/static/sw.js |
| +++ b/web/static/sw.js |
| @@ -160,6 +160,21 @@ async function handlePush(event) { |
| data: {}, |
| }); |
| return; |
| + } else if (data.release) { |
| + // A new server release sighted on this space's update channel |
| + // (releasealert.go) โ the heads-up that makes the supply-chain safety |
| + // hold useful. Server-composed public feed data (channel/version/hold), |
| + // the changelog banner's class: in the clear, shown even while the app |
| + // is open, its own tag so a fresh sighting replaces the old banner. |
| + await self.registration.showNotification(String(data.title || "Server update on the way"), { |
| + body: String(data.body || ""), |
| + icon: "/static/icon-192.png", |
| + badge: "/static/icon-192.png", |
| + tag: "lchat-release", |
| + renotify: true, |
| + data: {}, |
| + }); |
| + return; |
| } else { |
| // File the row into the local message store first (#457 phase 2) โ even |
| // when the notification is suppressed below, an idempotent put costs |