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/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 4 slice 1: workflow format, validated at the push that introduces it

8fe238d48181201272a44d9ba7b8d97342afa4ca · Keenan Nemetz · 17d ago · parent 1e955bfb

Phase 4 slice 1: workflow format, validated at the push that introduces it

.vole/workflows/*.yaml is parsed in pre-receive for any push that touches it, so a
broken workflow fails the push that added it, naming the file and every problem in
it. The alternative — accepting the file and discovering months later that it
never ran — is the failure this exists to prevent.

  version: 1
  on: {push: {branches: [main, "release/*"]}}   # globs, default: every branch
  jobs: {test: {steps: [{run: go test ./..., timeout: 20m}]}}

Strict by construction:

- Unknown keys are rejected rather than ignored, so `runs-on:` or a typo'd `setps:`
  cannot silently do nothing.
- Every problem is reported in one rejection, not the first one only: a person
  fixes the file once instead of once per push.
- Job names, step bodies, timeouts (default 10m, cap 1h) and the directory layout
  are all checked, including files that are not .yaml/.yml and nested directories.
- The validator is in internal/workflow and `vole workflow check` runs the same
  code, so client and server cannot disagree about what is valid.

Validation is scoped by a diff: a push that does not touch the workflow directory
costs one `git diff` and nothing else.

Adds gopkg.in/yaml.v3 (already in the module cache). Nothing executes yet — the
queue, runner and status reporting are the next slice, and the README says so
plainly rather than implying CI runs.

Verified on a live instance: pushing a workflow with a step that runs nothing and a
9h timeout is rejected with both problems named; the corrected file pushes and
`vole workflow list` reports `ci.yaml  on main,release/*  test(2 steps)`.

internal/hook/hook.go +39

@@ -17,6 +17,7 @@ import (
17 17 "github.com/rodent-software/vole/internal/issues"
18 18 "github.com/rodent-software/vole/internal/repo"
19 19 "github.com/rodent-software/vole/internal/requests"
20 "github.com/rodent-software/vole/internal/workflow"
20 21 )
21 22
22 23 type Config struct {
@@ -143,6 +144,17 @@ func PreReceive(cfg Config) int {
143 144 }
144 145 }
145 146
147 // Workflows are executable policy for the CI phase, so the version being
148 // pushed is validated here rather than when a runner picks it up.
149 if strings.HasPrefix(c.ref, "refs/heads/") && c.new != "" && c.new != gitx.ZeroSHA {
150 touches, err := touchesWorkflows(g, c)
151 if err != nil {
152 problems = append(problems, fmt.Sprintf("%s: cannot check %s: %v", c.ref, workflow.Dir, err))
153 } else if touches {
154 problems = append(problems, workflow.ValidateTree(g, c.new)...)
155 }
156 }
157
146 158 if a.IsProtected(c.ref) {
147 159 // Admins keep the bypass they always had. Everyone else needs an
148 160 // approved request that names the exact commit they are landing.
@@ -315,6 +327,33 @@ func approvalOwnership(g *gitx.Repo, c change, actor, actorID string, role acl.R
315 327 return problems
316 328 }
317 329
330 // touchesWorkflows reports whether this push changes anything under the workflow
331 // directory, so repositories without CI pay one diff and no more.
332 func touchesWorkflows(g *gitx.Repo, c change) (bool, error) {
333 if c.old == "" || c.old == gitx.ZeroSHA {
334 blobs, err := g.LsTreeR(c.new)
335 if err != nil {
336 return false, err
337 }
338 for _, b := range blobs {
339 if strings.HasPrefix(b.Name, workflow.Dir+"/") {
340 return true, nil
341 }
342 }
343 return false, nil
344 }
345 status, err := g.DiffStatus(c.old, c.new)
346 if err != nil {
347 return false, err
348 }
349 for p := range status {
350 if strings.HasPrefix(p, workflow.Dir+"/") {
351 return true, nil
352 }
353 }
354 return false, nil
355 }
356
318 357 // openNames returns a function rendering account ids as names, falling back to
319 358 // the id when the roster is unavailable. Display only: nothing is authorized by
320 359 // the result.