eleven / ocho Public

server: push release notes to admins on deploy (announce.go) (#463)

The server-side half of retiring the Announcer bot: on startup — which is when
a new binary can first speak for itself — diff the embedded changelog against
the announced-ids watermark in config, and Web-Push anything new to every
role-admin, full text in the banner. First run baselines silently (no backlog
blast), mirroring both the old Announcer's first tick and the Admin app's
in-thread baseline. No sidecar in the loop, so this works on every instance.

The words ride in clear deliberately: the changelog is branding-class
plaintext (baked into the binary, announced publicly), never user content,
and no key is touched anywhere in the path.

- announce.go: changelogClean (Go twin of format.js changelogParagraphs —
  shared test cases pin the pair), announceChangelog (baseline / diff / push /
  advance watermark, at-most-once across crashes).
- db.go: adminUserIDs.
- sw.js: renders the plaintext {changelog, title, body} payload under its own
  tag; tap opens the app. Shown even when active — the deploy just restarted
  the server, and this banner is the one app-closed signal.
- APNs/NSE (native banner) is the follow-up PR.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a4f0c3975c0a1e88fcc9d89a7d0bd0fd43f63242 parent b418ba8
6 files changed, +258 −0
  • announce.go +120 −0
  • announce_test.go +96 −0
  • changelog/2026-07-09-admin-update-push.md +4 −0
  • db.go +19 −0
  • main.go +3 −0
  • web/static/sw.js +16 −0
diff --git a/announce.go b/announce.go
new file mode 100644
index 0000000..954e1d1
--- /dev/null
+++ b/announce.go
@@ -0,0 +1,120 @@
+package main
+
+// 🤖 Changelog announcements to the space's admins — the server-side half of
+// retiring the Announcer bot. On startup (which is when a new binary can first
+// speak for itself) the server diffs its embedded changelog against the ids it
+// has already announced and pushes anything new to every role-admin, full text
+// in the banner. The words are branding-class plaintext (the changelog ships
+// in the binary and is announced publicly), never user content, so — uniquely
+// among pushes — the payload may carry them in the clear; nothing here touches
+// a key. The in-app half is the Admin app's update stream (admin.note events),
+// which materialises the same notes into the thread when an admin next opens
+// it; this push covers the app-closed case, with no sidecar in the loop.
+//
+// Web Push only for now; the native banner (relay/NSE) is the follow-up PR.
+
+import (
+ "encoding/json"
+ "log"
+ "regexp"
+ "strings"
+)
+
+// The announced-ids watermark lives in config, playing the role of the retired
+// Announcer's <session>.state.json: first run baselines silently (a fresh space
+// isn't greeted by the whole backlog), later runs announce only the diff.
+const changelogAnnouncedKey = "changelog_announced"
+
+var (
+ changelogMarkerRe = regexp.MustCompile(`^\s*(?:🤖|👨|👤|🧑|👩)\s+`)
+ changelogParaRe = regexp.MustCompile(`\n[ \t]*\n`)
+ changelogWrapRe = regexp.MustCompile(`[ \t]*\n[ \t]*`)
+)
+
+// changelogClean formats one embedded release note for a notification body:
+// drops the leading authorship marker (🤖/👨/… — repo metadata, not for
+// members) and collapses the ~72-column hard wraps back into flowing
+// paragraphs. Mirrors changelogParagraphs in web/static/format.js — the two
+// sides share test cases; keep them in sync.
+func changelogClean(text string) string {
+ clean := changelogMarkerRe.ReplaceAllString(text, "")
+ var out []string
+ for _, p := range changelogParaRe.Split(clean, -1) {
+ p = strings.TrimSpace(changelogWrapRe.ReplaceAllString(p, " "))
+ if p != "" {
+ out = append(out, p)
+ }
+ }
+ return strings.Join(out, "\n\n")
+}
+
+// announceChangelog runs once at startup: baseline or announce-the-diff, then
+// advance the watermark. Returns (fresh, sent) — entries newly announced and
+// push sends that succeeded — for tests; the caller ignores them.
+func (s *Server) announceChangelog() (fresh, sent int) {
+ raw, hadMark, _ := s.db.getConfig(changelogAnnouncedKey)
+ seen := map[string]bool{}
+ if hadMark {
+ var ids []string
+ if err := json.Unmarshal([]byte(raw), &ids); err != nil {
+ log.Printf("changelog: bad %s config (%v) — re-baselining", changelogAnnouncedKey, err)
+ hadMark = false
+ }
+ for _, id := range ids {
+ seen[id] = true
+ }
+ }
+
+ ids := make([]string, 0, len(s.changelog))
+ var texts []string
+ for _, e := range s.changelog {
+ ids = append(ids, e.ID)
+ if hadMark && !seen[e.ID] {
+ if t := changelogClean(e.Text); t != "" {
+ texts = append(texts, t)
+ }
+ }
+ }
+ // Advance the watermark before pushing (at-most-once): a crash mid-announce
+ // must not replay the same entries at every restart. Recorded even with no
+ // admins subscribed — old news by the time someone subscribes, same rule the
+ // Announcer applied to empty groups.
+ b, _ := json.Marshal(ids)
+ if err := s.db.setConfig(changelogAnnouncedKey, string(b)); err != nil {
+ log.Printf("changelog: save watermark: %v", err)
+ }
+ if !hadMark {
+ log.Printf("changelog: baselined at %d entries", len(ids))
+ return 0, 0
+ }
+ if len(texts) == 0 {
+ return 0, 0
+ }
+
+ admins, err := s.db.adminUserIDs()
+ if err != nil || len(admins) == 0 {
+ log.Printf("changelog: %d new entr(y/ies), no admins to notify (err=%v)", len(texts), err)
+ return len(texts), 0
+ }
+ recipients := make(map[string]bool, len(admins))
+ for _, id := range admins {
+ recipients[id] = true
+ }
+
+ body := strings.Join(texts, "\n\n")
+ // Web Push payloads cap around 4KB; the OS truncates banners far sooner.
+ if r := []rune(body); len(r) > 1500 {
+ body = string(r[:1500]) + "…"
+ }
+ payload, _ := json.Marshal(map[string]any{
+ "changelog": true,
+ "title": "What's new in " + s.db.getGroupName(),
+ "body": body,
+ })
+ // skipActive=false: a deploy just restarted the server, so presence is empty
+ // anyway — and this banner is the one signal an admin gets while the Admin
+ // app is closed. exceptToken="": there is no sender to exclude.
+ sent = s.sendToRecipients(payload, "changelog", "", false, recipients)
+ log.Printf("changelog: announced %d entr(y/ies) to %d admin subscription(s)", len(texts), sent)
+ return len(texts), sent
+}
diff --git a/announce_test.go b/announce_test.go
new file mode 100644
index 0000000..6028304
--- /dev/null
+++ b/announce_test.go
@@ -0,0 +1,96 @@
+package main
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+// changelogClean mirrors web/static/format.js changelogParagraphs — these cases
+// are shared with test/format.test.mjs so the two sides can't drift.
+func TestChangelogClean(t *testing.T) {
+ cases := []struct{ name, in, want string }{
+ {"drops the 🤖 authorship marker", "🤖 Unread dots now sync across your devices.", "Unread dots now sync across your devices."},
+ {"drops a person marker", "👨 Hand-written note.", "Hand-written note."},
+ {
+ "collapses hard wraps but keeps paragraph breaks",
+ "🤖 Your space can now keep itself up\nto date. Admins get a new\nUpdates section.\n\nUpdates take seconds.",
+ "Your space can now keep itself up to date. Admins get a new Updates section.\n\nUpdates take seconds.",
+ },
+ {"no marker passes through", "Plain note, no marker.", "Plain note, no marker."},
+ {"blank collapses to empty", " ", ""},
+ }
+ for _, c := range cases {
+ if got := changelogClean(c.in); got != c.want {
+ t.Errorf("%s: got %q, want %q", c.name, got, c.want)
+ }
+ }
+}
+
+func TestAnnounceChangelog(t *testing.T) {
+ _, s := newTestServer(t)
+ if len(s.changelog) < 2 {
+ t.Fatal("embedded changelog unexpectedly small")
+ }
+
+ // First run: no watermark → baseline silently, announce nothing.
+ fresh, sent := s.announceChangelog()
+ if fresh != 0 || sent != 0 {
+ t.Fatalf("first run should baseline: fresh=%d sent=%d", fresh, sent)
+ }
+ raw, ok, _ := s.db.getConfig(changelogAnnouncedKey)
+ if !ok {
+ t.Fatal("baseline did not write the watermark")
+ }
+ var ids []string
+ if err := json.Unmarshal([]byte(raw), &ids); err != nil || len(ids) != len(s.changelog) {
+ t.Fatalf("watermark should hold every entry id: %d != %d (%v)", len(ids), len(s.changelog), err)
+ }
+
+ // Steady state: nothing new → nothing announced.
+ if fresh, _ = s.announceChangelog(); fresh != 0 {
+ t.Fatalf("no new entries but fresh=%d", fresh)
+ }
+
+ // A "deploy": the stored watermark misses the two newest entries (as if this
+ // binary shipped them). They announce, and the watermark catches up.
+ trimmed, _ := json.Marshal(ids[:len(ids)-2])
+ if err := s.db.setConfig(changelogAnnouncedKey, string(trimmed)); err != nil {
+ t.Fatal(err)
+ }
+ fresh, sent = s.announceChangelog()
+ if fresh != 2 {
+ t.Fatalf("expected 2 fresh entries, got %d", fresh)
+ }
+ if sent != 0 { // no push subscriptions in the test DB
+ t.Fatalf("expected 0 sends without subscriptions, got %d", sent)
+ }
+ raw, _, _ = s.db.getConfig(changelogAnnouncedKey)
+ var after []string
+ _ = json.Unmarshal([]byte(raw), &after)
+ if len(after) != len(s.changelog) {
+ t.Fatalf("watermark should catch up: %d != %d", len(after), len(s.changelog))
+ }
+
+ // A corrupt watermark re-baselines rather than announcing the backlog.
+ _ = s.db.setConfig(changelogAnnouncedKey, "not-json")
+ if fresh, _ = s.announceChangelog(); fresh != 0 {
+ t.Fatalf("corrupt watermark should re-baseline, got fresh=%d", fresh)
+ }
+}
+
+func TestAdminUserIDs(t *testing.T) {
+ _, s := newTestServer(t)
+ a, _ := s.db.createUser("Ada")
+ b, _ := s.db.createUser("Bob")
+ if err := s.db.setUserRole(a, "admin"); err != nil {
+ t.Fatal(err)
+ }
+ admins, err := s.db.adminUserIDs()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(admins) != 1 || admins[0] != a {
+ t.Fatalf("want [%s], got %v", a, admins)
+ }
+ _ = b // stays a member — must not appear
+}
diff --git a/changelog/2026-07-09-admin-update-push.md b/changelog/2026-07-09-admin-update-push.md
new file mode 100644
index 0000000..637d744
--- /dev/null
+++ b/changelog/2026-07-09-admin-update-push.md
@@ -0,0 +1,4 @@
+🤖 Admins now get a notification when their space updates itself — the
+release notes arrive as a push, right on your phone or desktop, even
+with the app closed. The same notes land in the Admin app's update
+stream, so nothing depends on catching the banner.
diff --git a/db.go b/db.go
index da6b6ac..b577192 100644
--- a/db.go
+++ b/db.go
@@ -1043,6 +1043,25 @@ func (db *DB) userRole(id string) string {
return r
}
+// adminUserIDs returns the ids of every role-admin — the recipients of
+// changelog announcements (announce.go).
+func (db *DB) adminUserIDs() ([]string, error) {
+ rows, err := db.sql.Query("SELECT id FROM users WHERE COALESCE(role, 'member') = 'admin'")
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []string
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ out = append(out, id)
+ }
+ return out, rows.Err()
+}
+
// adminCount reports how many members hold the admin role. Removed users are
// hard-deleted rows, so this is the live count — 1 means the caller (if admin)
// holds the only admin key on the instance.
diff --git a/main.go b/main.go
index 3654cc5..46e6f35 100644
--- a/main.go
+++ b/main.go
@@ -243,6 +243,9 @@ func main() {
go s.runUpdateChecker()
// Presence idle sweep (presence.go). Tests call sweepPresence directly.
go s.runPresenceSweeper()
+ // Push release notes new in THIS binary to the admins (announce.go). Tests
+ // call announceChangelog directly.
+ go s.announceChangelog()
ln, where, err := listen(*addr, *socket)
if err != nil {
diff --git a/web/static/sw.js b/web/static/sw.js
index af45bfb..5043374 100644
--- a/web/static/sw.js
+++ b/web/static/sw.js
@@ -76,6 +76,22 @@ async function handlePush(event) {
// Test notification: always show, even if the app is focused.
title = "✅ Test notification";
body = "Push notifications are working!";
+ } else if (data.changelog) {
+ // Release notes for an admin (announce.go). Uniquely, the words arrive in
+ // clear — the changelog is branding-class plaintext baked into the binary,
+ // never user content — so there's nothing to decrypt. Shown even when the
+ // app is active: the deploy just restarted the server, and this banner is
+ // the one signal the update happened. Its own tag so it never collapses
+ // into (or gets replaced by) a message notification.
+ await self.registration.showNotification(String(data.title || "What's new"), {
+ body: String(data.body || ""),
+ icon: "/static/icon-192.png",
+ badge: "/static/icon-192.png",
+ tag: "lchat-changelog",
+ renotify: true,
+ data: {},
+ });
+ return;
} else {
// Real message: skip if the app is open/active (you're already seeing it).
if (await appIsActive()) return;