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/workflow/workflow_test.go +124
| @@ -0,0 +1,124 @@ | |||
| 1 | package workflow | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strings" | ||
| 5 | "testing" | ||
| 6 | "time" | ||
| 7 | ) | ||
| 8 | |||
| 9 | const validCI = `version: 1 | ||
| 10 | on: | ||
| 11 | push: | ||
| 12 | branches: [main, "release/*"] | ||
| 13 | jobs: | ||
| 14 | test: | ||
| 15 | steps: | ||
| 16 | - name: build | ||
| 17 | run: go build ./... | ||
| 18 | - run: go test ./... | ||
| 19 | timeout: 20m | ||
| 20 | ` | ||
| 21 | |||
| 22 | func TestValidWorkflowParses(t *testing.T) { | ||
| 23 | wf, problems := Parse(".vole/workflows/ci.yaml", validCI) | ||
| 24 | if len(problems) > 0 { | ||
| 25 | t.Fatalf("valid workflow rejected: %v", problems) | ||
| 26 | } | ||
| 27 | if len(wf.Jobs) != 1 || wf.Jobs[0].ID != "test" { | ||
| 28 | t.Fatalf("jobs = %+v", wf.Jobs) | ||
| 29 | } | ||
| 30 | if got := len(wf.Jobs[0].Steps); got != 2 { | ||
| 31 | t.Fatalf("steps = %d, want 2", got) | ||
| 32 | } | ||
| 33 | if wf.Jobs[0].Steps[0].Timeout != DefaultTimeout { | ||
| 34 | t.Fatalf("default timeout = %s", wf.Jobs[0].Steps[0].Timeout) | ||
| 35 | } | ||
| 36 | if wf.Jobs[0].Steps[1].Timeout != 20*time.Minute { | ||
| 37 | t.Fatalf("explicit timeout = %s", wf.Jobs[0].Steps[1].Timeout) | ||
| 38 | } | ||
| 39 | // A step with no run text of its own gets a readable label from the command. | ||
| 40 | if !strings.Contains(wf.Jobs[0].Steps[1].Name, "go test") { | ||
| 41 | t.Fatalf("step name = %q", wf.Jobs[0].Steps[1].Name) | ||
| 42 | } | ||
| 43 | if !wf.Applies("main") || !wf.Applies("release/1.2") { | ||
| 44 | t.Fatal("branch filters not applied") | ||
| 45 | } | ||
| 46 | if wf.Applies("feature/x") { | ||
| 47 | t.Fatal("branches filter matched an excluded branch") | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | func TestBranchesDefaultToAll(t *testing.T) { | ||
| 52 | wf, problems := Parse("w.yaml", "version: 1\njobs:\n j:\n steps:\n - run: echo hi\n") | ||
| 53 | if len(problems) > 0 { | ||
| 54 | t.Fatalf("rejected: %v", problems) | ||
| 55 | } | ||
| 56 | if !wf.Applies("anything") { | ||
| 57 | t.Fatal("a workflow without branch filters should run everywhere") | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | func TestInvalidWorkflowsRejected(t *testing.T) { | ||
| 62 | cases := []struct { | ||
| 63 | name string | ||
| 64 | src string | ||
| 65 | want string | ||
| 66 | }{ | ||
| 67 | {"no version", "jobs:\n j:\n steps:\n - run: echo\n", "version is required"}, | ||
| 68 | {"bad version", "version: 2\njobs:\n j:\n steps:\n - run: echo\n", "not supported"}, | ||
| 69 | {"no jobs", "version: 1\njobs: {}\n", "at least one job"}, | ||
| 70 | {"no steps", "version: 1\njobs:\n j:\n steps: []\n", "at least one step"}, | ||
| 71 | {"no run", "version: 1\njobs:\n j:\n steps:\n - name: x\n", "run is required"}, | ||
| 72 | {"bad job id", "version: 1\njobs:\n Bad Job:\n steps:\n - run: echo\n", "job names are"}, | ||
| 73 | {"bad timeout", "version: 1\njobs:\n j:\n steps:\n - run: echo\n timeout: soon\n", "not a duration"}, | ||
| 74 | {"huge timeout", "version: 1\njobs:\n j:\n steps:\n - run: echo\n timeout: 5h\n", "exceeds"}, | ||
| 75 | {"typo key", "version: 1\njobs:\n j:\n runs-on: x\n steps:\n - run: echo\n", "is not a known key"}, | ||
| 76 | {"empty", "", "empty"}, | ||
| 77 | {"bad glob", "version: 1\non:\n push:\n branches: [\"a b\"]\njobs:\n j:\n steps:\n - run: echo\n", "not a branch pattern"}, | ||
| 78 | } | ||
| 79 | for _, tc := range cases { | ||
| 80 | t.Run(tc.name, func(t *testing.T) { | ||
| 81 | _, problems := Parse(".vole/workflows/x.yaml", tc.src) | ||
| 82 | if len(problems) == 0 { | ||
| 83 | t.Fatalf("accepted invalid workflow:\n%s", tc.src) | ||
| 84 | } | ||
| 85 | joined := strings.Join(problems, "\n") | ||
| 86 | if !strings.Contains(joined, tc.want) { | ||
| 87 | t.Fatalf("problems do not mention %q:\n%s", tc.want, joined) | ||
| 88 | } | ||
| 89 | if !strings.Contains(joined, ".vole/workflows/x.yaml") { | ||
| 90 | t.Fatalf("problem does not name the file:\n%s", joined) | ||
| 91 | } | ||
| 92 | }) | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | func TestCheckRejectsLayoutMistakes(t *testing.T) { | ||
| 97 | _, problems := Check(map[string]string{ | ||
| 98 | ".vole/workflows/nested/deep.yaml": "version: 1\n", | ||
| 99 | ".vole/workflows/notes.txt": "hello\n", | ||
| 100 | }) | ||
| 101 | joined := strings.Join(problems, "\n") | ||
| 102 | if !strings.Contains(joined, "not in subdirectories") { | ||
| 103 | t.Fatalf("subdirectory accepted:\n%s", joined) | ||
| 104 | } | ||
| 105 | if !strings.Contains(joined, "must end in .yaml or .yml") { | ||
| 106 | t.Fatalf("non-yaml file accepted:\n%s", joined) | ||
| 107 | } | ||
| 108 | } | ||
| 109 | |||
| 110 | // TestEveryProblemIsReported: one rejection should list everything wrong, so a | ||
| 111 | // person fixes the file once instead of one error per push. | ||
| 112 | func TestEveryProblemIsReported(t *testing.T) { | ||
| 113 | _, problems := Parse(".vole/workflows/x.yaml", `version: 2 | ||
| 114 | jobs: | ||
| 115 | A: | ||
| 116 | steps: | ||
| 117 | - name: no command | ||
| 118 | - run: echo | ||
| 119 | timeout: 9h | ||
| 120 | `) | ||
| 121 | if len(problems) < 4 { | ||
| 122 | t.Fatalf("%d problems reported, want 4+:\n%s", len(problems), strings.Join(problems, "\n")) | ||
| 123 | } | ||
| 124 | } | ||