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 · 2h ago
4b8c9e1d
| 1 | # Accounts and access |
| 2 | |
| 3 | Vole has no password database. An account has two kinds of credential: |
| 4 | |
| 5 | - an **SSH key** for Git over SSH and remote administration; |
| 6 | - a **passkey** for signing in to the browser. |
| 7 | |
| 8 | Both resolve to the same account, so access rules, approvals, and audit records |
| 9 | do not care which one you used. |
| 10 | |
| 11 | ## Accounts and keys |
| 12 | |
| 13 | An account is identified by its **name**, chosen when the account is created and |
| 14 | fixed for good. It is what your contributor branches are named after |
| 15 | (`refs/heads/contrib/<name>/`), and it is the key for access rules, approvals, |
| 16 | file locks and CI records. There is no second internal identifier and no separate |
| 17 | display label, so a rename would strand every ref, approval and author field |
| 18 | already written. Keep the name. |
| 19 | |
| 20 | Names are never reused: because the account row outlives retirement, retiring |
| 21 | someone keeps their name attached to them. An old reference can never silently |
| 22 | point at a different person. |
| 23 | |
| 24 | ## Bot accounts |
| 25 | |
| 26 | An automation can have an account of its own, so the work it does is attributable |
| 27 | to it and it can be assigned an issue or named in a comment like anyone else. A bot |
| 28 | is created by an instance admin with the repository role it should have: |
| 29 | |
| 30 | ```sh |
| 31 | vole admin account create coding-agent --bot --key agent.pub --role contribute |
| 32 | ``` |
| 33 | |
| 34 | An instance admin can do the same from the **Create a bot account** form on the |
| 35 | Accounts page: name the account, paste its public key, and choose a role. A bot |
| 36 | cannot be given the instance-admin role in either place. |
| 37 | |
| 38 | The kind is fixed at creation, like the name. A bot differs from a person in three |
| 39 | ways, and only three: |
| 40 | |
| 41 | - **No browser sign-in.** A bot cannot be given a passkey or an enrollment link, |
| 42 | because it has no browser, and a link it could never use is a secret in the roster |
| 43 | for no reason. `account invite` and adding a passkey are refused for a bot. |
| 44 | - **No instance admin.** A bot is a repository automation, and the Admin role reaches |
| 45 | every repository and every instance surface, so it is refused when the account is |
| 46 | created and when its role is changed later. |
| 47 | - **It is shown as a bot** in the user directory and on its profile, so a reader |
| 48 | knows a machine wrote what they are reading. |
| 49 | |
| 50 | Everything else is the same, and deliberately so: a bot is subject to the rules of |
| 51 | whatever role it holds. A `contribute` bot may push only |
| 52 | `refs/heads/contrib/<name>/`, and CI on that tip waits for a maintainer to allow it, |
| 53 | exactly as for a person. A `maintain` bot may push shared branches, its pushes run |
| 54 | CI immediately, it receives the secrets its workflows declare, and its approval |
| 55 | counts when it is not the author. Granting a role is the trust decision, and the |
| 56 | audit log and every approval name the bot. |
| 57 | |
| 58 | A workflow can act as a bot without holding a long-lived key. A job that names the |
| 59 | account with `acts-as:` is given a credential when it is leased: a key generated for |
| 60 | that run, written into the job, and gone when the run ends. The key may do what the |
| 61 | account may do: discuss, file an issue, manage an item it wrote, open a change from a |
| 62 | branch it may push, triage and approve when the role allows it, and push the branches |
| 63 | the role allows. It cannot reach an admin surface, read repository secrets, or lease |
| 64 | work, because those are not repository writes. |
| 65 | |
| 66 | The credential is not the account's key, and it does not widen the role: a |
| 67 | `contribute` bot's job still opens changes in its own namespace, and a job cannot |
| 68 | approve unless its account can. See |
| 69 | [Workflow reference § acts-as](reference/workflow-reference.md#acts-as). A bot may also hold an |
| 70 | ordinary key, provisioned the way a person's is, for an automation that is not a |
| 71 | workflow run. A CI runner is that case: the runner process uses the bot's ordinary |
| 72 | key to lease work, while the jobs it runs still get a credential for the run. See |
| 73 | [Runners § The runner's account](runner.md#the-runners-account). |
| 74 | |
| 75 | Retiring a bot revokes it immediately: its key stops resolving, and a queued job it |
| 76 | was a runner for is no longer owed. Its name stays attached to its history, like |
| 77 | anyone else's. |
| 78 | |
| 79 | ## Profiles and the user directory |
| 80 | |
| 81 | Account names are public on an instance. `/-/u` lists everyone who has an account, |
| 82 | and `/-/u/<name>` is that person's profile: what they have opened, reviewed and |
| 83 | commented on, and the repositories that work is in. Retiring an account keeps its |
| 84 | page, marked as no longer active, because every closed issue and merged change |
| 85 | still refers to the name. |
| 86 | |
| 87 | The directory is a table of every account and its visible counts. Search it by |
| 88 | name, narrow it to active or retired accounts, and order it from a column header |
| 89 | — by name, repositories, issues, changes, reviews or joining date. The search, the |
| 90 | filter, the order and the page are all in the URL, so a view can be linked and |
| 91 | paging cannot change the question. |
| 92 | |
| 93 | What a profile counts is not public. Every number and every event is limited to |
| 94 | the repositories the reader may open, so two people can see different totals for |
| 95 | the same account, and a private repository's work contributes nothing to a |
| 96 | stranger's view. The page says what its numbers cover instead of pretending they |
| 97 | are the whole history. The same rule covers the counts beside a name in the |
| 98 | directory. |
| 99 | |
| 100 | A profile is derived, not stored. The numbers and the feed are read from the web |
| 101 | index (`data/index.db`), which is rebuilt from Git and from the CI records; no |
| 102 | profile is written to `data/users.db`. Keys, passkeys, sessions and a person's |
| 103 | instance role are not shown, with one exception: an instance admin sees the role |
| 104 | on the profile, because they can already see it on the admin pages. Git commit |
| 105 | authors are shown as text and never counted, because a commit's author is a name |
| 106 | someone typed, not the credential that proves an account. |
| 107 | |
| 108 | Writing `@name` in an issue, a comment, a change request, or a release note links |
| 109 | to that account's profile. It is the same mention syntax a workflow's `mentioned` |
| 110 | filter evaluates ([Workflows](workflows.md)), so a name that reads as an address |
| 111 | on the page is the name a job sees. A name that does not belong to an account |
| 112 | stays plain text, and a mention inside a code sample is not a mention. |
| 113 | |
| 114 | Both pages ask crawlers to leave them out of an index. The names are public; a |
| 115 | scraped roster is not what public has to mean. |
| 116 | |
| 117 | Account data lives in `data/users.db`. It also holds passkeys, one-time |
| 118 | enrollment links, browser sessions, repository access rules, and file locks. |
| 119 | Credentials are public keys, and session or enrollment-link tokens are stored |
| 120 | only as hashes. Vole reloads the roster every few seconds, so key changes and retirements |
| 121 | take effect without restarting the server. |
| 122 | |
| 123 | ```sh |
| 124 | vole admin account create alice --key ~/.ssh/id_ed25519.pub |
| 125 | vole admin account create bob --key bob.pub |
| 126 | vole admin account add-key alice --key ~/.ssh/id_ed25519_work.pub |
| 127 | vole admin account list |
| 128 | vole admin account rm-key alice SHA256:... |
| 129 | vole admin account retire alice |
| 130 | vole admin account backup backup/users.db |
| 131 | ``` |
| 132 | |
| 133 | `account create --key` writes the account and its first SSH key together. Without |
| 134 | `--key` (and with no key on stdin), it creates a browser-only account and prints a |
| 135 | one-time enrollment link. `account invite` issues a fresh link for an existing |
| 136 | account. `account add-key` adds another SSH key; use it to rotate credentials |
| 137 | without changing the account that access rules and approvals refer to. There is no |
| 138 | command to change the account name. |
| 139 | |
| 140 | ## Browser sign-in |
| 141 | |
| 142 | Browser sign-in uses a passkey (Touch ID, Windows Hello, a phone, or a security |
| 143 | key). It requires `public_url` to be an `https://` URL; Vole refuses to start a |
| 144 | WebAuthn ceremony without a canonical secure origin. |
| 145 | |
| 146 | 1. An instance admin creates the account and gives the person its one-time |
| 147 | enrollment link, or the server has `open_registration = true` and the person |
| 148 | creates the account at `/-/register`. |
| 149 | 2. The person registers a passkey. |
| 150 | 3. The passkey creates a browser session. The session cookie is HttpOnly and |
| 151 | Secure; Vole stores only its hash. |
| 152 | 4. From **Account**, the person can register another passkey, rename or remove the |
| 153 | ones they have, add or remove SSH keys, or review their sessions. A label is |
| 154 | display text only: the credential is what signs in, so renaming one is not a |
| 155 | re-registration. Removing a passkey cannot be undone, and the last one is |
| 156 | refused while it is the account's only way in — an SSH key counts as a way in |
| 157 | for Git, so an account with one loses only its browser sign-in. Otherwise the |
| 158 | person would need an admin to invite them back. |
| 159 | |
| 160 | The account field opts into WebAuthn conditional UI (`autocomplete="username |
| 161 | webauthn"`), so a supporting browser can offer a saved passkey while the field is |
| 162 | focused. The username-first form remains for browsers without autofill and for a |
| 163 | passkey created before discoverable credentials were required. New passkeys are |
| 164 | registered as discoverable credentials (resident keys) and require user |
| 165 | verification; that is what lets the browser find one without being told which |
| 166 | account it belongs to. |
| 167 | |
| 168 | A link is single-use and expires. Issuing a new link invalidates the previous |
| 169 | one. If the person loses every passkey, an instance admin issues a new link; there |
| 170 | is no password reset to phish. Retiring an account deletes its sessions |
| 171 | immediately, and removing a passkey deletes the sessions that passkey created, |
| 172 | including the one the removal was made from. |
| 173 | |
| 174 | Both writes are audited: `identity.credential-rename` names the passkey and its new |
| 175 | label, and `identity.credential-remove` names the passkey, with the account as the |
| 176 | subject. The account page's own record of the write is `web.passkey.rename` and |
| 177 | `web.passkey.remove`, the same way `web.key.add` and `web.key.remove` sit beside the |
| 178 | identity actions for SSH keys. |
| 179 | |
| 180 | The link carries its token in the URL fragment — `https://forge.example.com/-/invite#<token>` |
| 181 | — so the browser never sends it to the server, a proxy's access log, or the |
| 182 | referrer of anything the page loads. Links issued in the older `/-/invite/<token>` |
| 183 | shape still work: Vole redirects them into the fragment, and its own logs redact |
| 184 | the old path. |
| 185 | |
| 186 | Sign-in, registration, enrollment and passkey-adding are rate-limited per client |
| 187 | address (and per account name for sign-in), and the ceremony store is bounded and |
| 188 | expires after five minutes. A client that exceeds a budget gets `429` with |
| 189 | `Retry-After` rather than a slower answer. Behind a proxy, set |
| 190 | `git_http_client_ip_header` so those budgets follow the real client instead of |
| 191 | the proxy. |
| 192 | |
| 193 | An account name is not proof of anything: asking to sign in as an account that |
| 194 | does not exist, is retired, or has no passkey gets the same assertion request as |
| 195 | a real one. The difference only appears when the ceremony is finished, and the |
| 196 | failure is generic. |
| 197 | |
| 198 | A passkey's signature counter must advance. If an assertion arrives with a |
| 199 | counter that did not move, two copies of the key probably exist, so the sign-in |
| 200 | is refused and the event is recorded in the audit log as |
| 201 | `auth.clone-warning`. This can lock out an authenticator with a broken counter |
| 202 | until an admin removes the passkey; that is the deliberate trade. Synced |
| 203 | passkeys that always report zero are handled by WebAuthn's own rule and do not |
| 204 | trigger the refusal. |
| 205 | |
| 206 | With `open_registration = true`, `/-/register` creates a new account and its first |
| 207 | passkey without an instance admin. Set `open_registration_role` to the |
| 208 | instance-wide role those accounts should have; `contribute` is the usual choice |
| 209 | for an open-source instance: |
| 210 | |
| 211 | ```toml |
| 212 | open_registration = true |
| 213 | open_registration_role = "contribute" |
| 214 | ``` |
| 215 | |
| 216 | That role applies to every **public** repository, so a new contributor can read, |
| 217 | discuss, and push to `refs/heads/contrib/<name>/` without being added to each |
| 218 | repository. It does not open private repositories. Only `none`, `read` and |
| 219 | `contribute` are accepted: a stranger who typed a name must not be able to move a |
| 220 | shared branch, approve a review or administer the instance, so `maintain` and |
| 221 | `admin` are refused and have to be granted explicitly. A registered account can |
| 222 | add its own SSH key from **Account**, which is what lets it push. Open |
| 223 | registration has no email verification and is rate-limited per address, so leave |
| 224 | it off on a private instance unless you want that policy. |
| 225 | |
| 226 | Admins can inspect and revoke sessions: |
| 227 | |
| 228 | ```sh |
| 229 | vole admin account credentials alice |
| 230 | vole admin account rm-credential alice <credential-id> |
| 231 | vole admin account sessions alice |
| 232 | vole admin account revoke-sessions alice |
| 233 | vole admin account revoke-sessions --all |
| 234 | ``` |
| 235 | |
| 236 | `revoke-sessions --all` is the recovery step after restoring an old `users.db`. |
| 237 | |
| 238 | To see which account the current remote's key belongs to: |
| 239 | |
| 240 | ```sh |
| 241 | vole whoami |
| 242 | ``` |
| 243 | |
| 244 | To see the role that account holds on the repository named by the remote: |
| 245 | |
| 246 | ```sh |
| 247 | vole whoami --role |
| 248 | ``` |
| 249 | |
| 250 | ## Remote administration |
| 251 | |
| 252 | By default, server management commands run on the host. To use them from another |
| 253 | machine, mark an account as an **instance admin** and pass `--remote`: |
| 254 | |
| 255 | ```sh |
| 256 | vole admin account grant-admin alice |
| 257 | vole admin --remote ssh://git@forge.example.com:2222 account create bob --key bob.pub |
| 258 | vole admin --remote origin repo create demo --owner bob |
| 259 | vole admin --remote origin acl set-default-role demo read |
| 260 | ``` |
| 261 | |
| 262 | An instance admin is the instance-wide `admin` role. They can create accounts and |
| 263 | repositories, change access rules anywhere, and use `--remote`. They are also a |
| 264 | repository admin on every repository, private ones included: they can push, |
| 265 | change settings, and manage access without a repository-specific entry. SSH Git |
| 266 | and SSH LFS use the key's account for this check; anonymous HTTP clone has no |
| 267 | identity, so a private repository still answers `404` there. |
| 268 | |
| 269 | Instance admins also get an **Admin** page in the top navigation. Its tabs cover |
| 270 | the instance's overview, storage, runners, and effective configuration; an |
| 271 | Accounts tab that creates a person from an enrollment link or an automation |
| 272 | account; and the audit log. Existing accounts are managed from the users |
| 273 | directory at `/-/u`, which is searchable and paginated: an instance admin gets a |
| 274 | **Manage** action on each row that opens the account page, where they set its |
| 275 | instance-wide role (including `admin`), retire it, start a fresh enrollment link, |
| 276 | and inspect or revoke SSH keys and browser sessions. Repository creation is a |
| 277 | **New repository** action on the home page, and per-repository access rules live |
| 278 | on that repository's **Settings** tab, visible there to instance admins. |
| 279 | |
| 280 | The first instance admin must be granted on the host. Instance admin is the |
| 281 | instance-wide `admin` role, so `vole admin account role alice admin` and |
| 282 | `vole admin account grant-admin alice` mean the same thing. Vole refuses to remove or |
| 283 | retire the last active instance admin, so a remote mistake cannot lock everyone |
| 284 | out. Add `--json` to any remote command for a machine-readable response: |
| 285 | |
| 286 | ```sh |
| 287 | vole admin --remote origin --json account list |
| 288 | ``` |
| 289 | |
| 290 | Backups, garbage collection, and index rebuilds stay on the host; they are |
| 291 | recovery operations, not remote administration. |
| 292 | |
| 293 | ## Roles |
| 294 | |
| 295 | A role can be granted in two places: |
| 296 | |
| 297 | - **instance-wide**, on the account: it applies to every public repository; |
| 298 | - **per repository**: an explicit entry for that repository. |
| 299 | |
| 300 | The effective role is the higher of the repository's default role, the |
| 301 | repository entry for the account, and the account's instance-wide role. Instance |
| 302 | roles only take effect on public repositories. The exception is `admin`: an |
| 303 | instance admin is an admin on every repository, including private ones. |
| 304 | |
| 305 | Each repository also has a default role for everyone and optional roles for |
| 306 | specific accounts. |
| 307 | |
| 308 | | Role | What it allows | |
| 309 | |---|---| |
| 310 | | `none` | No access | |
| 311 | | `read` | Clone, fetch, and browse the repository | |
| 312 | | `contribute` | Read, file issues and comments, push only `refs/heads/contrib/<name>/*`, and open change requests from those branches | |
| 313 | | `maintain` | Everything the contribute role can do, plus push shared branches, triage issues, approve change requests, and manage the repository's secrets | |
| 314 | | `admin` | Everything a maintainer can do, plus manage access, push protected branches, override locks, and repair metadata | |
| 315 | |
| 316 | Rules are edited with a compare-and-swap. A page or a command that read the rules |
| 317 | before somebody else changed them is refused rather than allowed to write its copy |
| 318 | back, so an instance admin revoking a role cannot be undone by a repository admin |
| 319 | who still has the older page open. The operations that own one field — the |
| 320 | visibility toggle, adding one account, one runners entry — re-read the row and |
| 321 | apply only that field, so an unrelated concurrent change is kept. |
| 322 | |
| 323 | A new repository is private. Its default role is `none`, and its owner is an |
| 324 | admin. Private repositories return `404` to anonymous visitors, so people cannot |
| 325 | tell whether they exist. An instance-wide `read`, `contribute`, or `maintain` |
| 326 | grant does not open a private repository; an instance admin does. |
| 327 | |
| 328 | To publish a repository, give the default role `read`: |
| 329 | |
| 330 | ```sh |
| 331 | vole admin acl set-default-role demo read |
| 332 | ``` |
| 333 | |
| 334 | A repository admin can also change the description, default branch, and |
| 335 | visibility, and rename or delete the repository, from its **Settings** tab in |
| 336 | the browser. An instance admin has those rights on every repository too. |
| 337 | Settings is split into tabs: **General** (the repository's own fields), |
| 338 | **Access** (the instance-admin rules), **Secrets**, and **Danger** (rename and |
| 339 | delete). Each tab is offered only to the accounts that may use it, and a viewer |
| 340 | who can use one of them sees no row at all. Visibility |
| 341 | toggles the default role between `none` and `read`; explicit role entries are |
| 342 | left alone. The Accounts table shows only accounts with an explicit role or |
| 343 | runner entry; use **Add account** below it to grant another account a |
| 344 | repository-specific role. Access rules and review policy remain instance-admin |
| 345 | operations: instance admins can edit them on the repository's **Access** tab, |
| 346 | or with `vole admin acl`. |
| 347 | |
| 348 | A maintainer who is not an admin also opens the page, and its one tab is |
| 349 | Secrets. Values a repository keeps out of its code — a deploy key, a |
| 350 | registry token, a staging password — belong to the accounts that approve work and allow CI, |
| 351 | so the page shows them the names, versions and read times, and the form that sets |
| 352 | them. It never shows a value; `vole secret get` prints one when somebody needs it. |
| 353 | |
| 354 | ## Changing access |
| 355 | |
| 356 | ```sh |
| 357 | vole admin account create carol --role contribute --key carol.pub |
| 358 | vole admin account role alice maintain |
| 359 | vole admin account role bob none |
| 360 | |
| 361 | vole admin acl show demo |
| 362 | vole admin acl set-role demo alice maintain |
| 363 | vole admin acl set-role demo bob contribute |
| 364 | vole admin acl set-role demo carol read |
| 365 | vole admin acl set-default-role demo read |
| 366 | vole admin acl protect add demo refs/heads/main |
| 367 | vole admin acl set-approvals demo 2 |
| 368 | vole admin acl checks enable demo |
| 369 | vole admin acl require-issues enable demo |
| 370 | vole admin acl runners add demo ci-runner-1 |
| 371 | ``` |
| 372 | |
| 373 | - `account create --role` and `account role` set the account's instance-wide role. |
| 374 | On a public repository that role applies without a per-repository entry; on a |
| 375 | private repository it does not, unless the role is `admin`. |
| 376 | - `set-role` gives one account a role in one repository. The roles are `none`, |
| 377 | `read`, `contribute`, `maintain` and `admin`. `contributor` and `contrib` are |
| 378 | accepted as aliases for `contribute`; `write` is accepted as the historical |
| 379 | spelling of `maintain`. A repository entry can raise the instance-wide role but |
| 380 | cannot lower it. Use `none` to remove the entry. |
| 381 | - `set-default-role` changes the role for everyone else. `read` or higher makes |
| 382 | the repository public. |
| 383 | - `protect` manages the protected branch patterns. `set` replaces the list, |
| 384 | `add` and `remove` change it one entry at a time, `clear` empties it, and |
| 385 | `list` shows it. A pattern is an exact ref (`refs/heads/main`) or a prefix |
| 386 | ending in one `*` (`refs/heads/release/*`); `add` and `set` refuse anything |
| 387 | else, because a pattern the hook can never match would leave the branch open |
| 388 | while `acl show` listed it as protected. Admins can still push to protected |
| 389 | branches directly. |
| 390 | - `set-approvals` sets how many approvals a protected branch needs. `0` means |
| 391 | admin-only. |
| 392 | - `checks enable` requires CI checks to pass before a protected branch can be |
| 393 | updated; `checks disable` allows approvals alone to land it. |
| 394 | - `require-issues enable` requires at least one linked issue when a new change |
| 395 | request is opened; `disable` makes it optional again. The rule is checked when |
| 396 | the request is opened, so requests that already exist keep their history and |
| 397 | can still be approved and landed. |
| 398 | - `runners` manages the accounts whose CI results this repository trusts. It uses |
| 399 | the same `list`, `add`, `remove`, `set`, and `clear` verbs as `protect`. |
| 400 | Admins always count. A retired account stops counting even while its name is |
| 401 | still in the list: the ACL row outlives the account, so membership alone is not |
| 402 | a grant that lasts. See |
| 403 | [Runners](runner.md#who-can-run-jobs-and-report-results). |
| 404 | |
| 405 | ## Contributors and their branches |
| 406 | |
| 407 | The `contribute` role is for open-source repositories that accept outside patches. |
| 408 | A contributor can read, file issues and comments, manage the items it wrote, and |
| 409 | open a change request from a branch it may push — through the CLI or the browser, |
| 410 | which apply the same rules. Their Git writes are confined to a branch folder named |
| 411 | after their account name: |
| 412 | |
| 413 | ```text |
| 414 | refs/heads/contrib/<name>/<branch> |
| 415 | ``` |
| 416 | |
| 417 | For example, account `alice` may push `refs/heads/contrib/alice/fix-docs`. They |
| 418 | cannot push `refs/heads/main`, another account's folder, or tags. The pre-receive |
| 419 | hook checks the namespace, and rebasing or deleting a branch inside their own |
| 420 | folder is allowed because it is not shared history. |
| 421 | |
| 422 | They may also write `refs/heads/vole`, the issues branch, because that is where a |
| 423 | comment, an issue, or a change request lives. What the push may contain is the |
| 424 | same list the browser offers: a comment, an issue, a change request from their own |
| 425 | branch, or a change to an item they wrote. Pre-receive refuses anything else, so |
| 426 | the two transports cannot disagree about what a contributor may do. |
| 427 | |
| 428 | The namespace is the account name, so a ref says whose work it is without a |
| 429 | lookup. It is also why names are fixed at creation: renaming an account would |
| 430 | otherwise orphan every branch under its old folder. |
| 431 | |
| 432 | The browser's **Open change request** form lists only the branches the account |
| 433 | may use as a head. The head commit is resolved from that branch; a contributor |
| 434 | cannot name a commit they did not push. A maintainer can approve the request, and |
| 435 | landing it still goes through the same protected-branch gate. |
| 436 | |
| 437 | The CLI's `vole issue new`, `vole issue comment` and `vole change new` write the |
| 438 | metadata branch directly. Pre-receive checks the same per-path rules as the |
| 439 | browser, so a `contribute` account can use those commands too: it may file an |
| 440 | issue, comment, and open a change from a branch in its own namespace. |
| 441 | |
| 442 | Contributor branches are ordinary branches except for one rule: CI does not run |
| 443 | for them automatically. A maintainer or admin opens the change request for the |
| 444 | branch and chooses **Allow CI** there. The approval names the exact |
| 445 | commit, so every new push waits again. Treat runner isolation as part of the trust decision before granting |
| 446 | `contribute` on a public repository. |
| 447 | |
| 448 | Allowing CI also grants that commit the repository's secrets, if its workflow |
| 449 | declares them. It is the same decision as letting the code run on a runner: the |
| 450 | values arrive on the machine that runs it, and any step in that job can read the |
| 451 | ones the job declared. A maintainer who would not hand a value to an outside patch |
| 452 | should not approve a tip whose workflow asks for it. |
| 453 | |
| 454 | ## When rules are checked |
| 455 | |
| 456 | Every rule is checked before Vole accepts a push. If a push breaks a rule, none of |
| 457 | its branches move. Browser issue, comment, and approval writes check the same ACL |
| 458 | capability and use the same metadata writers and validation as the CLI. |
| 459 | |
| 460 | That includes protected branches, approvals, required checks, file size limits, |
| 461 | locks, issue formatting, and the CI runner allowlist. Privileged actions — such as |
| 462 | an admin force-push, a lock override, an account change, or a merge through review — |
| 463 | are written to `data/audit.log` and can be read by an instance admin at |
| 464 | **Admin → Audit log**. |
| 465 | |
| 466 | For the reasoning behind this design, see |
| 467 | [Architecture](architecture.md#trust-a-credential-resolves-to-an-account). For file locks, see |
| 468 | [LFS § Locking](lfs.md#locking-files). |
| 469 |