Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · server/ci_pages_test.go

Line attribution on main · 208 line(s)

88e980b0
Keenan Nemetz
17d ago
1 package server_test
2
3 // A site built by CI, end to end: the job declares a directory, the runner deploys
4 // it with the same snapshot code the pages CLI uses, and the site host serves the
5 // new tree. The stale case matters as much as the happy one — a snapshot deploy from
6 // an older commit would revert the site, so the runner refuses instead.
7
8 import (
d798eaac
Keenan Nemetz
16d ago
9 "io"
88e980b0
Keenan Nemetz
17d ago
10 "os"
d798eaac
Keenan Nemetz
16d ago
11 "os/exec"
88e980b0
Keenan Nemetz
17d ago
12 "path/filepath"
13 "strings"
14 "testing"
15 "time"
16
d37d301f
Keenan Nemetz
16h ago
17 "rodent.software/vole/ci/runs"
18 "rodent.software/vole/forge/config"
88e980b0
Keenan Nemetz
17d ago
19 )
20
21 func pagesCIFixture(t *testing.T) *fixture {
22 t.Helper()
23 return newFixtureWith(t, func(c *config.Config) {
24 c.LeaseSeconds = 5
25 c.PagesDomain = pagesDomain
26 c.PagesRef = "pages"
27 c.PagesProcs = 2
28 c.PagesCacheBytes = 1 << 20
29 })
30 }
31
32 func siteWorkflow(headline string) map[string]string {
33 return workflowFile(`version: 1
34 jobs:
35 site:
70b70264
Keenan Nemetz
15d ago
36 image: alpine:3.22
88e980b0
Keenan Nemetz
17d ago
37 steps:
38 - name: build the site
39 run: mkdir -p site && printf '` + headline + `\n' > site/index.html
40 pages:
41 path: site
42 `)
43 }
44
d798eaac
Keenan Nemetz
16d ago
45 // gatedSiteWorkflow is siteWorkflow with the build held until sentinel exists, so a
46 // test can land a second commit while the first build is still running. A run that is
47 // still queued when a newer tip arrives is now canceled outright (a superseded build
48 // is not started), so the staleness guard has to be exercised by a build that is
49 // already running — which is the case it is actually there for.
50 func gatedSiteWorkflow(headline, sentinel string) map[string]string {
51 return workflowFile(`version: 1
52 jobs:
53 site:
70b70264
Keenan Nemetz
15d ago
54 image: alpine:3.22
d798eaac
Keenan Nemetz
16d ago
55 steps:
56 - name: build the site
57 run: while [ ! -f ` + sentinel + ` ]; do sleep 0.1; done; mkdir -p site && printf '` + headline + `\n' > site/index.html
58 pages:
59 path: site
60 `)
61 }
62
88e980b0
Keenan Nemetz
17d ago
63 func TestWorkflowDeploysTheSite(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
64 t.Parallel()
88e980b0
Keenan Nemetz
17d ago
65 f := pagesCIFixture(t)
66 f.publish(f.repo)
67
68 work := workRepo(t, siteWorkflow("<h1>built by CI</h1>"))
69 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
70 t.Fatalf("push: %s", errs)
71 }
72 f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued })
73 f.runOnce(t)
74 done := f.terminalRun(t)
75
76 // The site is live: the deploy is a push to the pages branch, so the existing
77 // serving path needs to know nothing about CI.
78 deadline := time.Now().Add(5 * time.Second)
79 for {
80 code, body, _ := f.siteGet(f.repo+"."+pagesDomain, "/")
81 if code == 200 && strings.Contains(body, "built by CI") {
82 break
83 }
84 if time.Now().After(deadline) {
85 t.Fatalf("site after the deploy = %d %q", code, body)
86 }
87 time.Sleep(100 * time.Millisecond)
88 }
89
90 // The commit on the site branch names the run, so a clone (or the commit list)
91 // says which build put that tree there.
92 subject, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),
93 "log", "-1", "--format=%s", "refs/heads/pages")
94 if !strings.Contains(subject, done.ID) {
95 t.Fatalf("site commit %q does not name run %s", subject, done.ID)
96 }
97 if !strings.Contains(subject, "site:") {
98 t.Fatalf("site commit %q is not recognisable as a deploy", subject)
99 }
100 // The run log says what was published, so the job can be read after the fact.
ed03b3d1
Keenan Nemetz
3h ago
101 _, log := f.get("/" + f.repo + "/runs/" + done.ID + "/log")
88e980b0
Keenan Nemetz
17d ago
102 if !strings.Contains(log, "published 1 file(s) to pages") {
103 t.Fatalf("run log does not mention the deploy:\n%s", log)
104 }
105 }
106
d798eaac
Keenan Nemetz
16d ago
107 // TestWorkflowRefusesAStaleSiteDeploy is the guard: a build that is already running
108 // when the branch moves must not publish, or the site would go backwards. The build is
109 // held on a sentinel so the second commit lands while it is running — a run that is
110 // still queued at that moment is canceled instead of started, which is a different
111 // (and cheaper) answer.
88e980b0
Keenan Nemetz
17d ago
112 func TestWorkflowRefusesAStaleSiteDeploy(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
113 t.Parallel()
88e980b0
Keenan Nemetz
17d ago
114 f := pagesCIFixture(t)
115 f.publish(f.repo)
116
d798eaac
Keenan Nemetz
16d ago
117 sentinel := filepath.Join(t.TempDir(), "release")
118 work := workRepo(t, gatedSiteWorkflow("<h1>first build</h1>", sentinel))
88e980b0
Keenan Nemetz
17d ago
119 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
120 t.Fatalf("first push: %s", errs)
121 }
122 first := f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued })
123
d798eaac
Keenan Nemetz
16d ago
124 // Lease the first run and hold it in its step, so the second commit lands while the
125 // build is running rather than waiting.
126 held := exec.Command(binPath, "runner", "--remote", f.sshBase(), "--once", "--dir", t.TempDir())
127 held.Env = f.ciEnv()
128 held.Stderr = io.Discard
129 if err := held.Start(); err != nil {
130 t.Fatal(err)
131 }
132 t.Cleanup(func() { _ = held.Process.Kill(); _ = held.Wait() })
133 f.waitForRun(t, 15*time.Second, func(r *runs.Run) bool {
134 return r.ID == first.ID && r.Status == runs.StatusRunning
135 })
136
137 // A second commit lands while the first build is still running.
88e980b0
Keenan Nemetz
17d ago
138 if err := os.WriteFile(filepath.Join(work, "site-note.txt"), []byte("second\n"), 0o644); err != nil {
139 t.Fatal(err)
140 }
141 if _, errs, err := runGit(t, work, "add", "-A"); err != nil {
142 t.Fatal(errs)
143 }
144 if _, errs, err := runGit(t, work, "commit", "-qm", "second commit"); err != nil {
145 t.Fatal(errs)
146 }
147 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
148 t.Fatalf("second push: %s", errs)
149 }
150 f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool {
151 return r.Status == runs.StatusQueued && r.SHA != first.SHA
152 })
153
d798eaac
Keenan Nemetz
16d ago
154 // Let the held build finish; it is now building a commit that is no longer the tip.
155 if err := os.WriteFile(sentinel, []byte("go\n"), 0o644); err != nil {
156 t.Fatal(err)
157 }
158 stale := f.waitForRun(t, 30*time.Second, func(r *runs.Run) bool {
88e980b0
Keenan Nemetz
17d ago
159 return r.ID == first.ID && (r.Status == runs.StatusFailed || r.Status == runs.StatusSuccess)
160 })
161 if stale.Status != runs.StatusFailed {
162 t.Fatalf("the stale run ended %s, want failed", stale.Status)
163 }
ed03b3d1
Keenan Nemetz
3h ago
164 _, log := f.get("/" + f.repo + "/runs/" + stale.ID + "/log")
88e980b0
Keenan Nemetz
17d ago
165 if !strings.Contains(log, "the site was not deployed") {
166 t.Fatalf("stale run did not explain itself:\n%s", log)
167 }
168 if refs, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),
169 "for-each-ref", "--format=%(refname)", "refs/heads/pages"); refs != "" {
170 t.Fatalf("a stale build published a site: %q", refs)
171 }
172
173 // The newer run deploys normally, so the refusal is about staleness, not about CI
174 // deploys being broken.
175 f.runOnce(t)
176 fresh := f.waitForRun(t, 10*time.Second, func(r *runs.Run) bool {
177 return r.SHA != first.SHA && r.Status == runs.StatusSuccess
178 })
179 if fresh.ID == "" {
180 t.Fatal("the newer run did not succeed")
181 }
182 deadline := time.Now().Add(5 * time.Second)
183 for {
184 code, body, _ := f.siteGet(f.repo+"."+pagesDomain, "/")
185 if code == 200 && strings.Contains(body, "first build") {
186 break
187 }
188 if time.Now().After(deadline) {
189 t.Fatalf("site after the fresh deploy = %d %q", code, body)
190 }
191 time.Sleep(100 * time.Millisecond)
192 }
193 }
194
195 // terminalRun waits for the newest run to stop moving and complains with its log if
196 // it failed, so a broken deploy is diagnosed by the test output rather than by
197 // rerunning it under a debugger.
198 func (f *fixture) terminalRun(t *testing.T) *runs.Run {
199 t.Helper()
200 run := f.waitForRun(t, 15*time.Second, func(r *runs.Run) bool {
201 return r.Status == runs.StatusSuccess || r.Status == runs.StatusFailed
202 })
203 if run.Status == runs.StatusFailed {
ed03b3d1
Keenan Nemetz
3h ago
204 _, log := f.get("/" + f.repo + "/runs/" + run.ID + "/log")
88e980b0
Keenan Nemetz
17d ago
205 t.Fatalf("run %s failed (exit %d):\n%s", run.ID, run.ExitCode, log)
206 }
207 return run
208 }