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.

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)