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 8: a tag push builds the release
0f442b1c87ca3920f770531c9ad97277d0231f51 · Keenan Nemetz · 17d ago · parent 88e980b0
Phase 7 slice 8: a tag push builds the release on.push.tags opens CI to tag pushes; release: true on an artifact publishes it to that tag's release rather than only to the run. The runner commits the same pointers `vole release attach` writes, so the release page, the ETags, the immutability rules and the sweep needed no changes: CI gained a trigger, not a publish path. Three rules, each from something that went wrong in the first draft: - a workflow that says tags: runs ONLY on tags. Saying what you trigger on is saying it; the first version defaulted to every branch too and queued a doomed release build on every commit - a tag run never counts as a branch's required check. A tag on a branch tip produces a second run for the same sha, and ForSHA would have let a release build satisfy — or break — the gate for the commit it tags. It now filters to refs/heads/% - a lightweight tag cannot name a release: the build runs and the publish fails saying so, because a tag with no message has no notes and no tagger. The server resolves annotated-ness at enqueue (the tag object's type) rather than having the runner parse ls-remote peeled lines Tag and annotated-ness ride the run and the job payload beside the release artifact patterns, pinned at enqueue like everything else. Measured through the binary: a branch push queued nothing, a tag push queued the release job, the runner published dist/tool-linux, the release page listed it with the tag marked annotated, the download returned the bytes, and git log vole read "release: v1.0.0 artifacts from run 01M5MSX3...".
internal/server/tag_release_test.go +123
| @@ -0,0 +1,123 @@ | |||
| 1 | package server_test | ||
| 2 | |||
| 3 | // A tag push is a release build: the workflow opts in with on.push.tags, the run | ||
| 4 | // carries the tag, and the run's release-flagged artifacts are committed as the | ||
| 5 | // tag's release artifacts — the same pointers `vole release attach` writes. | ||
| 6 | |||
| 7 | import ( | ||
| 8 | "path/filepath" | ||
| 9 | "strings" | ||
| 10 | "testing" | ||
| 11 | "time" | ||
| 12 | |||
| 13 | "github.com/rodent-software/vole/internal/config" | ||
| 14 | "github.com/rodent-software/vole/internal/runs" | ||
| 15 | ) | ||
| 16 | |||
| 17 | func tagWorkflow() map[string]string { | ||
| 18 | return workflowFile(`version: 1 | ||
| 19 | on: | ||
| 20 | push: | ||
| 21 | tags: ['v*'] | ||
| 22 | jobs: | ||
| 23 | release: | ||
| 24 | steps: | ||
| 25 | - name: build | ||
| 26 | run: mkdir -p dist && printf 'release binary\n' > dist/tool-linux | ||
| 27 | artifacts: | ||
| 28 | - path: dist/* | ||
| 29 | release: true | ||
| 30 | `) | ||
| 31 | } | ||
| 32 | |||
| 33 | func TestTagPushBuildsARelease(t *testing.T) { | ||
| 34 | f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 5 }) | ||
| 35 | f.publish(f.repo) | ||
| 36 | |||
| 37 | work := workRepo(t, tagWorkflow()) | ||
| 38 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 39 | t.Fatalf("push: %s", errs) | ||
| 40 | } | ||
| 41 | // A workflow that says "tags:" runs on tags: a branch push must not queue it, or | ||
| 42 | // every commit would try to publish a release. | ||
| 43 | time.Sleep(1500 * time.Millisecond) // one spool tick | ||
| 44 | if list, _ := f.srv.Runs.Recent(f.repo, 10); len(list) != 0 { | ||
| 45 | t.Fatalf("a branch push queued %d run(s) for a tag-only workflow", len(list)) | ||
| 46 | } | ||
| 47 | |||
| 48 | if _, errs, err := runGit(t, work, "tag", "-a", "v1.0.0", "-m", "first release\n"); err != nil { | ||
| 49 | t.Fatalf("tag: %s", errs) | ||
| 50 | } | ||
| 51 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "refs/tags/v1.0.0:refs/tags/v1.0.0"); err != nil { | ||
| 52 | t.Fatalf("push tag: %s", errs) | ||
| 53 | } | ||
| 54 | |||
| 55 | queued := f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued }) | ||
| 56 | if queued.Tag != "v1.0.0" || !queued.TagAnnotated || queued.Branch != "" { | ||
| 57 | t.Fatalf("queued run: tag=%q annotated=%v branch=%q", queued.Tag, queued.TagAnnotated, queued.Branch) | ||
| 58 | } | ||
| 59 | if queued.SHA == "" { | ||
| 60 | t.Fatal("the run has no commit: a tag object is not something a runner can check out") | ||
| 61 | } | ||
| 62 | if len(queued.ReleaseArtifacts) != 1 || queued.ReleaseArtifacts[0] != "dist/*" { | ||
| 63 | t.Fatalf("release artifacts = %v", queued.ReleaseArtifacts) | ||
| 64 | } | ||
| 65 | |||
| 66 | f.runOnce(t) | ||
| 67 | done := f.terminalRun(t) | ||
| 68 | |||
| 69 | // The release page is the proof: the runner committed the pointers, and the web | ||
| 70 | // reads them exactly as it reads a person's `vole release attach`. | ||
| 71 | code, page := f.get("/r/" + f.repo + "/releases/v1.0.0") | ||
| 72 | if code != 200 { | ||
| 73 | t.Fatalf("release page = %d", code) | ||
| 74 | } | ||
| 75 | if !strings.Contains(page, "tool-linux") { | ||
| 76 | t.Fatalf("release page does not show the built artifact:\n%s", page) | ||
| 77 | } | ||
| 78 | code, body := f.get("/r/" + f.repo + "/releases/v1.0.0/artifacts/tool-linux") | ||
| 79 | if code != 200 || body != "release binary\n" { | ||
| 80 | t.Fatalf("artifact download = %d %q", code, body) | ||
| 81 | } | ||
| 82 | // The pointer commit names the run, so a clone says which build shipped the bytes. | ||
| 83 | subject, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"), | ||
| 84 | "log", "-1", "--format=%s", "refs/heads/vole") | ||
| 85 | if !strings.Contains(subject, done.ID) { | ||
| 86 | t.Fatalf("metadata commit %q does not name run %s", subject, done.ID) | ||
| 87 | } | ||
| 88 | // And the run page shows the tag it built for. | ||
| 89 | if _, runPage := f.get("/r/" + f.repo + "/runs/" + done.ID); !strings.Contains(runPage, "tag v1.0.0") { | ||
| 90 | t.Fatalf("run page does not show the tag:\n%s", runPage) | ||
| 91 | } | ||
| 92 | } | ||
| 93 | |||
| 94 | // TestTagPushRefusesALightweightTag: a lightweight tag has no notes and no tagger, so | ||
| 95 | // it cannot name a release. The build still runs — the failure is at the publish. | ||
| 96 | func TestTagPushRefusesALightweightTag(t *testing.T) { | ||
| 97 | f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 5 }) | ||
| 98 | f.publish(f.repo) | ||
| 99 | work := workRepo(t, tagWorkflow()) | ||
| 100 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 101 | t.Fatalf("push: %s", errs) | ||
| 102 | } | ||
| 103 | if _, errs, err := runGit(t, work, "tag", "v2.0.0"); err != nil { | ||
| 104 | t.Fatalf("tag: %s", errs) | ||
| 105 | } | ||
| 106 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "refs/tags/v2.0.0:refs/tags/v2.0.0"); err != nil { | ||
| 107 | t.Fatalf("push tag: %s", errs) | ||
| 108 | } | ||
| 109 | queued := f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued }) | ||
| 110 | if queued.TagAnnotated { | ||
| 111 | t.Fatal("a lightweight tag was reported as annotated") | ||
| 112 | } | ||
| 113 | f.runOnce(t) | ||
| 114 | failed := f.waitForRun(t, 10*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusFailed }) | ||
| 115 | _, log := f.get("/r/" + f.repo + "/runs/" + failed.ID + "/log") | ||
| 116 | if !strings.Contains(log, "lightweight tag") { | ||
| 117 | t.Fatalf("the failure does not explain itself:\n%s", log) | ||
| 118 | } | ||
| 119 | // Nothing was published, so the release page has no artifacts. | ||
| 120 | if _, page := f.get("/r/" + f.repo + "/releases/v2.0.0"); strings.Contains(page, "tool-linux") { | ||
| 121 | t.Fatalf("a lightweight tag got release artifacts:\n%s", page) | ||
| 122 | } | ||
| 123 | } | ||