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/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 }