How we built a CI runner in Go (in about 500 lines)
The architecture of kobeni, Rezee's pipeline executor - Postgres as the job queue, optimistic claims, a throwaway Docker container per step, zipped artifacts, and the gaps we haven't filled yet.
May 22, 2026 · 4 min read · Kash Gohil
Rezee's Code & Ship layer runs pipelines through a service called kobeni - a CI executor that is one Go file of about five hundred lines, with zero third-party dependencies. Like our git server, it gets small by refusing to own problems other software already solves: Postgres is the queue, Docker is the sandbox, and kobeni is mostly the glue that must never be clever.
Where does the work come from?
A push arrives at the git server, whose post-receive hook notifies makima (our API). Makima reads .rezee/actions.yml from the pushed commit, parses it, applies branch filters, and inserts a pipeline_runs row with status pending. (That hook is the whole event bus - we traced one push end to end and found it can stall a git push for 75 seconds when makima is unreachable.)
That row is the queue. There's no Redis, no broker - kobeni polls makima every five seconds for pending runs and spawns a goroutine per run. The interesting part is what stops two runners from executing the same run: an optimistic claim, done as a conditional UPDATE:
.post("/runs/:runId/claim", async ({ params }) => {
const result = await db
.update(pipelineRuns)
.set({ status: "running", startedAt: new Date() })
.where(and(eq(pipelineRuns.id, params.runId), eq(pipelineRuns.status, "pending")))
.returning({ id: pipelineRuns.id });
if (result.length === 0) return status(409, "already claimed");
return { ok: true };
})Whoever flips pending → running first wins; the loser gets a 409 and moves on. That single query is the entire distributed-coordination story, and it means you can run several kobeni instances against one database with no further machinery. Queues built on "a status column and one atomic UPDATE" are unfashionable and extremely hard to break - a claim we later raced with 100 concurrent workers and 700,000 claim attempts to make sure it wasn't just a slogan (zero double-claims; the interesting costs are in that post).
How is each step isolated?
Per run, kobeni makes a full git clone of the bare repo into a build directory and checks out the pushed SHA - a clone rather than a worktree, so the workspace is self-contained with no link back to the canonical repo.
Then each step gets a throwaway container:
containerName := "reze-step-" + stepID
args := []string{
"run", "--rm", "--name", containerName,
"-v", r.buildVolume + ":" + r.buildDir,
"-w", workdir,
}
for _, name := range secretNames {
args = append(args, "-e", name)
}
args = append(args, image, "sh", "-c", command)
cmd = exec.CommandContext(ctx, "docker", args...)
// On timeout the docker client is killed but the container may linger;
// force-remove it best-effort so it doesn't leak.
defer exec.Command("docker", "rm", "-f", containerName).Run()Details that earn their keep: these are sibling containers via the host Docker socket (not Docker-in-Docker); the socket and the repo storage are deliberately not mounted into step containers; secrets pass by -e NAME so values never appear in docker inspect or the process list (more on secrets); and every step runs under a context timeout (default 30 minutes) with best-effort container cleanup on the way out.
Jobs and steps currently run sequentially - a failing step skips the rest of its job, and the run is marked failed. There is no needs: graph yet; more on that below.
How do artifacts work?
After each job - success or failure, because failure artifacts are the ones you actually want - kobeni walks the workdir against the job's glob patterns (dist/** style, skipping .git), zips the matches in memory with archive/zip, and POSTs the zip to makima, which writes it to disk and records the metadata. Downloads stream straight from the API. No object storage yet; a small team's artifacts fit on a disk, and the interface won't change when they don't.
What's honestly not there yet?
The gaps, plainly, because a 500-line CI runner is 500 lines because of what it doesn't do:
- No dependency graph. The YAML has no
needs:; jobs run in file order. Parallel fan-out is the next real feature, and the claim mechanism already supports the concurrency it will need. - No retries. A failed step fails the run, permanently. Retry-with-backoff belongs in the platform, not in everyone's scripts - it's on the list.
- No lease heartbeat. If a runner dies mid-run, the run stays
runninguntil someone notices. The fix is a heartbeat column and a reaper that returns stale claims topending- the same optimistic-UPDATE trick, pointed backward. (We've since specified that reaper's exact shape for agent tasks; the CI version is the same query against different columns.) - Push triggers only.
on: pushwith branch filters; no PR, tag, schedule, or manual triggers yet.
We'd rather ship the honest minimum and grow it against our own usage than clone a decade of another platform's YAML on day one.
FAQ
Why use Postgres as a CI job queue?
Because the control plane already has Postgres, and a status column with an atomic conditional UPDATE gives exactly-once claiming, visibility (the queue is queryable), and crash-consistent state - with zero new infrastructure. Dedicated brokers earn their place at throughput levels a team-scale CI system doesn't reach.
Why one container per step instead of per job?
Stronger isolation and simpler reasoning: a step can't leak processes, environment mutations, or background daemons into the next step - only the shared build directory persists, which is the contract. The cost is container startup per step, which is small next to real build work.
Is it really only ~500 lines?
The runner is - one main.go, standard library only. It leans on makima for YAML parsing, state, and secrets (the API side is TypeScript), on git for checkout, and on Docker for isolation. The line count isn't the point; the small trusted surface is.