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.
Accounts and access
Vole has no password database. An account has two kinds of credential:
- an SSH key for Git over SSH and remote administration;
- a passkey for signing in to the browser.
Both resolve to the same account, so access rules, approvals, and audit records do not care which one you used.
Accounts and keys
An account is identified by its name, chosen when the account is created and
fixed for good. It is what your contributor branches are named after
(refs/heads/contrib/<name>/), and it is the key for access rules, approvals,
file locks and CI records. There is no second internal identifier and no separate
display label, so a rename would strand every ref, approval and author field
already written. Keep the name.
Names are never reused: because the account row outlives retirement, retiring someone keeps their name attached to them. An old reference can never silently point at a different person.
Bot accounts
An automation can have an account of its own, so the work it does is attributable to it and it can be assigned an issue or named in a comment like anyone else. A bot is created by an instance admin with the repository role it should have:
vole admin account create coding-agent --bot --key agent.pub --role contribute
An instance admin can do the same from the Create a bot account form on the Accounts page: name the account, paste its public key, and choose a role. A bot cannot be given the instance-admin role in either place.
The kind is fixed at creation, like the name. A bot differs from a person in three ways, and only three:
- No browser sign-in. A bot cannot be given a passkey or an enrollment link,
because it has no browser, and a link it could never use is a secret in the roster
for no reason.
account inviteand adding a passkey are refused for a bot. - No instance admin. A bot is a repository automation, and the Admin role reaches every repository and every instance surface, so it is refused when the account is created and when its role is changed later.
- It is shown as a bot in the user directory and on its profile, so a reader knows a machine wrote what they are reading.
Everything else is the same, and deliberately so: a bot is subject to the rules of
whatever role it holds. A contribute bot may push only
refs/heads/contrib/<name>/, and CI on that tip waits for a maintainer to allow it,
exactly as for a person. A maintain bot may push shared branches, its pushes run
CI immediately, it receives the secrets its workflows declare, and its approval
counts when it is not the author. Granting a role is the trust decision, and the
audit log and every approval name the bot.
A workflow can act as a bot without holding a long-lived key. A job that names the
account with acts-as: is given a credential when it is leased: a key generated for
that run, written into the job, and gone when the run ends. The key may do what the
account may do: discuss, file an issue, manage an item it wrote, open a change from a
branch it may push, triage and approve when the role allows it, and push the branches
the role allows. It cannot reach an admin surface, read repository secrets, or lease
work, because those are not repository writes.
The credential is not the account’s key, and it does not widen the role: a
contribute bot’s job still opens changes in its own namespace, and a job cannot
approve unless its account can. See
Workflow reference § acts-as. A bot may also hold an
ordinary key, provisioned the way a person’s is, for an automation that is not a
workflow run. A CI runner is that case: the runner process uses the bot’s ordinary
key to lease work, while the jobs it runs still get a credential for the run. See
Runners § The runner’s account.
Retiring a bot revokes it immediately: its key stops resolving, and a queued job it was a runner for is no longer owed. Its name stays attached to its history, like anyone else’s.
Profiles and the user directory
Account names are public on an instance. /-/u lists everyone who has an account,
and /-/u/<name> is that person’s profile: what they have opened, reviewed and
commented on, and the repositories that work is in. Retiring an account keeps its
page, marked as no longer active, because every closed issue and merged change
still refers to the name.
The directory is a table of every account and its visible counts. Search it by name, narrow it to active or retired accounts, and order it from a column header — by name, repositories, issues, changes, reviews or joining date. The search, the filter, the order and the page are all in the URL, so a view can be linked and paging cannot change the question.
What a profile counts is not public. Every number and every event is limited to the repositories the reader may open, so two people can see different totals for the same account, and a private repository’s work contributes nothing to a stranger’s view. The page says what its numbers cover instead of pretending they are the whole history. The same rule covers the counts beside a name in the directory.
A profile is derived, not stored. The numbers and the feed are read from the web
index (data/index.db), which is rebuilt from Git and from the CI records; no
profile is written to data/users.db. Keys, passkeys, sessions and a person’s
instance role are not shown, with one exception: an instance admin sees the role
on the profile, because they can already see it on the admin pages. Git commit
authors are shown as text and never counted, because a commit’s author is a name
someone typed, not the credential that proves an account.
Writing @name in an issue, a comment, a change request, or a release note links
to that account’s profile. It is the same mention syntax a workflow’s mentioned
filter evaluates (Workflows), so a name that reads as an address
on the page is the name a job sees. A name that does not belong to an account
stays plain text, and a mention inside a code sample is not a mention.
Both pages ask crawlers to leave them out of an index. The names are public; a scraped roster is not what public has to mean.
Account data lives in data/users.db. It also holds passkeys, one-time
enrollment links, browser sessions, repository access rules, and file locks.
Credentials are public keys, and session or enrollment-link tokens are stored
only as hashes. Vole reloads the roster every few seconds, so key changes and retirements
take effect without restarting the server.
vole admin account create alice --key ~/.ssh/id_ed25519.pub
vole admin account create bob --key bob.pub
vole admin account add-key alice --key ~/.ssh/id_ed25519_work.pub
vole admin account list
vole admin account rm-key alice SHA256:...
vole admin account retire alice
vole admin account backup backup/users.db
account create --key writes the account and its first SSH key together. Without
--key (and with no key on stdin), it creates a browser-only account and prints a
one-time enrollment link. account invite issues a fresh link for an existing
account. account add-key adds another SSH key; use it to rotate credentials
without changing the account that access rules and approvals refer to. There is no
command to change the account name.
Browser sign-in
Browser sign-in uses a passkey (Touch ID, Windows Hello, a phone, or a security
key). It requires public_url to be an https:// URL; Vole refuses to start a
WebAuthn ceremony without a canonical secure origin.
- An instance admin creates the account and gives the person its one-time
enrollment link, or the server has
open_registration = trueand the person creates the account at/-/register. - The person registers a passkey.
- The passkey creates a browser session. The session cookie is HttpOnly and Secure; Vole stores only its hash.
- From Account, the person can register another passkey, rename or remove the ones they have, add or remove SSH keys, or review their sessions. A label is display text only: the credential is what signs in, so renaming one is not a re-registration. Removing a passkey cannot be undone, and the last one is refused while it is the account’s only way in — an SSH key counts as a way in for Git, so an account with one loses only its browser sign-in. Otherwise the person would need an admin to invite them back.
The account field opts into WebAuthn conditional UI (autocomplete="username webauthn"), so a supporting browser can offer a saved passkey while the field is
focused. The username-first form remains for browsers without autofill and for a
passkey created before discoverable credentials were required. New passkeys are
registered as discoverable credentials (resident keys) and require user
verification; that is what lets the browser find one without being told which
account it belongs to.
A link is single-use and expires. Issuing a new link invalidates the previous one. If the person loses every passkey, an instance admin issues a new link; there is no password reset to phish. Retiring an account deletes its sessions immediately, and removing a passkey deletes the sessions that passkey created, including the one the removal was made from.
Both writes are audited: identity.credential-rename names the passkey and its new
label, and identity.credential-remove names the passkey, with the account as the
subject. The account page’s own record of the write is web.passkey.rename and
web.passkey.remove, the same way web.key.add and web.key.remove sit beside the
identity actions for SSH keys.
The link carries its token in the URL fragment — https://forge.example.com/-/invite#<token>
— so the browser never sends it to the server, a proxy’s access log, or the
referrer of anything the page loads. Links issued in the older /-/invite/<token>
shape still work: Vole redirects them into the fragment, and its own logs redact
the old path.
Sign-in, registration, enrollment and passkey-adding are rate-limited per client
address (and per account name for sign-in), and the ceremony store is bounded and
expires after five minutes. A client that exceeds a budget gets 429 with
Retry-After rather than a slower answer. Behind a proxy, set
git_http_client_ip_header so those budgets follow the real client instead of
the proxy.
An account name is not proof of anything: asking to sign in as an account that does not exist, is retired, or has no passkey gets the same assertion request as a real one. The difference only appears when the ceremony is finished, and the failure is generic.
A passkey’s signature counter must advance. If an assertion arrives with a
counter that did not move, two copies of the key probably exist, so the sign-in
is refused and the event is recorded in the audit log as
auth.clone-warning. This can lock out an authenticator with a broken counter
until an admin removes the passkey; that is the deliberate trade. Synced
passkeys that always report zero are handled by WebAuthn’s own rule and do not
trigger the refusal.
With open_registration = true, /-/register creates a new account and its first
passkey without an instance admin. Set open_registration_role to the
instance-wide role those accounts should have; contribute is the usual choice
for an open-source instance:
open_registration = true
open_registration_role = "contribute"
That role applies to every public repository, so a new contributor can read,
discuss, and push to refs/heads/contrib/<name>/ without being added to each
repository. It does not open private repositories. Only none, read and
contribute are accepted: a stranger who typed a name must not be able to move a
shared branch, approve a review or administer the instance, so maintain and
admin are refused and have to be granted explicitly. A registered account can
add its own SSH key from Account, which is what lets it push. Open
registration has no email verification and is rate-limited per address, so leave
it off on a private instance unless you want that policy.
Admins can inspect and revoke sessions:
vole admin account credentials alice
vole admin account rm-credential alice <credential-id>
vole admin account sessions alice
vole admin account revoke-sessions alice
vole admin account revoke-sessions --all
revoke-sessions --all is the recovery step after restoring an old users.db.
To see which account the current remote’s key belongs to:
vole whoami
To see the role that account holds on the repository named by the remote:
vole whoami --role
Remote administration
By default, server management commands run on the host. To use them from another
machine, mark an account as an instance admin and pass --remote:
vole admin account grant-admin alice
vole admin --remote ssh://git@forge.example.com:2222 account create bob --key bob.pub
vole admin --remote origin repo create demo --owner bob
vole admin --remote origin acl set-default-role demo read
An instance admin is the instance-wide admin role. They can create accounts and
repositories, change access rules anywhere, and use --remote. They are also a
repository admin on every repository, private ones included: they can push,
change settings, and manage access without a repository-specific entry. SSH Git
and SSH LFS use the key’s account for this check; anonymous HTTP clone has no
identity, so a private repository still answers 404 there.
Instance admins also get an Admin page in the top navigation. Its tabs cover
the instance’s overview, storage, runners, and effective configuration; an
Accounts tab that creates a person from an enrollment link or an automation
account; and the audit log. Existing accounts are managed from the users
directory at /-/u, which is searchable and paginated: an instance admin gets a
Manage action on each row that opens the account page, where they set its
instance-wide role (including admin), retire it, start a fresh enrollment link,
and inspect or revoke SSH keys and browser sessions. Repository creation is a
New repository action on the home page, and per-repository access rules live
on that repository’s Settings tab, visible there to instance admins.
The first instance admin must be granted on the host. Instance admin is the
instance-wide admin role, so vole admin account role alice admin and
vole admin account grant-admin alice mean the same thing. Vole refuses to remove or
retire the last active instance admin, so a remote mistake cannot lock everyone
out. Add --json to any remote command for a machine-readable response:
vole admin --remote origin --json account list
Backups, garbage collection, and index rebuilds stay on the host; they are recovery operations, not remote administration.
Roles
A role can be granted in two places:
- instance-wide, on the account: it applies to every public repository;
- per repository: an explicit entry for that repository.
The effective role is the higher of the repository’s default role, the
repository entry for the account, and the account’s instance-wide role. Instance
roles only take effect on public repositories. The exception is admin: an
instance admin is an admin on every repository, including private ones.
Each repository also has a default role for everyone and optional roles for specific accounts.
| Role | What it allows |
|---|---|
none |
No access |
read |
Clone, fetch, and browse the repository |
contribute |
Read, file issues and comments, push only refs/heads/contrib/<name>/*, and open change requests from those branches |
maintain |
Everything the contribute role can do, plus push shared branches, triage issues, approve change requests, and manage the repository’s secrets |
admin |
Everything a maintainer can do, plus manage access, push protected branches, override locks, and repair metadata |
Rules are edited with a compare-and-swap. A page or a command that read the rules before somebody else changed them is refused rather than allowed to write its copy back, so an instance admin revoking a role cannot be undone by a repository admin who still has the older page open. The operations that own one field — the visibility toggle, adding one account, one runners entry — re-read the row and apply only that field, so an unrelated concurrent change is kept.
A new repository is private. Its default role is none, and its owner is an
admin. Private repositories return 404 to anonymous visitors, so people cannot
tell whether they exist. An instance-wide read, contribute, or maintain
grant does not open a private repository; an instance admin does.
To publish a repository, give the default role read:
vole admin acl set-default-role demo read
A repository admin can also change the description, default branch, and
visibility, and rename or delete the repository, from its Settings tab in
the browser. An instance admin has those rights on every repository too.
Settings is split into tabs: General (the repository’s own fields),
Access (the instance-admin rules), Secrets, and Danger (rename and
delete). Each tab is offered only to the accounts that may use it, and a viewer
who can use one of them sees no row at all. Visibility
toggles the default role between none and read; explicit role entries are
left alone. The Accounts table shows only accounts with an explicit role or
runner entry; use Add account below it to grant another account a
repository-specific role. Access rules and review policy remain instance-admin
operations: instance admins can edit them on the repository’s Access tab,
or with vole admin acl.
A maintainer who is not an admin also opens the page, and its one tab is
Secrets. Values a repository keeps out of its code — a deploy key, a
registry token, a staging password — belong to the accounts that approve work and allow CI,
so the page shows them the names, versions and read times, and the form that sets
them. It never shows a value; vole secret get prints one when somebody needs it.
Changing access
vole admin account create carol --role contribute --key carol.pub
vole admin account role alice maintain
vole admin account role bob none
vole admin acl show demo
vole admin acl set-role demo alice maintain
vole admin acl set-role demo bob contribute
vole admin acl set-role demo carol read
vole admin acl set-default-role demo read
vole admin acl protect add demo refs/heads/main
vole admin acl set-approvals demo 2
vole admin acl checks enable demo
vole admin acl require-issues enable demo
vole admin acl runners add demo ci-runner-1
account create --roleandaccount roleset the account’s instance-wide role. On a public repository that role applies without a per-repository entry; on a private repository it does not, unless the role isadmin.set-rolegives one account a role in one repository. The roles arenone,read,contribute,maintainandadmin.contributorandcontribare accepted as aliases forcontribute;writeis accepted as the historical spelling ofmaintain. A repository entry can raise the instance-wide role but cannot lower it. Usenoneto remove the entry.set-default-rolechanges the role for everyone else.reador higher makes the repository public.protectmanages the protected branch patterns.setreplaces the list,addandremovechange it one entry at a time,clearempties it, andlistshows it. A pattern is an exact ref (refs/heads/main) or a prefix ending in one*(refs/heads/release/*);addandsetrefuse anything else, because a pattern the hook can never match would leave the branch open whileacl showlisted it as protected. Admins can still push to protected branches directly.set-approvalssets how many approvals a protected branch needs.0means admin-only.checks enablerequires CI checks to pass before a protected branch can be updated;checks disableallows approvals alone to land it.require-issues enablerequires at least one linked issue when a new change request is opened;disablemakes it optional again. The rule is checked when the request is opened, so requests that already exist keep their history and can still be approved and landed.runnersmanages the accounts whose CI results this repository trusts. It uses the samelist,add,remove,set, andclearverbs asprotect. Admins always count. A retired account stops counting even while its name is still in the list: the ACL row outlives the account, so membership alone is not a grant that lasts. See Runners.
Contributors and their branches
The contribute role is for open-source repositories that accept outside patches.
A contributor can read, file issues and comments, manage the items it wrote, and
open a change request from a branch it may push — through the CLI or the browser,
which apply the same rules. Their Git writes are confined to a branch folder named
after their account name:
refs/heads/contrib/<name>/<branch>
For example, account alice may push refs/heads/contrib/alice/fix-docs. They
cannot push refs/heads/main, another account’s folder, or tags. The pre-receive
hook checks the namespace, and rebasing or deleting a branch inside their own
folder is allowed because it is not shared history.
They may also write refs/heads/vole, the issues branch, because that is where a
comment, an issue, or a change request lives. What the push may contain is the
same list the browser offers: a comment, an issue, a change request from their own
branch, or a change to an item they wrote. Pre-receive refuses anything else, so
the two transports cannot disagree about what a contributor may do.
The namespace is the account name, so a ref says whose work it is without a lookup. It is also why names are fixed at creation: renaming an account would otherwise orphan every branch under its old folder.
The browser’s Open change request form lists only the branches the account may use as a head. The head commit is resolved from that branch; a contributor cannot name a commit they did not push. A maintainer can approve the request, and landing it still goes through the same protected-branch gate.
The CLI’s vole issue new, vole issue comment and vole change new write the
metadata branch directly. Pre-receive checks the same per-path rules as the
browser, so a contribute account can use those commands too: it may file an
issue, comment, and open a change from a branch in its own namespace.
Contributor branches are ordinary branches except for one rule: CI does not run
for them automatically. A maintainer or admin opens the change request for the
branch and chooses Allow CI there. The approval names the exact
commit, so every new push waits again. Treat runner isolation as part of the trust decision before granting
contribute on a public repository.
Allowing CI also grants that commit the repository’s secrets, if its workflow declares them. It is the same decision as letting the code run on a runner: the values arrive on the machine that runs it, and any step in that job can read the ones the job declared. A maintainer who would not hand a value to an outside patch should not approve a tip whose workflow asks for it.
When rules are checked
Every rule is checked before Vole accepts a push. If a push breaks a rule, none of its branches move. Browser issue, comment, and approval writes check the same ACL capability and use the same metadata writers and validation as the CLI.
That includes protected branches, approvals, required checks, file size limits,
locks, issue formatting, and the CI runner allowlist. Privileged actions — such as
an admin force-push, a lock override, an account change, or a merge through review —
are written to data/audit.log and can be read by an instance admin at
Admin → Audit log.
For the reasoning behind this design, see Architecture. For file locks, see LFS § Locking.