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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 21m ago 4b8c9e1d
history blame raw 27.3 KiB
1package ci
2
3import (
4 "strconv"
5 "strings"
6 "testing"
7 "time"
8)
9
10const validCI = `version: 1
11name: Verify main
12description: Build, test and lint every push to main.
13on:
14 push:
15 branches: [main, "release/*"]
16jobs:
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
28func 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
69func 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
87func 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
101func 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
139func 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.
156func TestEveryProblemIsReported(t *testing.T) {
157 t.Parallel()
158 _, problems := Parse(".vole/workflows/x.yaml", `version: 2
159jobs:
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.
175func TestArtifactsAreValidatedAtPush(t *testing.T) {
176 t.Parallel()
177 ok := `version: 1
178jobs:
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.
215func TestPagesDeclarationIsValidated(t *testing.T) {
216 t.Parallel()
217 ok := `version: 1
218jobs:
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
250jobs:
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.
271func 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
275on:
276 push:
277 tags: ['v*']
278jobs:
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
303jobs:
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
315jobs:
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
329on:
330 push:
331 branches: []
332 tags: ['v*']
333jobs:
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
345on:
346 push:
347 tags: ['../x']
348jobs:
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.
361func 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
365jobs:
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
393jobs:
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.
435func 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
439jobs:
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.
480func 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
484jobs:
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
498jobs:
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
526jobs:
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.
545func TestJobActsAsIsParsedAndValidated(t *testing.T) {
546 t.Parallel()
547 wf, problems := Parse("ci.yaml", `version: 1
548jobs:
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
577jobs:
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.
592func 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.
640func TestJobEnvironment(t *testing.T) {
641 t.Parallel()
642 body := `version: 1
643jobs:
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
662func 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
695func TestJobSecretsArePinnedByName(t *testing.T) {
696 t.Parallel()
697 body := `version: 1
698jobs:
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
727func 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
756func 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
771func TestServicesParseInNameOrder(t *testing.T) {
772 t.Parallel()
773 wf, problems := Parse(".vole/workflows/x.yaml", `version: 1
774jobs:
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
801func TestServiceProblemsAreRefused(t *testing.T) {
802 t.Parallel()
803 base := `version: 1
804jobs:
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