ci_test.go
⎇
Raw
1package e2e
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "net/http"
8 "net/url"
9 "os"
10 "path/filepath"
11 "strconv"
12 "strings"
13 "testing"
14 "time"
15)
16
17// The CI suite drives the real pipeline runner against a mock Docker Engine
18// on a unix socket, so no container engine is needed.
19
20const ciSimpleTOML = `
21image = "debian:latest"
22
23[on]
24manual = true
25push = ["main"]
26
27[[steps]]
28name = "hello"
29run_sh = "echo hello"
30`
31
32const ciArtifactTOML = `
33image = "debian:latest"
34work_dir = "/ci"
35
36[on]
37manual = true
38
39[[steps]]
40name = "build"
41run_sh = "echo building"
42publish_file = ["/ci/output.txt"]
43`
44
45// ciEnv starts a server wired to a fresh mock engine and seeds "ci-repo".
46func ciEnv(t *testing.T) (*env, *mockDocker, *session) {
47 t.Helper()
48 m := newMockDocker(t)
49 e := newEnv(t, "CI_DOCKER_SOCKET", m.sock)
50 admin := e.admin()
51 e.createRepo(admin, "ci-repo")
52 e.seedRepo("ci-repo", nil)
53 return e, m, admin
54}
55
56// ciSeedToml pushes a .hearthforge-ci.toml into ci-repo and returns the
57// commit sha. Re-seeding the same content is a no-op commit, so a test can
58// put its config back without failing.
59func ciSeedToml(e *env, toml string) string {
60 t := e.t
61 t.Helper()
62 work := t.TempDir()
63 gitRun(t, work, "clone", "-q", e.repoPath("ci-repo"), ".")
64 if err := os.WriteFile(filepath.Join(work, ".hearthforge-ci.toml"), []byte(toml), 0o644); err != nil {
65 t.Fatal(err)
66 }
67 gitRun(t, work, "add", ".hearthforge-ci.toml")
68 // Nothing to commit when the config is unchanged.
69 _, _ = gitTry(work, "commit", "-q", "-m", "Add CI config")
70 gitRun(t, work, "push", "-q", "origin", "HEAD:main")
71 e.Srv.Git.InvalidateRefCache("ci-repo")
72 return gitRun(t, work, "rev-parse", "HEAD")
73}
74
75// ciTrigger posts the manual trigger and returns the new run id. The route
76// always builds HEAD of the default branch, so the expected sha is checked
77// and a stale fixture fails loudly.
78func ciTrigger(e *env, admin *session, sha string, overrides url.Values) int64 {
79 t := e.t
80 t.Helper()
81 if head := e.headCommit("ci-repo"); head != sha {
82 t.Fatalf("ciTrigger: expected HEAD %s, repo HEAD is %s", sha, head)
83 }
84 if overrides == nil {
85 overrides = url.Values{}
86 }
87 loc := admin.post("/ci-repo/ci/run", overrides).mustRedirect("/ci-repo/ci/")
88 id, err := strconv.ParseInt(idFromPath(t, loc), 10, 64)
89 if err != nil {
90 t.Fatalf("run id in %q: %v", loc, err)
91 }
92 return id
93}
94
95// ciLatestRunID is the highest run id in the database, or 0.
96func ciLatestRunID(e *env) int64 {
97 var id sql.NullInt64
98 if err := e.DB.QueryRowContext(context.Background(),
99 `SELECT MAX(id) FROM ci_runs`).Scan(&id); err != nil {
100 e.t.Fatal(err)
101 }
102 return id.Int64
103}
104
105// ciPushRun pushes a commit to a branch over HTTP and returns the run the
106// push trigger created. The manual route always builds the default branch.
107func ciPushRun(e *env, sha, branch string) int64 {
108 t := e.t
109 t.Helper()
110 before := ciLatestRunID(e)
111 gitRun(t, e.repoPath("ci-repo"), "push", "--force", e.authURL("ci-repo"),
112 sha+":refs/heads/"+branch)
113 e.Srv.Git.InvalidateRefCache("ci-repo")
114 deadline := time.Now().Add(10 * time.Second)
115 for time.Now().Before(deadline) {
116 if id := ciLatestRunID(e); id > before {
117 return id
118 }
119 time.Sleep(50 * time.Millisecond)
120 }
121 t.Fatalf("push to %s did not create a run", branch)
122 return 0
123}
124
125// ciWaitForRun polls until the run leaves pending/running/queued.
126func ciWaitForRun(e *env, runID int64, timeout ...time.Duration) string {
127 t := e.t
128 t.Helper()
129 limit := 15 * time.Second
130 if len(timeout) > 0 {
131 limit = timeout[0]
132 }
133 deadline := time.Now().Add(limit)
134 for time.Now().Before(deadline) {
135 status := ciRunStatus(e, runID)
136 if status != "" && status != "pending" && status != "running" && status != "queued" {
137 return status
138 }
139 time.Sleep(50 * time.Millisecond)
140 }
141 t.Fatalf("run %d did not complete within %s", runID, limit)
142 return ""
143}
144
145func ciRunStatus(e *env, runID int64) string {
146 var status string
147 err := e.DB.QueryRowContext(context.Background(),
148 `SELECT status FROM ci_runs WHERE id = ?`, runID).Scan(&status)
149 if err != nil {
150 e.t.Fatalf("run %d: %v", runID, err)
151 }
152 return status
153}
154
155// ciStepRow is one row of ci_steps.
156type ciStepRow struct {
157 Status string
158 Log string
159}
160
161// ciSteps returns every step of a run with that name, in insertion order.
162func ciSteps(e *env, runID int64, name string) []ciStepRow {
163 rows, err := e.DB.QueryContext(context.Background(),
164 `SELECT status, log FROM ci_steps WHERE run_id = ? AND name = ? ORDER BY id ASC`,
165 runID, name)
166 if err != nil {
167 e.t.Fatal(err)
168 }
169 defer rows.Close()
170 var out []ciStepRow
171 for rows.Next() {
172 var s ciStepRow
173 if err := rows.Scan(&s.Status, &s.Log); err != nil {
174 e.t.Fatal(err)
175 }
176 out = append(out, s)
177 }
178 return out
179}
180
181// ciStep returns the first step of a run with that name.
182func ciStep(e *env, runID int64, name string) ciStepRow {
183 steps := ciSteps(e, runID, name)
184 if len(steps) == 0 {
185 e.t.Fatalf("run %d has no step %q", runID, name)
186 }
187 return steps[0]
188}
189
190// ciOverrides decodes the stored variable overrides of a run.
191func ciOverrides(e *env, runID int64) map[string]string {
192 var raw sql.NullString
193 if err := e.DB.QueryRowContext(context.Background(),
194 `SELECT variable_overrides FROM ci_runs WHERE id = ?`, runID).Scan(&raw); err != nil {
195 e.t.Fatal(err)
196 }
197 out := map[string]string{}
198 if raw.String != "" {
199 if err := json.Unmarshal([]byte(raw.String), &out); err != nil {
200 e.t.Fatalf("overrides %q: %v", raw.String, err)
201 }
202 }
203 return out
204}
205
206func ciRunPath(runID int64) string { return "/ci-repo/ci/" + strconv.FormatInt(runID, 10) }
207
208// eqStrings compares two string lists.
209func eqStrings(a, b []string) bool {
210 if len(a) != len(b) {
211 return false
212 }
213 for i := range a {
214 if a[i] != b[i] {
215 return false
216 }
217 }
218 return true
219}
220
221// ── pipelines tab ────────────────────────────────────────────────────────
222
223func TestCIPipelinesTab(t *testing.T) {
224 e, m, admin := ciEnv(t)
225
226 t.Run("tab is visible in repo nav", func(t *testing.T) {
227 r := admin.get("/ci-repo").mustStatus(200)
228 if !contains(r.Texts(".repo-tab"), "Pipelines") {
229 t.Errorf("repo tabs = %v", r.Texts(".repo-tab"))
230 }
231 })
232
233 t.Run("history page shows empty state when no runs", func(t *testing.T) {
234 r := admin.get("/ci-repo/ci").mustStatus(200)
235 if !r.Has(".empty-state") {
236 t.Fatal("empty state missing")
237 }
238 if !strings.Contains(r.Text(".empty-state"), "No pipeline runs yet") {
239 t.Errorf("empty state = %q", r.Text(".empty-state"))
240 }
241 })
242
243 t.Run("the run form posts and overrides a declared variable", func(t *testing.T) {
244 // Regression: an input-less form posted an empty body and the route
245 // crashed whenever the config declared a variable.
246 sha := ciSeedToml(e, `
247image = "debian:latest"
248
249[on]
250manual = true
251
252[variables]
253 [variables.GREETING]
254 default = "hello"
255 description = "What to echo"
256
257[[steps]]
258name = "say"
259run_sh = "echo $GREETING"
260`)
261 m.reset()
262 m.queueExec(execResp{output: "hi\n"})
263
264 r := admin.get("/ci-repo/ci").mustStatus(200)
265 if got := r.Value(`input[name="var_GREETING"]`); got != "hello" {
266 t.Fatalf("var_GREETING default = %q", got)
267 }
268 runID := ciTrigger(e, admin, sha, url.Values{"var_GREETING": {"goodbye"}})
269 ciWaitForRun(e, runID)
270
271 got := ciOverrides(e, runID)
272 if len(got) != 1 || got["GREETING"] != "goodbye" {
273 t.Errorf("overrides = %v", got)
274 }
275 })
276
277 t.Run("an untouched variable field is not recorded as an override", func(t *testing.T) {
278 sha := ciSeedToml(e, `
279image = "debian:latest"
280
281[on]
282manual = true
283
284[variables]
285 [variables.GREETING]
286 default = "hello"
287
288[[steps]]
289name = "say"
290run_sh = "echo $GREETING"
291`)
292 m.reset()
293 m.queueExec(execResp{output: "hi\n"})
294
295 runID := ciTrigger(e, admin, sha, url.Values{"var_GREETING": {"hello"}})
296 ciWaitForRun(e, runID)
297
298 if got := ciOverrides(e, runID); len(got) != 0 {
299 t.Errorf("overrides = %v, want none", got)
300 }
301 })
302
303 t.Run("an empty POST to the run route does not crash", func(t *testing.T) {
304 sha := ciSeedToml(e, `
305image = "debian:latest"
306
307[on]
308manual = true
309
310[variables]
311 [variables.GREETING]
312 default = "hello"
313
314[[steps]]
315name = "say"
316run_sh = "echo $GREETING"
317`)
318 _ = sha
319 m.reset()
320 m.queueExec(execResp{output: "hi\n"})
321
322 r := admin.post("/ci-repo/ci/run", url.Values{})
323 if r.Code != http.StatusFound {
324 t.Fatalf("status = %d, body %s", r.Code, r.BodyString())
325 }
326 id, err := strconv.ParseInt(idFromPath(t, r.Location()), 10, 64)
327 if err != nil {
328 t.Fatal(err)
329 }
330 ciWaitForRun(e, id)
331 })
332
333 t.Run("help section is collapsible and contains template download", func(t *testing.T) {
334 r := admin.get("/ci-repo/ci").mustStatus(200)
335 if !r.Has("details.ci-help") {
336 t.Error("help section missing")
337 }
338 if !r.Has(`a[download=".hearthforge-ci.toml"]`) {
339 t.Error("template download link missing")
340 }
341 })
342}
343
344// ── successful run ───────────────────────────────────────────────────────
345
346func TestCISuccessfulRun(t *testing.T) {
347 e, m, admin := ciEnv(t)
348 m.queueExec(execResp{output: "hello from mock CI\n"})
349 sha := ciSeedToml(e, ciSimpleTOML)
350 runID := ciTrigger(e, admin, sha, nil)
351 ciWaitForRun(e, runID)
352
353 t.Run("run status is success", func(t *testing.T) {
354 if got := ciRunStatus(e, runID); got != "success" {
355 t.Errorf("status = %q", got)
356 }
357 })
358
359 t.Run("step status is success and log is captured", func(t *testing.T) {
360 step := ciStep(e, runID, "hello")
361 if step.Status != "success" {
362 t.Errorf("step status = %q", step.Status)
363 }
364 if !strings.Contains(step.Log, "hello from mock CI") {
365 t.Errorf("step log = %q", step.Log)
366 }
367 })
368
369 t.Run("history page shows the completed run", func(t *testing.T) {
370 r := admin.get("/ci-repo/ci").mustStatus(200)
371 if r.Count(".ci-status-pill.ci-status-success") == 0 {
372 t.Error("no success pill on the history page")
373 }
374 })
375
376 t.Run("run detail page shows step and log", func(t *testing.T) {
377 r := admin.get(ciRunPath(runID)).mustStatus(200)
378 steps := r.Texts(".ci-step")
379 if len(steps) < 2 {
380 t.Fatalf("steps = %v", steps)
381 }
382 // Setup is a real step and sorts before the config's steps.
383 if !strings.Contains(steps[0], "pipeline setup") || !strings.Contains(steps[0], "success") {
384 t.Errorf("first step = %q", steps[0])
385 }
386 if !strings.Contains(steps[1], "hello") {
387 t.Errorf("second step = %q", steps[1])
388 }
389 if !contains(r.Texts(".ci-step-log"), "hello from mock CI") {
390 t.Errorf("logs = %v", r.Texts(".ci-step-log"))
391 }
392 })
393
394 t.Run("retry re-executes the same run in-place", func(t *testing.T) {
395 r := admin.post(ciRunPath(runID)+"/retry", url.Values{})
396 if got := r.mustRedirect(ciRunPath(runID)); got != ciRunPath(runID) {
397 t.Errorf("redirect = %q", got)
398 }
399 if got := ciWaitForRun(e, runID); got != "success" {
400 t.Errorf("status after retry = %q", got)
401 }
402 // No new run was created: the row still exists under the same id.
403 if ciRunStatus(e, runID) != "success" {
404 t.Error("run row changed")
405 }
406 })
407}
408
409// ── failing run ──────────────────────────────────────────────────────────
410
411func TestCIFailingRun(t *testing.T) {
412 e, m, admin := ciEnv(t)
413 m.queueExec(execResp{output: "build error: file not found\n", exitCode: 1})
414 sha := ciSeedToml(e, ciSimpleTOML)
415 runID := ciTrigger(e, admin, sha, nil)
416 ciWaitForRun(e, runID)
417
418 t.Run("run status is failure", func(t *testing.T) {
419 if got := ciRunStatus(e, runID); got != "failure" {
420 t.Errorf("status = %q", got)
421 }
422 })
423
424 t.Run("step status is failure and error log captured", func(t *testing.T) {
425 step := ciStep(e, runID, "hello")
426 if step.Status != "failure" {
427 t.Errorf("step status = %q", step.Status)
428 }
429 if !strings.Contains(step.Log, "build error") {
430 t.Errorf("step log = %q", step.Log)
431 }
432 })
433
434 t.Run("run detail page shows failure status", func(t *testing.T) {
435 r := admin.get(ciRunPath(runID)).mustStatus(200)
436 if r.Count(".ci-status-pill.ci-status-failure") == 0 {
437 t.Error("no failure pill on the run page")
438 }
439 })
440}
441
442// ── cancel ───────────────────────────────────────────────────────────────
443
444func TestCICancel(t *testing.T) {
445 e, _, admin := ciEnv(t)
446
447 t.Run("cancelling a pending run marks it cancelled", func(t *testing.T) {
448 sha := ciSeedToml(e, ciSimpleTOML)
449 runID := ciTrigger(e, admin, sha, nil)
450 admin.post(ciRunPath(runID)+"/cancel", url.Values{}).mustRedirect(ciRunPath(runID))
451
452 status := ciWaitForRun(e, runID)
453 switch status {
454 case "cancelled", "success", "failure":
455 default:
456 t.Fatalf("status = %q", status)
457 }
458 })
459}
460
461// ── artifacts ────────────────────────────────────────────────────────────
462
463func TestCIArtifacts(t *testing.T) {
464 e, _, admin := ciEnv(t)
465 sha := ciSeedToml(e, ciArtifactTOML)
466 runID := ciTrigger(e, admin, sha, nil)
467 ciWaitForRun(e, runID)
468
469 var artifactID int64
470 var filename string
471 var count int
472 rows, err := e.DB.QueryContext(context.Background(),
473 `SELECT id, filename FROM ci_artifacts WHERE run_id = ? ORDER BY id`, runID)
474 if err != nil {
475 t.Fatal(err)
476 }
477 for rows.Next() {
478 if err := rows.Scan(&artifactID, &filename); err != nil {
479 t.Fatal(err)
480 }
481 count++
482 }
483 rows.Close()
484
485 t.Run("artifact row created in DB", func(t *testing.T) {
486 if count != 1 {
487 t.Fatalf("artifacts = %d", count)
488 }
489 if filename != "output.txt" {
490 t.Errorf("filename = %q", filename)
491 }
492 })
493
494 t.Run("artifact is downloadable via HTTP", func(t *testing.T) {
495 if artifactID <= 0 {
496 t.Fatal("no artifact id")
497 }
498 r := e.anon().get(ciRunPath(runID) + "/artifacts/" + strconv.FormatInt(artifactID, 10))
499 r.mustStatus(200)
500 if r.BodyString() != "artifact-content-123" {
501 t.Errorf("body = %q", r.BodyString())
502 }
503 })
504
505 t.Run("run detail page shows artifact list", func(t *testing.T) {
506 r := admin.get(ciRunPath(runID)).mustStatus(200)
507 if r.Count(".ci-artifact-item") == 0 {
508 t.Fatal("no artifact items")
509 }
510 if !strings.Contains(r.Text(".ci-artifact-name"), "output.txt") {
511 t.Errorf("artifact name = %q", r.Text(".ci-artifact-name"))
512 }
513 })
514}
515
516// ── badge ────────────────────────────────────────────────────────────────
517
518func TestCIBadge(t *testing.T) {
519 e, m, admin := ciEnv(t)
520 m.queueExec(execResp{output: "ok\n"})
521 sha := ciSeedToml(e, ciSimpleTOML)
522 runID := ciTrigger(e, admin, sha, nil)
523 ciWaitForRun(e, runID)
524
525 t.Run("badge SVG returns success status after successful run", func(t *testing.T) {
526 r := e.anon().get("/ci-repo/ci/badge.svg").mustStatus(200)
527 if !strings.Contains(r.Header.Get("Content-Type"), "image/svg+xml") {
528 t.Errorf("content type = %q", r.Header.Get("Content-Type"))
529 }
530 if !r.Contains("<svg") || !r.Contains("success") {
531 t.Errorf("badge = %q", r.BodyString())
532 }
533 })
534
535 t.Run("badge returns 404 for private repo when not logged in", func(t *testing.T) {
536 e.createRepo(admin, "private-ci-repo", "is_private", "1")
537 e.anon().get("/private-ci-repo/ci/badge.svg").mustStatus(404)
538 })
539}
540
541// ── secrets ──────────────────────────────────────────────────────────────
542
543func TestCISecrets(t *testing.T) {
544 e, m, admin := ciEnv(t)
545
546 t.Run("can add, list, and delete a secret via settings", func(t *testing.T) {
547 admin.post("/ci-repo/settings/ci-secrets", url.Values{
548 "name": {"MY_SECRET"}, "value": {"super-secret-value"},
549 "description": {"A test secret"},
550 }).mustRedirect("/ci-repo/settings")
551
552 r := admin.get("/ci-repo/settings").mustStatus(200)
553 if n := len(matchingTexts(r.Texts("code"), "MY_SECRET")); n != 1 {
554 t.Fatalf("MY_SECRET shown %d times", n)
555 }
556 if !r.Contains("●●●●●●") {
557 t.Error("secret value is not masked")
558 }
559
560 id := r.Value(`form[action="/ci-repo/settings/ci-secrets/delete"] input[name=id]`)
561 if id == "" {
562 t.Fatal("no delete form for the secret")
563 }
564 admin.post("/ci-repo/settings/ci-secrets/delete", url.Values{"id": {id}}).
565 mustRedirect("/ci-repo/settings")
566
567 r = admin.get("/ci-repo/settings").mustStatus(200)
568 if n := len(matchingTexts(r.Texts("code"), "MY_SECRET")); n != 0 {
569 t.Errorf("MY_SECRET still shown %d times", n)
570 }
571 })
572
573 t.Run("secret value is masked in step logs", func(t *testing.T) {
574 admin.post("/ci-repo/settings/ci-secrets", url.Values{
575 "name": {"MASK_ME"}, "value": {"s3cr3t-p4ssw0rd"},
576 }).mustRedirect("/ci-repo/settings")
577
578 m.reset()
579 m.queueExec(execResp{output: "s3cr3t-p4ssw0rd is the value\n"})
580 sha := ciSeedToml(e, ciSimpleTOML)
581 runID := ciTrigger(e, admin, sha, nil)
582 ciWaitForRun(e, runID)
583
584 step := ciStep(e, runID, "hello")
585 if strings.Contains(step.Log, "s3cr3t-p4ssw0rd") {
586 t.Errorf("secret leaked into the log: %q", step.Log)
587 }
588 if !strings.Contains(step.Log, "[MASKED]") {
589 t.Errorf("log = %q", step.Log)
590 }
591 })
592}
593
594// matchingTexts keeps the entries containing sub.
595func matchingTexts(list []string, sub string) []string {
596 var out []string
597 for _, s := range list {
598 if strings.Contains(s, sub) {
599 out = append(out, s)
600 }
601 }
602 return out
603}
604
605// ── per-repo run IDs ─────────────────────────────────────────────────────
606
607func TestCIPerRepoRunIDs(t *testing.T) {
608 e, m, admin := ciEnv(t)
609 sha := ciSeedToml(e, ciSimpleTOML)
610 for range 2 {
611 m.reset()
612 ciWaitForRun(e, ciTrigger(e, admin, sha, nil))
613 }
614
615 t.Run("repo_run_id is set and increments per repo", func(t *testing.T) {
616 rows, err := e.DB.QueryContext(context.Background(),
617 `SELECT repo_run_id FROM ci_runs ORDER BY repo_run_id ASC`)
618 if err != nil {
619 t.Fatal(err)
620 }
621 defer rows.Close()
622 i := 0
623 for rows.Next() {
624 var id sql.NullInt64
625 if err := rows.Scan(&id); err != nil {
626 t.Fatal(err)
627 }
628 if !id.Valid || id.Int64 <= 0 {
629 t.Fatal("repo_run_id is not set")
630 }
631 i++
632 if id.Int64 != int64(i) {
633 t.Fatalf("repo_run_id %d at position %d", id.Int64, i)
634 }
635 }
636 if i == 0 {
637 t.Fatal("no runs")
638 }
639 })
640
641 t.Run("run detail page shows repo-local run number", func(t *testing.T) {
642 var runID, repoRunID int64
643 if err := e.DB.QueryRowContext(context.Background(),
644 `SELECT id, repo_run_id FROM ci_runs ORDER BY id ASC LIMIT 1`).
645 Scan(&runID, &repoRunID); err != nil {
646 t.Fatal(err)
647 }
648 r := admin.get(ciRunPath(runID)).mustStatus(200)
649 want := "#" + strconv.FormatInt(repoRunID, 10)
650 if !strings.Contains(r.Text("h2"), want) {
651 t.Errorf("heading = %q, want %q", r.Text("h2"), want)
652 }
653 })
654}
655
656// ── skip reasons ─────────────────────────────────────────────────────────
657
658const ciSkipIfTOML = `
659image = "debian:latest"
660
661[on]
662manual = true
663
664[[steps]]
665name = "first"
666run_sh = "echo first"
667
668[[steps]]
669name = "second"
670run_if = "false"
671run_sh = "echo second"
672
673[[steps]]
674name = "third"
675run_sh = "echo third"
676`
677
678func TestCISkipReasons(t *testing.T) {
679 e, m, admin := ciEnv(t)
680
681 t.Run("run_if failure sets skip reason in log", func(t *testing.T) {
682 sha := ciSeedToml(e, ciSkipIfTOML)
683 m.reset()
684 m.queueExec(execResp{output: "first\n"}) // first step
685 m.queueExec(execResp{exitCode: 1}) // run_if check of second
686 m.queueExec(execResp{output: "third\n"}) // third step
687 runID := ciTrigger(e, admin, sha, nil)
688 ciWaitForRun(e, runID)
689
690 step := ciStep(e, runID, "second")
691 if step.Status != "skipped" {
692 t.Errorf("status = %q", step.Status)
693 }
694 if !strings.Contains(step.Log, "condition not met") {
695 t.Errorf("log = %q", step.Log)
696 }
697 })
698
699 t.Run("failed step causes remaining steps to be skipped with reason", func(t *testing.T) {
700 sha := ciSeedToml(e, ciSkipIfTOML)
701 m.reset()
702 m.queueExec(execResp{output: "boom\n", exitCode: 1}) // first step fails
703 runID := ciTrigger(e, admin, sha, nil)
704 ciWaitForRun(e, runID)
705
706 step := ciStep(e, runID, "third")
707 if step.Status != "skipped" {
708 t.Errorf("status = %q", step.Status)
709 }
710 if !strings.Contains(step.Log, "previous step failed") {
711 t.Errorf("log = %q", step.Log)
712 }
713 })
714}
715
716// ── docker unavailable ───────────────────────────────────────────────────
717
718func TestCIDockerUnavailable(t *testing.T) {
719 // newEnv points CI_DOCKER_SOCKET at a path that does not exist.
720 e := newEnv(t)
721 admin := e.admin()
722 e.createRepo(admin, "ci-repo")
723 e.seedRepo("ci-repo", nil)
724
725 t.Run("run is marked skipped when docker socket is missing", func(t *testing.T) {
726 sha := ciSeedToml(e, ciSimpleTOML)
727 runID := ciTrigger(e, admin, sha, nil)
728 if got := ciWaitForRun(e, runID); got != "skipped" {
729 t.Errorf("status = %q", got)
730 }
731 })
732}
733
734// ── manual trigger without on.manual ─────────────────────────────────────
735
736const ciNoManualTOML = `
737image = "debian:latest"
738
739[on]
740push = ["main"]
741
742[[steps]]
743name = "hello"
744run_sh = "echo hi"
745`
746
747func TestCIManualTriggerWithoutOnManual(t *testing.T) {
748 e, m, admin := ciEnv(t)
749
750 t.Run("manual run is allowed even without manual = true in config", func(t *testing.T) {
751 sha := ciSeedToml(e, ciNoManualTOML)
752 m.reset()
753 m.queueExec(execResp{output: "hi\n"})
754 runID := ciTrigger(e, admin, sha, nil)
755 if got := ciWaitForRun(e, runID); got != "success" {
756 t.Errorf("status = %q", got)
757 }
758 })
759
760 t.Run("Run pipeline button is not disabled when toml lacks manual = true", func(t *testing.T) {
761 ciSeedToml(e, ciNoManualTOML)
762 r := admin.get("/ci-repo/ci").mustStatus(200)
763 if !contains(r.Texts("button"), "Run pipeline") {
764 t.Fatalf("buttons = %v", r.Texts("button"))
765 }
766 if r.Has("button[disabled]") {
767 t.Error("the Run pipeline button is disabled")
768 }
769 })
770}
771
772// ── auto-refresh toggle ──────────────────────────────────────────────────
773
774func TestCIAutoRefreshToggle(t *testing.T) {
775 e, _, admin := ciEnv(t)
776
777 t.Run("Pause refresh button appears on active run and ?refresh=off shows Resume", func(t *testing.T) {
778 sha := ciSeedToml(e, ciSimpleTOML)
779 runID := ciTrigger(e, admin, sha, nil)
780
781 // The run may already have finished, so only the refresh link's
782 // existence is checked, exactly as the browser test did.
783 admin.get(ciRunPath(runID)).mustStatus(200)
784
785 r := admin.get(ciRunPath(runID) + "?refresh=off").mustStatus(200)
786 if n := r.Count(`meta[http-equiv="refresh"]`); n != 0 {
787 t.Errorf("meta refresh count = %d", n)
788 }
789 ciWaitForRun(e, runID)
790 })
791}
792
793// ── purge cache ──────────────────────────────────────────────────────────
794
795func TestCIPurgeCache(t *testing.T) {
796 _, _, admin := ciEnv(t)
797
798 t.Run("Purge caches button is visible and submits successfully", func(t *testing.T) {
799 r := admin.get("/ci-repo/ci").mustStatus(200)
800 if !contains(r.Texts("button"), "Purge caches") {
801 t.Fatalf("buttons = %v", r.Texts("button"))
802 }
803 redirect := admin.post("/ci-repo/ci/purge-cache", url.Values{})
804 redirect.mustRedirect("/ci-repo/ci")
805
806 r = admin.follow(redirect).mustStatus(200)
807 if !strings.Contains(r.Text("h2"), "Pipelines") {
808 t.Errorf("heading = %q", r.Text("h2"))
809 }
810 msg := r.Text(".form-success, .form-error")
811 if !strings.Contains(strings.ToLower(msg), "purge") {
812 t.Errorf("message = %q", msg)
813 }
814 })
815}
816
817// ── repo upload ──────────────────────────────────────────────────────────
818
819const ciCloneTOML = `
820image = "debian:latest"
821work_dir = "/ci/build"
822clone_project_to = "/ci/build/project"
823
824[on]
825manual = true
826
827[[steps]]
828name = "hello"
829run_sh = "echo hi"
830`
831
832func TestCIRepoUpload(t *testing.T) {
833 e, m, admin := ciEnv(t)
834 ciSeedToml(e, ciCloneTOML)
835
836 // Sibling subtests reseed the config, and the trigger route always builds
837 // HEAD. Put the clone config back first.
838 trigger := func() int64 {
839 sha := ciSeedToml(e, ciCloneTOML)
840 return ciTrigger(e, admin, sha, nil)
841 }
842
843 t.Run("the checkout is uploaded, not bind-mounted", func(t *testing.T) {
844 m.reset()
845 runID := trigger()
846 if got := ciWaitForRun(e, runID); got != "success" {
847 t.Fatalf("status = %q", got)
848 }
849 if !contains(m.uploadedPaths(), "/ci/build/project") {
850 t.Fatalf("uploads = %v", m.uploadedPaths())
851 }
852 if len(m.uploadsTo("/ci/build/project")[0].body) == 0 {
853 t.Error("the checkout upload is empty")
854 }
855 binds, _ := json.Marshal(m.createBody()["HostConfig"])
856 if strings.Contains(string(binds), e.DataDir) {
857 t.Errorf("binds reference the data directory: %s", binds)
858 }
859 })
860
861 t.Run("the container never runs git", func(t *testing.T) {
862 m.reset()
863 runID := trigger()
864 if got := ciWaitForRun(e, runID); got != "success" {
865 t.Fatalf("status = %q", got)
866 }
867 if strings.Contains(m.allCommandText(), "git") {
868 t.Errorf("commands = %v", m.commands())
869 }
870 })
871
872 t.Run("a failing checkout fails the run before any step runs", func(t *testing.T) {
873 m.reset()
874 m.queueExec(execResp{}) // mkdir work_dir
875 m.queueExec(execResp{output: "mkdir: read-only\n", exitCode: 1}) // mkdir dest
876
877 runID := trigger()
878 if got := ciWaitForRun(e, runID); got != "failure" {
879 t.Fatalf("status = %q", got)
880 }
881 if got := ciStep(e, runID, "hello").Status; got != "skipped" {
882 t.Errorf("hello status = %q", got)
883 }
884 setup := ciStep(e, runID, "pipeline setup")
885 if setup.Status != "failure" {
886 t.Errorf("setup status = %q", setup.Status)
887 }
888 if !strings.Contains(setup.Log, "mkdir: read-only") {
889 t.Errorf("setup log = %q", setup.Log)
890 }
891 })
892
893 t.Run("a cache path inside the clone directory is rejected", func(t *testing.T) {
894 m.reset()
895 sha := ciSeedToml(e, `
896image = "debian:latest"
897clone_project_to = "/ci/build/project"
898cache = ["/ci/build/project/target"]
899
900[on]
901manual = true
902
903[[steps]]
904name = "hello"
905run_sh = "echo hi"
906`)
907 runID := ciTrigger(e, admin, sha, nil)
908 if got := ciWaitForRun(e, runID); got != "failure" {
909 t.Fatalf("status = %q", got)
910 }
911 if n := m.uploadCount(); n != 0 {
912 t.Errorf("uploads = %d, want 0", n)
913 }
914 if log := ciStep(e, runID, "pipeline setup").Log; !strings.Contains(log, "overlaps clone_project_to") {
915 t.Errorf("setup log = %q", log)
916 }
917 })
918
919 t.Run("a cache path above the clone directory is rejected", func(t *testing.T) {
920 m.reset()
921 sha := ciSeedToml(e, `
922image = "debian:latest"
923clone_project_to = "/ci/build/project"
924cache = ["/ci/build"]
925
926[on]
927manual = true
928
929[[steps]]
930name = "hello"
931run_sh = "echo hi"
932`)
933 runID := ciTrigger(e, admin, sha, nil)
934 if got := ciWaitForRun(e, runID); got != "failure" {
935 t.Fatalf("status = %q", got)
936 }
937 if n := m.uploadCount(); n != 0 {
938 t.Errorf("uploads = %d, want 0", n)
939 }
940 })
941
942 t.Run("a relative clone_project_to is rejected", func(t *testing.T) {
943 m.reset()
944 sha := ciSeedToml(e, `
945image = "debian:latest"
946work_dir = "/ci/build"
947clone_project_to = "project"
948
949[on]
950manual = true
951
952[[steps]]
953name = "hello"
954run_sh = "echo hi"
955`)
956 runID := ciTrigger(e, admin, sha, nil)
957 if got := ciWaitForRun(e, runID); got != "failure" {
958 t.Fatalf("status = %q", got)
959 }
960 if log := ciStep(e, runID, "pipeline setup").Log; !strings.Contains(log, "must be an absolute path") {
961 t.Errorf("setup log = %q", log)
962 }
963 })
964
965 t.Run("the upload carries the requested commit", func(t *testing.T) {
966 m.reset()
967 runID := trigger()
968 if got := ciWaitForRun(e, runID); got != "success" {
969 t.Fatalf("status = %q", got)
970 }
971 ups := m.uploadsTo("/ci/build/project")
972 if len(ups) == 0 {
973 t.Fatal("no upload to the clone directory")
974 }
975 // The archive must hold the CI config at the triggered commit, and no
976 // .git. A dropped commit argument would still produce a valid tar.
977 names := tarEntryNames(t, ups[0].body)
978 if !contains(names, ".hearthforge-ci.toml") {
979 t.Errorf("entries = %v", names)
980 }
981 for _, n := range names {
982 if strings.HasPrefix(n, ".git/") {
983 t.Errorf("archive contains %q", n)
984 }
985 }
986 // git archive writes uid 0 and no entry for the archive root, so the
987 // destination keeps the mode the container gave it.
988 for _, h := range tarHeaders(t, ups[0].body) {
989 if h.uid != 0 {
990 t.Errorf("entry %q has uid %d", h.name, h.uid)
991 }
992 if h.name == "./" {
993 t.Error("archive contains a root entry")
994 }
995 }
996 })
997}
998
999// ── copy from another image ──────────────────────────────────────────────
1000
1001const ciCopyTOML = `
1002image = "debian:latest"
1003
1004[on]
1005manual = true
1006
1007[[copy]]
1008image = "docker.io/oven/bun:1.4.0-alpine"
1009from = "/usr/local/bin/bun"
1010to = "/usr/local/bin"
1011
1012[[steps]]
1013name = "hello"
1014run_sh = "bun --version"
1015`
1016
1017func TestCICopyFromAnotherImage(t *testing.T) {
1018 e, m, admin := ciEnv(t)
1019
1020 t.Run("pulls the source image and uploads its files", func(t *testing.T) {
1021 sha := ciSeedToml(e, ciCopyTOML)
1022 m.reset()
1023 m.queueExec(execResp{}) // mkdir of the copy target
1024 m.queueExec(execResp{output: "1.4.0\n"}) // the step
1025
1026 runID := ciTrigger(e, admin, sha, nil)
1027 if got := ciWaitForRun(e, runID); got != "success" {
1028 t.Fatalf("status = %q", got)
1029 }
1030 if !contains(m.pulledImages(), "docker.io/oven/bun") {
1031 t.Errorf("pulls = %v", m.pulledImages())
1032 }
1033 if !contains(m.uploadedPaths(), "/usr/local/bin") {
1034 t.Errorf("uploads = %v", m.uploadedPaths())
1035 }
1036 })
1037}
1038
1039// ── always and warn_on_fail ──────────────────────────────────────────────
1040
1041const ciFlagsTOML = `
1042image = "debian:latest"
1043
1044[on]
1045manual = true
1046
1047[[steps]]
1048name = "lint"
1049run_sh = "make lint"
1050warn_on_fail = true
1051
1052[[steps]]
1053name = "build"
1054run_sh = "make"
1055
1056[[steps]]
1057name = "cleanup"
1058run_sh = "rm -rf /scratch"
1059always = true
1060`
1061
1062func TestCIAlwaysAndWarnOnFail(t *testing.T) {
1063 e, m, admin := ciEnv(t)
1064
1065 run := func(sha string) int64 {
1066 runID := ciTrigger(e, admin, sha, nil)
1067 ciWaitForRun(e, runID)
1068 return runID
1069 }
1070
1071 t.Run("warn_on_fail marks the step and lets the run continue", func(t *testing.T) {
1072 sha := ciSeedToml(e, ciFlagsTOML)
1073 m.reset()
1074 m.queueExec(execResp{output: "style nit\n", exitCode: 1}) // lint
1075 m.queueExec(execResp{output: "built\n"}) // build
1076 m.queueExec(execResp{}) // cleanup
1077
1078 runID := run(sha)
1079
1080 lint := ciStep(e, runID, "lint")
1081 if lint.Status != "warning" {
1082 t.Errorf("lint status = %q", lint.Status)
1083 }
1084 if !strings.Contains(lint.Log, "style nit") {
1085 t.Errorf("lint log = %q", lint.Log)
1086 }
1087 if got := ciStep(e, runID, "build").Status; got != "success" {
1088 t.Errorf("build status = %q", got)
1089 }
1090 if got := ciRunStatus(e, runID); got != "warning" {
1091 t.Errorf("run status = %q", got)
1092 }
1093 })
1094
1095 t.Run("always runs after a failure, other steps stay skipped", func(t *testing.T) {
1096 sha := ciSeedToml(e, ciFlagsTOML)
1097 m.reset()
1098 m.queueExec(execResp{output: "ok\n"}) // lint
1099 m.queueExec(execResp{output: "boom\n", exitCode: 1}) // build fails
1100 m.queueExec(execResp{output: "cleaned\n"}) // cleanup, always
1101
1102 runID := run(sha)
1103
1104 if got := ciStep(e, runID, "build").Status; got != "failure" {
1105 t.Errorf("build status = %q", got)
1106 }
1107 cleanup := ciStep(e, runID, "cleanup")
1108 if cleanup.Status != "success" {
1109 t.Errorf("cleanup status = %q", cleanup.Status)
1110 }
1111 if !strings.Contains(cleanup.Log, "cleaned") {
1112 t.Errorf("cleanup log = %q", cleanup.Log)
1113 }
1114 if got := ciRunStatus(e, runID); got != "failure" {
1115 t.Errorf("run status = %q", got)
1116 }
1117 })
1118
1119 t.Run("a failing always step keeps the run failed", func(t *testing.T) {
1120 sha := ciSeedToml(e, ciFlagsTOML)
1121 m.reset()
1122 m.queueExec(execResp{output: "ok\n"}) // lint
1123 m.queueExec(execResp{output: "boom\n", exitCode: 1}) // build fails
1124 m.queueExec(execResp{output: "no\n", exitCode: 1}) // cleanup also fails
1125
1126 runID := run(sha)
1127
1128 if got := ciStep(e, runID, "cleanup").Status; got != "failure" {
1129 t.Errorf("cleanup status = %q", got)
1130 }
1131 if got := ciRunStatus(e, runID); got != "failure" {
1132 t.Errorf("run status = %q", got)
1133 }
1134 })
1135}
1136
1137// ── duplicate step names ─────────────────────────────────────────────────
1138
1139const ciDupesTOML = `
1140image = "debian:latest"
1141
1142[on]
1143manual = true
1144
1145[[steps]]
1146name = "check"
1147run_sh = "echo one"
1148
1149[[steps]]
1150name = "check"
1151run_sh = "echo two"
1152`
1153
1154func TestCIDuplicateStepNames(t *testing.T) {
1155 e, m, admin := ciEnv(t)
1156
1157 t.Run("each occurrence gets its own row, in file order", func(t *testing.T) {
1158 sha := ciSeedToml(e, ciDupesTOML)
1159 m.reset()
1160 m.queueExec(execResp{output: "one\n"})
1161 m.queueExec(execResp{output: "two\n"})
1162
1163 runID := ciTrigger(e, admin, sha, nil)
1164 if got := ciWaitForRun(e, runID); got != "success" {
1165 t.Fatalf("status = %q", got)
1166 }
1167 rows := ciSteps(e, runID, "check")
1168 if len(rows) != 2 {
1169 t.Fatalf("rows = %d", len(rows))
1170 }
1171 if !strings.Contains(rows[0].Log, "one") || !strings.Contains(rows[1].Log, "two") {
1172 t.Errorf("logs = %q, %q", rows[0].Log, rows[1].Log)
1173 }
1174 for _, r := range rows {
1175 if r.Status != "success" {
1176 t.Errorf("status = %q", r.Status)
1177 }
1178 }
1179 })
1180
1181 t.Run("the second occurrence can fail on its own", func(t *testing.T) {
1182 sha := ciSeedToml(e, ciDupesTOML)
1183 m.reset()
1184 m.queueExec(execResp{output: "one\n"})
1185 m.queueExec(execResp{output: "boom\n", exitCode: 1})
1186
1187 runID := ciTrigger(e, admin, sha, nil)
1188 if got := ciWaitForRun(e, runID); got != "failure" {
1189 t.Fatalf("status = %q", got)
1190 }
1191 rows := ciSteps(e, runID, "check")
1192 got := []string{}
1193 for _, r := range rows {
1194 got = append(got, r.Status)
1195 }
1196 if !eqStrings(got, []string{"success", "failure"}) {
1197 t.Errorf("statuses = %v", got)
1198 }
1199 })
1200}
1201
1202// ── timeouts override warn_on_fail ───────────────────────────────────────
1203
1204const ciTimeoutTOML = `
1205image = "debian:latest"
1206
1207[on]
1208manual = true
1209
1210[[steps]]
1211name = "lint"
1212run_sh = "make lint"
1213warn_on_fail = true
1214timeout = 1
1215
1216[[steps]]
1217name = "build"
1218run_sh = "make"
1219`
1220
1221func TestCITimeoutsOverrideWarnOnFail(t *testing.T) {
1222 e, m, admin := ciEnv(t)
1223
1224 t.Run("a timed-out warn_on_fail step fails the run", func(t *testing.T) {
1225 sha := ciSeedToml(e, ciTimeoutTOML)
1226 m.reset()
1227 m.queueExec(execResp{delay: 3 * time.Second})
1228
1229 runID := ciTrigger(e, admin, sha, nil)
1230 if got := ciWaitForRun(e, runID, 30*time.Second); got != "failure" {
1231 t.Fatalf("status = %q", got)
1232 }
1233 // A timeout destroys the container, so nothing after it can run.
1234 // Reporting that as a warning would hide a dead pipeline.
1235 lint := ciStep(e, runID, "lint")
1236 if lint.Status != "failure" {
1237 t.Errorf("lint status = %q", lint.Status)
1238 }
1239 if !strings.Contains(lint.Log, "timed out") {
1240 t.Errorf("lint log = %q", lint.Log)
1241 }
1242 })
1243}
1244
1245// ── clear failures are recorded ──────────────────────────────────────────
1246
1247const ciClearTOML = `
1248image = "debian:latest"
1249work_dir = "/ci/build"
1250clone_project_to = "/ci/build/project"
1251
1252[on]
1253manual = true
1254
1255[[steps]]
1256name = "first"
1257run_sh = "false"
1258
1259[[steps]]
1260name = "second"
1261always = true
1262clear = true
1263run_sh = "echo hi"
1264`
1265
1266func TestCIClearFailuresAreRecorded(t *testing.T) {
1267 e, m, admin := ciEnv(t)
1268
1269 t.Run("a clear failure lands on the step, not the console", func(t *testing.T) {
1270 sha := ciSeedToml(e, ciClearTOML)
1271 m.reset()
1272 m.queueExec(execResp{}) // mkdir work_dir
1273 m.queueExec(execResp{}) // mkdir clone_project_to
1274 m.queueExec(execResp{output: "boom\n", exitCode: 1}) // first, fails
1275 m.queueExec(execResp{output: "rm: device busy\n", exitCode: 1}) // clear
1276
1277 runID := ciTrigger(e, admin, sha, nil)
1278 if got := ciWaitForRun(e, runID); got != "failure" {
1279 t.Fatalf("status = %q", got)
1280 }
1281 second := ciStep(e, runID, "second")
1282 if second.Status != "failure" {
1283 t.Errorf("second status = %q", second.Status)
1284 }
1285 if !strings.Contains(second.Log, "Failed to reset") ||
1286 !strings.Contains(second.Log, "device busy") {
1287 t.Errorf("second log = %q", second.Log)
1288 }
1289 })
1290
1291 t.Run("a clear step re-extracts the checkout", func(t *testing.T) {
1292 sha := ciSeedToml(e, ciClearTOML)
1293 m.reset()
1294 m.queueExec(execResp{}) // mkdir work_dir
1295 m.queueExec(execResp{}) // mkdir clone_project_to
1296 m.queueExec(execResp{output: "ok\n"}) // first
1297 m.queueExec(execResp{}) // clear: rm -rf
1298 m.queueExec(execResp{}) // mkdir clone_project_to again
1299 m.queueExec(execResp{output: "hi\n"}) // second
1300
1301 runID := ciTrigger(e, admin, sha, nil)
1302 if got := ciWaitForRun(e, runID); got != "success" {
1303 t.Fatalf("status = %q", got)
1304 }
1305 if n := len(m.uploadsTo("/ci/build/project")); n != 2 {
1306 t.Errorf("uploads to the clone directory = %d, want 2", n)
1307 }
1308 if strings.Contains(m.allCommandText(), "git") {
1309 t.Errorf("commands = %v", m.commands())
1310 }
1311 })
1312
1313 t.Run("clear removes and recreates the directory in one exec", func(t *testing.T) {
1314 // `rm -rf` can delete the container's WorkingDir. A second exec would
1315 // then fail to chdir before its command starts.
1316 sha := ciSeedToml(e, `
1317image = "debian:latest"
1318work_dir = "/ci/build"
1319clone_project_to = "/ci/build"
1320
1321[on]
1322manual = true
1323
1324[[steps]]
1325name = "first"
1326run_sh = "true"
1327
1328[[steps]]
1329name = "second"
1330clear = true
1331run_sh = "echo hi"
1332`)
1333 m.reset()
1334 runID := ciTrigger(e, admin, sha, nil)
1335 if got := ciWaitForRun(e, runID); got != "success" {
1336 t.Fatalf("status = %q", got)
1337 }
1338 var removals []string
1339 for _, c := range m.commands() {
1340 if joined := strings.Join(c, " "); strings.Contains(joined, "rm -rf") {
1341 removals = append(removals, joined)
1342 }
1343 }
1344 if len(removals) != 1 {
1345 t.Fatalf("rm -rf execs = %v", removals)
1346 }
1347 if !strings.Contains(removals[0], "mkdir -p") {
1348 t.Errorf("removal exec = %q", removals[0])
1349 }
1350 })
1351}
1352
1353// ── cache volumes ────────────────────────────────────────────────────────
1354
1355const ciCacheTOML = `
1356image = "debian:latest"
1357cache = ["/ci/cache/target", "/ci/cache/registry"]
1358
1359[on]
1360manual = true
1361push = ["main", "some-feature"]
1362
1363[[steps]]
1364name = "hello"
1365run_sh = "echo hi"
1366`
1367
1368func TestCICacheVolumes(t *testing.T) {
1369 e, m, admin := ciEnv(t)
1370
1371 run := func(sha, branch string) int64 {
1372 var runID int64
1373 if branch == "main" {
1374 runID = ciTrigger(e, admin, sha, nil)
1375 } else {
1376 runID = ciPushRun(e, sha, branch)
1377 }
1378 ciWaitForRun(e, runID)
1379 return runID
1380 }
1381
1382 t.Run("two cache paths sharing a prefix get distinct volumes", func(t *testing.T) {
1383 sha := ciSeedToml(e, ciCacheTOML)
1384 m.reset()
1385 run(sha, "main")
1386
1387 vols := m.createdVolumes()
1388 if len(vols) != 2 {
1389 t.Fatalf("volumes = %v", vols)
1390 }
1391 if vols[0].name == vols[1].name {
1392 t.Error("both cache paths share one volume")
1393 }
1394 // The path is otherwise unrecoverable from a digest.
1395 got := []string{
1396 vols[0].labels["com.hearthforge.cache-path"],
1397 vols[1].labels["com.hearthforge.cache-path"],
1398 }
1399 if !eqStrings(got, []string{"/ci/cache/target", "/ci/cache/registry"}) {
1400 t.Errorf("cache-path labels = %v", got)
1401 }
1402 })
1403
1404 t.Run("a volume the config no longer names is pruned", func(t *testing.T) {
1405 sha := ciSeedToml(e, ciCacheTOML)
1406 m.reset()
1407 m.setVolumesOnHost("hearthforge-ci-cache-leftover-from-an-old-config")
1408
1409 run(sha, "main")
1410
1411 if got := m.deletedVolumes(); !eqStrings(got,
1412 []string{"hearthforge-ci-cache-leftover-from-an-old-config"}) {
1413 t.Errorf("deleted = %v", got)
1414 }
1415 })
1416
1417 t.Run("volumes still in the config survive", func(t *testing.T) {
1418 sha := ciSeedToml(e, ciCacheTOML)
1419 m.reset()
1420 // Prime the host list with the names this config creates.
1421 run(sha, "main")
1422 var inUse []string
1423 for _, v := range m.createdVolumes() {
1424 inUse = append(inUse, v.name)
1425 }
1426
1427 m.reset()
1428 m.setVolumesOnHost(inUse...)
1429 run(sha, "main")
1430
1431 if got := m.deletedVolumes(); len(got) != 0 {
1432 t.Errorf("deleted = %v", got)
1433 }
1434 })
1435
1436 t.Run("a run off the default branch prunes nothing", func(t *testing.T) {
1437 sha := ciSeedToml(e, ciCacheTOML)
1438 m.reset()
1439 m.setVolumesOnHost("hearthforge-ci-cache-belongs-to-the-default-branch")
1440
1441 // The config is read per commit, so pruning from a feature branch
1442 // would delete the default branch's caches.
1443 run(sha, "some-feature")
1444
1445 if got := m.deletedVolumes(); len(got) != 0 {
1446 t.Errorf("deleted = %v", got)
1447 }
1448 })
1449}
1450
1451// ── cache size caps ──────────────────────────────────────────────────────
1452
1453const ciCappedTOML = `
1454image = "debian:latest"
1455cache = [{ path = "/ci/cache/target", max_size = "1g" }, "/ci/cache/registry"]
1456
1457[on]
1458manual = true
1459
1460[[steps]]
1461name = "hello"
1462run_sh = "echo hi"
1463`
1464
1465func TestCICacheSizeCaps(t *testing.T) {
1466 e, m, admin := ciEnv(t)
1467
1468 run := func(sha string) int64 {
1469 runID := ciTrigger(e, admin, sha, nil)
1470 ciWaitForRun(e, runID)
1471 return runID
1472 }
1473
1474 // names returns the volume names the config produces, in declaration
1475 // order.
1476 names := func(sha string) (string, string) {
1477 m.reset()
1478 run(sha)
1479 vols := m.createdVolumes()
1480 if len(vols) != 2 {
1481 t.Fatalf("volumes = %v", vols)
1482 }
1483 return vols[0].name, vols[1].name
1484 }
1485
1486 const gib = int64(1024 * 1024 * 1024)
1487
1488 t.Run("an oversized cache is dropped and reported on the run", func(t *testing.T) {
1489 sha := ciSeedToml(e, ciCappedTOML)
1490 target, registry := names(sha)
1491
1492 m.reset()
1493 m.setVolumesOnHost(target, registry)
1494 m.setVolumeUsage(map[string]ciVolumeUsage{
1495 target: {Size: 2 * gib},
1496 registry: {Size: 9 * gib},
1497 })
1498 runID := run(sha)
1499
1500 // Only the capped one goes, however large the uncapped one grows.
1501 if got := m.deletedVolumes(); !eqStrings(got, []string{target}) {
1502 t.Fatalf("deleted = %v", got)
1503 }
1504 log := ciStep(e, runID, "cache").Log
1505 if !strings.Contains(log, "/ci/cache/target") || !strings.Contains(log, "2.0G") {
1506 t.Errorf("cache step log = %q", log)
1507 }
1508 })
1509
1510 t.Run("a cache under its cap survives", func(t *testing.T) {
1511 sha := ciSeedToml(e, ciCappedTOML)
1512 target, registry := names(sha)
1513
1514 m.reset()
1515 m.setVolumesOnHost(target, registry)
1516 m.setVolumeUsage(map[string]ciVolumeUsage{target: {Size: 100}})
1517 run(sha)
1518
1519 if got := m.deletedVolumes(); len(got) != 0 {
1520 t.Errorf("deleted = %v", got)
1521 }
1522 })
1523
1524 t.Run("a cache a concurrent run holds is left alone", func(t *testing.T) {
1525 sha := ciSeedToml(e, ciCappedTOML)
1526 target, registry := names(sha)
1527
1528 m.reset()
1529 m.setVolumesOnHost(target, registry)
1530 m.setVolumeUsage(map[string]ciVolumeUsage{target: {Size: 9 * gib, RefCount: 1}})
1531 run(sha)
1532
1533 if got := m.deletedVolumes(); len(got) != 0 {
1534 t.Errorf("deleted = %v", got)
1535 }
1536 })
1537
1538 t.Run("an unmeasured cache is never dropped", func(t *testing.T) {
1539 sha := ciSeedToml(e, ciCappedTOML)
1540 target, registry := names(sha)
1541
1542 m.reset()
1543 m.setVolumesOnHost(target, registry)
1544 // Docker reports -1 for a size it has not computed.
1545 m.setVolumeUsage(map[string]ciVolumeUsage{target: {Size: -1}})
1546 runID := run(sha)
1547
1548 if got := m.deletedVolumes(); len(got) != 0 {
1549 t.Errorf("deleted = %v", got)
1550 }
1551 if steps := ciSteps(e, runID, "cache"); len(steps) != 0 {
1552 t.Errorf("cache step = %v", steps)
1553 }
1554 })
1555}
1556