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 · 2h ago c284cb2c
source preview history blame raw 23.9 KiB

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 invite and 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.

  1. An instance admin creates the account and gives the person its one-time enrollment link, or the server has open_registration = true and the person creates the account at /-/register.
  2. The person registers a passkey.
  3. The passkey creates a browser session. The session cookie is HttpOnly and Secure; Vole stores only its hash.
  4. 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 --role and account role set 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 is admin.
  • set-role gives one account a role in one repository. The roles are none, read, contribute, maintain and admin. contributor and contrib are accepted as aliases for contribute; write is accepted as the historical spelling of maintain. A repository entry can raise the instance-wide role but cannot lower it. Use none to remove the entry.
  • set-default-role changes the role for everyone else. read or higher makes the repository public.
  • protect manages the protected branch patterns. set replaces the list, add and remove change it one entry at a time, clear empties it, and list shows it. A pattern is an exact ref (refs/heads/main) or a prefix ending in one * (refs/heads/release/*); add and set refuse anything else, because a pattern the hook can never match would leave the branch open while acl show listed it as protected. Admins can still push to protected branches directly.
  • set-approvals sets how many approvals a protected branch needs. 0 means admin-only.
  • checks enable requires CI checks to pass before a protected branch can be updated; checks disable allows approvals alone to land it.
  • require-issues enable requires at least one linked issue when a new change request is opened; disable makes 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.
  • runners manages the accounts whose CI results this repository trusts. It uses the same list, add, remove, set, and clear verbs as protect. 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.