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 })
383
384 t.Run("run detail page shows step and log", func(t *testing.T) {
385 r := admin.get(ciRunPath(runID)).mustStatus(200)
386 steps := r.Texts(".ci-step")
387 if len(steps) < 2 {
388 t.Fatalf("steps = %v", steps)
389 }
390 // Setup is a real step and sorts before the config's steps.
391 if !strings.Contains(steps[0], "pipeline setup") || !strings.Contains(steps[0], "success") {
392 t.Errorf("first step = %q", steps[0])
393 }
394 if !strings.Contains(steps[1], "hello") {
395 t.Errorf("second step = %q", steps[1])
396 }
397 if !contains(r.Texts(".ci-step-log"), "hello from mock CI") {
398 t.Errorf("logs = %v", r.Texts(".ci-step-log"))
399 }
400 })
401
402 t.Run("retry re-executes the same run in-place", func(t *testing.T) {
403 r := admin.post(ciRunPath(runID)+"/retry", url.Values{})
404 if got := r.mustRedirect(ciRunPath(runID)); got != ciRunPath(runID) {
405 t.Errorf("redirect = %q", got)
406 }
407 if got := ciWaitForRun(e, runID); got != "success" {
408 t.Errorf("status after retry = %q", got)
409 }
410 // No new run was created: the row still exists under the same id.
411 if ciRunStatus(e, runID) != "success" {
412 t.Error("run row changed")
413 }
414 })
415}
416
417// ── failing run ──────────────────────────────────────────────────────────
418
419func TestCIFailingRun(t *testing.T) {
420 e, m, admin := ciEnv(t)
421 m.queueExec(execResp{output: "build error: file not found\n", exitCode: 1})
422 sha := ciSeedToml(e, ciSimpleTOML)
423 runID := ciTrigger(e, admin, sha, nil)
424 ciWaitForRun(e, runID)
425
426 t.Run("run status is failure", func(t *testing.T) {
427 if got := ciRunStatus(e, runID); got != "failure" {
428 t.Errorf("status = %q", got)
429 }
430 })
431
432 t.Run("step status is failure and error log captured", func(t *testing.T) {
433 step := ciStep(e, runID, "hello")
434 if step.Status != "failure" {
435 t.Errorf("step status = %q", step.Status)
436 }
437 if !strings.Contains(step.Log, "build error") {
438 t.Errorf("step log = %q", step.Log)
439 }
440 })
441
442 t.Run("run detail page shows failure status", func(t *testing.T) {
443 r := admin.get(ciRunPath(runID)).mustStatus(200)
444 if r.Count(".ci-status-pill.ci-status-failure") == 0 {
445 t.Error("no failure pill on the run page")
446 }
447 })
448}
449
450// ── cancel ───────────────────────────────────────────────────────────────
451
452func TestCICancel(t *testing.T) {
453 e, _, admin := ciEnv(t)
454
455 t.Run("cancelling a pending run marks it cancelled", func(t *testing.T) {
456 sha := ciSeedToml(e, ciSimpleTOML)
457 runID := ciTrigger(e, admin, sha, nil)
458 admin.post(ciRunPath(runID)+"/cancel", url.Values{}).mustRedirect(ciRunPath(runID))
459
460 status := ciWaitForRun(e, runID)
461 switch status {
462 case "cancelled", "success", "failure":
463 default:
464 t.Fatalf("status = %q", status)
465 }
466 })
467}
468
469// ── artifacts ────────────────────────────────────────────────────────────
470
471func TestCIArtifacts(t *testing.T) {
472 e, _, admin := ciEnv(t)
473 sha := ciSeedToml(e, ciArtifactTOML)
474 runID := ciTrigger(e, admin, sha, nil)
475 ciWaitForRun(e, runID)
476
477 var artifactID int64
478 var filename string
479 var count int
480 rows, err := e.DB.QueryContext(context.Background(),
481 `SELECT id, filename FROM ci_artifacts WHERE run_id = ? ORDER BY id`, runID)
482 if err != nil {
483 t.Fatal(err)
484 }
485 for rows.Next() {
486 if err := rows.Scan(&artifactID, &filename); err != nil {
487 t.Fatal(err)
488 }
489 count++
490 }
491 rows.Close()
492
493 t.Run("artifact row created in DB", func(t *testing.T) {
494 if count != 1 {
495 t.Fatalf("artifacts = %d", count)
496 }
497 if filename != "output.txt" {
498 t.Errorf("filename = %q", filename)
499 }
500 })
501
502 t.Run("artifact is downloadable via HTTP", func(t *testing.T) {
503 if artifactID <= 0 {
504 t.Fatal("no artifact id")
505 }
506 r := e.anon().get(ciRunPath(runID) + "/artifacts/" + strconv.FormatInt(artifactID, 10))
507 r.mustStatus(200)
508 if r.BodyString() != "artifact-content-123" {
509 t.Errorf("body = %q", r.BodyString())
510 }
511 })
512
513 t.Run("run detail page shows artifact list", func(t *testing.T) {
514 r := admin.get(ciRunPath(runID)).mustStatus(200)
515 if r.Count(".ci-artifact-item") == 0 {
516 t.Fatal("no artifact items")
517 }
518 if !strings.Contains(r.Text(".ci-artifact-name"), "output.txt") {
519 t.Errorf("artifact name = %q", r.Text(".ci-artifact-name"))
520 }
521 })
522}
523
524// ── badge ────────────────────────────────────────────────────────────────
525
526func TestCIBadge(t *testing.T) {
527 e, m, admin := ciEnv(t)
528 m.queueExec(execResp{output: "ok\n"})
529 sha := ciSeedToml(e, ciSimpleTOML)
530 runID := ciTrigger(e, admin, sha, nil)
531 ciWaitForRun(e, runID)
532
533 t.Run("badge SVG returns success status after successful run", func(t *testing.T) {
534 r := e.anon().get("/ci-repo/ci/badge.svg").mustStatus(200)
535 if !strings.Contains(r.Header.Get("Content-Type"), "image/svg+xml") {
536 t.Errorf("content type = %q", r.Header.Get("Content-Type"))
537 }
538 if !r.Contains("<svg") || !r.Contains("success") {
539 t.Errorf("badge = %q", r.BodyString())
540 }
541 })
542
543 t.Run("badge returns 404 for private repo when not logged in", func(t *testing.T) {
544 e.createRepo(admin, "private-ci-repo", "is_private", "1")
545 e.anon().get("/private-ci-repo/ci/badge.svg").mustStatus(404)
546 })
547}
548
549// ── secrets ──────────────────────────────────────────────────────────────
550
551func TestCISecrets(t *testing.T) {
552 e, m, admin := ciEnv(t)
553
554 t.Run("can add, list, and delete a secret via settings", func(t *testing.T) {
555 admin.post("/ci-repo/settings/ci-secrets", url.Values{
556 "name": {"MY_SECRET"}, "value": {"super-secret-value"},
557 "description": {"A test secret"},
558 }).mustRedirect("/ci-repo/settings")
559
560 r := admin.get("/ci-repo/settings").mustStatus(200)
561 if n := len(matchingTexts(r.Texts("code"), "MY_SECRET")); n != 1 {
562 t.Fatalf("MY_SECRET shown %d times", n)
563 }
564 if !r.Contains("●●●●●●") {
565 t.Error("secret value is not masked")
566 }
567
568 id := r.Value(`form[action="/ci-repo/settings/ci-secrets/delete"] input[name=id]`)
569 if id == "" {
570 t.Fatal("no delete form for the secret")
571 }
572 admin.post("/ci-repo/settings/ci-secrets/delete", url.Values{"id": {id}}).
573 mustRedirect("/ci-repo/settings")
574
575 r = admin.get("/ci-repo/settings").mustStatus(200)
576 if n := len(matchingTexts(r.Texts("code"), "MY_SECRET")); n != 0 {
577 t.Errorf("MY_SECRET still shown %d times", n)
578 }
579 })
580
581 t.Run("secret value is masked in step logs", func(t *testing.T) {
582 admin.post("/ci-repo/settings/ci-secrets", url.Values{
583 "name": {"MASK_ME"}, "value": {"s3cr3t-p4ssw0rd"},
584 }).mustRedirect("/ci-repo/settings")
585
586 m.reset()
587 m.queueExec(execResp{output: "s3cr3t-p4ssw0rd is the value\n"})
588 sha := ciSeedToml(e, ciSimpleTOML)
589 runID := ciTrigger(e, admin, sha, nil)
590 ciWaitForRun(e, runID)
591
592 step := ciStep(e, runID, "hello")
593 if strings.Contains(step.Log, "s3cr3t-p4ssw0rd") {
594 t.Errorf("secret leaked into the log: %q", step.Log)
595 }
596 if !strings.Contains(step.Log, "[MASKED]") {
597 t.Errorf("log = %q", step.Log)
598 }
599 })
600}
601
602// matchingTexts keeps the entries containing sub.
603func matchingTexts(list []string, sub string) []string {
604 var out []string
605 for _, s := range list {
606 if strings.Contains(s, sub) {
607 out = append(out, s)
608 }
609 }
610 return out
611}
612
613// ── per-repo run IDs ─────────────────────────────────────────────────────
614
615func TestCIPerRepoRunIDs(t *testing.T) {
616 e, m, admin := ciEnv(t)
617 sha := ciSeedToml(e, ciSimpleTOML)
618 for range 2 {
619 m.reset()
620 ciWaitForRun(e, ciTrigger(e, admin, sha, nil))
621 }
622
623 t.Run("repo_run_id is set and increments per repo", func(t *testing.T) {
624 rows, err := e.DB.QueryContext(context.Background(),
625 `SELECT repo_run_id FROM ci_runs ORDER BY repo_run_id ASC`)
626 if err != nil {
627 t.Fatal(err)
628 }
629 defer rows.Close()
630 i := 0
631 for rows.Next() {
632 var id sql.NullInt64
633 if err := rows.Scan(&id); err != nil {
634 t.Fatal(err)
635 }
636 if !id.Valid || id.Int64 <= 0 {
637 t.Fatal("repo_run_id is not set")
638 }
639 i++
640 if id.Int64 != int64(i) {
641 t.Fatalf("repo_run_id %d at position %d", id.Int64, i)
642 }
643 }
644 if i == 0 {
645 t.Fatal("no runs")
646 }
647 })
648
649 t.Run("run detail page shows repo-local run number", func(t *testing.T) {
650 var runID, repoRunID int64
651 if err := e.DB.QueryRowContext(context.Background(),
652 `SELECT id, repo_run_id FROM ci_runs ORDER BY id ASC LIMIT 1`).
653 Scan(&runID, &repoRunID); err != nil {
654 t.Fatal(err)
655 }
656 r := admin.get(ciRunPath(runID)).mustStatus(200)
657 want := "#" + strconv.FormatInt(repoRunID, 10)
658 if !strings.Contains(r.Text("h2"), want) {
659 t.Errorf("heading = %q, want %q", r.Text("h2"), want)
660 }
661 })
662}
663
664// ── skip reasons ─────────────────────────────────────────────────────────
665
666const ciSkipIfTOML = `
667image = "debian:latest"
668
669[on]
670manual = true
671
672[[steps]]
673name = "first"
674run_sh = "echo first"
675
676[[steps]]
677name = "second"
678run_if = "false"
679run_sh = "echo second"
680
681[[steps]]
682name = "third"
683run_sh = "echo third"
684`
685
686func TestCISkipReasons(t *testing.T) {
687 e, m, admin := ciEnv(t)
688
689 t.Run("run_if failure sets skip reason in log", func(t *testing.T) {
690 sha := ciSeedToml(e, ciSkipIfTOML)
691 m.reset()
692 m.queueExec(execResp{output: "first\n"}) // first step
693 m.queueExec(execResp{exitCode: 1}) // run_if check of second
694 m.queueExec(execResp{output: "third\n"}) // third step
695 runID := ciTrigger(e, admin, sha, nil)
696 ciWaitForRun(e, runID)
697
698 step := ciStep(e, runID, "second")
699 if step.Status != "skipped" {
700 t.Errorf("status = %q", step.Status)
701 }
702 if !strings.Contains(step.Log, "condition not met") {
703 t.Errorf("log = %q", step.Log)
704 }
705 })
706
707 t.Run("failed step causes remaining steps to be skipped with reason", func(t *testing.T) {
708 sha := ciSeedToml(e, ciSkipIfTOML)
709 m.reset()
710 m.queueExec(execResp{output: "boom\n", exitCode: 1}) // first step fails
711 runID := ciTrigger(e, admin, sha, nil)
712 ciWaitForRun(e, runID)
713
714 step := ciStep(e, runID, "third")
715 if step.Status != "skipped" {
716 t.Errorf("status = %q", step.Status)
717 }
718 if !strings.Contains(step.Log, "previous step failed") {
719 t.Errorf("log = %q", step.Log)
720 }
721 })
722}
723
724// ── docker unavailable ───────────────────────────────────────────────────
725
726func TestCIDockerUnavailable(t *testing.T) {
727 // newEnv points CI_DOCKER_SOCKET at a path that does not exist.
728 e := newEnv(t)
729 admin := e.admin()
730 e.createRepo(admin, "ci-repo")
731 e.seedRepo("ci-repo", nil)
732
733 t.Run("run is marked skipped when docker socket is missing", func(t *testing.T) {
734 sha := ciSeedToml(e, ciSimpleTOML)
735 runID := ciTrigger(e, admin, sha, nil)
736 if got := ciWaitForRun(e, runID); got != "skipped" {
737 t.Errorf("status = %q", got)
738 }
739 })
740}
741
742// ── manual trigger without on.manual ─────────────────────────────────────
743
744const ciNoManualTOML = `
745image = "debian:latest"
746
747[on]
748push = ["main"]
749
750[[steps]]
751name = "hello"
752run_sh = "echo hi"
753`
754
755func TestCIManualTriggerWithoutOnManual(t *testing.T) {
756 e, m, admin := ciEnv(t)
757
758 t.Run("manual run is allowed even without manual = true in config", func(t *testing.T) {
759 sha := ciSeedToml(e, ciNoManualTOML)
760 m.reset()
761 m.queueExec(execResp{output: "hi\n"})
762 runID := ciTrigger(e, admin, sha, nil)
763 if got := ciWaitForRun(e, runID); got != "success" {
764 t.Errorf("status = %q", got)
765 }
766 })
767
768 t.Run("Run pipeline button is not disabled when toml lacks manual = true", func(t *testing.T) {
769 ciSeedToml(e, ciNoManualTOML)
770 r := admin.get("/ci-repo/ci").mustStatus(200)
771 if !contains(r.Texts("button"), "Run pipeline") {
772 t.Fatalf("buttons = %v", r.Texts("button"))
773 }
774 if r.Has("button[disabled]") {
775 t.Error("the Run pipeline button is disabled")
776 }
777 })
778}
779
780// ── auto-refresh toggle ──────────────────────────────────────────────────
781
782func TestCIAutoRefreshToggle(t *testing.T) {
783 e, _, admin := ciEnv(t)
784
785 t.Run("Pause refresh button appears on active run and ?refresh=off shows Resume", func(t *testing.T) {
786 sha := ciSeedToml(e, ciSimpleTOML)
787 runID := ciTrigger(e, admin, sha, nil)
788
789 // The run may already have finished, so only the refresh link's
790 // existence is checked, exactly as the browser test did.
791 admin.get(ciRunPath(runID)).mustStatus(200)
792
793 r := admin.get(ciRunPath(runID) + "?refresh=off").mustStatus(200)
794 if n := r.Count(`meta[http-equiv="refresh"]`); n != 0 {
795 t.Errorf("meta refresh count = %d", n)
796 }
797 ciWaitForRun(e, runID)
798 })
799}
800
801// ── purge cache ──────────────────────────────────────────────────────────
802
803func TestCIPurgeCache(t *testing.T) {
804 _, _, admin := ciEnv(t)
805
806 t.Run("Purge caches button is visible and submits successfully", func(t *testing.T) {
807 r := admin.get("/ci-repo/ci").mustStatus(200)
808 if !contains(r.Texts("button"), "Purge caches") {
809 t.Fatalf("buttons = %v", r.Texts("button"))
810 }
811 redirect := admin.post("/ci-repo/ci/purge-cache", url.Values{})
812 redirect.mustRedirect("/ci-repo/ci")
813
814 r = admin.follow(redirect).mustStatus(200)
815 if !strings.Contains(r.Text("h2"), "Pipelines") {
816 t.Errorf("heading = %q", r.Text("h2"))
817 }
818 msg := r.Text(".form-success, .form-error")
819 if !strings.Contains(strings.ToLower(msg), "purge") {
820 t.Errorf("message = %q", msg)
821 }
822 })
823}
824
825// ── repo upload ──────────────────────────────────────────────────────────
826
827const ciCloneTOML = `
828image = "debian:latest"
829work_dir = "/ci/build"
830clone_project_to = "/ci/build/project"
831
832[on]
833manual = true
834
835[[steps]]
836name = "hello"
837run_sh = "echo hi"
838`
839
840func TestCIRepoUpload(t *testing.T) {
841 e, m, admin := ciEnv(t)
842 ciSeedToml(e, ciCloneTOML)
843
844 // Sibling subtests reseed the config, and the trigger route always builds
845 // HEAD. Put the clone config back first.
846 trigger := func() int64 {
847 sha := ciSeedToml(e, ciCloneTOML)
848 return ciTrigger(e, admin, sha, nil)
849 }
850
851 t.Run("the checkout is uploaded, not bind-mounted", func(t *testing.T) {
852 m.reset()
853 runID := trigger()
854 if got := ciWaitForRun(e, runID); got != "success" {
855 t.Fatalf("status = %q", got)
856 }
857 if len(m.uploadsInto(t, "/ci/build/project")) == 0 {
858 t.Fatalf("no upload carries files under the clone directory; uploads = %v", m.uploadedPaths())
859 }
860 binds, _ := json.Marshal(m.createBody()["HostConfig"])
861 if strings.Contains(string(binds), e.DataDir) {
862 t.Errorf("binds reference the data directory: %s", binds)
863 }
864 })
865
866 t.Run("the container never runs git", func(t *testing.T) {
867 m.reset()
868 runID := trigger()
869 if got := ciWaitForRun(e, runID); got != "success" {
870 t.Fatalf("status = %q", got)
871 }
872 if strings.Contains(m.allCommandText(), "git") {
873 t.Errorf("commands = %v", m.commands())
874 }
875 })
876
877 t.Run("a failing checkout fails the run before any step runs", func(t *testing.T) {
878 m.reset()
879 m.uploadError = "read-only file system"
880
881 runID := trigger()
882 if got := ciWaitForRun(e, runID); got != "failure" {
883 t.Fatalf("status = %q", got)
884 }
885 if got := ciStep(e, runID, "hello").Status; got != "skipped" {
886 t.Errorf("hello status = %q", got)
887 }
888 setup := ciStep(e, runID, "pipeline setup")
889 if setup.Status != "failure" {
890 t.Errorf("setup status = %q", setup.Status)
891 }
892 if !strings.Contains(setup.Log, "read-only file system") {
893 t.Errorf("setup log = %q", setup.Log)
894 }
895 })
896
897 t.Run("a cache path inside the clone directory is rejected", func(t *testing.T) {
898 m.reset()
899 sha := ciSeedToml(e, `
900image = "debian:latest"
901clone_project_to = "/ci/build/project"
902cache = ["/ci/build/project/target"]
903
904[on]
905manual = true
906
907[[steps]]
908name = "hello"
909run_sh = "echo hi"
910`)
911 runID := ciTrigger(e, admin, sha, nil)
912 if got := ciWaitForRun(e, runID); got != "failure" {
913 t.Fatalf("status = %q", got)
914 }
915 if n := m.uploadCount(); n != 0 {
916 t.Errorf("uploads = %d, want 0", n)
917 }
918 if log := ciStep(e, runID, "pipeline setup").Log; !strings.Contains(log, "overlaps clone_project_to") {
919 t.Errorf("setup log = %q", log)
920 }
921 })
922
923 t.Run("a cache path above the clone directory is rejected", func(t *testing.T) {
924 m.reset()
925 sha := ciSeedToml(e, `
926image = "debian:latest"
927clone_project_to = "/ci/build/project"
928cache = ["/ci/build"]
929
930[on]
931manual = true
932
933[[steps]]
934name = "hello"
935run_sh = "echo hi"
936`)
937 runID := ciTrigger(e, admin, sha, nil)
938 if got := ciWaitForRun(e, runID); got != "failure" {
939 t.Fatalf("status = %q", got)
940 }
941 if n := m.uploadCount(); n != 0 {
942 t.Errorf("uploads = %d, want 0", n)
943 }
944 })
945
946 t.Run("a relative clone_project_to is rejected", func(t *testing.T) {
947 m.reset()
948 sha := ciSeedToml(e, `
949image = "debian:latest"
950work_dir = "/ci/build"
951clone_project_to = "project"
952
953[on]
954manual = true
955
956[[steps]]
957name = "hello"
958run_sh = "echo hi"
959`)
960 runID := ciTrigger(e, admin, sha, nil)
961 if got := ciWaitForRun(e, runID); got != "failure" {
962 t.Fatalf("status = %q", got)
963 }
964 if log := ciStep(e, runID, "pipeline setup").Log; !strings.Contains(log, "must be an absolute path") {
965 t.Errorf("setup log = %q", log)
966 }
967 })
968
969 t.Run("the upload carries the requested commit", func(t *testing.T) {
970 m.reset()
971 runID := trigger()
972 if got := ciWaitForRun(e, runID); got != "success" {
973 t.Fatalf("status = %q", got)
974 }
975 ups := m.uploadsInto(t, "/ci/build/project")
976 if len(ups) == 0 {
977 t.Fatal("no upload into the clone directory")
978 }
979 // The archive is extracted at / and carries the clone directory as
980 // its prefix. It must hold the CI config at the triggered commit,
981 // and no .git. A dropped commit argument would still produce a
982 // valid tar.
983 if ups[0].path != "/" {
984 t.Errorf("upload path = %q, want /", ups[0].path)
985 }
986 names := tarEntryNames(t, ups[0].body)
987 if !contains(names, "ci/build/project/.hearthforge-ci.toml") {
988 t.Errorf("entries = %v", names)
989 }
990 for _, n := range names {
991 // git archive adds a pax header carrying the commit id.
992 if n == "pax_global_header" {
993 continue
994 }
995 if strings.Contains(n, ".git/") || !strings.HasPrefix(n, "ci/build/project/") {
996 t.Errorf("unexpected entry %q", n)
997 }
998 }
999 // git archive writes uid 0, so the files belong to root in the
1000 // container.
1001 for _, h := range tarHeaders(t, ups[0].body) {
1002 if h.uid != 0 {
1003 t.Errorf("entry %q has uid %d", h.name, h.uid)
1004 }
1005 }
1006 })
1007}
1008
1009// ── copy from another image ──────────────────────────────────────────────
1010
1011const ciCopyTOML = `
1012image = "debian:latest"
1013
1014[on]
1015manual = true
1016
1017[[copy]]
1018image = "docker.io/oven/bun:1.4.0-alpine"
1019from = "/usr/local/bin/bun"
1020to = "/usr/local/bin"
1021
1022[[steps]]
1023name = "hello"
1024run_sh = "bun --version"
1025`
1026
1027func TestCICopyFromAnotherImage(t *testing.T) {
1028 e, m, admin := ciEnv(t)
1029
1030 t.Run("pulls the source image and uploads its files", func(t *testing.T) {
1031 sha := ciSeedToml(e, ciCopyTOML)
1032 m.reset()
1033 m.queueExec(execResp{output: "1.4.0\n"}) // the step
1034
1035 runID := ciTrigger(e, admin, sha, nil)
1036 if got := ciWaitForRun(e, runID); got != "success" {
1037 t.Fatalf("status = %q", got)
1038 }
1039 if !contains(m.pulledImages(), "docker.io/oven/bun") {
1040 t.Errorf("pulls = %v", m.pulledImages())
1041 }
1042 if !contains(m.uploadedPaths(), "/usr/local/bin") || len(m.uploadsInto(t, "/usr/local")) == 0 {
1043 t.Errorf("uploads = %v", m.uploadedPaths())
1044 }
1045 })
1046}
1047
1048// ── always and warn_on_fail ──────────────────────────────────────────────
1049
1050const ciFlagsTOML = `
1051image = "debian:latest"
1052
1053[on]
1054manual = true
1055
1056[[steps]]
1057name = "lint"
1058run_sh = "make lint"
1059warn_on_fail = true
1060
1061[[steps]]
1062name = "build"
1063run_sh = "make"
1064
1065[[steps]]
1066name = "cleanup"
1067run_sh = "rm -rf /scratch"
1068always = true
1069`
1070
1071func TestCIAlwaysAndWarnOnFail(t *testing.T) {
1072 e, m, admin := ciEnv(t)
1073
1074 run := func(sha string) int64 {
1075 runID := ciTrigger(e, admin, sha, nil)
1076 ciWaitForRun(e, runID)
1077 return runID
1078 }
1079
1080 t.Run("warn_on_fail marks the step and lets the run continue", func(t *testing.T) {
1081 sha := ciSeedToml(e, ciFlagsTOML)
1082 m.reset()
1083 m.queueExec(execResp{output: "style nit\n", exitCode: 1}) // lint
1084 m.queueExec(execResp{output: "built\n"}) // build
1085 m.queueExec(execResp{}) // cleanup
1086
1087 runID := run(sha)
1088
1089 lint := ciStep(e, runID, "lint")
1090 if lint.Status != "warning" {
1091 t.Errorf("lint status = %q", lint.Status)
1092 }
1093 if !strings.Contains(lint.Log, "style nit") {
1094 t.Errorf("lint log = %q", lint.Log)
1095 }
1096 if got := ciStep(e, runID, "build").Status; got != "success" {
1097 t.Errorf("build status = %q", got)
1098 }
1099 if got := ciRunStatus(e, runID); got != "warning" {
1100 t.Errorf("run status = %q", got)
1101 }
1102 })
1103
1104 t.Run("always runs after a failure, other steps stay skipped", func(t *testing.T) {
1105 sha := ciSeedToml(e, ciFlagsTOML)
1106 m.reset()
1107 m.queueExec(execResp{output: "ok\n"}) // lint
1108 m.queueExec(execResp{output: "boom\n", exitCode: 1}) // build fails
1109 m.queueExec(execResp{output: "cleaned\n"}) // cleanup, always
1110
1111 runID := run(sha)
1112
1113 if got := ciStep(e, runID, "build").Status; got != "failure" {
1114 t.Errorf("build status = %q", got)
1115 }
1116 cleanup := ciStep(e, runID, "cleanup")
1117 if cleanup.Status != "success" {
1118 t.Errorf("cleanup status = %q", cleanup.Status)
1119 }
1120 if !strings.Contains(cleanup.Log, "cleaned") {
1121 t.Errorf("cleanup log = %q", cleanup.Log)
1122 }
1123 if got := ciRunStatus(e, runID); got != "failure" {
1124 t.Errorf("run status = %q", got)
1125 }
1126 })
1127
1128 t.Run("a failing always step keeps the run failed", func(t *testing.T) {
1129 sha := ciSeedToml(e, ciFlagsTOML)
1130 m.reset()
1131 m.queueExec(execResp{output: "ok\n"}) // lint
1132 m.queueExec(execResp{output: "boom\n", exitCode: 1}) // build fails
1133 m.queueExec(execResp{output: "no\n", exitCode: 1}) // cleanup also fails
1134
1135 runID := run(sha)
1136
1137 if got := ciStep(e, runID, "cleanup").Status; got != "failure" {
1138 t.Errorf("cleanup status = %q", got)
1139 }
1140 if got := ciRunStatus(e, runID); got != "failure" {
1141 t.Errorf("run status = %q", got)
1142 }
1143 })
1144}
1145
1146// ── duplicate step names ─────────────────────────────────────────────────
1147
1148const ciDupesTOML = `
1149image = "debian:latest"
1150
1151[on]
1152manual = true
1153
1154[[steps]]
1155name = "check"
1156run_sh = "echo one"
1157
1158[[steps]]
1159name = "check"
1160run_sh = "echo two"
1161`
1162
1163func TestCIDuplicateStepNames(t *testing.T) {
1164 e, m, admin := ciEnv(t)
1165
1166 t.Run("each occurrence gets its own row, in file order", func(t *testing.T) {
1167 sha := ciSeedToml(e, ciDupesTOML)
1168 m.reset()
1169 m.queueExec(execResp{output: "one\n"})
1170 m.queueExec(execResp{output: "two\n"})
1171
1172 runID := ciTrigger(e, admin, sha, nil)
1173 if got := ciWaitForRun(e, runID); got != "success" {
1174 t.Fatalf("status = %q", got)
1175 }
1176 rows := ciSteps(e, runID, "check")
1177 if len(rows) != 2 {
1178 t.Fatalf("rows = %d", len(rows))
1179 }
1180 if !strings.Contains(rows[0].Log, "one") || !strings.Contains(rows[1].Log, "two") {
1181 t.Errorf("logs = %q, %q", rows[0].Log, rows[1].Log)
1182 }
1183 for _, r := range rows {
1184 if r.Status != "success" {
1185 t.Errorf("status = %q", r.Status)
1186 }
1187 }
1188 })
1189
1190 t.Run("the second occurrence can fail on its own", func(t *testing.T) {
1191 sha := ciSeedToml(e, ciDupesTOML)
1192 m.reset()
1193 m.queueExec(execResp{output: "one\n"})
1194 m.queueExec(execResp{output: "boom\n", exitCode: 1})
1195
1196 runID := ciTrigger(e, admin, sha, nil)
1197 if got := ciWaitForRun(e, runID); got != "failure" {
1198 t.Fatalf("status = %q", got)
1199 }
1200 rows := ciSteps(e, runID, "check")
1201 got := []string{}
1202 for _, r := range rows {
1203 got = append(got, r.Status)
1204 }
1205 if !eqStrings(got, []string{"success", "failure"}) {
1206 t.Errorf("statuses = %v", got)
1207 }
1208 })
1209}
1210
1211// ── timeouts override warn_on_fail ───────────────────────────────────────
1212
1213const ciTimeoutTOML = `
1214image = "debian:latest"
1215
1216[on]
1217manual = true
1218
1219[[steps]]
1220name = "lint"
1221run_sh = "make lint"
1222warn_on_fail = true
1223timeout = 1
1224
1225[[steps]]
1226name = "build"
1227run_sh = "make"
1228`
1229
1230func TestCITimeoutsOverrideWarnOnFail(t *testing.T) {
1231 e, m, admin := ciEnv(t)
1232
1233 t.Run("a timed-out warn_on_fail step fails the run", func(t *testing.T) {
1234 sha := ciSeedToml(e, ciTimeoutTOML)
1235 m.reset()
1236 m.queueExec(execResp{delay: 3 * time.Second})
1237
1238 runID := ciTrigger(e, admin, sha, nil)
1239 if got := ciWaitForRun(e, runID, 30*time.Second); got != "failure" {
1240 t.Fatalf("status = %q", got)
1241 }
1242 // A timeout destroys the container, so nothing after it can run.
1243 // Reporting that as a warning would hide a dead pipeline.
1244 lint := ciStep(e, runID, "lint")
1245 if lint.Status != "failure" {
1246 t.Errorf("lint status = %q", lint.Status)
1247 }
1248 if !strings.Contains(lint.Log, "timed out") {
1249 t.Errorf("lint log = %q", lint.Log)
1250 }
1251 })
1252}
1253
1254// ── clear failures are recorded ──────────────────────────────────────────
1255
1256const ciClearTOML = `
1257image = "debian:latest"
1258work_dir = "/ci/build"
1259clone_project_to = "/ci/build/project"
1260
1261[on]
1262manual = true
1263
1264[[steps]]
1265name = "first"
1266run_sh = "false"
1267
1268[[steps]]
1269name = "second"
1270always = true
1271clear = true
1272run_sh = "echo hi"
1273`
1274
1275func TestCIClearFailuresAreRecorded(t *testing.T) {
1276 e, m, admin := ciEnv(t)
1277
1278 t.Run("a clear failure lands on the step, not the console", func(t *testing.T) {
1279 sha := ciSeedToml(e, ciClearTOML)
1280 m.reset()
1281 m.queueExec(execResp{output: "boom\n", exitCode: 1}) // first, fails
1282 m.queueExec(execResp{output: "rm: device busy\n", exitCode: 1}) // clear
1283
1284 runID := ciTrigger(e, admin, sha, nil)
1285 if got := ciWaitForRun(e, runID); got != "failure" {
1286 t.Fatalf("status = %q", got)
1287 }
1288 second := ciStep(e, runID, "second")
1289 if second.Status != "failure" {
1290 t.Errorf("second status = %q", second.Status)
1291 }
1292 if !strings.Contains(second.Log, "Failed to reset") ||
1293 !strings.Contains(second.Log, "device busy") {
1294 t.Errorf("second log = %q", second.Log)
1295 }
1296 })
1297
1298 t.Run("a clear step re-extracts the checkout", func(t *testing.T) {
1299 sha := ciSeedToml(e, ciClearTOML)
1300 m.reset()
1301 m.queueExec(execResp{output: "ok\n"}) // first
1302 m.queueExec(execResp{}) // clear: rm -rf
1303 m.queueExec(execResp{output: "hi\n"}) // second
1304
1305 runID := ciTrigger(e, admin, sha, nil)
1306 if got := ciWaitForRun(e, runID); got != "success" {
1307 t.Fatalf("status = %q", got)
1308 }
1309 if n := len(m.uploadsInto(t, "/ci/build/project")); n != 2 {
1310 t.Errorf("uploads into the clone directory = %d, want 2", n)
1311 }
1312 if strings.Contains(m.allCommandText(), "git") {
1313 t.Errorf("commands = %v", m.commands())
1314 }
1315 })
1316
1317 t.Run("clear removes and recreates the directory in one exec", func(t *testing.T) {
1318 // `rm -rf` can delete the container's WorkingDir. A second exec would
1319 // then fail to chdir before its command starts.
1320 sha := ciSeedToml(e, `
1321image = "debian:latest"
1322work_dir = "/ci/build"
1323clone_project_to = "/ci/build"
1324
1325[on]
1326manual = true
1327
1328[[steps]]
1329name = "first"
1330run_sh = "true"
1331
1332[[steps]]
1333name = "second"
1334clear = true
1335run_sh = "echo hi"
1336`)
1337 m.reset()
1338 runID := ciTrigger(e, admin, sha, nil)
1339 if got := ciWaitForRun(e, runID); got != "success" {
1340 t.Fatalf("status = %q", got)
1341 }
1342 var removals []string
1343 for _, c := range m.commands() {
1344 if joined := strings.Join(c, " "); strings.Contains(joined, "rm -rf") {
1345 removals = append(removals, joined)
1346 }
1347 }
1348 if len(removals) != 1 {
1349 t.Fatalf("rm -rf execs = %v", removals)
1350 }
1351 if !strings.Contains(removals[0], "mkdir -p") {
1352 t.Errorf("removal exec = %q", removals[0])
1353 }
1354 })
1355}
1356
1357// ── cache volumes ────────────────────────────────────────────────────────
1358
1359const ciCacheTOML = `
1360image = "debian:latest"
1361cache = ["/ci/cache/target", "/ci/cache/registry"]
1362
1363[on]
1364manual = true
1365push = ["main", "some-feature"]
1366
1367[[steps]]
1368name = "hello"
1369run_sh = "echo hi"
1370`
1371
1372func TestCICacheVolumes(t *testing.T) {
1373 e, m, admin := ciEnv(t)
1374
1375 run := func(sha, branch string) int64 {
1376 var runID int64
1377 if branch == "main" {
1378 runID = ciTrigger(e, admin, sha, nil)
1379 } else {
1380 runID = ciPushRun(e, sha, branch)
1381 }
1382 ciWaitForRun(e, runID)
1383 return runID
1384 }
1385
1386 t.Run("two cache paths sharing a prefix get distinct volumes", func(t *testing.T) {
1387 sha := ciSeedToml(e, ciCacheTOML)
1388 m.reset()
1389 run(sha, "main")
1390
1391 vols := m.createdVolumes()
1392 if len(vols) != 2 {
1393 t.Fatalf("volumes = %v", vols)
1394 }
1395 if vols[0].name == vols[1].name {
1396 t.Error("both cache paths share one volume")
1397 }
1398 // The path is otherwise unrecoverable from a digest.
1399 got := []string{
1400 vols[0].labels["com.hearthforge.cache-path"],
1401 vols[1].labels["com.hearthforge.cache-path"],
1402 }
1403 if !eqStrings(got, []string{"/ci/cache/target", "/ci/cache/registry"}) {
1404 t.Errorf("cache-path labels = %v", got)
1405 }
1406 })
1407
1408 t.Run("a volume the config no longer names is pruned", func(t *testing.T) {
1409 sha := ciSeedToml(e, ciCacheTOML)
1410 m.reset()
1411 m.setVolumesOnHost("hearthforge-ci-cache-leftover-from-an-old-config")
1412
1413 run(sha, "main")
1414
1415 if got := m.deletedVolumes(); !eqStrings(got,
1416 []string{"hearthforge-ci-cache-leftover-from-an-old-config"}) {
1417 t.Errorf("deleted = %v", got)
1418 }
1419 })
1420
1421 t.Run("volumes still in the config survive", func(t *testing.T) {
1422 sha := ciSeedToml(e, ciCacheTOML)
1423 m.reset()
1424 // Prime the host list with the names this config creates.
1425 run(sha, "main")
1426 var inUse []string
1427 for _, v := range m.createdVolumes() {
1428 inUse = append(inUse, v.name)
1429 }
1430
1431 m.reset()
1432 m.setVolumesOnHost(inUse...)
1433 run(sha, "main")
1434
1435 if got := m.deletedVolumes(); len(got) != 0 {
1436 t.Errorf("deleted = %v", got)
1437 }
1438 })
1439
1440 t.Run("a run off the default branch prunes nothing", func(t *testing.T) {
1441 sha := ciSeedToml(e, ciCacheTOML)
1442 m.reset()
1443 m.setVolumesOnHost("hearthforge-ci-cache-belongs-to-the-default-branch")
1444
1445 // The config is read per commit, so pruning from a feature branch
1446 // would delete the default branch's caches.
1447 run(sha, "some-feature")
1448
1449 if got := m.deletedVolumes(); len(got) != 0 {
1450 t.Errorf("deleted = %v", got)
1451 }
1452 })
1453}
1454
1455// ── cache size caps ──────────────────────────────────────────────────────
1456
1457const ciCappedTOML = `
1458image = "debian:latest"
1459cache = [{ path = "/ci/cache/target", max_size = "1g" }, "/ci/cache/registry"]
1460
1461[on]
1462manual = true
1463
1464[[steps]]
1465name = "hello"
1466run_sh = "echo hi"
1467`
1468
1469func TestCICacheSizeCaps(t *testing.T) {
1470 e, m, admin := ciEnv(t)
1471
1472 run := func(sha string) int64 {
1473 runID := ciTrigger(e, admin, sha, nil)
1474 ciWaitForRun(e, runID)
1475 return runID
1476 }
1477
1478 // names returns the volume names the config produces, in declaration
1479 // order.
1480 names := func(sha string) (string, string) {
1481 m.reset()
1482 run(sha)
1483 vols := m.createdVolumes()
1484 if len(vols) != 2 {
1485 t.Fatalf("volumes = %v", vols)
1486 }
1487 return vols[0].name, vols[1].name
1488 }
1489
1490 const gib = int64(1024 * 1024 * 1024)
1491
1492 t.Run("an oversized cache is dropped and reported on the run", func(t *testing.T) {
1493 sha := ciSeedToml(e, ciCappedTOML)
1494 target, registry := names(sha)
1495
1496 m.reset()
1497 m.setVolumesOnHost(target, registry)
1498 m.setVolumeUsage(map[string]ciVolumeUsage{
1499 target: {Size: 2 * gib},
1500 registry: {Size: 9 * gib},
1501 })
1502 runID := run(sha)
1503
1504 // Only the capped one goes, however large the uncapped one grows.
1505 if got := m.deletedVolumes(); !eqStrings(got, []string{target}) {
1506 t.Fatalf("deleted = %v", got)
1507 }
1508 log := ciStep(e, runID, "cache").Log
1509 if !strings.Contains(log, "/ci/cache/target") || !strings.Contains(log, "2.0G") {
1510 t.Errorf("cache step log = %q", log)
1511 }
1512 })
1513
1514 t.Run("a cache under its cap survives", func(t *testing.T) {
1515 sha := ciSeedToml(e, ciCappedTOML)
1516 target, registry := names(sha)
1517
1518 m.reset()
1519 m.setVolumesOnHost(target, registry)
1520 m.setVolumeUsage(map[string]ciVolumeUsage{target: {Size: 100}})
1521 run(sha)
1522
1523 if got := m.deletedVolumes(); len(got) != 0 {
1524 t.Errorf("deleted = %v", got)
1525 }
1526 })
1527
1528 t.Run("a cache a concurrent run holds is left alone", func(t *testing.T) {
1529 sha := ciSeedToml(e, ciCappedTOML)
1530 target, registry := names(sha)
1531
1532 m.reset()
1533 m.setVolumesOnHost(target, registry)
1534 m.setVolumeUsage(map[string]ciVolumeUsage{target: {Size: 9 * gib, RefCount: 1}})
1535 run(sha)
1536
1537 if got := m.deletedVolumes(); len(got) != 0 {
1538 t.Errorf("deleted = %v", got)
1539 }
1540 })
1541
1542 t.Run("an unmeasured cache is never dropped", func(t *testing.T) {
1543 sha := ciSeedToml(e, ciCappedTOML)
1544 target, registry := names(sha)
1545
1546 m.reset()
1547 m.setVolumesOnHost(target, registry)
1548 // Docker reports -1 for a size it has not computed.
1549 m.setVolumeUsage(map[string]ciVolumeUsage{target: {Size: -1}})
1550 runID := run(sha)
1551
1552 if got := m.deletedVolumes(); len(got) != 0 {
1553 t.Errorf("deleted = %v", got)
1554 }
1555 if steps := ciSteps(e, runID, "cache"); len(steps) != 0 {
1556 t.Errorf("cache step = %v", steps)
1557 }
1558 })
1559}
1560
1561// ── host-built archives ─────────────────────────────────────────────────
1562
1563const ciArchiveTOML = `
1564image = "debian:latest"
1565work_dir = "/ci"
1566
1567[on]
1568manual = true
1569
1570[[steps]]
1571name = "build"
1572run_sh = "echo building"
1573publish_tar = ["/ci/dist"]
1574publish_gzip = ["/ci/dist"]
1575publish_zstd = ["/ci/dist"]
1576publish_zip = ["/ci/dist"]
1577`
1578
1579// TestCIArchivesBuiltOnHost checks that publish_* archives are built from
1580// the engine's tar stream. The mock runs no tar, gzip, zstd or zip, so any
1581// exec for them would fail the test.
1582func TestCIArchivesBuiltOnHost(t *testing.T) {
1583 e, m, admin := ciEnv(t)
1584 m.queueExec(execResp{output: "building\n"})
1585 sha := ciSeedToml(e, ciArchiveTOML)
1586 runID := ciTrigger(e, admin, sha, nil)
1587 if got := ciWaitForRun(e, runID); got != "success" {
1588 t.Fatalf("status = %q", got)
1589 }
1590 for _, c := range m.commands() {
1591 if len(c) > 0 && (c[0] == "tar" || c[0] == "zip") {
1592 t.Errorf("archive tool run in the container: %v", c)
1593 }
1594 }
1595
1596 artifacts := map[string]int64{}
1597 rows, err := e.DB.QueryContext(context.Background(),
1598 `SELECT id, filename FROM ci_artifacts WHERE run_id = ?`, runID)
1599 if err != nil {
1600 t.Fatal(err)
1601 }
1602 for rows.Next() {
1603 var id int64
1604 var name string
1605 if err := rows.Scan(&id, &name); err != nil {
1606 t.Fatal(err)
1607 }
1608 artifacts[name] = id
1609 }
1610 rows.Close()
1611 if len(artifacts) != 4 {
1612 t.Fatalf("artifacts = %v", artifacts)
1613 }
1614 download := func(name string) []byte {
1615 t.Helper()
1616 id, ok := artifacts[name]
1617 if !ok {
1618 t.Fatalf("artifact %s missing from %v", name, artifacts)
1619 }
1620 return admin.get(ciRunPath(runID) + "/artifacts/" + strconv.FormatInt(id, 10)).mustStatus(200).Body
1621 }
1622 // The mock answers every archive request with one file "dist" holding
1623 // artifact-content-123.
1624 checkTar := func(name string, r io.Reader) {
1625 t.Helper()
1626 tr := tar.NewReader(r)
1627 h, err := tr.Next()
1628 if err != nil || h.Name != "dist" {
1629 t.Fatalf("%s: first entry %v, err %v", name, h, err)
1630 }
1631 data, _ := io.ReadAll(tr)
1632 if string(data) != "artifact-content-123" {
1633 t.Errorf("%s: content = %q", name, data)
1634 }
1635 }
1636
1637 t.Run("tar", func(t *testing.T) {
1638 checkTar("dist.tar", bytes.NewReader(download("dist.tar")))
1639 })
1640 t.Run("gzip", func(t *testing.T) {
1641 gz, err := gzip.NewReader(bytes.NewReader(download("dist.tar.gz")))
1642 if err != nil {
1643 t.Fatal(err)
1644 }
1645 checkTar("dist.tar.gz", gz)
1646 })
1647 t.Run("zstd", func(t *testing.T) {
1648 dec, err := zstd.NewReader(bytes.NewReader(download("dist.tar.zst")))
1649 if err != nil {
1650 t.Fatal(err)
1651 }
1652 defer dec.Close()
1653 checkTar("dist.tar.zst", dec)
1654 })
1655 t.Run("zip", func(t *testing.T) {
1656 data := download("dist.zip")
1657 zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
1658 if err != nil {
1659 t.Fatal(err)
1660 }
1661 if len(zr.File) != 1 || zr.File[0].Name != "dist" {
1662 t.Fatalf("zip entries = %v", zr.File)
1663 }
1664 f, _ := zr.File[0].Open()
1665 body, _ := io.ReadAll(f)
1666 if string(body) != "artifact-content-123" {
1667 t.Errorf("zip content = %q", body)
1668 }
1669 })
1670}
1671