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.
Phase 3: change requests, with approvals bound to the commit they reviewed
1eeca4e67a65d71217d51b1c41632330225b351a · Keenan Nemetz · 18d 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.
internal/acl/acl.go +15
| @@ -53,6 +53,18 @@ type ACL struct { | |||
| 53 | 53 | Default string `json:"default"` | |
| 54 | 54 | Entries map[string]string `json:"entries"` | |
| 55 | 55 | Protected []string `json:"protected,omitempty"` | |
| 56 | // ApprovalsRequired is how many approvals a change request needs before it | ||
| 57 | // may be pushed to a protected branch. Nil means the default of 1; an | ||
| 58 | // explicit 0 means only admins may push, which is the pre-reviews behaviour. | ||
| 59 | ApprovalsRequired *int `json:"approvals_required,omitempty"` | ||
| 60 | } | ||
| 61 | |||
| 62 | // RequiredApprovals resolves the nil-means-default rule. | ||
| 63 | func (a *ACL) RequiredApprovals() int { | ||
| 64 | if a.ApprovalsRequired == nil { | ||
| 65 | return 1 | ||
| 66 | } | ||
| 67 | return *a.ApprovalsRequired | ||
| 56 | 68 | } | |
| 57 | 69 | ||
| 58 | 70 | func Empty(owner string) *ACL { | |
| @@ -77,6 +89,9 @@ func Parse(data []byte) (*ACL, error) { | |||
| 77 | 89 | if _, err := ParseRole(a.Default); err != nil { | |
| 78 | 90 | return nil, err | |
| 79 | 91 | } | |
| 92 | if a.ApprovalsRequired != nil && *a.ApprovalsRequired < 0 { | ||
| 93 | return nil, fmt.Errorf("approvals_required cannot be negative") | ||
| 94 | } | ||
| 80 | 95 | for k, v := range a.Entries { | |
| 81 | 96 | if _, err := ParseRole(v); err != nil { | |
| 82 | 97 | return nil, fmt.Errorf("acl entry %q: %w", k, err) | |