Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

396 commit(s) on main

Turn off the disk reserve for test-spawned runners Keenan Nemetz · 58m ago c284cb2c
source preview history blame raw 10.1 KiB

Architecture and design

This page is for people who want to understand how Vole works and why it makes the choices it does. If you only want to use Vole, the guides are enough.

The goal is not novelty. The goal is a forge that stays understandable, works on hardware you control, and keeps the important parts in Git.

Vole is built for one team on one server. It does not try to be a hosted service, and it does not connect to other Vole instances. That keeps the design small.

Trust: a credential resolves to an account

Vole uses one rule everywhere:

A credential proves which account you are. Vole checks every change before accepting it, and records the decision in data/audit.log.

There are two credentials, and they resolve to the same account: the name fixed at creation. Contributor branches and metadata authors are written with it, and access rules, approval files, locks and CI records are keyed by it.

  • an SSH key for Git over SSH, administration, and CI;
  • a passkey for browser sessions.

There is no password database. The browser performs a WebAuthn ceremony, Vole stores only the credential’s public half, and the resulting session is an opaque token whose hash alone is in users.db.

There are two ways to write metadata, and they offer the same account the same actions: a browser session, where each form asks for one capability, and a push, where pre-receive asks the same capabilities path by path. Both are built on the same metadata writers, so the file format and its validation do not fork. An untrusted contributor’s Git writes are confined to refs/heads/contrib/<name>/, and the vole branch it may also write is checked the same way the browser’s forms are; CI for a contributor branch does not run until a maintainer allows the exact tip. A change request opened from a branch follows it: after an accepted push, the server records the new tip as the request’s head, so approvals of the old tip stop counting. Everything else still changes by pushing over SSH. Approvals, comments, and CI results belong to the account the credential resolved to, never to a name typed into a form.

Vole does not require or check Git commit signatures. A signature would only repeat what the credential already established, and it cannot reliably prove who authored a commit. Vole would rather keep one clear path than add a second one that can disagree with the first.

What that means

On the server, Vole can show a complete history of who pushed what. A clone of a repository, by itself, cannot prove who pushed a commit. The instance promises a complete record; it does not promise offline proof.

That trade-off is wrong if you plan to mirror the repository somewhere controlled by someone else. For a team running its own forge, it keeps the system simple and auditable.

Where data lives

Data Where it lives Rebuildable?
Repositories data/repos/<name>.git It is the source
Issues, requests, approvals, release pointers The vole branch in each repository It is the source
CI records: finished attempts, steps, artifact pointers, and who reported them data/runs.db It is the source; back it up
Accounts, instance roles, SSH keys, passkeys, enrollment links, sessions, access rules, locks, page-view counters data/users.db No; back it up
Web/search index: issues, change requests, comments, state changes, activity events, default-branch summaries (head, languages, commits, authors), tags, release artifacts, and the [#42](/vole/n/42) alias numbering data/index.db Yes, with vole admin index rebuild
CI queue, in-progress runs, contributor CI approvals data/runs.db Partly; in-progress work and approvals are lost
CI logs data/logs/ No; one file per attempt, back it up
LFS files, build artifacts data/lfs/ No; stored by content
Privileged-action history data/audit.log It is the record; a privileged change that cannot be written there is refused
Repository secrets data/secrets.db, sealed under the key in data/secrets.key No; back up both, and keep the key apart from the database
SSH host key data/ssh_host_ed25519_key No; it is the secret

Git holds the repository history. SQLite holds the account database, the CI queue and records, and generated indexes. Anything that can be rebuilt comes from Git or stored files. Anything that cannot be rebuilt is small and easy to back up.

The web index is derived from each repository’s vole branch. Post-receive applies a push’s delta as the push lands, so a page does not pay for the work the push already did. Every read still checks the branch it was built from — reading the loose ref, which is a file, rather than starting a process — and repairs the rows if a hook did not run. vole admin index rebuild is the last resort.

Writers of that branch take turns. A write reads the branch, decides, and moves the ref only if nobody else moved it first, so two people commenting at the same time cannot lose either comment; the price of that safety is that a writer which loses the race reads the branch again. Under load that price is the whole cost, so writers of one repository queue behind each other instead: the read, the decision, and the ref move happen while one writer holds the branch, and the compare-and-swap is left for a push or a hand-run git command that could not wait.

A push only queues behind those writers when it has metadata to move. Most do not: a branch becomes interesting to a change request when one is opened from it, and post-receive asks the index which open requests follow the branch before it reads the metadata branch at all. A push to a branch nobody is proposing from therefore writes an audit line and nothing else.

The vole branch contains:

issues/<ulid>/issue.md
issues/<ulid>/comments/<ulid>.md
changes/<ulid>/change.md
changes/<ulid>/comments/<ulid>.md
changes/<ulid>/approvals/<account-name>.md
releases/<tag>/<artifact>

People write the issue and request files. Vole moves the head of a change request that follows a branch: post-receive derives the new head from the accepted ref and validates the request tree before committing it. A tagged build that publishes release artifacts writes those pointers the same way. Everything that is pushed is checked at the boundary.

Run records are not in Git. A runner sends its result over the authenticated SSH channel that leased the job, and the server stores it in data/runs.db; a clone or backup of a repository does not carry CI history.

URL layout

A repository owns the root of the URL space. /<name> is its summary page, /<name>/issues and the rest are its tabs, and /<name>.git is the Git transport a clone uses. There is no owner segment: repository names are unique on the instance, so one name is enough.

Every page the forge itself serves is under /-/: /-/admin, /-/u/<account>, /-/activity, /-/login, and so on. A repository name can never begin with -, so the two spaces cannot collide and no route has to know the repository names. The four names the forge keeps at the root are the ones a URL cannot move: static for assets, and robots.txt, sitemap.xml, sitemap-pages.xml because crawlers only look there. Repository creation refuses those names.

/<name>.git is recognized before the router, because a wildcard in a route fills a whole path segment and cannot match <name>.git. Keeping the suffix on the transport is what lets the page (/<name>, which is also the Go module path) and the clone URL share a name.

Why these choices

Choice Reason
Use the Git command-line tool Full push behavior and hooks without reimplementing Git
SSH for Git writes, passkeys for the browser Each transport gets the credential it can actually use, and both resolve to one account
Check everything before accepting a change The push boundary and the narrow web writers share the same metadata validation
Store metadata in Git, rules in SQLite Repository content travels with clones; local settings stay local
Passkeys, not passwords No shared secret to phish, reset, or store; recovery is an instance-admin-issued enrollment link
One program with separate serve and runner modes Easy to deploy anywhere
Two long-term secrets: the host key, and the key that seals repository secrets A deploy key has to live somewhere, and a value in Git is a value in every clone

Using Vole as a library

Vole is also a Go library. You can build tools that talk to an instance or embed parts of Vole without running the vole command.

Package What it does
client Talks to an instance: metadata writes, identity lookups, file transfer, reviews, and site deploys
core/id, core/git, core/lfs Shared primitives and storage
forge Instance setup and administration
forge/config, forge/identity, forge/access, forge/repo, forge/index, forge/session, forge/secret Settings, accounts, permissions, repositories, search, browser sessions, and each repository’s sealed secrets
metadata Issues, requests, releases, comments, approvals, and validation
ci Workflow validation and the job queue
ci/agent The embeddable runner: leasing, execution, and reporting
ci/container The container a job runs in, shared by the runner and vole workflow run
ci/cache The per-repository job cache: namespaces, environment, and eviction
server Network listeners and web pages, with adapters for SSH, Git HTTP, sites, and hooks

cmd/vole is only the command-line front end. Logic shared by more than one caller lives in the packages above.

Scope

Vole is built for one team on one server. It does not support multiple tenants, federation, custom domains with automatic certificates, horizontal scaling, or a Kubernetes deployment story. These are not part of Vole today.

CI isolation is a runner responsibility: every job names its OCI image in the workflow, or references a definition under .vole/images, and the runner executes it in a container. The runner pulls or builds the image and owns the container runtime; the server only pins the image and definition and records what ran.