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>
6 files changed,
+258
−0
announce.go+120 −0announce_test.go+96 −0changelog/2026-07-09-admin-update-push.md+4 −0db.go+19 −0main.go+3 −0web/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; |