Ping: A Semver-Aware Release Notifier in Go

Dependabot and Renovate tell you about new versions by opening pull requests. Sometimes you want something simpler: "tell me when React 19.2 ships", or "tell the team when our internal UI kit releases anything in ^4". Ping does exactly that. You list the packages an application depends on, give each one a semver range, and Ping notifies you by email and in the app when a matching version is published.

It's built for teams: organizations, members and applications, role-based access, and private registries with their credentials encrypted at rest. Today it speaks npm and npm-compatible registries. Earlier versions supported a dozen ecosystems; more on that at the end.

The interesting part is how little infrastructure it needs.

One binary, one database

Ping is a small Go monolith, about six thousand lines, shipped as one binary with four subcommands:

ping migrate   # one-shot: apply embedded migrations
ping serve     # HTTP API + htmx UI, writes events but processes none
ping scan      # polls packages that are due, records new versions
ping notify    # outbox relay, delivery dispatcher, session cleanup

The three long-running processes never call each other. They coordinate only through Postgres, and Postgres is the only infrastructure there is. The scan queue, the event bus and the notification delivery queue are all ordinary tables, claimed with FOR UPDATE SKIP LOCKED. There's no Redis, no message broker, no LISTEN/NOTIFY. Each worker polls on a short interval, and goes again immediately when a batch comes back full.

For a self-hosted tool, that's the whole deployment: a Postgres container and the same distroless image started with different arguments.

The scan queue is just the packages table

There's no separate queue. Every package row has a next_check_at, and the scanner claims the ones that are due:

UPDATE packages p SET next_check_at = now() + $2, last_checked_at = now()
FROM (
	SELECT id FROM packages
	WHERE next_check_at <= now() AND deleted_at IS NULL
	ORDER BY next_check_at LIMIT $1 FOR UPDATE SKIP LOCKED
) due
WHERE p.id = due.id
RETURNING p.id, p.registry_id, p.name, p.etag, ...

The claim pushes next_check_at forward provisionally, so a crashed scanner doesn't leave a package stuck. When the scan finishes, the real next time replaces it. The default interval is 15 minutes, with ±20% jitter. Without jitter, a hundred packages added in one import would all come due at the same second, forever.

Being a polite client

Polling a public registry every 15 minutes for every watched package adds up, so the fetcher does as little as possible:

func (s *Service) fetch(ctx context.Context, baseURL, name, etag, token string) (*fetchResult, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/"+url.PathEscape(name), nil)
	// ...
	if etag != "" {
		req.Header.Set("If-None-Match", etag)
	}
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	// ...
	case resp.StatusCode == http.StatusNotModified:
		return &fetchResult{NotModified: true, ETag: etag}, nil
backoff := min(s.baseInterval(cfg, p)<<min(p.FailCount, 8), cfg.MaxFailBackoff)
if errx.Is(cause, errx.KindNotFound) || errx.Is(cause, errx.KindForbidden) || errx.Is(cause, errx.KindUnauthorized) {
	// A missing package or rejected credentials won't heal on their own;
	// park the package at the max backoff instead of climbing towards it.
	backoff = cfg.MaxFailBackoff
}

Registry tokens are stored encrypted with AES-256-GCM. They're decrypted only inside the scanner, cached in memory for a minute, and never included in the registry type that other packages see.

From "new version" to "your range matched"

When the scanner finds versions it hasn't seen, it sorts them by semver and inserts them in one transaction, with ON CONFLICT DO NOTHING as a guard. In the same transaction it writes one event per new version to an outbox table. The version and its event are committed together or not at all.

The first scan of a newly watched package is a backfill. It records the whole history but emits nothing. Otherwise adding lodash would send you a few hundred notifications.

A subscriber then checks the new version against every active watch on that package, using Masterminds/semver:

// matches reports whether version satisfies rng; an invalid range never matches.
func matches(rng string, version *semver.Version) bool {
	c, err := semver.NewConstraint(rng)
	return err == nil && c.Check(version)
}

for _, w := range watches {
	if !matches(w.SemverRange, published) ||
		(w.LastMatchedVersion != nil && *w.LastMatchedVersion == e.Version) {
		continue
	}
	// record last_matched_version, emit applications.dep.version_matched
}

An invalid range simply never matches, and ranges are validated when they're saved, so that case shouldn't happen. Each match also gets a bump classification (major, minor, patch or prerelease) and generated compare and changelog links, so "what changed?" is one click away.

One subtlety worth knowing: like most semver libraries, Masterminds only matches prerelease versions when the range itself mentions a prerelease. ^2.0.0 won't match 2.1.0-beta.1, but >=2.0.0-0 will. If you want to hear about betas, write the range that way.

Exactly once, without a broker

Outbox relays deliver at least once. A relay can crash after running a handler but before marking the event done. So every handler run is guarded by an inbox row, keyed by handler and event, in the same transaction as the handler's own work:

for _, h := range b.handlers[m.EventName] {
	err := db.Tx(ctx, b.pool, func(tx pgx.Tx) error {
		n, err := db.Exec(ctx, tx, "", `INSERT INTO inbox (handler_key, event_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, h.key, m.ID)
		if err != nil || n == 0 {
			return err
		}
		return h.fn(ctx, tx, m.Payload)
	})
	// ...
}

If the inbox row already exists, the handler already ran, and nothing happens. That makes each handler effectively exactly-once, using nothing but a unique key. A second guard, the last matched version on each watch, makes sure a team isn't notified twice about the same release.

Failed events retry with exponential backoff and end up marked as dead after eight attempts. Events stuck in "processing" for more than five minutes go back to pending, and processed events are cleaned up after a week.

Notifications

A match fans out into one notification, a recipient per member of the organization, and a delivery per member and channel, all in one transaction. Deliveries have their own SKIP LOCKED queue with retries. Whether someone gets email follows a simple precedence: their own setting, then the organization's, then on by default.

Email is the one place where exactly-once isn't possible. The email is sent before the delivery is marked done, so a crash between the two means a duplicate. For a release notification that's the right trade-off: a duplicate email is a much smaller problem than a missing one.

Small on purpose

A few other choices keep it that way:

How it got here: three versions

Ping didn't start this small. It's the third version of the same idea, and the first one had a different name.

Checkify (January to March 2026) was a Go API and a React single-page app. The API ran as four binaries, each with its own Dockerfile: the HTTP API, a version checker, a notifier and a migrator. They coordinated only through Postgres, with each worker on a cron schedule. It tried to cover everything at once:

Matching was simpler than it looks now. Each package stored only its latest version, and each tracked dependency had four switches (major, minor, patch, prerelease) plus an optional regex filter. And the worker "queues" were plain SELECT ... LIMIT queries with no row locking. That's fine for one copy of each worker, but run two and they'd both pick up the same rows.

ping-v1 (July 2026) was a clean rewrite under the new name. The React app was replaced by templ and htmx with no JavaScript build step. The layered layout made way for flat feature packages, and roles became an explicit permission map with owner, admin, member and viewer. Registry tokens and webhook URLs were encrypted with AES-GCM. The matching model changed too: instead of switches and a regex, you pick a target release line, so 11.3 means "tell me about 11.3.x", and each release track (stable, beta, rc) keeps its own latest version. Billing and manifest import didn't make it into the rewrite. It still shipped as four separate binaries, and claiming work still didn't lock rows.

The current Ping is the version described in this post. It's one binary instead of four, and work is claimed with SKIP LOCKED, so running more than one scanner or notifier is safe. It uses a real outbox and inbox instead of workers writing each other's tables, and semver ranges instead of release lines. It supports one ecosystem, done properly, instead of twelve.

Looking back at the three codebases, most of what changed was removal. Each version dropped features that the previous one had built but that the core loop didn't need. And each made the core loop more reliable: polling a registry, detecting a version, deciding who cares and telling them exactly once.

What's next

The obvious gaps are on the edges. More registry types (a new type is one more fetch function, chosen by the registry's type, and the earlier versions already have fetchers to port), and more channels: Slack, Discord and Telegram are already designed in as extension points. The core loop stays the same: claim what's due, fetch politely, commit the version and its event together, and let every handler run exactly once.

← All posts