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/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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 23m ago 4b8c9e1d
source preview history blame raw 10.1 KiB
1# Architecture and design
2
3This page is for people who want to understand how Vole works and why it makes
4the choices it does. If you only want to use Vole, the guides are enough.
5
6The goal is not novelty. The goal is a forge that stays understandable, works on
7hardware you control, and keeps the important parts in Git.
8
9Vole is built for one team on one server. It does not try to be a hosted service,
10and it does not connect to other Vole instances. That keeps the design small.
11
12## Trust: a credential resolves to an account
13
14Vole 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
19There are two credentials, and they resolve to the same account: the **name**
20fixed at creation. Contributor branches and metadata authors are written with it,
21and 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
26There is no password database. The browser performs a WebAuthn ceremony, Vole
27stores only the credential's public half, and the resulting session is an opaque
28token whose hash alone is in `users.db`.
29
30There are two ways to write metadata, and they offer the same account the same
31actions: a browser session, where each form asks for one capability, and a push,
32where pre-receive asks the same capabilities path by path. Both are built on the
33same metadata writers, so the file format and its validation do not fork. An
34untrusted contributor's Git writes are confined to `refs/heads/contrib/<name>/`,
35and the `vole` branch it may also write is checked the same way the browser's
36forms are; CI for a contributor branch does not run until a maintainer allows the
37exact tip. A change request opened from a
38branch follows it: after an accepted push, the server records the new tip as the
39request's head, so approvals of the old tip stop counting. Everything else still
40changes by pushing over SSH. Approvals, comments, and CI results belong to the
41account the credential resolved to, never to a name typed into a form.
42
43Vole does not require or check Git commit signatures. A signature would only
44repeat what the credential already established, and it cannot reliably prove who
45authored a commit. Vole would rather keep one clear path than add a second one
46that can disagree with the first.
47
48## What that means
49
50On the server, Vole can show a complete history of who pushed what. A clone of a
51repository, by itself, cannot prove who pushed a commit. The instance promises a
52complete record; it does not promise offline proof.
53
54That trade-off is wrong if you plan to mirror the repository somewhere controlled
55by someone else. For a team running its own forge, it keeps the system simple and
56auditable.
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
74Git holds the repository history. SQLite holds the account database, the CI queue
75and records, and generated indexes. Anything that can be rebuilt comes from Git or
76stored files. Anything that cannot be rebuilt is small and easy to back up.
77
78The web index is derived from each repository's `vole` branch. Post-receive applies
79a push's delta as the push lands, so a page does not pay for the work the push
80already did. Every read still checks the branch it was built from — reading the
81loose ref, which is a file, rather than starting a process — and repairs the rows
82if a hook did not run. `vole admin index rebuild` is the last resort.
83
84Writers of that branch take turns. A write reads the branch, decides, and moves the
85ref only if nobody else moved it first, so two people commenting at the same time
86cannot lose either comment; the price of that safety is that a writer which loses
87the race reads the branch again. Under load that price is the whole cost, so writers
88of one repository queue behind each other instead: the read, the decision, and the
89ref move happen while one writer holds the branch, and the compare-and-swap is left
90for a push or a hand-run git command that could not wait.
91
92A push only queues behind those writers when it has metadata to move. Most do not:
93a branch becomes interesting to a change request when one is opened from it, and
94post-receive asks the index which open requests follow the branch before it reads
95the metadata branch at all. A push to a branch nobody is proposing from therefore
96writes an audit line and nothing else.
97
98The `vole` branch contains:
99
100```text
101issues/<ulid>/issue.md
102issues/<ulid>/comments/<ulid>.md
103changes/<ulid>/change.md
104changes/<ulid>/comments/<ulid>.md
105changes/<ulid>/approvals/<account-name>.md
106releases/<tag>/<artifact>
107```
108
109People write the issue and request files. Vole moves the head of a change request
110that follows a branch: post-receive derives the new head from the accepted ref and
111validates the request tree before committing it. A tagged build that publishes
112release artifacts writes those pointers the same way. Everything that is pushed is
113checked at the boundary.
114
115Run records are not in Git. A runner sends its result over the authenticated SSH
116channel that leased the job, and the server stores it in `data/runs.db`; a clone or
117backup of a repository does not carry CI history.
118
119## URL layout
120
121A 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
123transport a clone uses. There is no owner segment: repository names are unique
124on the instance, so one name is enough.
125
126Every page the forge itself serves is under `/-/`: `/-/admin`, `/-/u/<account>`,
127`/-/activity`, `/-/login`, and so on. A repository name can never begin with `-`,
128so the two spaces cannot collide and no route has to know the repository names.
129The 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
131crawlers only look there. Repository creation refuses those names.
132
133`/<name>.git` is recognized before the router, because a wildcard in a route
134fills a whole path segment and cannot match `<name>.git`. Keeping the suffix on
135the transport is what lets the page (`/<name>`, which is also the Go module path)
136and 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
152Vole is also a Go library. You can build tools that talk to an instance or embed
153parts 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
169caller lives in the packages above.
170
171## Scope
172
173Vole is built for one team on one server. It does not support multiple tenants,
174federation, custom domains with automatic certificates, horizontal scaling, or a
175Kubernetes deployment story. These are not part of Vole today.
176
177CI isolation is a runner responsibility: every job names its OCI image in the
178workflow, or references a definition under `.vole/images`, and the runner executes
179it in a container. The runner pulls or builds the image and owns the container
180runtime; the server only pins the image and definition and records what ran.
181