vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
| 1 | package ci |
| 2 | |
| 3 | import ( |
| 4 | "strconv" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | const validCI = `version: 1 |
| 11 | name: Verify main |
| 12 | description: Build, test and lint every push to main. |
| 13 | on: |
| 14 | push: |
| 15 | branches: [main, "release/*"] |
| 16 | jobs: |
| 17 | test: |
| 18 | name: Unit tests |
| 19 | description: Runs the test suite. |
| 20 | image: alpine:3.22 |
| 21 | steps: |
| 22 | - name: build |
| 23 | run: go build ./... |
| 24 | - run: go test ./... |
| 25 | timeout: 20m |
| 26 | ` |
| 27 | |
| 28 | func TestValidWorkflowParses(t *testing.T) { |
| 29 | t.Parallel() |
| 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 | } |
| 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 | } |
| 43 | if got := len(wf.Jobs[0].Steps); got != 2 { |
| 44 | t.Fatalf("steps = %d, want 2", got) |
| 45 | } |
| 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) |
| 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 | |
| 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 | |
| 87 | func TestBranchesDefaultToAll(t *testing.T) { |
| 88 | t.Parallel() |
| 89 | wf, problems := Parse("w.yaml", "version: 1\njobs:\n j:\n image: alpine:3.22\n steps:\n - run: echo hi\n") |
| 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 | } |
| 96 | if !wf.Applies("contrib/01ACCOUNT/fix") { |
| 97 | t.Fatal("the all-branches default must include contributor branches") |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | func TestInvalidWorkflowsRejected(t *testing.T) { |
| 102 | t.Parallel() |
| 103 | cases := []struct { |
| 104 | name string |
| 105 | src string |
| 106 | want string |
| 107 | }{ |
| 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"}, |
| 110 | {"no jobs", "version: 1\njobs: {}\n", "at least one job"}, |
| 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"}, |
| 117 | {"empty", "", "empty"}, |
| 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"}, |
| 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"}, |
| 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) { |
| 140 | t.Parallel() |
| 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) { |
| 157 | t.Parallel() |
| 158 | _, problems := Parse(".vole/workflows/x.yaml", `version: 2 |
| 159 | jobs: |
| 160 | A: |
| 161 | image: alpine:3.22 |
| 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 | } |
| 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) { |
| 176 | t.Parallel() |
| 177 | ok := `version: 1 |
| 178 | jobs: |
| 179 | build: |
| 180 | image: alpine:3.22 |
| 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 { |
| 205 | body := "version: 1\njobs:\n build:\n image: alpine:3.22\n steps:\n - run: true\n artifacts:\n" + decl |
| 206 | if _, problems := Parse("ci.yaml", body); len(problems) == 0 { |
| 207 | t.Errorf("declaration %q was accepted", strings.TrimSpace(decl)) |
| 208 | } |
| 209 | } |
| 210 | } |
| 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) { |
| 216 | t.Parallel() |
| 217 | ok := `version: 1 |
| 218 | jobs: |
| 219 | site: |
| 220 | image: alpine:3.22 |
| 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 { |
| 242 | body := "version: 1\njobs:\n site:\n image: alpine:3.22\n steps:\n - run: true\n pages:\n" + decl |
| 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: |
| 252 | image: alpine:3.22 |
| 253 | steps: |
| 254 | - run: true |
| 255 | pages: |
| 256 | path: site |
| 257 | b: |
| 258 | image: alpine:3.22 |
| 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 | } |
| 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) { |
| 272 | t.Parallel() |
| 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: |
| 280 | image: alpine:3.22 |
| 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: |
| 305 | image: alpine:3.22 |
| 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: |
| 317 | image: alpine:3.22 |
| 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: |
| 335 | image: alpine:3.22 |
| 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: |
| 350 | image: alpine:3.22 |
| 351 | steps: |
| 352 | - run: true |
| 353 | `); len(problems) == 0 { |
| 354 | t.Fatal("a tag pattern with .. was accepted") |
| 355 | } |
| 356 | } |
| 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) { |
| 362 | t.Parallel() |
| 363 | // A job that says nothing keeps the pre-targeting behaviour: any agent. |
| 364 | wf, problems := Parse("ci.yaml", `version: 1 |
| 365 | jobs: |
| 366 | build: |
| 367 | image: alpine:3.22 |
| 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{ |
| 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", |
| 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] |
| 396 | image: alpine:3.22 |
| 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 | } |
| 431 | |
| 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 | |
| 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 | |
| 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) { |
| 546 | t.Parallel() |
| 547 | wf, problems := Parse("ci.yaml", `version: 1 |
| 548 | jobs: |
| 549 | agent: |
| 550 | image: alpine:3.22 |
| 551 | acts-as: coding-agent |
| 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" { |
| 559 | t.Fatalf("acts-as = %q", got) |
| 560 | } |
| 561 | for _, bad := range []string{"Coding-Agent", "a", "not an account", "bot!"} { |
| 562 | src := "version: 1\njobs:\n agent:\n image: alpine:3.22\n acts-as: " + strconv.Quote(bad) + |
| 563 | "\n steps:\n - run: true\n" |
| 564 | if _, problems := Parse("ci.yaml", src); len(problems) == 0 { |
| 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) |
| 568 | } |
| 569 | } |
| 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 | } |
| 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 | |
| 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 | |
| 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 | } |
| 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 | } |
| 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 | } |
| 841 |