cmd / sockeye
sockeye is a git hosting,
Continuous Integration,
and code review tool for private git repos.
It was called cibot until October 2026.
It replaces GitHub Pull Requests and GitHub Actions.
A repo that needs GitHub, for go get or for a deploy platform such as
Render, gets a mirror of main there.
Every other repo backs up to object storage.
Architecture
One binary, soc, has two entrypoints:
soc server
soc runner
The server is an HTTP server backed by one SQLite file. 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 serving server: it serializes merges, holds live job output in memory, and stores the repos on its disk. A deploy boots the new build in an idle slot and flips traffic to it.
The runner long polls the server for jobs.
When it receives a job, it checks out that job's tree
from the server's git,
finds the Checkfile in the job's directory,
and runs the matching command.
It reports the result (success, failure, error) to the server,
which shows it on the change.
I host the server and the runners on Ubicloud VMs: the server on a small VM, and the runners on a larger VM. More runners give more parallelism.
Self-hosted git
Code lives in bare repos on the server 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 server,
which validates the token before the CGI runs.
Name the server once per machine, then log in:
soc git setup https://sockeye.example.com/git
soc login
soc login signs a challenge with a key from my ssh-agent
and trades the signature for a token.
The token lives seven days,
so each machine logs in again the next week.
git's credential helper holds it,
scoped to the server's host.
soc login also prints a five-minute link
that signs my browser in to the dashboard.
Then clone from the server:
git clone https://sockeye.example.com/git/app.git
The server is origin, and the only remote.
A pre-receive hook rejects direct writes to main.
A post-receive hook reports each push to the server.
Push
The hook POSTs the refs moved by the push, and the server:
- 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: test -z "$(go tool goimports -l .)"
The name appears as a check on the change page.
A runner 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.
A runner has the toolchain every repo needs, but not every Go tool.
A repo pins its own goimports and deadcode,
so a check runs the version the repo chose
(see go / checks).
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.
sockeye runners run on dedicated hosts
with the toolchain installed,
and share Go's build and module caches on local disk.
Jobs begin within 1 second of a push.
Job scheduling
A write that can move a job or a runner, such as a push, a finished job, or a new runner, calls one loop on the server. In a SQLite transaction, the loop pairs an unassigned job with an idle runner. It repeats until no job or no runner is left.
Runners ping the server. The server drops a runner that stops pinging for 30 seconds, which returns its job to the queue.
Changes
A change is sockeye's review unit.
It has an id like APP-42,
which is also its branch name
and its worktree directory name.
soc list [--repo R] [--all] # open changes, recent activity first
soc show [ID] # one change, and its checks
soc show [ID] --wait # the same, once its checks finish
soc check [ID] <name> # one check's output, on stdout
soc open [ID] # one change, in a browser
soc checkout [ID] # worktree for a change; prints its path
soc edit [ID] # title/description on stdin, or $EDITOR
soc comment [ID] # body on stdin, or $EDITOR
soc comment edit <N> # body on stdin, or $EDITOR
soc comment delete <N> # retract your own comment
soc close [ID] # close w/o merge
soc merge [ID] # squash-merge into the base branch
The id defaults to the current branch.
The commands find the server, the repo, and the token
from the clone's origin remote and git's credential helper.
soc checkout with no id allocates a change.
The server takes the next number,
writes the branch in the bare repo at main,
and the CLI cuts a worktree at ~/.worktrees/<repo>/<ID>.
soc checkout also sets the worktree up.
It symlinks an untracked .env from the main clone into the worktree.
Then it runs the command the clone's tree.setup git config key names:
git config tree.setup bin/tree-setup
One repo uses that command
to give the worktree its own development database
(see postgres / dev test clusters).
The key is config rather than a tracked hook,
so checking out somebody else's change cannot run their branch.
soc checkout reports both steps, 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:
soc show APP-42
git fetch origin && git diff origin/main...origin/APP-42
echo "the retry loop needs a ceiling" | soc comment APP-42
A review is one comment, with no threading and no approval state. Checks gate merges. The server refuses a reviewer's comment when the reviewer's commit is behind the change's head. The web UI is read-only. A write wakes every open page, and each live section of the page fetches itself again (see go / wakeups and web / live regions).
Merge
soc merge tells the server to squash-merge the change onto main.
The server verifies that:
- The change has a title, and the title matches the subject convention.
- The change has no conflicts with
main. - All checks on the head commit passed.
git push && soc show --wait && soc merge
soc show --wait blocks until the checks finish.
The server writes a squash commit with Co-Authored-By and Reviewed-by trailers,
updates main, and backs the commit up.
A repo with a GitHub mirror gets the commit pushed there.
Every other repo gets a git bundle of the commit in object storage.
A merge whose backup fails leaves main where it was.
A repo can also deploy on merge.
A hook on the server takes the merged SHA and ships it,
and soc merge prints the hook's output.
Deploy tracking
sockeye records a deploy per service to show which commit is live:
soc deploy live [--repo R] # per service: sha, and the changes in it
soc deploy list [--repo R] # 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 on a server that has it. A server without it prints a five-minute login link for an operator on the VM, and every browser session also asks for an authenticator code.
A runner mints its own token the same way a laptop does. It signs a challenge with an SSH key on the runner VM, and the server gives the runner's service account a token that reads every repo and writes none. The runner mints again when the token is six days old. I used to paste a new runner token every week, and a missed week stopped every check with a git auth error.
I list and revoke tokens from the terminal:
soc token list [--all]
soc token revoke <id>
The server's journal is the audit log. It records logins, account changes, token lifecycle, and merges.
Several instances
I run one instance for personal projects and one for work. They share no VM, database, or key, so a machine with access to one cannot reach the other.
The CLI asks the current clone's origin for its server,
so a command in a clone reaches the right instance.
Outside a clone, SOCKEYE_URL names the server:
SOCKEYE_URL=https://sockeye.example.com soc login
Each instance labels its authenticator entry with its own host, so the two entries in my phone stay apart.
One instance builds sockeye, and a merge there deploys that instance. I upgrade the other instance by hand with a script that migrates the database before it installs the binary.
Output
SQLite stores check output as plain text for 30 days.
soc check APP-42 sdk/go/tests
soc check golint # id from the current branch
A check reports failed on non-zero exit or errored on runner setup failure.
Output prints to stdout.
Open source mirrors
I develop hml, highlight, and
is on sockeye
and mirror them to GitHub, which go get resolves.
GitHub receives main and the tags.
sockeye never mirrors change branches,
so I close pull requests there.
Each README says so.