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. | |