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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 50m ago
4b8c9e1d
| 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 | "io" |
| 10 | "os" |
| 11 | "os/exec" |
| 12 | "path/filepath" |
| 13 | "strings" |
| 14 | "testing" |
| 15 | "time" |
| 16 | |
| 17 | "rodent.software/vole/ci/runs" |
| 18 | "rodent.software/vole/forge/config" |
| 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: |
| 36 | image: alpine:3.22 |
| 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 | |
| 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: |
| 54 | image: alpine:3.22 |
| 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 | |
| 63 | func TestWorkflowDeploysTheSite(t *testing.T) { |
| 64 | t.Parallel() |
| 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. |
| 101 | _, log := f.get("/" + f.repo + "/runs/" + done.ID + "/log") |
| 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 | |
| 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. |
| 112 | func TestWorkflowRefusesAStaleSiteDeploy(t *testing.T) { |
| 113 | t.Parallel() |
| 114 | f := pagesCIFixture(t) |
| 115 | f.publish(f.repo) |
| 116 | |
| 117 | sentinel := filepath.Join(t.TempDir(), "release") |
| 118 | work := workRepo(t, gatedSiteWorkflow("<h1>first build</h1>", sentinel)) |
| 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 | |
| 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. |
| 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 | |
| 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 { |
| 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 | } |
| 164 | _, log := f.get("/" + f.repo + "/runs/" + stale.ID + "/log") |
| 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 { |
| 204 | _, log := f.get("/" + f.repo + "/runs/" + run.ID + "/log") |
| 205 | t.Fatalf("run %s failed (exit %d):\n%s", run.ID, run.ExitCode, log) |
| 206 | } |
| 207 | return run |
| 208 | } |
| 209 |