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 4: only allowed runners green-light a branch

852c067d5fb8226a46574c82077c2fc10bba2eb4 · Keenan Nemetz · 17d ago · parent 5d6db024

Phase 4: only allowed runners green-light a branch

Signing made a result attributable; this makes the attribution matter. A required
check is a claim about a commit, and until now any admin of the repository could
satisfy it — which is exactly the party the check exists to constrain.

`acl.runners` is an allowlist of the accounts whose CI results count for a
repository, and the gate's rule is: a green run counts only when its attestation
names an account on that list. Admins are implicit, because they can rewrite the list
and a protected branch already lets them land anything with no checks at all — so the
list is about everybody else. A writer running CI on their laptop gets their result
recorded and not counted, and the run page says which, so the answer to "why is my
green run not gating?" is where somebody looks for it rather than only in a refusal.

It is deliberately not a role. Read/write answers "may this account touch the
repository"; this answers "may this account's build be believed about it", and
conflating them would make every writer trusted about builds.

The same list decides who may lease a job, since running one executes the
repository's code: the answer arrives before the build rather than after it. That
also loosens the old lease rule, which required admin — a named runner needs no
write access at all.

`vole acl runners <repo>` shows the list, bare names replace it, `+name` adds, and
`none` clears it. There is no `-name`: revoking trust is the operation where being
made to state the whole list is a feature, and the list is printed after every change
so a dropped name is visible at once.

Fixed on the way: the new "skip non-successful runs" test would have dereferenced a
nil run for the no-run case, which TestMissingRunBlocksWithAHint caught as a panic in
the hook rather than a message.

Verified: TestOnlyAllowedRunnersGreenLightABranch walks the whole rule — an unlisted
writer is given no work, and the run page and the refusal both explain it; naming them
a runner lets them lease, run and report (attested to them); revoking the runner
refuses the merge naming the account and the command that would allow it, with the run
page saying "does not count as a check"; allowing them again lets the same green
result land, with nothing else changed.
TestACLRunnersEditsTheList covers show, +add, replacement and the unknown-account
refusal. internal/acl tests pin the rule itself: admins implicit, listed accounts
count, read-only accounts do not, and an empty account never does; plus list
normalisation and the round trip through the ref it is stored on.
scripts/ci.sh green (gofmt, vet, build, markdown links, go test -race ./...).

internal/acl/acl.go +47

@@ -62,6 +62,52 @@ type ACL struct {
62 62 // them before merging. A repository with no workflows is unaffected, because
63 63 // there is nothing to run.
64 64 ChecksRequired *bool `json:"checks_required,omitempty"`
65 // Runners are the accounts whose CI results count as checks here. It is an
66 // allowlist rather than a role because it answers a different question from
67 // read/write: not "may this account touch the repository" but "may this
68 // account's build be believed about it". Naming somebody a runner also lets
69 // them lease jobs for this repository, which means executing its code.
70 //
71 // Admins are implicit: they can rewrite this list, and a protected branch
72 // already lets them land anything with no checks at all, so restricting them
73 // here would be theatre. Everyone else — a writer who runs CI on their laptop
74 // included — gets their result recorded but not counted.
75 Runners []string `json:"runners,omitempty"`
76 }
77
78 // CanReport says whether a CI result reported by this account counts as a check
79 // for this repository. An empty account never counts: an unattributed result is not
80 // a result anybody is answerable for.
81 func (a *ACL) CanReport(accountID string) bool {
82 if accountID == "" {
83 return false
84 }
85 if a.RoleFor(accountID).AtLeast(Admin) {
86 return true
87 }
88 for _, r := range a.Runners {
89 if r == accountID {
90 return true
91 }
92 }
93 return false
94 }
95
96 // SetRunners replaces the allowlist, dropping blanks and duplicates so the file
97 // does not depend on how an operator typed it.
98 func (a *ACL) SetRunners(ids []string) {
99 seen := map[string]bool{}
100 out := a.Runners[:0]
101 for _, id := range ids {
102 id = strings.TrimSpace(id)
103 if id == "" || seen[id] {
104 continue
105 }
106 seen[id] = true
107 out = append(out, id)
108 }
109 sort.Strings(out)
110 a.Runners = out
65 111 }
66 112
67 113 // RequiresChecks resolves the nil-means-true rule.
@@ -110,6 +156,7 @@ func Parse(data []byte) (*ACL, error) {
110 156 return nil, fmt.Errorf("acl entry %q: %w", k, err)
111 157 }
112 158 }
159 a.SetRunners(a.Runners)
113 160 return a, nil
114 161 }
115 162