A GitHub webhook passes through a bridge that verifies and records event #42 once, then to a sync and the tracker; the echo coming back matches the snapshot, title=A, and is crossed out as no change.

Syncing GitHub issues both ways without a public endpoint per project

One bridge receives and records GitHub's webhooks; a sync that connects out to it uses per-issue snapshots so its own echoes change nothing.

Published Project 2026

Poietic Issues sourceTwo-way GitHub sync pull requestTracker notices pull requestADR 0029: GitHub issues sync both ways through the bridgeADR 0032: notice webhooks and change reasons

Two-way sync between GitHub issues and Poietic Issues has an awkward first requirement: GitHub has to be able to reach it. GitHub announces a new issue or comment by POSTing a webhook to a public URL. The tracker’s container sleeps when idle, and the sync process runs on a local K3s cluster with no inbound route. Giving every project that wants outside events its own public endpoint would mean repeating the signature checks, the storage and the exposure each time.

Poietic Bridge splits that work in two. The bridge listens on the internet: it receives a delivery, checks that it is genuine, records it and holds it in order. It never acts on it. Listeners connect out to the bridge and decide what each event means. The sync is one such listener, so it needs outbound HTTPS and nothing else.

Accepting a delivery once

A GitHub delivery arrives at /in/<terminal>, where a terminal is one configured source with its own secret and stream. The Worker checks the HMAC-SHA256 in X-Hub-Signature-256 against the exact request bytes, writes the raw body to R2, puts a small envelope on a Cloudflare queue and answers 202. GitHub expects an answer within ten seconds and does not retry a failed delivery by itself, so the receiver does only that much before replying. If R2 or the queue fails, GitHub gets a 500 and shows the delivery as failed, and the owner can redeliver it.

After answering, the Worker also appends the event straight to the terminal’s stream in the background. That path only speeds things up. The queue appends the same event later anyway, retrying up to 100 times with backoff capped at five minutes, and then moves it to a dead-letter queue that nothing consumes automatically. The final attempt logs an error naming the R2 key, so the body can still be found. Each terminal’s stream is a Durable Object with a SQLite table whose delivery_id column is unique. The second append of a delivery finds the existing row and stops, which also makes a deliberate redelivery from GitHub’s settings harmless.

Listeners read the stream by sequence number. A named subscription keeps its position on the bridge, and the SDK holds a WebSocket open so the bridge can push each new event as it is appended. The socket uses Cloudflare’s hibernation, so a quiet terminal costs no Durable Object time. Delivery to a subscription is at least once: a listener that crashes after handling an event, but before its position is saved, sees that event again.

Writing both ways without an echo

That guarantee moves the duplicate problem to the listener, and two-way sync makes it worse. When the sync copies a title change from the tracker to GitHub, GitHub sends a webhook about the sync’s own edit. Handled naively, that webhook would be copied back to the tracker, which would emit an event of its own, and so on.

The sync, apps/github-sync in the poietic-issues repository, keeps a snapshot for each linked pair: the title, body, open or closed state and labels that both sides agreed on after the last sync. A change is whatever differs from it:

export function changedFields(prev: Snapshot, next: Snapshot): Partial<Snapshot> {
  const out: Partial<Snapshot> = {};
  if (prev.title !== next.title) out.title = next.title;
  if (prev.body !== next.body) out.body = next.body;
  if (prev.state !== next.state) out.state = next.state;
  if (!sameLabels(prev.labels, next.labels)) out.labels = next.labels;
  return out;
}

When a difference is written to the other side, the snapshot moves forward with it. The echo then carries values the snapshot already holds, the function returns an empty object, and the handler stops there. A redelivered event falls out the same way. Because this doesn’t depend on recognising the sync’s GitHub writes by their sender, it works with a personal token. In the other direction, the sync also skips tracker events whose actor is its own agent.

Order is the other hazard. The bridge does not guarantee source order, and neither does GitHub. So the sync ignores the webhook’s copy of the issue, reads the issue from GitHub’s REST API, and compares that with the snapshot. A late delivery cannot roll a field back to an older value.

Links have to survive a lost state file, too. The SQLite store on the pod’s volume is not the only record of which issues belong together. Tracker issues carry the GitHub URL as metadata, and every GitHub body the sync writes ends with an invisible <!-- poietic-issues:<id> --> marker. An empty store can be rebuilt from the tracker, and a crash between creating a GitHub issue and recording its link is adopted when the echo arrives, instead of producing a duplicate.

Some things are deliberately left out. Assignees are not synced, since there is no mapping between GitHub and tracker users yet. Conflicting edits to the same field on both sides within one sync interval resolve to whichever event is handled last. A new subscription starts at the newest event, so issues from before the sync existed appear only when they next change. The integration tests run the real tracker in-process against an in-memory GitHub, on SQLite and Postgres, and cover echo suppression, redelivery, marker adoption and rebuilding the store. The pull request’s live check started the bundle against the production bridge and tracker and covered health endpoints and a clean shutdown. It does not record a real issue making the round trip.

The tracker as a source

With the bridge in place, the tracker could send through it as well. The pipeline agents had been polling the tracker’s event log, about 100,000 requests a day while nothing happened, and each request woke its container. A tracker webhook in notice mode sends the bridge only { seq, id, type, projectId, issueId, actorId, createdAt }. The bridge keeps every body indefinitely and does not apply tracker permissions, so it is never given issue content. A woken agent reads the event log as itself, and the tracker’s access rules still decide what it sees. Running that runbook against production created the live notice webhook, and showed that one of its piped commands passed the whole JSON response where only the secret belonged, which was then corrected.