← All posts

The post-receive hook is the event bus - one git push, measured end to end

How a git push in Rezee triggers pipelines and signed webhooks across two services with no message broker - the env-injected shell hook, fire-and-forget at every hop, and a measured 75-second push stall that the design's own honesty exposed.

May 21, 2026 · 8 min read · Kash Gohil

Rezee is one workspace for the whole product development lifecycle - code, planning, CI/CD, chat, and docs. Two services own the two halves of "a push happened": denji owns the git bytes, makima owns the metadata (which repo, whose pipeline, which webhooks). When you git push, denji receives the packfile - but the pipeline that should run and the webhook that should fire live in makima. This post is the seam between them, which turns out to be the humblest possible mechanism: a shell script git already runs, and an HTTP call. No broker, no queue, no event service. And because we measured it end to end, this post also contains a genuine bug the measurement found.

How does a push in denji reach makima?

git already has an extension point for exactly this: the post-receive hook, which git runs after a push updates refs, feeding it one old new ref line per updated ref on stdin. denji installs a hook into every repo at creation, and it's about as small as code gets (services/denji/internal/repo/store.go):

#!/bin/sh
while IFS=' ' read -r old new ref; do
	if [ -n "$REZE_MAKIMA_URL" ]; then
		curl -sf -X POST "$REZE_MAKIMA_URL/internal/push-event" \
			-H "Content-Type: application/json" \
			-H "X-Internal-Secret: $REZE_INTERNAL_SECRET" \
			-d "{\"repo\":\"$REZE_REPO\",\"pusher\":\"$REZE_PUSHER\",\"ref\":\"$ref\",\"sha\":\"$new\"}" || true
	fi
done

The clever part isn't the hook - it's how it learns which push it's reporting. A hook has no idea who pushed or over what URL; it just runs. So denji injects the context as environment variables onto the git receive-pack subprocess at push time (services/denji/internal/transport/smarthttp.go):

if subcommand == "receive-pack" && h.makimaURL != "" {
	cmd.Env = append(os.Environ(),
		"REZE_MAKIMA_URL="+h.makimaURL,
		"REZE_INTERNAL_SECRET="+h.secret,
		"REZE_REPO="+repoName,
		"REZE_PUSHER="+usernameFromBasicAuth(r.Header.Get("Authorization")),
	)
}

git passes that environment down to the hook it spawns. So the transport layer (which knows the repo and the authenticated pusher) hands the hook (which knows the refs and SHAs) exactly the four facts makima needs, through the one channel a hook can always see: its environment. Unset REZE_MAKIMA_URL and the hook is a silent no-op - which is exactly how denji runs in local development without makima.

What does makima do with the event?

/internal/push-event lands in makima's handlePushEvent (services/makima/src/modules/pipelines/index.ts), which does two independent things - and the order matters:

// Deliver push webhook regardless of whether a pipeline exists.
deliver(wsSlug, repoName, "push", { repo: repoName, ref, sha, pusher }).catch(() => {});

const yaml = await loadActionsYaml(wsSlug, repoName, sha);
if (!yaml?.jobs || Object.keys(yaml.jobs).length === 0) return;

const branch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : null;
if (!branch) return;                                     // tag push -> no pipeline
if (!matchesBranchFilter(branch, yaml.on?.push?.branches ?? [])) return;

const [run] = await db.insert(pipelineRuns).values({ repoId: repo.id, sha, ref, pusher }).returning(...);
// ...then insert the jobs and steps that kobeni will claim

Webhooks fire for every push. Pipelines are conditional: makima reads .rezee/actions.yml from the pushed SHA itself (via denji's blob API - the config travels with the code, so a branch can change its own pipeline), skips tag pushes, applies the branch glob filter, and only then inserts the pipeline_runs row that kobeni will claim. The insert is the entire handoff to CI - the row is the queue.

Fire-and-forget, at every single hop

The load-bearing design decision is that a push must never be held hostage to anything downstream of it. Trace the failure-swallowing through the whole chain and it's almost comically consistent:

  • The hook ends every curl with || true - makima returning an error, or being unreachable, cannot fail the push.
  • handlePushEvent calls deliver(...).catch(() => {}) - a webhook subsystem failure cannot fail the event handler.
  • deliver itself does fetch(hook.url, ...).catch(() => {}) per webhook - one dead customer endpoint cannot affect another, or the push.

Every hop degrades to "the push still succeeds, you just don't get the side effect." That's the right default: your ability to push code should not depend on whether your Slack webhook's server is up. We tested it by pushing with makima down, slow, and unreachable, and in every case the commit landed on the server. But "the push succeeds" and "the push is fast" are different promises, and measuring the second one is where it got interesting.

What does the hook cost a push?

We copied the real hook verbatim into a harness (scripts/bench/push-event) that installs it in a local bare repo and times git push while pointing the hook at a mock makima in four states of health:

git push latency by makima health (ms) git push latency by makima health (ms) median push time 0200400600 38.349.7563.747.5 no hookmakima healthymakima slow (500ms)makima down (refused) median ms

The healthy cases are exactly what you'd hope. A push with no hook is our 38ms floor; with the hook calling a healthy makima, ~50ms - the notification adds ~11ms and nobody notices. And makima being down in the ordinary sense (process stopped, connection refused) costs almost nothing: 47ms, because a refused connection fails instantly and || true moves on. So far the fire-and-forget story holds.

But look at makima slow: a makima that takes 500ms to respond makes the push take 564ms. The hook runs curl synchronously - git waits for the hook, the hook waits for curl, curl waits for makima. Every millisecond of makima's response time is on the critical path of the user's push. Fire-and-forget it is not; it's fire-and-wait-for-the-ack.

The 75-second push (a bug the measurement found)

Then there's the case that isn't on the chart because it wouldn't fit: a makima at an unroutable address - host down, or a firewall black-holing the connection rather than refusing it. The measured median push time:

75,222 milliseconds. Seventy-five seconds.

The push still succeeded - || true did its job for correctness. But the user's git push hung for a minute and a quarter first, because the hook's curl -sf has no --connect-timeout and no --max-time, so it waits out the operating system's default TCP connect timeout before failing. Connection refused is instant (there's a host saying "no"); connection to a black hole (no host answering at all) waits the full timeout. The fire-and-forget design correctly protects the outcome of the push and completely fails to protect its latency, and only measuring the unroutable case - not the polite refused case everyone tests - exposes it.

The fix is one flag: curl -sf --max-time 5. Bounding the hook's patience turns a 75-second stall into a 5-second one and keeps the || true correctness. This is the honest value of tracing a path end to end with a stopwatch: the design was sound in the way we'd reasoned about it (a down makima doesn't break pushes) and unsound in a way we hadn't (a black-holed makima wedges them), and the difference is invisible until you point the hook at 192.0.2.1 and watch git sit there.

Why not a real message bus for this?

Because the mechanism git already gives you - a hook that runs a program - is a message bus with delivery semantics you can see. The "broker" is the hook process; the "topic" is an HTTP endpoint; the "durability" is deliberately none (fire-and-forget, because a missed pipeline is re-triggerable by re-pushing, and missed webhooks are an accepted loss). Adding Kafka to notify one service that a push happened would be adding infrastructure to move four strings across a process boundary that a shell script and curl already move in 11 milliseconds. The cross-service call rides the same shared-secret internal channel as every other denji-to-makima request, so it inherits the trust boundary rather than inventing one.

The place this design would need to grow up is exactly the latency finding above: a broker's fire-and-forget is genuinely asynchronous, where the hook's is synchronous-with-a-timeout. For our scale (a push notifies one makima), a bounded synchronous call is simpler and its failure modes are now measured. If push volume or fan-out grew, moving the notification off the push's critical path - the hook enqueues locally, a separate process delivers - is the same worker-shaped move we made for the agent runtime.

FAQ

Why does the hook read the pipeline config from the pushed SHA, not the branch tip?

Because the pipeline that runs should be the one defined in the code being pushed, not whatever's currently at the branch tip (which a concurrent push may have moved). Reading .rezee/actions.yml at the exact pushed SHA means a commit carries its own CI definition - you can change your pipeline and test the change in the same push, and there's no race between "which config" and "which code."

One push updates three branches - how many events fire?

Three. git's post-receive feeds the hook one old new ref line per updated ref on stdin, and the hook's while read loop curls makima once per line. So a push of three branches is three independent push-events, each evaluated against the branch filter on its own - which is correct, because each branch may or may not match a pipeline trigger.

Are the outbound webhooks signed?

Yes - HMAC-SHA256 over the JSON body with the webhook's secret, sent as X-Rezee-Signature-256: sha256=..., so a receiver can verify the payload came from Rezee and wasn't tampered with. That's the same standard scheme GitHub uses, and it's the one part of this pipeline where the delivery is to an untrusted third party, so it's the one part that's cryptographically authenticated rather than riding an internal shared secret.