cmd / cibot
cibot is a git hosting,
Continuous Integration,
and code review tool for private git repos.
It replaces GitHub Pull Requests and GitHub Actions.
I use GitHub as a mirror of main for history, disaster recovery,
and integrations such as 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. I run one farmer: it serializes merges, holds live job output in memory, and stores the repos on its disk.
The worker long polls the farmer for jobs.
When it receives a job, it checks out that job's tree
from the farmer's git,
finds the Checkfile in the job's directory,
and runs the matching command.
It reports the result (success, failure, error) to the farmer,
which shows it on the change.
I 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. More workers give more parallelism.
Self-hosted git
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.
A personal token authenticates each request.
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 ps.
The command hands the token to git's credential helper,
scoped to the farmer's host.
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.
Push
The hook POSTs the refs moved by the push, 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.
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.
A worker runs the command in /bin/bash -eo pipefail.
Comments start with #. The parser ignores blank lines.
A Checkfile at the root of the repo runs on every push.
A Checkfile in a subdirectory runs when files in that directory change.
Checks that need a running service
A check is a shell command,
so it can start what it needs.
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 given arguments against it:
tests: with-serverd ./test.sh
The tests make real HTTP requests, and the server logs stay in the run output when a test fails.
Wrappers compose.
I test the client SDKs for backwards compatibility with with-go-sdk,
which 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
that points at a sibling clone.
Every push tests the versions customers run
and the version about to ship.
Speed
Hosted CI services often start checks 30-60 seconds after a push, because of multi-tenant queues and container cache misses.
cibot workers run on dedicated hosts
with all dependencies installed,
and share Go's build and module caches on local disk.
Jobs begin within 1 second of a push.
Job scheduling
Postgres triggers assign jobs to workers.
When a new job appears or a worker becomes available,
a resolve trigger finds an unassigned job
and an idle worker, then inserts an assignment into the run table.
Workers ping the farmer every second. The farmer deletes a worker that stops pinging. Foreign keys cascade the delete to its assignment, which returns the job to the queue.
Changes
A change is cibot's review unit.
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 show [ID] --wait # the same, once its checks finish
cibot check [ID] <name> # one check's output, on stdout
cibot open [ID] # one change, in a browser
cibot checkout [ID] # worktree for a change; prints its path
cibot edit [ID] # title/description in $EDITOR
cibot comment [ID] # body on stdin
cibot comment edit <N> # body on stdin, or $EDITOR
cibot comment delete <N> # retract your own comment
cibot close [ID] # close w/o merge
cibot merge [ID] # squash-merge into the base branch
The id defaults to the current branch.
The commands find the farmer, the repo, and the token
from the clone's origin remote and git's credential helper.
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>.
cibot checkout also sets the worktree up.
It symlinks an untracked .env from the main clone into the worktree.
For a repo with a cmd/db it runs go run ./cmd/db newdb,
which gives the worktree its own development database
(see postgres / dev test clusters).
It reports both, and neither is fatal.
The first line reports the worktree:
worktree created APP-42 (/Users/me/.worktrees/app/APP-42)
The output prints reused where the worktree was already on disk.
With an id the command is idempotent.
A change pushed with a single commit takes that commit's subject and body as its title and description. A later push never overwrites them.
Review
I review 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
A review is one comment, with no threading and no approval state.
Checks gate merges.
A comment records the reviewed commit SHA to show drift from HEAD.
The web UI is read-only and updates live through Postgres NOTIFY
(see go / wakeups and
web / live regions).
Merge
cibot merge tells the farmer to squash-merge the change onto main.
The farmer verifies that:
- The title matches the subject convention.
- The change has no conflicts with
main. - All checks on the head commit passed.
git push && cibot show --wait && cibot merge
cibot show --wait blocks until the checks finish.
The farmer writes a squash commit with Co-Authored-By and Reviewed-by trailers,
updates main, and pushes to the GitHub mirror.
Deploy tracking
cibot records a deploy per service to show which commit is live:
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 when its merge commit is an ancestor of the service's SHA
(git merge-base --is-ancestor).
Login and audit
WorkOS SSO with Azure AD authenticates users. WorkOS audit logs record logins, token lifecycle, and merges.
Output
Postgres stores check output as plain text for 30 days.
cibot check APP-42 sdk/go/tests
cibot check golint # id from the current branch
A check reports failed on non-zero exit or errored on worker setup failure.
Output prints to stdout.
Open source mirrors
I develop hml, highlight, and
is on cibot
and mirror them to GitHub, which go get resolves.
GitHub receives main and the tags.
cibot never mirrors change branches,
so I close pull requests there.
Each README says so.