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.
Add browser auth, contributor access, and gated CI
6c84d387e5f608360e30484a1a5b1cff41076850 · Keenan Nemetz · 16d ago · parent c36d5073
Add browser auth, contributor access, and gated CI Browser sign-in now uses passkeys. Enrollment links are one-time, sessions are hashed in users.db, and issue/comment/approval writes use the session account. HTTPS public_url is required for WebAuthn. Add the contributor role between read and maintain. Contributors can discuss and open change requests, and may push only refs/heads/contrib/<account-id>/. Contributor CI is held per commit until a maintainer allows the exact tip from the repository page. Rename the write role to maintain. write remains accepted as the historical spelling, and access.Write remains as a Go alias so existing callers and ACL rows keep working. Update the web, CLI, queue schema, and guides for the new flows, and cover the role, namespace, auth, and CI-gating paths in tests.
server/ci.go +62
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "github.com/rodent-software/vole/ci" | |
| 10 | 10 | "github.com/rodent-software/vole/ci/runs" | |
| 11 | 11 | "github.com/rodent-software/vole/core/id" | |
| 12 | "github.com/rodent-software/vole/forge/access" | ||
| 12 | 13 | "github.com/rodent-software/vole/metadata" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| @@ -65,6 +66,20 @@ func (s *Server) enqueue(ev runs.Event) { | |||
| 65 | 66 | if ev.New == "" || strings.HasPrefix(ev.New, "0000000000000000000000000000000000000000") { | |
| 66 | 67 | return // a deletion | |
| 67 | 68 | } | |
| 69 | // A contributor branch is untrusted code. It does not reach a runner until a | ||
| 70 | // writer has allowed this exact tip; the approval is keyed by SHA, so a new | ||
| 71 | // push is a new decision. This check covers both the spool and reconciliation. | ||
| 72 | if _, contributorBranch := access.ContributorRefAccount(ev.Ref); contributorBranch { | ||
| 73 | allowed, err := s.Runs.CIAllowed(ev.Repo, ev.Ref, ev.New) | ||
| 74 | if err != nil { | ||
| 75 | s.Log.Warn("ci: cannot read contributor approval", "repo", ev.Repo, "ref", ev.Ref, "err", err) | ||
| 76 | return | ||
| 77 | } | ||
| 78 | if !allowed { | ||
| 79 | s.Log.Debug("ci: contributor branch is waiting for a maintainer to allow it", "repo", ev.Repo, "ref", ev.Ref) | ||
| 80 | return | ||
| 81 | } | ||
| 82 | } | ||
| 68 | 83 | m, err := s.Repos.Open(ev.Repo) | |
| 69 | 84 | if err != nil { | |
| 70 | 85 | s.Log.Warn("ci: cannot open the pushed repository", "repo", ev.Repo, "err", err) | |
| @@ -226,6 +241,20 @@ func (s *Server) reconcileRefs() { | |||
| 226 | 241 | if held, ok := cursors[ref]; ok && held == tip { | |
| 227 | 242 | continue | |
| 228 | 243 | } | |
| 244 | // A contributor tip without an approval is not owed. Leave the cursor | ||
| 245 | // untouched: the allow action queues the tip, and if that enqueue hits a | ||
| 246 | // transient error the next reconciliation can retry it. | ||
| 247 | if _, contributorBranch := access.ContributorRefAccount(ref); contributorBranch { | ||
| 248 | allowed, err := s.Runs.CIAllowed(m.Name, ref, tip) | ||
| 249 | if err != nil { | ||
| 250 | s.Log.Warn("ci: cannot read contributor approval", "repo", m.Name, "ref", ref, "err", err) | ||
| 251 | continue | ||
| 252 | } | ||
| 253 | if !allowed { | ||
| 254 | s.Log.Debug("ci: contributor branch is waiting for a maintainer to allow it", "repo", m.Name, "ref", ref) | ||
| 255 | continue | ||
| 256 | } | ||
| 257 | } | ||
| 229 | 258 | // enqueue re-checks the tip against the ref, so an event that raced with | |
| 230 | 259 | // another push is dropped there rather than trusted here. | |
| 231 | 260 | s.enqueue(runs.Event{ | |
| @@ -322,3 +351,36 @@ func (s *Server) watchLeases(ctx context.Context) { | |||
| 322 | 351 | } | |
| 323 | 352 | } | |
| 324 | 353 | } | |
| 354 | |||
| 355 | // allowContributorCI is the queue-side half of the "Allow CI" button. The web has | ||
| 356 | // already checked that the approver has maintain access; this re-checks the ref is | ||
| 357 | // still the contributor branch tip that was approved, records the decision, and | ||
| 358 | // queues it. The approval is keyed by SHA, so the next push waits again. | ||
| 359 | func (s *Server) allowContributorCI(repo, ref, sha, approver string) error { | ||
| 360 | if _, ok := access.ContributorRefAccount(ref); !ok { | ||
| 361 | return fmt.Errorf("ci: %s is not a contributor branch", ref) | ||
| 362 | } | ||
| 363 | m, err := s.Repos.Open(repo) | ||
| 364 | if err != nil { | ||
| 365 | return err | ||
| 366 | } | ||
| 367 | tip, err := m.Git().RevParseRaw(ref) | ||
| 368 | if err != nil || tip == "" { | ||
| 369 | return fmt.Errorf("ci: contributor branch %s does not exist", ref) | ||
| 370 | } | ||
| 371 | if tip != sha { | ||
| 372 | return fmt.Errorf("ci: %s moved; approve the new tip", ref) | ||
| 373 | } | ||
| 374 | now := time.Now().UTC() | ||
| 375 | if err := s.Runs.AllowCI(repo, ref, sha, approver, now); err != nil { | ||
| 376 | return err | ||
| 377 | } | ||
| 378 | // Reconciliation may already have recorded this tip while it was unapproved. | ||
| 379 | // Dropping the cursor makes the next pass retry if enqueue hits a transient | ||
| 380 | // error; enqueue is idempotent, so a successful pass costs only a second no-op. | ||
| 381 | if err := s.Runs.DropCursor(repo, ref); err != nil { | ||
| 382 | s.Log.Warn("ci: cannot clear a contributor ref cursor", "repo", repo, "ref", ref, "err", err) | ||
| 383 | } | ||
| 384 | s.enqueue(runs.Event{Repo: repo, Ref: ref, New: sha, At: now.Format(time.RFC3339Nano)}) | ||
| 385 | return nil | ||
| 386 | } | ||