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 }