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.
Phase 3: change requests, with approvals bound to the commit they reviewed
1eeca4e67a65d71217d51b1c41632330225b351a · Keenan Nemetz · 17d ago · parent 92b37587
Phase 3: change requests, with approvals bound to the commit they reviewed Review is now a git artifact. A request names a base branch and one head commit; an approval names that same commit, so a branch that moves after approval is not approved any more. Because `git push` is the merge, the gate lives in pre-receive and there is no authenticated write surface to build. requests/<ulid>/meta.json title, author, base, head, state requests/<ulid>/description.md requests/<ulid>/approvals/<user>.json base + head + created Two push shapes are accepted for a protected branch: the new tip is the approved head, or the new tip is a merge whose parents are the branch tip and the approved head. "The approved head is an ancestor of the new tip" is refused on purpose — accepting it would let anyone append unapproved commits and land them together with an approved one. Admins keep their bypass; `vole acl approvals <repo> <n>` sets the count, and 0 restores admin-only. Three integrity holes found while building it, each now covered by a test: - An approval file names a principal, but nothing tied that name to the pusher, so any writer could commit approvals/<someone-else>.json. The hook now requires a newly added approval to be named after the SSH identity. That check initially skipped the push that *creates* the branch, where there is no old commit to diff against — which let whoever created the branch seed any approval they liked. Fixed to inspect the whole tree on a first push. - Approvals were bound to the head but not the branch, so rewriting a request's base repointed an approval at somewhere the approver never looked. - Self-approval was refused at the CLI but not in validation, so a hand-written tree could carry one. Also relaxes the CLI to accept options after arguments, as git does. Refusing `vole request approve <id> --as keenan` is a local dialect, not git's behaviour, and `git commit -m msg path` is the shape people already type. Exit 129, no bundling and `--`/`--end-of-options` are unchanged. Verified end to end on a real instance with two keys: a writer's direct push to a protected main is refused with a message naming the exact commit; the same push still fails with a request open but unapproved; self-approval fails; an admin approval lets it through; main advances; the web shows the approval as current and the request as already merged; the audit log records the merge with request id and approver. Known rough edge: a merged request stays open until someone closes it. Marking it merged automatically needs the server to write metadata during a push, which the design deliberately does not do. Phase 4 CI is the natural place to close it.
README.md +40−4
| @@ -38,8 +38,10 @@ Working today: | |||
| 38 | 38 | every key added or revoked. A running instance re-reads the directory every few | |
| 39 | 39 | seconds, so `vole user add-key` — or an edit by hand — applies without a restart, | |
| 40 | 40 | and revocation takes effect the same way | |
| 41 | - Issues stored as files in the repository on the `vole` branch, validated at | ||
| 42 | push time and rendered read-only on the web | ||
| 41 | - Issues and change requests stored as files in the repository on the `vole` | ||
| 42 | branch, validated at push time and rendered read-only on the web | ||
| 43 | - Review as a git artifact: an approval names the exact commit it reviewed, and | ||
| 44 | `git push` is the merge, gated by `pre-receive` | ||
| 43 | 45 | - Repo create / rename / delete from the CLI | |
| 44 | 46 | - A derived SQLite index that can be deleted and rebuilt from the repos | |
| 45 | 47 | - An instance ed25519 key generated at first boot (used for metadata signing in | |
| @@ -91,8 +93,8 @@ git add -A && git commit -m "issue: docs page blank" && git push origin vole | |||
| 91 | 93 | ||
| 92 | 94 | That branch holds Vole's metadata and is deliberately narrow: | |
| 93 | 95 | ||
| 94 | - Only `issues/` (and `requests/`, once change requests land) may appear in it; | ||
| 95 | anything else is rejected at push. | ||
| 96 | - Only `issues/` and `requests/` may appear in it; anything else is rejected at | ||
| 97 | push. | ||
| 96 | 98 | - History must be linear. Merge commits are refused, so `git log vole` reads as | |
| 97 | 99 | a list of changes rather than a graph. | |
| 98 | 100 | - Force-pushing it requires repo admin and is written to `data/audit.log`, because | |
| @@ -129,6 +131,40 @@ frontmatter. Closing an issue is editing `state` to `closed`. The push is | |||
| 129 | 131 | rejected with the offending path named if the tree does not match the format, and | |
| 130 | 132 | every issue is a normal commit you can `git log`, `git blame` and merge. | |
| 131 | 133 | ||
| 134 | ## Reviewing a change | ||
| 135 | |||
| 136 | Review is a git artifact, not a web session. An approval records the exact commit | ||
| 137 | that was reviewed, so a branch that moves after approval is not approved any more. | ||
| 138 | Because `git push` is the merge, the gate lives in `pre-receive` and there is no | ||
| 139 | write surface to authenticate: | ||
| 140 | |||
| 141 | ```sh | ||
| 142 | # the author pushes a branch and opens a request | ||
| 143 | git push origin feature:refs/heads/feature | ||
| 144 | vole request new --title "Retry on timeout" --base main --head feature -m "Rationale." | ||
| 145 | |||
| 146 | # an approver reviews that commit and approves it | ||
| 147 | vole request approve <id> --as <your-name> | ||
| 148 | |||
| 149 | # the author lands it; the hook checks the approvals match the new tip | ||
| 150 | git push origin feature:refs/heads/main | ||
| 151 | ``` | ||
| 152 | |||
| 153 | The push is refused unless the new tip *is* the approved commit, or is a merge | ||
| 154 | whose parents are the branch tip and the approved commit. That second shape is why | ||
| 155 | "merge, then push" works while "append a commit and call it approved" does not. | ||
| 156 | |||
| 157 | ```sh | ||
| 158 | vole acl protect demo refs/heads/main # protect a branch | ||
| 159 | vole acl approvals demo 2 # require two approvals (default 1) | ||
| 160 | vole acl approvals demo 0 # back to admin-only | ||
| 161 | ``` | ||
| 162 | |||
| 163 | Admins can push a protected branch directly. An approval can only be written by | ||
| 164 | the person it names — the hook compares the file's name to the SSH identity — so | ||
| 165 | `approvals/keenan.json` cannot be committed by anyone else. Every merge is | ||
| 166 | recorded in `data/audit.log` with the request id and approvers. | ||
| 167 | |||
| 132 | 168 | ## Backup | |
| 133 | 169 | ||
| 134 | 170 | Two things, and both are small: | |