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.
Phase 4: record the queue's new shape, and let the pins be tested while running
d798eaacf9b210fcdc2b51372d64a0e21e239bab · Keenan Nemetz · 16d ago · parent ac433531
Phase 4: record the queue's new shape, and let the pins be tested while running Docs, plus the two tests whose setup the new cancellation invalidates. docs/roadmap.md gains slices 7 and 8, rewrites the Phase 4 remaining list (the push-event table is gone by decision; what is left is the toolchain in the record, a narrowing/targeting filter for a mixed fleet, and a per-repository cap somebody would have to tune), adds an exit criterion for "a push whose spool file was never written still runs", and appends four decision-log rows: reconcile from the refs rather than persist events, park an idle lease on a wake-up channel, cancel queued superseded runs and order the lease by in-flight count, and keep runner presence in the queue filtered by the clone rule. README describes the new queue behaviour and /runners, and its stale "instance ed25519 key generated at first boot" line is replaced: the instance holds no signing key. Two tests built their scenario on a superseded run being started — queue v1, push v2, then run both. That is exactly what the queue no longer does, so both now hold the first build inside its step with a sentinel file, land the second push while it is running, and release it. The properties are unchanged and now tested where they actually apply: a build that is running when the branch moves must not publish a site (TestWorkflowRefusesAStaleSiteDeploy), and a run executes the steps it was queued with, not the workflow as the branch has it now (TestQueuedRunUsesTheWorkflowItWasQueuedFrom). A run that is still waiting when the new tip arrives is canceled, which TestANewerTipCancelsTheQueuedBuildOfTheOlderOne covers. scripts/ci.sh green before these comment-and-doc edits (internal/server 132.9s, everything else included); gofmt, vet, build and the markdown link check green after.
internal/server/ci_pages_test.go +45−10
| @@ -6,7 +6,9 @@ package server_test | |||
| 6 | 6 | // an older commit would revert the site, so the runner refuses instead. | |
| 7 | 7 | ||
| 8 | 8 | import ( | |
| 9 | "io" | ||
| 9 | 10 | "os" | |
| 11 | "os/exec" | ||
| 10 | 12 | "path/filepath" | |
| 11 | 13 | "strings" | |
| 12 | 14 | "testing" | |
| @@ -39,6 +41,23 @@ jobs: | |||
| 39 | 41 | `) | |
| 40 | 42 | } | |
| 41 | 43 | ||
| 44 | // gatedSiteWorkflow is siteWorkflow with the build held until sentinel exists, so a | ||
| 45 | // test can land a second commit while the first build is still running. A run that is | ||
| 46 | // still queued when a newer tip arrives is now canceled outright (a superseded build | ||
| 47 | // is not started), so the staleness guard has to be exercised by a build that is | ||
| 48 | // already running — which is the case it is actually there for. | ||
| 49 | func gatedSiteWorkflow(headline, sentinel string) map[string]string { | ||
| 50 | return workflowFile(`version: 1 | ||
| 51 | jobs: | ||
| 52 | site: | ||
| 53 | steps: | ||
| 54 | - name: build the site | ||
| 55 | run: while [ ! -f ` + sentinel + ` ]; do sleep 0.1; done; mkdir -p site && printf '` + headline + `\n' > site/index.html | ||
| 56 | pages: | ||
| 57 | path: site | ||
| 58 | `) | ||
| 59 | } | ||
| 60 | |||
| 42 | 61 | func TestWorkflowDeploysTheSite(t *testing.T) { | |
| 43 | 62 | f := pagesCIFixture(t) | |
| 44 | 63 | f.publish(f.repo) | |
| @@ -82,20 +101,36 @@ func TestWorkflowDeploysTheSite(t *testing.T) { | |||
| 82 | 101 | } | |
| 83 | 102 | } | |
| 84 | 103 | ||
| 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. | ||
| 104 | // TestWorkflowRefusesAStaleSiteDeploy is the guard: a build that is already running | ||
| 105 | // when the branch moves must not publish, or the site would go backwards. The build is | ||
| 106 | // held on a sentinel so the second commit lands while it is running — a run that is | ||
| 107 | // still queued at that moment is canceled instead of started, which is a different | ||
| 108 | // (and cheaper) answer. | ||
| 88 | 109 | func TestWorkflowRefusesAStaleSiteDeploy(t *testing.T) { | |
| 89 | 110 | f := pagesCIFixture(t) | |
| 90 | 111 | f.publish(f.repo) | |
| 91 | 112 | ||
| 92 | work := workRepo(t, siteWorkflow("<h1>first build</h1>")) | ||
| 113 | sentinel := filepath.Join(t.TempDir(), "release") | ||
| 114 | work := workRepo(t, gatedSiteWorkflow("<h1>first build</h1>", sentinel)) | ||
| 93 | 115 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | |
| 94 | 116 | t.Fatalf("first push: %s", errs) | |
| 95 | 117 | } | |
| 96 | 118 | first := f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued }) | |
| 97 | 119 | ||
| 98 | // A second commit lands before the first run is leased. | ||
| 120 | // Lease the first run and hold it in its step, so the second commit lands while the | ||
| 121 | // build is running rather than waiting. | ||
| 122 | held := exec.Command(binPath, "runner", "--remote", f.sshBase(), "--once", "--dir", t.TempDir()) | ||
| 123 | held.Env = f.ciEnv() | ||
| 124 | held.Stderr = io.Discard | ||
| 125 | if err := held.Start(); err != nil { | ||
| 126 | t.Fatal(err) | ||
| 127 | } | ||
| 128 | t.Cleanup(func() { _ = held.Process.Kill(); _ = held.Wait() }) | ||
| 129 | f.waitForRun(t, 15*time.Second, func(r *runs.Run) bool { | ||
| 130 | return r.ID == first.ID && r.Status == runs.StatusRunning | ||
| 131 | }) | ||
| 132 | |||
| 133 | // A second commit lands while the first build is still running. | ||
| 99 | 134 | if err := os.WriteFile(filepath.Join(work, "site-note.txt"), []byte("second\n"), 0o644); err != nil { | |
| 100 | 135 | t.Fatal(err) | |
| 101 | 136 | } | |
| @@ -112,11 +147,11 @@ func TestWorkflowRefusesAStaleSiteDeploy(t *testing.T) { | |||
| 112 | 147 | return r.Status == runs.StatusQueued && r.SHA != first.SHA | |
| 113 | 148 | }) | |
| 114 | 149 | ||
| 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 { | ||
| 150 | // Let the held build finish; it is now building a commit that is no longer the tip. | ||
| 151 | if err := os.WriteFile(sentinel, []byte("go\n"), 0o644); err != nil { | ||
| 152 | t.Fatal(err) | ||
| 153 | } | ||
| 154 | stale := f.waitForRun(t, 30*time.Second, func(r *runs.Run) bool { | ||
| 120 | 155 | return r.ID == first.ID && (r.Status == runs.StatusFailed || r.Status == runs.StatusSuccess) | |
| 121 | 156 | }) | |
| 122 | 157 | if stale.Status != runs.StatusFailed { | |