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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

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

Phase 7 slice 7: the build deploys the site

88e980b0e88b24a20605ceeb9e42fd0dacee7842 · Keenan Nemetz · 17d ago · parent 213f4d6d

Phase 7 slice 7: the build deploys the site

pages: {path: site} on a job publishes that directory as the site branch, using
the same snapshot code `vole pages deploy` uses — a site published by a workflow
and one published by a person have to be the same commit shape, or the version
that drifts is the one nobody runs by hand.

- workflow: pages.path validated at push — one relative directory, no glob (a
  site is one tree), and only one job per workflow may declare it, since two
  publishers race and the winner is whichever runner finished last
- the directory is pinned into the queued run beside the steps and the artifact
  patterns; the branch comes from the server (pages_ref), which a workflow
  cannot know
- runner: after the artifacts, checks that the run's SHA is still the tip of its
  branch and refuses otherwise. Deploy is a snapshot, so a stale build finishing
  late would silently revert the site; the failure names both commits
- the site commit says where it came from: "site: main from run <id> (<sha>)"

Fixed on the way, caught by the end-to-end test: the shared push helper treated a
URL as a missing *named* remote, so a runner's deploy committed locally and
reported success while the site 404'd. A URL is now a remote in its own right.

Measured through the binary: two pushes queued two runs; the older one was
refused ("this run built f1f415ec but main is at 6beaa530 now; the site was not
deployed") and published nothing; the newer one deployed and the site host served
the new tree, with `git log pages` showing
"site: main from run 01M5MSC3... (6beaa530)".

internal/server/ci_pages_test.go +169

@@ -0,0 +1,169 @@
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 (
9 "os"
10 "path/filepath"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/rodent-software/vole/internal/config"
16 "github.com/rodent-software/vole/internal/runs"
17 )
18
19 func pagesCIFixture(t *testing.T) *fixture {
20 t.Helper()
21 return newFixtureWith(t, func(c *config.Config) {
22 c.LeaseSeconds = 5
23 c.PagesDomain = pagesDomain
24 c.PagesRef = "pages"
25 c.PagesProcs = 2
26 c.PagesCacheBytes = 1 << 20
27 })
28 }
29
30 func siteWorkflow(headline string) map[string]string {
31 return workflowFile(`version: 1
32 jobs:
33 site:
34 steps:
35 - name: build the site
36 run: mkdir -p site && printf '` + headline + `\n' > site/index.html
37 pages:
38 path: site
39 `)
40 }
41
42 func TestWorkflowDeploysTheSite(t *testing.T) {
43 f := pagesCIFixture(t)
44 f.publish(f.repo)
45
46 work := workRepo(t, siteWorkflow("<h1>built by CI</h1>"))
47 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
48 t.Fatalf("push: %s", errs)
49 }
50 f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued })
51 f.runOnce(t)
52 done := f.terminalRun(t)
53
54 // The site is live: the deploy is a push to the pages branch, so the existing
55 // serving path needs to know nothing about CI.
56 deadline := time.Now().Add(5 * time.Second)
57 for {
58 code, body, _ := f.siteGet(f.repo+"."+pagesDomain, "/")
59 if code == 200 && strings.Contains(body, "built by CI") {
60 break
61 }
62 if time.Now().After(deadline) {
63 t.Fatalf("site after the deploy = %d %q", code, body)
64 }
65 time.Sleep(100 * time.Millisecond)
66 }
67
68 // The commit on the site branch names the run, so a clone (or the commit list)
69 // says which build put that tree there.
70 subject, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),
71 "log", "-1", "--format=%s", "refs/heads/pages")
72 if !strings.Contains(subject, done.ID) {
73 t.Fatalf("site commit %q does not name run %s", subject, done.ID)
74 }
75 if !strings.Contains(subject, "site:") {
76 t.Fatalf("site commit %q is not recognisable as a deploy", subject)
77 }
78 // The run log says what was published, so the job can be read after the fact.
79 _, log := f.get("/r/" + f.repo + "/runs/" + done.ID + "/log")
80 if !strings.Contains(log, "published 1 file(s) to pages") {
81 t.Fatalf("run log does not mention the deploy:\n%s", log)
82 }
83 }
84
85 // TestWorkflowRefusesAStaleSiteDeploy is the guard: two pushes queue two runs, the
86 // older one is leased first, and its build is no longer the branch tip — so it must
87 // not publish, or the site would go backwards.
88 func TestWorkflowRefusesAStaleSiteDeploy(t *testing.T) {
89 f := pagesCIFixture(t)
90 f.publish(f.repo)
91
92 work := workRepo(t, siteWorkflow("<h1>first build</h1>"))
93 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
94 t.Fatalf("first push: %s", errs)
95 }
96 first := f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued })
97
98 // A second commit lands before the first run is leased.
99 if err := os.WriteFile(filepath.Join(work, "site-note.txt"), []byte("second\n"), 0o644); err != nil {
100 t.Fatal(err)
101 }
102 if _, errs, err := runGit(t, work, "add", "-A"); err != nil {
103 t.Fatal(errs)
104 }
105 if _, errs, err := runGit(t, work, "commit", "-qm", "second commit"); err != nil {
106 t.Fatal(errs)
107 }
108 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
109 t.Fatalf("second push: %s", errs)
110 }
111 f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool {
112 return r.Status == runs.StatusQueued && r.SHA != first.SHA
113 })
114
115 // The oldest queued run is the stale build.
116 // Failure is the expected outcome here, so this waits without treating a
117 // failed run as a broken test.
118 f.runOnce(t)
119 stale := f.waitForRun(t, 15*time.Second, func(r *runs.Run) bool {
120 return r.ID == first.ID && (r.Status == runs.StatusFailed || r.Status == runs.StatusSuccess)
121 })
122 if stale.Status != runs.StatusFailed {
123 t.Fatalf("the stale run ended %s, want failed", stale.Status)
124 }
125 _, log := f.get("/r/" + f.repo + "/runs/" + stale.ID + "/log")
126 if !strings.Contains(log, "the site was not deployed") {
127 t.Fatalf("stale run did not explain itself:\n%s", log)
128 }
129 if refs, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),
130 "for-each-ref", "--format=%(refname)", "refs/heads/pages"); refs != "" {
131 t.Fatalf("a stale build published a site: %q", refs)
132 }
133
134 // The newer run deploys normally, so the refusal is about staleness, not about CI
135 // deploys being broken.
136 f.runOnce(t)
137 fresh := f.waitForRun(t, 10*time.Second, func(r *runs.Run) bool {
138 return r.SHA != first.SHA && r.Status == runs.StatusSuccess
139 })
140 if fresh.ID == "" {
141 t.Fatal("the newer run did not succeed")
142 }
143 deadline := time.Now().Add(5 * time.Second)
144 for {
145 code, body, _ := f.siteGet(f.repo+"."+pagesDomain, "/")
146 if code == 200 && strings.Contains(body, "first build") {
147 break
148 }
149 if time.Now().After(deadline) {
150 t.Fatalf("site after the fresh deploy = %d %q", code, body)
151 }
152 time.Sleep(100 * time.Millisecond)
153 }
154 }
155
156 // terminalRun waits for the newest run to stop moving and complains with its log if
157 // it failed, so a broken deploy is diagnosed by the test output rather than by
158 // rerunning it under a debugger.
159 func (f *fixture) terminalRun(t *testing.T) *runs.Run {
160 t.Helper()
161 run := f.waitForRun(t, 15*time.Second, func(r *runs.Run) bool {
162 return r.Status == runs.StatusSuccess || r.Status == runs.StatusFailed
163 })
164 if run.Status == runs.StatusFailed {
165 _, log := f.get("/r/" + f.repo + "/runs/" + run.ID + "/log")
166 t.Fatalf("run %s failed (exit %d):\n%s", run.ID, run.ExitCode, log)
167 }
168 return run
169 }