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.

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 {