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