eleven / ocho Public

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>
Paul Campbell f3d0bf38dd202a9d082a1c1da16eff3404a13dac parent c4df276
10 files changed, +304 −2
  • changelog/2026-07-13-release-heads-up.md +7 −0
  • docs/server-releases.md +9 −0
  • releasealert.go +110 −0
  • releasealert_test.go +96 −0
  • updatecheck.go +3 −0
  • web/pages/admin.html +6 −0
  • web/static/admin.js +14 −0
  • web/static/apps/admin.js +41 −1
  • web/static/shell.js +3 −1
  • web/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