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