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.
|
c5902451
Keenan Nemetz
16d ago
|
1 | package ci |
|
8fe238d4
Keenan Nemetz
17d ago
|
2 | |
| 3 | import ( | |
|
6c03e461
Keenan Nemetz
16d ago
|
4 | "strconv" |
|
8fe238d4
Keenan Nemetz
17d ago
|
5 | "strings" |
| 6 | "testing" | |
| 7 | "time" | |
| 8 | ) | |
| 9 | ||
| 10 | const validCI = `version: 1 | |
|
1cde8b9e
Keenan Nemetz
8d ago
|
11 | name: Verify main |
| 12 | description: Build, test and lint every push to main. | |
|
8fe238d4
Keenan Nemetz
17d ago
|
13 | on: |
| 14 | push: | |
| 15 | branches: [main, "release/*"] | |
| 16 | jobs: | |
| 17 | test: | |
|
1cde8b9e
Keenan Nemetz
8d ago
|
18 | name: Unit tests |
| 19 | description: Runs the test suite. | |
|
70b70264
Keenan Nemetz
15d ago
|
20 | image: alpine:3.22 |
|
8fe238d4
Keenan Nemetz
17d ago
|
21 | steps: |
| 22 | - name: build | |
| 23 | run: go build ./... | |
| 24 | - run: go test ./... | |
| 25 | timeout: 20m | |
| 26 | ` | |
| 27 | ||
| 28 | func TestValidWorkflowParses(t *testing.T) { | |
|
44530e74
Keenan Nemetz
16d ago
|
29 | t.Parallel() |
|
8fe238d4
Keenan Nemetz
17d ago
|
30 | wf, problems := Parse(".vole/workflows/ci.yaml", validCI) |
| 31 | if len(problems) > 0 { | |
| 32 | t.Fatalf("valid workflow rejected: %v", problems) | |
| 33 | } | |
| 34 | if len(wf.Jobs) != 1 || wf.Jobs[0].ID != "test" { | |
| 35 | t.Fatalf("jobs = %+v", wf.Jobs) | |
| 36 | } | |
|
1cde8b9e
Keenan Nemetz
8d ago
|
37 | if wf.Name != "Verify main" || wf.Description != "Build, test and lint every push to main." { |
| 38 | t.Fatalf("workflow metadata = %q / %q", wf.Name, wf.Description) | |
| 39 | } | |
| 40 | if wf.Jobs[0].Name != "Unit tests" || wf.Jobs[0].Description != "Runs the test suite." { | |
| 41 | t.Fatalf("job metadata = %q / %q", wf.Jobs[0].Name, wf.Jobs[0].Description) | |
| 42 | } | |
|
8fe238d4
Keenan Nemetz
17d ago
|
43 | if got := len(wf.Jobs[0].Steps); got != 2 { |
| 44 | t.Fatalf("steps = %d, want 2", got) | |
| 45 | } | |
|
e7aa0785
Keenan Nemetz
6d ago
|
46 | // A step that declares no timeout keeps no number of its own: what it gets is |
| 47 | // decided when it runs, from the job's budget or DefaultTimeout. | |
| 48 | if wf.Jobs[0].Steps[0].Timeout != 0 { | |
| 49 | t.Fatalf("an undeclared step timeout = %s, want zero", wf.Jobs[0].Steps[0].Timeout) | |
| 50 | } | |
| 51 | if got := StepLimit(-1, wf.Jobs[0].Steps[0].Timeout); got != DefaultTimeout { | |
| 52 | t.Fatalf("an undeclared step runs for %s, want %s", got, DefaultTimeout) | |
|
8fe238d4
Keenan Nemetz
17d ago
|
53 | } |
| 54 | if wf.Jobs[0].Steps[1].Timeout != 20*time.Minute { | |
| 55 | t.Fatalf("explicit timeout = %s", wf.Jobs[0].Steps[1].Timeout) | |
| 56 | } | |
| 57 | // A step with no run text of its own gets a readable label from the command. | |
| 58 | if !strings.Contains(wf.Jobs[0].Steps[1].Name, "go test") { | |
| 59 | t.Fatalf("step name = %q", wf.Jobs[0].Steps[1].Name) | |
| 60 | } | |
| 61 | if !wf.Applies("main") || !wf.Applies("release/1.2") { | |
| 62 | t.Fatal("branch filters not applied") | |
| 63 | } | |
| 64 | if wf.Applies("feature/x") { | |
| 65 | t.Fatal("branches filter matched an excluded branch") | |
| 66 | } | |
| 67 | } | |
| 68 | ||
|
1cde8b9e
Keenan Nemetz
8d ago
|
69 | func TestNamesDefaultToTheFileAndJobID(t *testing.T) { |
| 70 | t.Parallel() | |
| 71 | wf, problems := Parse(".vole/workflows/verify.yaml", | |
| 72 | "version: 1\njobs:\n test:\n image: alpine:3.22\n steps:\n - run: echo hi\n") | |
| 73 | if len(problems) > 0 { | |
| 74 | t.Fatalf("rejected: %v", problems) | |
| 75 | } | |
| 76 | if wf.Name != "verify" { | |
| 77 | t.Fatalf("default workflow name = %q, want the file base name", wf.Name) | |
| 78 | } | |
| 79 | if wf.Description != "" || wf.Jobs[0].Description != "" { | |
| 80 | t.Fatalf("an absent description became %q / %q", wf.Description, wf.Jobs[0].Description) | |
| 81 | } | |
| 82 | if wf.Jobs[0].Name != "test" { | |
| 83 | t.Fatalf("default job name = %q, want the job ID", wf.Jobs[0].Name) | |
| 84 | } | |
| 85 | } | |
| 86 | ||
|
8fe238d4
Keenan Nemetz
17d ago
|
87 | func TestBranchesDefaultToAll(t *testing.T) { |
|
44530e74
Keenan Nemetz
16d ago
|
88 | t.Parallel() |
|
70b70264
Keenan Nemetz
15d ago
|
89 | wf, problems := Parse("w.yaml", "version: 1\njobs:\n j:\n image: alpine:3.22\n steps:\n - run: echo hi\n") |
|
8fe238d4
Keenan Nemetz
17d ago
|
90 | if len(problems) > 0 { |
| 91 | t.Fatalf("rejected: %v", problems) | |
| 92 | } | |
| 93 | if !wf.Applies("anything") { | |
| 94 | t.Fatal("a workflow without branch filters should run everywhere") | |
| 95 | } | |
|
6c84d387
Keenan Nemetz
15d ago
|
96 | if !wf.Applies("contrib/01ACCOUNT/fix") { |
| 97 | t.Fatal("the all-branches default must include contributor branches") | |
| 98 | } | |
|
8fe238d4
Keenan Nemetz
17d ago
|
99 | } |
| 100 | ||
| 101 | func TestInvalidWorkflowsRejected(t *testing.T) { | |
|
44530e74
Keenan Nemetz
16d ago
|
102 | t.Parallel() |
|
8fe238d4
Keenan Nemetz
17d ago
|
103 | cases := []struct { |
| 104 | name string | |
| 105 | src string | |
| 106 | want string | |
| 107 | }{ | |
|
70b70264
Keenan Nemetz
15d ago
|
108 | {"no version", "jobs:\n j:\n image: alpine:3.22\n steps:\n - run: echo\n", "version is required"}, |
| 109 | {"bad version", "version: 2\njobs:\n j:\n image: alpine:3.22\n steps:\n - run: echo\n", "not supported"}, | |
|
8fe238d4
Keenan Nemetz
17d ago
|
110 | {"no jobs", "version: 1\njobs: {}\n", "at least one job"}, |
|
70b70264
Keenan Nemetz
15d ago
|
111 | {"no steps", "version: 1\njobs:\n j:\n image: alpine:3.22\n steps: []\n", "at least one step"}, |
| 112 | {"no run", "version: 1\njobs:\n j:\n image: alpine:3.22\n steps:\n - name: x\n", "run is required"}, | |
| 113 | {"bad job id", "version: 1\njobs:\n Bad Job:\n image: alpine:3.22\n steps:\n - run: echo\n", "job names are"}, | |
| 114 | {"bad timeout", "version: 1\njobs:\n j:\n image: alpine:3.22\n steps:\n - run: echo\n timeout: soon\n", "not a duration"}, | |
| 115 | {"huge timeout", "version: 1\njobs:\n j:\n image: alpine:3.22\n steps:\n - run: echo\n timeout: 5h\n", "exceeds"}, | |
| 116 | {"typo key", "version: 1\njobs:\n j:\n run-on: x\n image: alpine:3.22\n steps:\n - run: echo\n", "is not a known key"}, | |
|
8fe238d4
Keenan Nemetz
17d ago
|
117 | {"empty", "", "empty"}, |
|
70b70264
Keenan Nemetz
15d ago
|
118 | {"bad glob", "version: 1\non:\n push:\n branches: [\"a b\"]\njobs:\n j:\n image: alpine:3.22\n steps:\n - run: echo\n", "not a branch pattern"}, |
|
1cde8b9e
Keenan Nemetz
8d ago
|
119 | {"multiline description", "version: 1\ndescription: \"one\\ntwo\"\njobs:\n j:\n image: alpine:3.22\n steps:\n - run: echo\n", "must be one line"}, |
| 120 | {"long job description", "version: 1\njobs:\n j:\n image: alpine:3.22\n description: \"" + strings.Repeat("x", MaxDescriptionBytes+1) + "\"\n steps:\n - run: echo\n", "limit"}, | |
|
8fe238d4
Keenan Nemetz
17d ago
|
121 | } |
| 122 | for _, tc := range cases { | |
| 123 | t.Run(tc.name, func(t *testing.T) { | |
| 124 | _, problems := Parse(".vole/workflows/x.yaml", tc.src) | |
| 125 | if len(problems) == 0 { | |
| 126 | t.Fatalf("accepted invalid workflow:\n%s", tc.src) | |
| 127 | } | |
| 128 | joined := strings.Join(problems, "\n") | |
| 129 | if !strings.Contains(joined, tc.want) { | |
| 130 | t.Fatalf("problems do not mention %q:\n%s", tc.want, joined) | |
| 131 | } | |
| 132 | if !strings.Contains(joined, ".vole/workflows/x.yaml") { | |
| 133 | t.Fatalf("problem does not name the file:\n%s", joined) | |
| 134 | } | |
| 135 | }) | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | func TestCheckRejectsLayoutMistakes(t *testing.T) { | |
|
44530e74
Keenan Nemetz
16d ago
|
140 | t.Parallel() |
|
8fe238d4
Keenan Nemetz
17d ago
|
141 | _, problems := Check(map[string]string{ |
| 142 | ".vole/workflows/nested/deep.yaml": "version: 1\n", | |
| 143 | ".vole/workflows/notes.txt": "hello\n", | |
| 144 | }) | |
| 145 | joined := strings.Join(problems, "\n") | |
| 146 | if !strings.Contains(joined, "not in subdirectories") { | |
| 147 | t.Fatalf("subdirectory accepted:\n%s", joined) | |
| 148 | } | |
| 149 | if !strings.Contains(joined, "must end in .yaml or .yml") { | |
| 150 | t.Fatalf("non-yaml file accepted:\n%s", joined) | |
| 151 | } | |
| 152 | } | |
| 153 | ||
| 154 | // TestEveryProblemIsReported: one rejection should list everything wrong, so a | |
| 155 | // person fixes the file once instead of one error per push. | |
| 156 | func TestEveryProblemIsReported(t *testing.T) { | |
|
44530e74
Keenan Nemetz
16d ago
|
157 | t.Parallel() |
|
8fe238d4
Keenan Nemetz
17d ago
|
158 | _, problems := Parse(".vole/workflows/x.yaml", `version: 2 |
| 159 | jobs: | |
| 160 | A: | |
|
70b70264
Keenan Nemetz
15d ago
|
161 | image: alpine:3.22 |
|
8fe238d4
Keenan Nemetz
17d ago
|
162 | steps: |
| 163 | - name: no command | |
| 164 | - run: echo | |
| 165 | timeout: 9h | |
| 166 | `) | |
| 167 | if len(problems) < 4 { | |
| 168 | t.Fatalf("%d problems reported, want 4+:\n%s", len(problems), strings.Join(problems, "\n")) | |
| 169 | } | |
| 170 | } | |
|
213f4d6d
Keenan Nemetz
17d ago
|
171 | |
| 172 | // TestArtifactsAreValidatedAtPush is the reason artifacts are declared rather than | |
| 173 | // run as a shell command: a bad path fails the push that introduced it, not a job | |
| 174 | // hours later. | |
| 175 | func TestArtifactsAreValidatedAtPush(t *testing.T) { | |
|
44530e74
Keenan Nemetz
16d ago
|
176 | t.Parallel() |
|
213f4d6d
Keenan Nemetz
17d ago
|
177 | ok := `version: 1 |
| 178 | jobs: | |
| 179 | build: | |
|
70b70264
Keenan Nemetz
15d ago
|
180 | image: alpine:3.22 |
|
213f4d6d
Keenan Nemetz
17d ago
|
181 | steps: |
| 182 | - run: make dist | |
| 183 | artifacts: | |
| 184 | - path: dist/* | |
| 185 | - path: bin/tool-* | |
| 186 | ` | |
| 187 | wf, problems := Parse("ci.yaml", ok) | |
| 188 | if len(problems) > 0 { | |
| 189 | t.Fatalf("a valid declaration was refused: %v", problems) | |
| 190 | } | |
| 191 | if got := wf.Jobs[0].Artifacts; len(got) != 2 || got[0].Path != "dist/*" || got[1].Path != "bin/tool-*" { | |
| 192 | t.Fatalf("artifacts = %+v", got) | |
| 193 | } | |
| 194 | ||
| 195 | bad := []string{ | |
| 196 | " - path: \"\"\n", // empty | |
| 197 | " - path: /etc/passwd\n", // absolute | |
| 198 | " - path: ../outside/*\n", // climbs out | |
| 199 | " - path: dist/\n", // a directory | |
| 200 | " - path: dist/**\n", // a glob people expect to recurse | |
| 201 | " - path: dist/[\n", // not a glob | |
| 202 | " - pathx: dist/*\n", // unknown key | |
| 203 | } | |
| 204 | for _, decl := range bad { | |
|
70b70264
Keenan Nemetz
15d ago
|
205 | body := "version: 1\njobs:\n build:\n image: alpine:3.22\n steps:\n - run: true\n artifacts:\n" + decl |
|
213f4d6d
Keenan Nemetz
17d ago
|
206 | if _, problems := Parse("ci.yaml", body); len(problems) == 0 { |
| 207 | t.Errorf("declaration %q was accepted", strings.TrimSpace(decl)) | |
| 208 | } | |
| 209 | } | |
| 210 | } | |
|
88e980b0
Keenan Nemetz
17d ago
|
211 | |
| 212 | // TestPagesDeclarationIsValidated covers the site deploy declaration: one directory, | |
| 213 | // one job per workflow, and nothing that would make "which tree is the site" a | |
| 214 | // question. | |
| 215 | func TestPagesDeclarationIsValidated(t *testing.T) { | |
|
44530e74
Keenan Nemetz
16d ago
|
216 | t.Parallel() |
|
88e980b0
Keenan Nemetz
17d ago
|
217 | ok := `version: 1 |
| 218 | jobs: | |
| 219 | site: | |
|
70b70264
Keenan Nemetz
15d ago
|
220 | image: alpine:3.22 |
|
88e980b0
Keenan Nemetz
17d ago
|
221 | steps: |
| 222 | - run: make site | |
| 223 | pages: | |
| 224 | path: dist/site/ | |
| 225 | ` | |
| 226 | wf, problems := Parse("ci.yaml", ok) | |
| 227 | if len(problems) > 0 { | |
| 228 | t.Fatalf("a valid pages declaration was refused: %v", problems) | |
| 229 | } | |
| 230 | if wf.Jobs[0].Pages == nil || wf.Jobs[0].Pages.Path != "dist/site" { | |
| 231 | t.Fatalf("pages = %+v, want dist/site without the trailing slash", wf.Jobs[0].Pages) | |
| 232 | } | |
| 233 | ||
| 234 | bad := []string{ | |
| 235 | " path: \"\"\n", // empty | |
| 236 | " path: /var/www\n", // absolute | |
| 237 | " path: ../outside\n", // climbs out | |
| 238 | " path: dist/*\n", // a glob: a site is one tree | |
| 239 | " pathx: dist\n", // unknown key | |
| 240 | } | |
| 241 | for _, decl := range bad { | |
|
70b70264
Keenan Nemetz
15d ago
|
242 | body := "version: 1\njobs:\n site:\n image: alpine:3.22\n steps:\n - run: true\n pages:\n" + decl |
|
88e980b0
Keenan Nemetz
17d ago
|
243 | if _, problems := Parse("ci.yaml", body); len(problems) == 0 { |
| 244 | t.Errorf("pages declaration %q was accepted", strings.TrimSpace(decl)) | |
| 245 | } | |
| 246 | } | |
| 247 | ||
| 248 | // Two publishers would race: the winner would be whichever runner finished last. | |
| 249 | two := `version: 1 | |
| 250 | jobs: | |
| 251 | a: | |
|
70b70264
Keenan Nemetz
15d ago
|
252 | image: alpine:3.22 |
|
88e980b0
Keenan Nemetz
17d ago
|
253 | steps: |
| 254 | - run: true | |
| 255 | pages: | |
| 256 | path: site | |
| 257 | b: | |
|
70b70264
Keenan Nemetz
15d ago
|
258 | image: alpine:3.22 |
|
88e980b0
Keenan Nemetz
17d ago
|
259 | steps: |
| 260 | - run: true | |
| 261 | pages: | |
| 262 | path: other | |
| 263 | ` | |
| 264 | if _, problems := Parse("ci.yaml", two); len(problems) == 0 { | |
| 265 | t.Fatal("two jobs publishing the site were accepted") | |
| 266 | } | |
| 267 | } | |
|
0f442b1c
Keenan Nemetz
17d ago
|
268 | |
| 269 | // TestTagTriggers are the rules a release build depends on: tags have no default, | |
| 270 | // and saying "tags:" means you said what the workflow triggers on. | |
| 271 | func TestTagTriggers(t *testing.T) { | |
|
44530e74
Keenan Nemetz
16d ago
|
272 | t.Parallel() |
|
0f442b1c
Keenan Nemetz
17d ago
|
273 | // Tag-only: no branch triggers, because the workflow said what it runs on. |
| 274 | tagOnly, problems := Parse("ci.yaml", `version: 1 | |
| 275 | on: | |
| 276 | push: | |
| 277 | tags: ['v*'] | |
| 278 | jobs: | |
| 279 | release: | |
|
70b70264
Keenan Nemetz
15d ago
|
280 | image: alpine:3.22 |
|
0f442b1c
Keenan Nemetz
17d ago
|
281 | steps: |
| 282 | - run: make dist | |
| 283 | artifacts: | |
| 284 | - path: dist/* | |
| 285 | release: true | |
| 286 | `) | |
| 287 | if len(problems) > 0 { | |
| 288 | t.Fatalf("a tag-only workflow was refused: %v", problems) | |
| 289 | } | |
| 290 | wf := tagOnly | |
| 291 | if len(wf.Branches) != 0 { | |
| 292 | t.Fatalf("tag-only workflow also runs on branches: %v", wf.Branches) | |
| 293 | } | |
| 294 | if !wf.AppliesToTag("v1.0.0") || wf.AppliesToTag("nightly") { | |
| 295 | t.Fatalf("tag matching is wrong for %v", wf.Tags) | |
| 296 | } | |
| 297 | if !wf.Jobs[0].Artifacts[0].Release { | |
| 298 | t.Fatal("the release flag did not survive parsing") | |
| 299 | } | |
| 300 | ||
| 301 | // Neither list says nothing, so the default is every branch — unchanged behaviour. | |
| 302 | plain, problems := Parse("ci.yaml", `version: 1 | |
| 303 | jobs: | |
| 304 | test: | |
|
70b70264
Keenan Nemetz
15d ago
|
305 | image: alpine:3.22 |
|
0f442b1c
Keenan Nemetz
17d ago
|
306 | steps: |
| 307 | - run: true | |
| 308 | `) | |
| 309 | if len(problems) > 0 || len(plain.Branches) != 1 || plain.Branches[0] != "*" { | |
| 310 | t.Fatalf("a workflow with no triggers = %+v %v", plain, problems) | |
| 311 | } | |
| 312 | ||
| 313 | // Release artifacts on a branch workflow have no tag to attach to. | |
| 314 | if _, problems := Parse("ci.yaml", `version: 1 | |
| 315 | jobs: | |
| 316 | build: | |
|
70b70264
Keenan Nemetz
15d ago
|
317 | image: alpine:3.22 |
|
0f442b1c
Keenan Nemetz
17d ago
|
318 | steps: |
| 319 | - run: true | |
| 320 | artifacts: | |
| 321 | - path: dist/* | |
| 322 | release: true | |
| 323 | `); len(problems) == 0 { | |
| 324 | t.Fatal("release artifacts were accepted without a tag trigger") | |
| 325 | } | |
| 326 | ||
| 327 | // An explicit empty branch list means branches too, alongside tags. | |
| 328 | both, problems := Parse("ci.yaml", `version: 1 | |
| 329 | on: | |
| 330 | push: | |
| 331 | branches: [] | |
| 332 | tags: ['v*'] | |
| 333 | jobs: | |
| 334 | build: | |
|
70b70264
Keenan Nemetz
15d ago
|
335 | image: alpine:3.22 |
|
0f442b1c
Keenan Nemetz
17d ago
|
336 | steps: |
| 337 | - run: true | |
| 338 | `) | |
| 339 | if len(problems) > 0 || len(both.Branches) != 0 || len(both.Tags) != 1 { | |
| 340 | t.Fatalf("branches: [] with tags = %+v %v", both, problems) | |
| 341 | } | |
| 342 | ||
| 343 | // A tag pattern that is not a pattern is refused like a branch one. | |
| 344 | if _, problems := Parse("ci.yaml", `version: 1 | |
| 345 | on: | |
| 346 | push: | |
| 347 | tags: ['../x'] | |
| 348 | jobs: | |
| 349 | build: | |
|
70b70264
Keenan Nemetz
15d ago
|
350 | image: alpine:3.22 |
|
0f442b1c
Keenan Nemetz
17d ago
|
351 | steps: |
| 352 | - run: true | |
| 353 | `); len(problems) == 0 { | |
| 354 | t.Fatal("a tag pattern with .. was accepted") | |
| 355 | } | |
| 356 | } | |
|
6c03e461
Keenan Nemetz
16d ago
|
357 | |
| 358 | // runs-on names the machine a job needs. Both spellings are accepted, the labels are | |
| 359 | // normalised, and a label no runner could ever carry fails the push rather than | |
| 360 | // leaving the job queued forever behind an error nobody sees. | |
| 361 | func TestRunsOnLabelsAreParsedAndValidated(t *testing.T) { | |
|
44530e74
Keenan Nemetz
16d ago
|
362 | t.Parallel() |
|
c5902451
Keenan Nemetz
16d ago
|
363 | // A job that says nothing keeps the pre-targeting behaviour: any agent. |
|
6c03e461
Keenan Nemetz
16d ago
|
364 | wf, problems := Parse("ci.yaml", `version: 1 |
| 365 | jobs: | |
| 366 | build: | |
|
70b70264
Keenan Nemetz
15d ago
|
367 | image: alpine:3.22 |
|
6c03e461
Keenan Nemetz
16d ago
|
368 | steps: |
| 369 | - run: true | |
| 370 | `) | |
| 371 | if len(problems) > 0 { | |
| 372 | t.Fatalf("problems: %v", problems) | |
| 373 | } | |
| 374 | if len(wf.Jobs[0].Labels) != 0 { | |
| 375 | t.Fatalf("labels = %v, want none", wf.Jobs[0].Labels) | |
| 376 | } | |
| 377 | ||
| 378 | // One label as a scalar, several as a list. | |
| 379 | for _, src := range []string{ | |
|
70b70264
Keenan Nemetz
15d ago
|
380 | "version: 1\njobs:\n build:\n runs-on: linux\n image: alpine:3.22\n steps:\n - run: true\n", |
| 381 | "version: 1\njobs:\n build:\n runs-on: [linux]\n image: alpine:3.22\n steps:\n - run: true\n", | |
|
6c03e461
Keenan Nemetz
16d ago
|
382 | } { |
| 383 | wf, problems := Parse("ci.yaml", src) | |
| 384 | if len(problems) > 0 { | |
| 385 | t.Fatalf("problems for %q: %v", src, problems) | |
| 386 | } | |
| 387 | if len(wf.Jobs[0].Labels) != 1 || wf.Jobs[0].Labels[0] != "linux" { | |
| 388 | t.Fatalf("labels = %v, want [linux]", wf.Jobs[0].Labels) | |
| 389 | } | |
| 390 | } | |
| 391 | ||
| 392 | wf, problems = Parse("ci.yaml", `version: 1 | |
| 393 | jobs: | |
| 394 | build: | |
| 395 | runs-on: [linux, arm64, linux] | |
|
70b70264
Keenan Nemetz
15d ago
|
396 | image: alpine:3.22 |
|
6c03e461
Keenan Nemetz
16d ago
|
397 | steps: |
| 398 | - run: true | |
| 399 | `) | |
| 400 | if len(problems) > 0 { | |
| 401 | t.Fatalf("problems: %v", problems) | |
| 402 | } | |
| 403 | // A repeated label is asked for once. | |
| 404 | if got := strings.Join(wf.Jobs[0].Labels, ","); got != "linux,arm64" { | |
| 405 | t.Fatalf("labels = %q, want linux,arm64", got) | |
| 406 | } | |
| 407 | ||
| 408 | // A label that can never be a runner's label is refused, and the message names the | |
| 409 | // rule rather than only the value. | |
| 410 | for _, bad := range []string{"Linux", "-x", "a b", ""} { | |
| 411 | src := "version: 1\njobs:\n build:\n runs-on: [" + strconv.Quote(bad) + "]\n" + | |
| 412 | " steps:\n - run: true\n" | |
| 413 | if _, problems := Parse("ci.yaml", src); len(problems) == 0 { | |
| 414 | t.Fatalf("runs-on label %q was accepted", bad) | |
| 415 | } else if !strings.Contains(strings.Join(problems, " "), "runs-on") { | |
| 416 | t.Fatalf("the problem does not name runs-on: %v", problems) | |
| 417 | } | |
| 418 | } | |
| 419 | ||
| 420 | // Too many labels is describing a machine, which is what --repos is for. | |
| 421 | many := make([]string, MaxLabels+1) | |
| 422 | for i := range many { | |
| 423 | many[i] = "l" + strconv.Itoa(i) | |
| 424 | } | |
| 425 | src := "version: 1\njobs:\n build:\n runs-on: [" + strings.Join(many, ", ") + "]\n" + | |
| 426 | " steps:\n - run: true\n" | |
| 427 | if _, problems := Parse("ci.yaml", src); len(problems) == 0 { | |
| 428 | t.Fatal("a job with too many labels was accepted") | |
| 429 | } | |
| 430 | } | |
|
3b39a84c
Keenan Nemetz
14d ago
|
431 | |
|
d7958024
Keenan Nemetz
6d ago
|
432 | // network asks for the isolation a job needs. It is one of two values, and a value |
| 433 | // no runner could satisfy fails the push rather than leaving the job queued behind an | |
| 434 | // error nobody sees. A job that says nothing keeps the pre-network behaviour. | |
| 435 | func TestJobNetworkIsParsedAndValidated(t *testing.T) { | |
| 436 | t.Parallel() | |
| 437 | // Saying nothing is not the same as saying none: the runner's own default applies. | |
| 438 | wf, problems := Parse("ci.yaml", `version: 1 | |
| 439 | jobs: | |
| 440 | build: | |
| 441 | image: alpine:3.22 | |
| 442 | steps: | |
| 443 | - run: true | |
| 444 | `) | |
| 445 | if len(problems) > 0 { | |
| 446 | t.Fatalf("problems: %v", problems) | |
| 447 | } | |
| 448 | if got := wf.Jobs[0].Network; got != "" { | |
| 449 | t.Fatalf("network = %q, want empty for a job that does not ask", got) | |
| 450 | } | |
| 451 | ||
| 452 | for _, want := range []string{NetworkNone, NetworkBridge} { | |
| 453 | wf, problems := Parse("ci.yaml", "version: 1\njobs:\n build:\n network: "+want+"\n"+ | |
| 454 | " image: alpine:3.22\n steps:\n - run: true\n") | |
| 455 | if len(problems) > 0 { | |
| 456 | t.Fatalf("network %q: %v", want, problems) | |
| 457 | } | |
| 458 | if got := wf.Jobs[0].Network; got != want { | |
| 459 | t.Fatalf("network = %q, want %q", got, want) | |
| 460 | } | |
| 461 | } | |
| 462 | ||
| 463 | // Anything else is refused, and the message names the field so a typo is findable. | |
| 464 | for _, bad := range []string{"Bridge", "host", "docker", "on"} { | |
| 465 | src := "version: 1\njobs:\n build:\n network: " + bad + "\n" + | |
| 466 | " image: alpine:3.22\n steps:\n - run: true\n" | |
| 467 | _, problems := Parse("ci.yaml", src) | |
| 468 | if len(problems) == 0 { | |
| 469 | t.Fatalf("network %s was accepted", bad) | |
| 470 | } | |
| 471 | if !strings.Contains(strings.Join(problems, " "), "network") { | |
| 472 | t.Fatalf("the problem does not name network: %v", problems) | |
| 473 | } | |
| 474 | } | |
| 475 | } | |
| 476 | ||
|
e7aa0785
Keenan Nemetz
6d ago
|
477 | // A job's timeout is the clock its steps share. It is optional, bounded, and a step |
| 478 | // that asks for longer than the job can give is a mistake worth naming at push time | |
| 479 | // rather than discovering when the budget runs out. | |
| 480 | func TestJobTimeoutIsParsedAndValidated(t *testing.T) { | |
| 481 | t.Parallel() | |
| 482 | // Saying nothing keeps the old behaviour: each step is bounded on its own. | |
| 483 | wf, problems := Parse("ci.yaml", `version: 1 | |
| 484 | jobs: | |
| 485 | build: | |
| 486 | image: alpine:3.22 | |
| 487 | steps: | |
| 488 | - run: true | |
| 489 | `) | |
| 490 | if len(problems) > 0 { | |
| 491 | t.Fatalf("problems: %v", problems) | |
| 492 | } | |
| 493 | if got := wf.Jobs[0].Timeout; got != 0 { | |
| 494 | t.Fatalf("timeout = %s, want none for a job that does not budget one", got) | |
| 495 | } | |
| 496 | ||
| 497 | wf, problems = Parse("ci.yaml", `version: 1 | |
| 498 | jobs: | |
| 499 | build: | |
| 500 | image: alpine:3.22 | |
| 501 | timeout: 90m | |
| 502 | steps: | |
| 503 | - run: true | |
| 504 | timeout: 20m | |
| 505 | `) | |
| 506 | if len(problems) > 0 { | |
| 507 | t.Fatalf("problems: %v", problems) | |
| 508 | } | |
| 509 | if got := wf.Jobs[0].Timeout; got != 90*time.Minute { | |
| 510 | t.Fatalf("timeout = %s, want 1h30m", got) | |
| 511 | } | |
| 512 | ||
| 513 | // A budget no runner would honour, or one that is not a duration, fails the push. | |
| 514 | for _, bad := range []string{"0", "-5m", "5h", "soon", "1"} { | |
| 515 | src := "version: 1\njobs:\n build:\n image: alpine:3.22\n timeout: " + | |
| 516 | strconv.Quote(bad) + "\n steps:\n - run: true\n" | |
| 517 | if _, problems := Parse("ci.yaml", src); len(problems) == 0 { | |
| 518 | t.Fatalf("job timeout %q was accepted", bad) | |
| 519 | } else if !strings.Contains(strings.Join(problems, " "), "timeout") { | |
| 520 | t.Fatalf("the problem does not name the timeout: %v", problems) | |
| 521 | } | |
| 522 | } | |
| 523 | ||
| 524 | // A step that asks for more time than the job has could never use it. | |
| 525 | src := `version: 1 | |
| 526 | jobs: | |
| 527 | build: | |
| 528 | image: alpine:3.22 | |
| 529 | timeout: 30m | |
| 530 | steps: | |
| 531 | - run: true | |
| 532 | timeout: 45m | |
| 533 | ` | |
| 534 | _, problems = Parse("ci.yaml", src) | |
| 535 | if len(problems) == 0 { | |
| 536 | t.Fatal("a step budget longer than the job's was accepted") | |
| 537 | } | |
| 538 | if !strings.Contains(strings.Join(problems, " "), "longer than the job's timeout") { | |
| 539 | t.Fatalf("the problem does not explain the budget: %v", problems) | |
| 540 | } | |
| 541 | } | |
| 542 | ||
|
ac541b26
Keenan Nemetz
6d ago
|
543 | // acts-as names the bot account a job acts as. The name is checked at push and the |
| 544 | // account itself when the job is leased, because a roster changes without a push. | |
| 545 | func TestJobActsAsIsParsedAndValidated(t *testing.T) { | |
|
6e1fca51
Keenan Nemetz
6d ago
|
546 | t.Parallel() |
| 547 | wf, problems := Parse("ci.yaml", `version: 1 | |
| 548 | jobs: | |
| 549 | agent: | |
| 550 | image: alpine:3.22 | |
|
ac541b26
Keenan Nemetz
6d ago
|
551 | acts-as: coding-agent |
|
6e1fca51
Keenan Nemetz
6d ago
|
552 | steps: |
| 553 | - run: true | |
| 554 | `) | |
| 555 | if len(problems) > 0 { | |
| 556 | t.Fatalf("problems: %v", problems) | |
| 557 | } | |
| 558 | if got := wf.Jobs[0].Bot; got != "coding-agent" { | |
|
ac541b26
Keenan Nemetz
6d ago
|
559 | t.Fatalf("acts-as = %q", got) |
|
6e1fca51
Keenan Nemetz
6d ago
|
560 | } |
| 561 | for _, bad := range []string{"Coding-Agent", "a", "not an account", "bot!"} { | |
|
ac541b26
Keenan Nemetz
6d ago
|
562 | src := "version: 1\njobs:\n agent:\n image: alpine:3.22\n acts-as: " + strconv.Quote(bad) + |
|
6e1fca51
Keenan Nemetz
6d ago
|
563 | "\n steps:\n - run: true\n" |
| 564 | if _, problems := Parse("ci.yaml", src); len(problems) == 0 { | |
|
ac541b26
Keenan Nemetz
6d ago
|
565 | t.Fatalf("acts-as %q was accepted", bad) |
| 566 | } else if !strings.Contains(strings.Join(problems, " "), "acts-as") { | |
| 567 | t.Fatalf("the problem does not name acts-as: %v", problems) | |
|
6e1fca51
Keenan Nemetz
6d ago
|
568 | } |
| 569 | } | |
|
ac541b26
Keenan Nemetz
6d ago
|
570 | // The field was named `bot` before the account kind had a label of its own, so |
| 571 | // the old spelling is rejected rather than quietly accepted: one key, one way. | |
| 572 | if _, problems := Parse("ci.yaml", "version: 1\njobs:\n agent:\n image: alpine:3.22\n bot: coding-agent\n steps:\n - run: true\n"); len(problems) == 0 { | |
| 573 | t.Fatal("the old bot key was accepted") | |
| 574 | } | |
|
6e1fca51
Keenan Nemetz
6d ago
|
575 | // A workflow cannot replace the variables that point at the credential. |
| 576 | src := `version: 1 | |
| 577 | jobs: | |
| 578 | agent: | |
| 579 | image: alpine:3.22 | |
| 580 | env: | |
| 581 | VOLE_SSH_COMMAND: ssh -i /tmp/other | |
| 582 | steps: | |
| 583 | - run: true | |
| 584 | ` | |
| 585 | if _, problems := Parse("ci.yaml", src); len(problems) == 0 { | |
| 586 | t.Fatal("a workflow replaced a credential variable") | |
| 587 | } | |
| 588 | } | |
| 589 | ||
|
e7aa0785
Keenan Nemetz
6d ago
|
590 | // StepLimit is the one rule the runner and a local run share, so it is checked on |
| 591 | // its own rather than through either of them. | |
| 592 | func TestStepLimit(t *testing.T) { | |
| 593 | t.Parallel() | |
| 594 | // left is -1 when the job has no budget. | |
| 595 | cases := []struct { | |
| 596 | name string | |
| 597 | left string | |
| 598 | own time.Duration | |
| 599 | want time.Duration | |
| 600 | }{ | |
| 601 | {"no job budget, no step timeout", "none", 0, DefaultTimeout}, | |
| 602 | {"no job budget, step timeout", "none", 20 * time.Minute, 20 * time.Minute}, | |
| 603 | {"budget larger than the step", "2h", 20 * time.Minute, 20 * time.Minute}, | |
| 604 | {"step does not declare one", "2h", 0, 2 * time.Hour}, | |
| 605 | {"budget smaller than the step", "10m", 20 * time.Minute, 10 * time.Minute}, | |
| 606 | {"budget spent", "spent", 20 * time.Minute, 0}, | |
| 607 | {"budget spent, no step timeout", "spent", 0, 0}, | |
| 608 | } | |
| 609 | for _, c := range cases { | |
| 610 | left := time.Duration(-1) | |
| 611 | switch c.left { | |
| 612 | case "spent": | |
| 613 | left = 0 | |
| 614 | case "2h": | |
| 615 | left = 2 * time.Hour | |
| 616 | case "10m": | |
| 617 | left = 10 * time.Minute | |
| 618 | } | |
| 619 | if got := StepLimit(left, c.own); got != c.want { | |
| 620 | t.Fatalf("%s: StepLimit(%s, %s) = %s, want %s", c.name, c.left, c.own, got, c.want) | |
| 621 | } | |
| 622 | } | |
| 623 | ||
| 624 | // A deadline in the past is spent, not unlimited, and no deadline is -1. | |
| 625 | now := time.Now() | |
| 626 | if got := BudgetLeft(now.Add(-time.Second), now); got != 0 { | |
| 627 | t.Fatalf("a past deadline left %s, want 0", got) | |
| 628 | } | |
| 629 | if got := BudgetLeft(time.Time{}, now); got != -1 { | |
| 630 | t.Fatalf("no deadline left %s, want -1", got) | |
| 631 | } | |
| 632 | if got := BudgetLeft(now.Add(time.Minute), now); got <= 0 || got > time.Minute { | |
| 633 | t.Fatalf("a minute left = %s", got) | |
| 634 | } | |
| 635 | } | |
| 636 | ||
|
3b39a84c
Keenan Nemetz
14d ago
|
637 | // TestJobEnvironment: a job's env is how it opts in to things the runner provides |
| 638 | // but does not decide, such as the cache at /cache. It is validated like the image | |
| 639 | // definition's env, and it may not replace what the sandbox itself sets. | |
| 640 | func TestJobEnvironment(t *testing.T) { | |
| 641 | t.Parallel() | |
| 642 | body := `version: 1 | |
| 643 | jobs: | |
| 644 | test: | |
| 645 | image: alpine:3.22 | |
| 646 | env: | |
| 647 | GOCACHE: /cache/go-build | |
| 648 | GOFLAGS: "-mod=readonly" | |
| 649 | steps: | |
| 650 | - run: go test ./... | |
| 651 | ` | |
| 652 | wf, problems := Parse("w.yaml", body) | |
| 653 | if len(problems) > 0 { | |
| 654 | t.Fatalf("rejected: %v", problems) | |
| 655 | } | |
| 656 | env := wf.Jobs[0].Env | |
| 657 | if env["GOCACHE"] != "/cache/go-build" || env["GOFLAGS"] != "-mod=readonly" { | |
| 658 | t.Fatalf("env = %v", env) | |
| 659 | } | |
| 660 | } | |
| 661 | ||
| 662 | func TestJobEnvironmentIsValidated(t *testing.T) { | |
| 663 | t.Parallel() | |
| 664 | for name, tc := range map[string]struct{ env, want string }{ | |
| 665 | "bad name": {"bad-name: x", "is not a variable name"}, | |
| 666 | "leading digit": {"1X: x", "is not a variable name"}, | |
| 667 | "newline": {"X: \"one\\ntwo\"", "must be one line"}, | |
| 668 | "reserved CI": {"CI: \"false\"", "set by the runner"}, | |
| 669 | "reserved home": {"HOME: \"/home/ci\"", "set by the runner"}, | |
| 670 | "reserved tmp": {"TMPDIR: \"/var/tmp\"", "set by the runner"}, | |
| 671 | } { | |
| 672 | t.Run(name, func(t *testing.T) { | |
| 673 | t.Parallel() | |
| 674 | body := "version: 1\njobs:\n j:\n image: alpine:3.22\n env:\n " + tc.env + | |
| 675 | "\n steps:\n - run: echo hi\n" | |
| 676 | _, problems := Parse("w.yaml", body) | |
| 677 | if len(problems) == 0 { | |
| 678 | t.Fatalf("%s was accepted", tc.env) | |
| 679 | } | |
| 680 | if !strings.Contains(strings.Join(problems, "\n"), tc.want) { | |
| 681 | t.Fatalf("problems = %v; want one mentioning %q", problems, tc.want) | |
| 682 | } | |
| 683 | }) | |
| 684 | } | |
| 685 | ||
| 686 | // A value that is one byte over the limit is refused, not truncated. | |
| 687 | big := strings.Repeat("x", MaxEnvValue+1) | |
| 688 | body := "version: 1\njobs:\n j:\n image: alpine:3.22\n env:\n BIG: " + big + | |
| 689 | "\n steps:\n - run: echo hi\n" | |
| 690 | if _, problems := Parse("w.yaml", body); len(problems) == 0 { | |
| 691 | t.Fatal("an oversized value was accepted") | |
| 692 | } | |
| 693 | } | |
|
4ac42680
Keenan Nemetz
7d ago
|
694 | |
| 695 | func TestJobSecretsArePinnedByName(t *testing.T) { | |
| 696 | t.Parallel() | |
| 697 | body := `version: 1 | |
| 698 | jobs: | |
| 699 | deploy: | |
| 700 | image: alpine:3.22 | |
| 701 | secrets: [DEPLOY_KEY, REGISTRY_TOKEN] | |
| 702 | steps: | |
| 703 | - run: ./deploy.sh | |
| 704 | publish: | |
| 705 | image: alpine:3.22 | |
| 706 | secrets: DEPLOY_KEY | |
| 707 | steps: | |
| 708 | - run: ./publish.sh | |
| 709 | ` | |
| 710 | wf, problems := Parse("w.yaml", body) | |
| 711 | if len(problems) > 0 { | |
| 712 | t.Fatalf("rejected: %v", problems) | |
| 713 | } | |
| 714 | byID := map[string]Job{} | |
| 715 | for _, job := range wf.Jobs { | |
| 716 | byID[job.ID] = job | |
| 717 | } | |
| 718 | if got := strings.Join(byID["deploy"].Secrets, ","); got != "DEPLOY_KEY,REGISTRY_TOKEN" { | |
| 719 | t.Fatalf("deploy secrets = %q", got) | |
| 720 | } | |
| 721 | // The one-name spelling is the obvious thing to write when there is one. | |
| 722 | if got := strings.Join(byID["publish"].Secrets, ","); got != "DEPLOY_KEY" { | |
| 723 | t.Fatalf("publish secrets = %q", got) | |
| 724 | } | |
| 725 | } | |
| 726 | ||
| 727 | func TestJobSecretsAreValidated(t *testing.T) { | |
| 728 | t.Parallel() | |
| 729 | for name, tc := range map[string]struct{ secrets, want string }{ | |
| 730 | "lower case": {`[deploy_key]`, "is not a secret name"}, | |
| 731 | "leading digit": {`[1KEY]`, "is not a secret name"}, | |
| 732 | "dash": {`[DEPLOY-KEY]`, "is not a secret name"}, | |
| 733 | "reserved": {`[HOME]`, "set by the runner"}, | |
| 734 | "repeated": {`[KEY, KEY]`, "listed twice"}, | |
| 735 | "too long": {`[` + strings.Repeat("A", 65) + `]`, "at most 64"}, | |
| 736 | "empty entry": {`[""]`, "needs a name"}, | |
| 737 | // A mapping is the shape someone writes when they put the value in the file. | |
| 738 | // It is refused by the decoder rather than by a rule of ours. | |
| 739 | "a value not a name": {`[{key: value}]`, "cannot unmarshal"}, | |
| 740 | } { | |
| 741 | t.Run(name, func(t *testing.T) { | |
| 742 | t.Parallel() | |
| 743 | body := "version: 1\njobs:\n j:\n image: alpine:3.22\n secrets: " + tc.secrets + | |
| 744 | "\n steps:\n - run: echo hi\n" | |
| 745 | _, problems := Parse("w.yaml", body) | |
| 746 | if len(problems) == 0 { | |
| 747 | t.Fatalf("%s was accepted", tc.secrets) | |
| 748 | } | |
| 749 | if !strings.Contains(strings.Join(problems, "\n"), tc.want) { | |
| 750 | t.Fatalf("problems = %v; want one mentioning %q", problems, tc.want) | |
| 751 | } | |
| 752 | }) | |
| 753 | } | |
| 754 | } | |
| 755 | ||
| 756 | func TestSecretNamesAreEnvironmentVariableNames(t *testing.T) { | |
| 757 | t.Parallel() | |
| 758 | if err := ValidateSecretName("DEPLOY_KEY_2"); err != nil { | |
| 759 | t.Fatalf("a valid name was refused: %v", err) | |
| 760 | } | |
| 761 | for _, bad := range []string{"", "lower", "WITH-DASH", "1LEADING", strings.Repeat("A", 65)} { | |
| 762 | if err := ValidateSecretName(bad); err == nil { | |
| 763 | t.Fatalf("%q was accepted", bad) | |
| 764 | } | |
| 765 | } | |
| 766 | if !ReservedEnvName("HOME") || ReservedEnvName("DEPLOY_KEY") { | |
| 767 | t.Fatal("the runner's own variables are not distinguished") | |
| 768 | } | |
| 769 | } | |
|
c1929d2f
Keenan Nemetz
5d ago
|
770 | |
| 771 | func TestServicesParseInNameOrder(t *testing.T) { | |
| 772 | t.Parallel() | |
| 773 | wf, problems := Parse(".vole/workflows/x.yaml", `version: 1 | |
| 774 | jobs: | |
| 775 | test: | |
| 776 | image: golang:1.27-bookworm | |
| 777 | network: bridge | |
| 778 | services: | |
| 779 | cache: | |
| 780 | image: redis:8 | |
| 781 | db: | |
| 782 | image: postgres:17 | |
| 783 | env: | |
| 784 | POSTGRES_PASSWORD: test | |
| 785 | PGDATA: /tmp/pgdata | |
| 786 | steps: | |
| 787 | - run: go test ./... | |
| 788 | `) | |
| 789 | if len(problems) > 0 { | |
| 790 | t.Fatalf("rejected: %v", problems) | |
| 791 | } | |
| 792 | svcs := wf.Jobs[0].Services | |
| 793 | if len(svcs) != 2 || svcs[0].ID != "cache" || svcs[1].ID != "db" { | |
| 794 | t.Fatalf("services = %+v, want cache then db", svcs) | |
| 795 | } | |
| 796 | if svcs[1].Image != "postgres:17" || svcs[1].Env["PGDATA"] != "/tmp/pgdata" { | |
| 797 | t.Fatalf("db = %+v", svcs[1]) | |
| 798 | } | |
| 799 | } | |
| 800 | ||
| 801 | func TestServiceProblemsAreRefused(t *testing.T) { | |
| 802 | t.Parallel() | |
| 803 | base := `version: 1 | |
| 804 | jobs: | |
| 805 | test: | |
| 806 | image: golang:1.27-bookworm | |
| 807 | network: bridge | |
| 808 | services: | |
| 809 | db: | |
| 810 | image: postgres:17 | |
| 811 | steps: | |
| 812 | - run: echo hi | |
| 813 | ` | |
| 814 | cases := []struct { | |
| 815 | name string | |
| 816 | src string | |
| 817 | want string | |
| 818 | }{ | |
| 819 | {"no network", strings.Replace(base, " network: bridge\n", "", 1), "need network: bridge"}, | |
| 820 | {"network none", strings.Replace(base, "network: bridge", "network: none", 1), "need network: bridge"}, | |
| 821 | {"no image", strings.Replace(base, " image: postgres:17\n", "", 1), "image is required"}, | |
| 822 | {"image file", strings.Replace(base, "image: postgres:17", "image: file:db", 1), "reserved for image-file"}, | |
| 823 | {"bad name", strings.Replace(base, " db:", " DB:", 1), "service name is"}, | |
| 824 | {"bad env name", strings.Replace(base, " steps:", " env:\n BAD NAME: x\n steps:", 1), "not a variable name"}, | |
| 825 | {"multiline env", strings.Replace(base, " steps:", " env:\n X: \"one\\ntwo\"\n steps:", 1), "one line"}, | |
| 826 | {"unknown key", strings.Replace(base, " image: postgres:17", " image: postgres:17\n ports: [5432]", 1), "not a known key"}, | |
| 827 | } | |
| 828 | for _, tc := range cases { | |
| 829 | t.Run(tc.name, func(t *testing.T) { | |
| 830 | _, problems := Parse(".vole/workflows/x.yaml", tc.src) | |
| 831 | if len(problems) == 0 { | |
| 832 | t.Fatalf("accepted invalid services:\n%s", tc.src) | |
| 833 | } | |
| 834 | joined := strings.Join(problems, "\n") | |
| 835 | if !strings.Contains(joined, tc.want) { | |
| 836 | t.Fatalf("problems do not mention %q:\n%s", tc.want, joined) | |
| 837 | } | |
| 838 | }) | |
| 839 | } | |
| 840 | } |