vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 23m ago
4b8c9e1d
| 1 | # Architecture and design |
| 2 | |
| 3 | This page is for people who want to understand how Vole works and why it makes |
| 4 | the choices it does. If you only want to use Vole, the guides are enough. |
| 5 | |
| 6 | The goal is not novelty. The goal is a forge that stays understandable, works on |
| 7 | hardware you control, and keeps the important parts in Git. |
| 8 | |
| 9 | Vole is built for one team on one server. It does not try to be a hosted service, |
| 10 | and it does not connect to other Vole instances. That keeps the design small. |
| 11 | |
| 12 | ## Trust: a credential resolves to an account |
| 13 | |
| 14 | Vole uses one rule everywhere: |
| 15 | |
| 16 | > A credential proves which account you are. Vole checks every change before |
| 17 | > accepting it, and records the decision in `data/audit.log`. |
| 18 | |
| 19 | There are two credentials, and they resolve to the same account: the **name** |
| 20 | fixed at creation. Contributor branches and metadata authors are written with it, |
| 21 | and access rules, approval files, locks and CI records are keyed by it. |
| 22 | |
| 23 | - an **SSH key** for Git over SSH, administration, and CI; |
| 24 | - a **passkey** for browser sessions. |
| 25 | |
| 26 | There is no password database. The browser performs a WebAuthn ceremony, Vole |
| 27 | stores only the credential's public half, and the resulting session is an opaque |
| 28 | token whose hash alone is in `users.db`. |
| 29 | |
| 30 | There are two ways to write metadata, and they offer the same account the same |
| 31 | actions: a browser session, where each form asks for one capability, and a push, |
| 32 | where pre-receive asks the same capabilities path by path. Both are built on the |
| 33 | same metadata writers, so the file format and its validation do not fork. An |
| 34 | untrusted contributor's Git writes are confined to `refs/heads/contrib/<name>/`, |
| 35 | and the `vole` branch it may also write is checked the same way the browser's |
| 36 | forms are; CI for a contributor branch does not run until a maintainer allows the |
| 37 | exact tip. A change request opened from a |
| 38 | branch follows it: after an accepted push, the server records the new tip as the |
| 39 | request's head, so approvals of the old tip stop counting. Everything else still |
| 40 | changes by pushing over SSH. Approvals, comments, and CI results belong to the |
| 41 | account the credential resolved to, never to a name typed into a form. |
| 42 | |
| 43 | Vole does not require or check Git commit signatures. A signature would only |
| 44 | repeat what the credential already established, and it cannot reliably prove who |
| 45 | authored a commit. Vole would rather keep one clear path than add a second one |
| 46 | that can disagree with the first. |
| 47 | |
| 48 | ## What that means |
| 49 | |
| 50 | On the server, Vole can show a complete history of who pushed what. A clone of a |
| 51 | repository, by itself, cannot prove who pushed a commit. The instance promises a |
| 52 | complete record; it does not promise offline proof. |
| 53 | |
| 54 | That trade-off is wrong if you plan to mirror the repository somewhere controlled |
| 55 | by someone else. For a team running its own forge, it keeps the system simple and |
| 56 | auditable. |
| 57 | |
| 58 | ## Where data lives |
| 59 | |
| 60 | | Data | Where it lives | Rebuildable? | |
| 61 | |---|---|---| |
| 62 | | Repositories | `data/repos/<name>.git` | It is the source | |
| 63 | | Issues, requests, approvals, release pointers | The `vole` branch in each repository | It is the source | |
| 64 | | CI records: finished attempts, steps, artifact pointers, and who reported them | `data/runs.db` | It is the source; back it up | |
| 65 | | Accounts, instance roles, SSH keys, passkeys, enrollment links, sessions, access rules, locks, page-view counters | `data/users.db` | No; back it up | |
| 66 | | Web/search index: issues, change requests, comments, state changes, activity events, default-branch summaries (head, languages, commits, authors), tags, release artifacts, and the `#42` alias numbering | `data/index.db` | Yes, with `vole admin index rebuild` | |
| 67 | | CI queue, in-progress runs, contributor CI approvals | `data/runs.db` | Partly; in-progress work and approvals are lost | |
| 68 | | CI logs | `data/logs/` | No; one file per attempt, back it up | |
| 69 | | LFS files, build artifacts | `data/lfs/` | No; stored by content | |
| 70 | | Privileged-action history | `data/audit.log` | It is the record; a privileged change that cannot be written there is refused | |
| 71 | | 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 | |
| 72 | | SSH host key | `data/ssh_host_ed25519_key` | No; it is the secret | |
| 73 | |
| 74 | Git holds the repository history. SQLite holds the account database, the CI queue |
| 75 | and records, and generated indexes. Anything that can be rebuilt comes from Git or |
| 76 | stored files. Anything that cannot be rebuilt is small and easy to back up. |
| 77 | |
| 78 | The web index is derived from each repository's `vole` branch. Post-receive applies |
| 79 | a push's delta as the push lands, so a page does not pay for the work the push |
| 80 | already did. Every read still checks the branch it was built from — reading the |
| 81 | loose ref, which is a file, rather than starting a process — and repairs the rows |
| 82 | if a hook did not run. `vole admin index rebuild` is the last resort. |
| 83 | |
| 84 | Writers of that branch take turns. A write reads the branch, decides, and moves the |
| 85 | ref only if nobody else moved it first, so two people commenting at the same time |
| 86 | cannot lose either comment; the price of that safety is that a writer which loses |
| 87 | the race reads the branch again. Under load that price is the whole cost, so writers |
| 88 | of one repository queue behind each other instead: the read, the decision, and the |
| 89 | ref move happen while one writer holds the branch, and the compare-and-swap is left |
| 90 | for a push or a hand-run git command that could not wait. |
| 91 | |
| 92 | A push only queues behind those writers when it has metadata to move. Most do not: |
| 93 | a branch becomes interesting to a change request when one is opened from it, and |
| 94 | post-receive asks the index which open requests follow the branch before it reads |
| 95 | the metadata branch at all. A push to a branch nobody is proposing from therefore |
| 96 | writes an audit line and nothing else. |
| 97 | |
| 98 | The `vole` branch contains: |
| 99 | |
| 100 | ```text |
| 101 | issues/<ulid>/issue.md |
| 102 | issues/<ulid>/comments/<ulid>.md |
| 103 | changes/<ulid>/change.md |
| 104 | changes/<ulid>/comments/<ulid>.md |
| 105 | changes/<ulid>/approvals/<account-name>.md |
| 106 | releases/<tag>/<artifact> |
| 107 | ``` |
| 108 | |
| 109 | People write the issue and request files. Vole moves the head of a change request |
| 110 | that follows a branch: post-receive derives the new head from the accepted ref and |
| 111 | validates the request tree before committing it. A tagged build that publishes |
| 112 | release artifacts writes those pointers the same way. Everything that is pushed is |
| 113 | checked at the boundary. |
| 114 | |
| 115 | Run records are not in Git. A runner sends its result over the authenticated SSH |
| 116 | channel that leased the job, and the server stores it in `data/runs.db`; a clone or |
| 117 | backup of a repository does not carry CI history. |
| 118 | |
| 119 | ## URL layout |
| 120 | |
| 121 | A repository owns the root of the URL space. `/<name>` is its summary page, |
| 122 | `/<name>/issues` and the rest are its tabs, and `/<name>.git` is the Git |
| 123 | transport a clone uses. There is no owner segment: repository names are unique |
| 124 | on the instance, so one name is enough. |
| 125 | |
| 126 | Every page the forge itself serves is under `/-/`: `/-/admin`, `/-/u/<account>`, |
| 127 | `/-/activity`, `/-/login`, and so on. A repository name can never begin with `-`, |
| 128 | so the two spaces cannot collide and no route has to know the repository names. |
| 129 | The four names the forge keeps at the root are the ones a URL cannot move: |
| 130 | `static` for assets, and `robots.txt`, `sitemap.xml`, `sitemap-pages.xml` because |
| 131 | crawlers only look there. Repository creation refuses those names. |
| 132 | |
| 133 | `/<name>.git` is recognized before the router, because a wildcard in a route |
| 134 | fills a whole path segment and cannot match `<name>.git`. Keeping the suffix on |
| 135 | the transport is what lets the page (`/<name>`, which is also the Go module path) |
| 136 | and the clone URL share a name. |
| 137 | |
| 138 | ## Why these choices |
| 139 | |
| 140 | | Choice | Reason | |
| 141 | |---|---| |
| 142 | | Use the Git command-line tool | Full push behavior and hooks without reimplementing Git | |
| 143 | | SSH for Git writes, passkeys for the browser | Each transport gets the credential it can actually use, and both resolve to one account | |
| 144 | | Check everything before accepting a change | The push boundary and the narrow web writers share the same metadata validation | |
| 145 | | Store metadata in Git, rules in SQLite | Repository content travels with clones; local settings stay local | |
| 146 | | Passkeys, not passwords | No shared secret to phish, reset, or store; recovery is an instance-admin-issued enrollment link | |
| 147 | | One program with separate serve and runner modes | Easy to deploy anywhere | |
| 148 | | 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 | |
| 149 | |
| 150 | ## Using Vole as a library |
| 151 | |
| 152 | Vole is also a Go library. You can build tools that talk to an instance or embed |
| 153 | parts of Vole without running the `vole` command. |
| 154 | |
| 155 | | Package | What it does | |
| 156 | |---|---| |
| 157 | | `client` | Talks to an instance: metadata writes, identity lookups, file transfer, reviews, and site deploys | |
| 158 | | `core/id`, `core/git`, `core/lfs` | Shared primitives and storage | |
| 159 | | `forge` | Instance setup and administration | |
| 160 | | `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 | |
| 161 | | `metadata` | Issues, requests, releases, comments, approvals, and validation | |
| 162 | | `ci` | Workflow validation and the job queue | |
| 163 | | `ci/agent` | The embeddable runner: leasing, execution, and reporting | |
| 164 | | `ci/container` | The container a job runs in, shared by the runner and `vole workflow run` | |
| 165 | | `ci/cache` | The per-repository job cache: namespaces, environment, and eviction | |
| 166 | | `server` | Network listeners and web pages, with adapters for SSH, Git HTTP, sites, and hooks | |
| 167 | |
| 168 | `cmd/vole` is only the command-line front end. Logic shared by more than one |
| 169 | caller lives in the packages above. |
| 170 | |
| 171 | ## Scope |
| 172 | |
| 173 | Vole is built for one team on one server. It does not support multiple tenants, |
| 174 | federation, custom domains with automatic certificates, horizontal scaling, or a |
| 175 | Kubernetes deployment story. These are not part of Vole today. |
| 176 | |
| 177 | CI isolation is a runner responsibility: every job names its OCI image in the |
| 178 | workflow, or references a definition under `.vole/images`, and the runner executes |
| 179 | it in a container. The runner pulls or builds the image and owns the container |
| 180 | runtime; the server only pins the image and definition and records what ran. |
| 181 |