cmd / cibot
cibot is a git hosting,
Continuous Integration,
and code review tool for private git repos.
It replaces tools like GitHub Pull Requests and GitHub Actions.
I do use GitHub as a mirror;
it holds main for history, disaster recovery, and integrations with tools like
Render.
Architecture
cibot has two entrypoints:
cibot farmer
cibot worker
The farmer is an HTTP server backed by Postgres. It owns the bare git repos, serves the web dashboard and the git transport, creates test jobs from pushes, and merges changes. Run exactly one: it serializes merges, holds live job output in memory, and the repos are on its disk.
The worker long polls the farmer for jobs.
When it receives one, it materializes that job's tree
from the farmer's git,
finds the Checkfile in the job's directory,
and runs the matching command.
It reports results (success, failure, error) back to the farmer,
which shows them on the change.
I self-host the farmer and workers on Ubicloud VMs: the farmer on a small VM with a managed Postgres database, and the workers on a larger VM that shares a private subnet with the farmer. Add parallelism by adding workers.
Self-hosted git
In-development code lives in bare repos on the farmer VM,
one per repo under /srv/git/<name>.git.
One git http-backend serves all of them behind
Caddy for TLS.
Each request is authenticated by a personal token:
Caddy forwards the auth decision to the farmer,
which validates the token before the CGI runs.
Authenticate once per machine:
cibot git setup https://cibot.example.com/git
The token goes on stdin,
so it stays out of shell history and out of ps.
It is handed to whatever credential helper git already uses,
scoped to the farmer's host,
leaving github.com credentials alone.
Then clone from the farmer:
git clone https://cibot.example.com/git/app.git
The farmer is origin, and the only remote.
A pre-receive hook rejects direct writes to main.
A post-receive hook reports each push to the farmer.
The push is the trigger
There is no webhook and no polling. The hook POSTs the refs a push moved, and the farmer:
- Reads the changed files out of the bare repo (
git diff,git show) - Collects the directories containing those files
- Walks up each directory to its parents
- Reads
Checkfiles at each level - Creates a job per entry
A change to sdk/go/account.go
runs Checkfiles in sdk/go/, sdk/, and /.
Added, removed, renamed, and modified files all count,
so deleting a file runs its directory's tests.
Checkfile
A Checkfile defines test jobs for its directory.
Each line contains a name and command separated by a colon:
gotest: go test -race -cover ./...
lint: goimports -l . | grep . && exit 1 || true
The name appears as a check on the change page.
The command is run by a worker in /bin/bash -eo pipefail.
Comments start with #. Blank lines are ignored.
Place a Checkfile at the root of the repo
to run jobs on every push.
Place Checkfiles in subdirectories
to run jobs only when files in those directories change.
Checks that need a running service
A check is a shell command,
so it can bring up what it needs
instead of mocking it.
One repo has a with-serverd script on $PATH that
installs the server binary,
migrates a database,
creates a team and a credential,
starts serverd serve,
and runs the arguments it was given against it:
tests: with-serverd ./test.sh
The tests make real HTTP requests, so the server's behavior is not described a second time in a stub, and its logs are part of the run's output when something fails.
Wrappers compose,
which is how the client SDKs were tested for backwards compatibility.
with-go-sdk takes a version
and runs its command against that version:
gohead: with-serverd with-go-sdk head go test ./...
gov1: with-serverd with-go-sdk 1.5 go test ./...
gov2: with-serverd with-go-sdk 2 go test ./...
A number is a release from the registry.
head is the working copy,
which in a separate repo is a replace directive
pointing at a sibling clone.
Every push then tests the versions customers are running
alongside the one about to ship,
and the same wrappers reproduce a bug report
on the reporter's version locally.
Why it's fast
Hosted CI services often start checks 30-60 seconds after a push. Common causes are multi-tenant queues (noisy neighbors) and containerization (cache misses on every run).
cibot workers run on dedicated hosts
with all dependencies pre-installed,
and share Go's build and module caches on local disk.
The repo is already on the box.
Jobs typically begin within 1 second of a push.
Job scheduling
Postgres triggers assign jobs to workers.
When new jobs appear or workers become available,
a resolve trigger finds an unassigned job
and an idle worker, then inserts an assignment into the run table.
Foreign keys cascade deletes:
if a worker stops pinging, its assignment is removed
and the job returns to the queue.
Workers ping the farmer every second. A worker that stops pinging is garbage collected, and its in-flight job is re-run rather than lost.
Changes
A change is cibot's review unit,
what a pull request used to be.
It has an id like APP-42,
which is also its branch name
and its worktree directory name.
cibot list [--repo R] [--all] # open changes, recent activity first
cibot show [ID] # one change, and its checks
cibot checkout [ID] # worktree for a change; prints its path
cibot edit [ID] # title/description in $EDITOR
cibot comment [ID] # body on stdin
cibot approve [ID] # optional body on stdin
cibot close [ID] # close w/o merge
cibot merge [ID] [--force R] # squash-merge into the base branch
The id defaults to the current branch, because branches are change ids.
The commands find the farmer, the repo, and the token
from the clone's origin remote and git's credential helper,
so there is nothing else to configure.
cibot checkout with no id allocates a change.
The farmer takes the next number,
writes the branch in the bare repo at main,
and the CLI cuts a worktree at ~/.worktrees/<repo>/<ID>.
Naming happens once, by the farmer,
which is one fewer decision at the start of work.
A change pushed at a single commit takes that commit's subject and body as its title and description. A later push never overwrites them.
Review
Reviews happen through an agent in the terminal:
cibot show APP-42
git fetch origin && git diff origin/main...origin/APP-42
echo "the retry loop needs a ceiling" | cibot comment APP-42
cibot approve APP-42
One comment per review, no threading, no inline anchors.
Nothing prints a diff,
because the farmer is origin
and git diff reads better than anything an API could serialize.
The web dashboard renders the same change read-only,
with the diff, the checks, the commits, and the approvals.
The diff is colored by highlight.
Every write is a CLI verb.
The page refreshes itself:
a write fires a Postgres NOTIFY,
and each section re-fetches at its own URL with an ETag,
so a section that has not changed is left alone.
Merge
cibot merge asks the farmer to squash the change onto main.
It is the farmer's act, not the CLI's:
the farmer owns the bare repo and the mirror credential,
and it is the singleton that can serialize two merges.
The farmer refuses a change that is untitled,
whose title breaks the subject convention,
that conflicts with main,
or whose checks are not green.
Green means every check on the head being merged
has a passing latest run.
A head whose Checkfiles have not been read yet
is refused rather than assumed empty,
which is what stops a script that pushes and merges in one breath.
The gate lives in the farmer, not the CLI. Any token holder can POST the merge endpoint, so a check the client performs is a suggestion.
The subject convention is a regexp on /settings,
farmer-wide because the convention is the team's,
with the conventions themselves in prose beside it.
Both, because a regexp says what is wrong
and not what to write instead,
and the person reading the refusal
is the one who does not know the rule.
See git / workflow
for the convention itself.
This is the only place a subject can be argued with:
no check can read it,
since the title is not in the tree,
and the push that would run a check
comes before cibot edit.
CI itself breaks, so there is an override:
cibot merge APP-42 --force "worker VM is down"
The reason is the flag's value,
so --force cannot be typed without one,
and it is posted on the change as a comment.
It covers a check a worker never ran,
not the subject,
which is always fixable with cibot edit.
The squash commit's message is the change's title and description,
plus a Co-Authored-By trailer per commit author
(which is how an agent's work stays attributed through a squash)
and a Reviewed-by trailer per commenter.
The farmer then pushes that commit to the GitHub mirror,
which is what Render deploys,
and rolls main back if that push fails.
Deploy tracking
cibot records deploys, so "is APP-42 in production?" is a query rather than a guess. It does not run them. Whatever ships the code POSTs a row as each service goes live.
One POST per service, rather than one at the end, so a failure partway through still records what shipped.
The deploying tool needs no secret of its own.
The API and the git transport validate the same personal token,
so a script on a laptop asks git for the one
cibot git setup stored in the credential helper.
The row's deployer is the token holder,
which attributes a deploy to the person who ran it.
That token also pushes and merges,
so CI should hold a service token instead.
cibot deploy live [--repo R] [--json] # per service: sha, and the changes in it
cibot deploy list [--repo R] [--json] # deploys, newest first
A change is live on a service when its merge sha
is an ancestor of that service's live sha.
The farmer holds the bare repo,
so that is a local git merge-base --is-ancestor.
Ancestry handles the awkward cases without extra modelling:
a batch shipped together are all ancestors of one sha,
and a rollback moves the live sha backwards,
so the changes above it correctly stop reading as live.
--json is there because the caller is usually an agent.
Login and audit
WorkOS SSO over Azure AD is the only way into the dashboard. Azure AD authenticates every employee, so a separate flag says which of them have cibot: a first login writes an inactive account row, and an operator activates it. One flag covers the session and the git token, so deactivating someone takes both on their next request.
The audit log lives at WorkOS and nowhere else: logins, accounts activated and deactivated, tokens issued and revoked, approvals, and merges. An operator with shell on the farmer can edit its database but not that copy, which is what makes a bypass tamper-evident.
Output
Output is plain text: whatever the command printed to stdout and stderr. Completed output is stored in Postgres for 30 days. Each check on a change page links to its run, with cancel and retry buttons.
Plain text is deliberate. When a check fails, copy the output and paste it to an agent to review and fix.
Open source mirrors
hml, highlight, and
is develop on cibot
and are mirrored to GitHub,
which is what a go get resolves.
GitHub receives main and the tags.
Change branches are never mirrored,
and pull requests there are closed,
because there is nothing on GitHub to merge into.
Each README says so.