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 | } | ||