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.
|
520470e1
Keenan Nemetz
16d ago
|
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 | ||
|
6c84d387
Keenan Nemetz
15d ago
|
12 | ## Trust: a credential resolves to an account |
|
520470e1
Keenan Nemetz
16d ago
|
13 | |
| 14 | Vole uses one rule everywhere: | |
| 15 | ||
|
6c84d387
Keenan Nemetz
15d ago
|
16 | > A credential proves which account you are. Vole checks every change before |
| 17 | > accepting it, and records the decision in `data/audit.log`. | |
|
520470e1
Keenan Nemetz
16d ago
|
18 | |
|
62576fa8
Keenan Nemetz
14d ago
|
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. | |
|
6c84d387
Keenan Nemetz
15d ago
|
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 | ||
|
7e0b5950
Keenan Nemetz
6d ago
|
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 | |
|
e050e14a
Keenan Nemetz
13d ago
|
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. | |
|
520470e1
Keenan Nemetz
16d ago
|
42 | |
| 43 | Vole does not require or check Git commit signatures. A signature would only | |
|
6c84d387
Keenan Nemetz
15d ago
|
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. | |
|
520470e1
Keenan Nemetz
16d ago
|
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 | | |
|
b312e166
Keenan Nemetz
10d ago
|
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 | | |
|
4506a4d3
Keenan Nemetz
13d ago
|
65 | | Accounts, instance roles, SSH keys, passkeys, enrollment links, sessions, access rules, locks, page-view counters | `data/users.db` | No; back it up | |
|
2a8f7cef
Keenan Nemetz
9d ago
|
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` | |
|
b312e166
Keenan Nemetz
10d ago
|
67 | | CI queue, in-progress runs, contributor CI approvals | `data/runs.db` | Partly; in-progress work and approvals are lost | |
|
5ece70a5
Keenan Nemetz
10d ago
|
68 | | CI logs | `data/logs/` | No; one file per attempt, back it up | |
| 69 | | LFS files, build artifacts | `data/lfs/` | No; stored by content | | |
|
b1499c72
Keenan Nemetz
12d ago
|
70 | | Privileged-action history | `data/audit.log` | It is the record; a privileged change that cannot be written there is refused | |
|
0c40a1d3
Keenan Nemetz
7d ago
|
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 | |
|
520470e1
Keenan Nemetz
16d ago
|
72 | | SSH host key | `data/ssh_host_ed25519_key` | No; it is the secret | |
| 73 | ||
|
b312e166
Keenan Nemetz
10d ago
|
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. | |
|
520470e1
Keenan Nemetz
16d ago
|
77 | |
|
dced06da
Keenan Nemetz
9d ago
|
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 | ||
|
122cbe4f
Keenan Nemetz
7d ago
|
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 | ||
|
520470e1
Keenan Nemetz
16d ago
|
98 | The `vole` branch contains: |
| 99 | ||
| 100 | ```text | |
| 101 | issues/<ulid>/issue.md | |
| 102 | issues/<ulid>/comments/<ulid>.md | |
|
b492b557
Keenan Nemetz
3d ago
|
103 | changes/<ulid>/change.md |
| 104 | changes/<ulid>/comments/<ulid>.md | |
| 105 | changes/<ulid>/approvals/<account-name>.md | |
|
520470e1
Keenan Nemetz
16d ago
|
106 | releases/<tag>/<artifact> |
| 107 | ``` | |
| 108 | ||
|
b312e166
Keenan Nemetz
10d ago
|
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 | ||
|
59badc63
Keenan Nemetz
10d ago
|
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. | |
|
520470e1
Keenan Nemetz
16d ago
|
118 | |
|
b7396a11
Keenan Nemetz
2h ago
|
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 | ||
|
520470e1
Keenan Nemetz
16d ago
|
138 | ## Why these choices |
| 139 | ||
| 140 | | Choice | Reason | | |
| 141 | |---|---| | |
| 142 | | Use the Git command-line tool | Full push behavior and hooks without reimplementing Git | | |
|
474a812d
Keenan Nemetz
15d ago
|
143 | | SSH for Git writes, passkeys for the browser | Each transport gets the credential it can actually use, and both resolve to one account | |
|
6c84d387
Keenan Nemetz
15d ago
|
144 | | Check everything before accepting a change | The push boundary and the narrow web writers share the same metadata validation | |
|
ab326d3a
Keenan Nemetz
14d ago
|
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 | | |
|
520470e1
Keenan Nemetz
16d ago
|
147 | | One program with separate serve and runner modes | Easy to deploy anywhere | |
|
0c40a1d3
Keenan Nemetz
7d ago
|
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 | |
|
520470e1
Keenan Nemetz
16d ago
|
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 | | |
|
0c40a1d3
Keenan Nemetz
7d ago
|
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 | |
|
520470e1
Keenan Nemetz
16d ago
|
161 | | `metadata` | Issues, requests, releases, comments, approvals, and validation | |
|
3b39a84c
Keenan Nemetz
14d ago
|
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 | | |
|
520470e1
Keenan Nemetz
16d ago
|
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, | |
|
7da9111e
Keenan Nemetz
15d ago
|
174 | federation, custom domains with automatic certificates, horizontal scaling, or a |
| 175 | Kubernetes deployment story. These are not part of Vole today. | |
| 176 | ||
|
70b70264
Keenan Nemetz
15d ago
|
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. |