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 2: a release is a tag the web can read

c56911e2c3ede0f70c7cde560042806dbf4b4ab0 · Keenan Nemetz · 17d ago · parent f76c635b

Phase 7 slice 2: a release is a tag the web can read

A release is an annotated tag, so nothing new is stored: the tag message is the
notes, the tree is the tarball, and both are already in every clone.

- internal/gitx/tags.go lists refs/tags peeled to their commits and tells an
  annotated tag from a lightweight one; a lightweight tag's %(contents) is the
  commit message, so reading it as notes would invent a release description
  nobody wrote
- internal/gitx/archive.go streams `git archive`; tarballs are generated per
  request and never stored, so they cannot disagree with the tag
- internal/web/releases.go renders the list (lightweight tags labelled), the
  release page (notes through the same sanitiser as every pushed body, commits
  since the previous release, two downloads) and the archive handlers: ETag is
  the tag object id plus the format, conditional requests get 304, always
  Content-Disposition: attachment with nosniff, compressed streams queue behind
  a semaphore of 4 while plain tar does not
- "since the previous release" is `git describe --abbrev=0 <tag>^`, not the next
  row of the tag list: two tags made in the same second sort arbitrarily, and a
  page that changes its baseline between requests is worse than one without one
- the page warns when the tagged tree tracks LFS, because git archive copies
  pointer files verbatim (measured in spike D)
- cmd/vole release create writes the annotated tag and refuses an empty message
  or an existing name: re-tagging is how a download link comes to mean different
  bytes

Measured end to end against the binary with an 11.6 MiB, 2,566-file repository:
release list 6.1 ms, release page 12.6 ms, .tar 53.7 ms / 13.7 MiB, .tar.gz
218 ms / 1.8 MiB, conditional request 304. Every file in the served tarball
hashed to its ls-tree blob id (2,565 of 2,565).

internal/server/releases_test.go +304

@@ -0,0 +1,304 @@
1 package server_test
2
3 // A release is an annotated tag, so these tests push tags the way a user would
4 // and then read the web: the list, the notes, the commits since the previous
5 // release, and the tarball generated from the tag.
6
7 import (
8 "archive/tar"
9 "bytes"
10 "compress/gzip"
11 "io"
12 "net/http"
13 "os"
14 "path/filepath"
15 "strings"
16 "testing"
17 )
18
19 // releaseFixture builds a repository with one commit tagged v0.9.0 and a second
20 // commit tagged v1.0.0, both pushed, so "since the previous release" has a
21 // previous release to point at.
22 func releaseFixture(t *testing.T) *fixture {
23 t.Helper()
24 f := newFixture(t)
25 f.publish(f.repo)
26
27 work := workRepo(t, map[string]string{"readme.md": "first\n", "main.go": "package main\n"})
28 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
29 t.Fatalf("initial push: %s", errs)
30 }
31 if _, errs, err := runGit(t, work, "tag", "-a", "v0.9.0", "-m", "beta\n"); err != nil {
32 t.Fatalf("tag v0.9.0: %s", errs)
33 }
34
35 if err := os.WriteFile(filepath.Join(work, "readme.md"), []byte("first\nsecond\n"), 0o644); err != nil {
36 t.Fatal(err)
37 }
38 if _, errs, err := runGit(t, work, "add", "-A"); err != nil {
39 t.Fatalf("add: %s", errs)
40 }
41 if _, errs, err := runGit(t, work, "commit", "-qm", "add the second line"); err != nil {
42 t.Fatalf("commit: %s", errs)
43 }
44 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
45 t.Fatalf("second push: %s", errs)
46 }
47 if _, errs, err := runGit(t, work, "tag", "-a", "v1.0.0", "-m", "First stable release\n\nHighlights:\n- the second line\n"); err != nil {
48 t.Fatalf("tag v1.0.0: %s", errs)
49 }
50 // A lightweight tag is not a release; it must still be listed and downloadable.
51 if _, errs, err := runGit(t, work, "tag", "scratch-tag"); err != nil {
52 t.Fatalf("lightweight tag: %s", errs)
53 }
54 for _, tag := range []string{"v0.9.0", "v1.0.0", "scratch-tag"} {
55 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "refs/tags/"+tag+":refs/tags/"+tag); err != nil {
56 t.Fatalf("push tag %s: %s", tag, errs)
57 }
58 }
59 return f
60 }
61
62 func TestReleaseListAndNotes(t *testing.T) {
63 f := releaseFixture(t)
64
65 code, body := f.get("/r/" + f.repo + "/releases")
66 if code != 200 {
67 t.Fatalf("releases page = %d", code)
68 }
69 for _, want := range []string{"v1.0.0", "v0.9.0", "First stable release", "beta"} {
70 if !strings.Contains(body, want) {
71 t.Fatalf("releases page does not show %q:\n%s", want, body)
72 }
73 }
74 // The lightweight tag is listed but labelled, because it is not a release.
75 if !strings.Contains(body, "lightweight") {
76 t.Fatalf("releases page does not label the lightweight tag:\n%s", body)
77 }
78 // The repository page counts releases, and counts only the annotated ones.
79 if _, repoPage := f.get("/r/" + f.repo); !strings.Contains(repoPage, "/releases") {
80 t.Fatalf("repository page does not link to releases:\n%s", repoPage)
81 }
82
83 code, page := f.get("/r/" + f.repo + "/releases/v1.0.0")
84 if code != 200 {
85 t.Fatalf("release page = %d", code)
86 }
87 for _, want := range []string{
88 "First stable release",
89 "the second line", // the notes body is rendered, not just the subject
90 "1 commit(s) since v0.9.0",
91 "add the second line", // the commit the release contains
92 "archive.tar", "archive.tar.gz",
93 } {
94 if !strings.Contains(page, want) {
95 t.Fatalf("release page does not show %q:\n%s", want, page)
96 }
97 }
98 // A repository with no LFS attributes gets no LFS warning.
99 if strings.Contains(page, "pointer files") {
100 t.Fatalf("release page warns about LFS in a repository that does not use it:\n%s", page)
101 }
102 // The first release has no previous tag to range over, and still renders.
103 if code, first := f.get("/r/" + f.repo + "/releases/v0.9.0"); code != 200 || !strings.Contains(first, "beta") {
104 t.Fatalf("first release page = %d, notes missing", code)
105 }
106 if code, _ := f.get("/r/" + f.repo + "/releases/nope"); code != 404 {
107 t.Fatalf("unknown tag = %d, want 404", code)
108 }
109 }
110
111 // TestReleaseNotesAreSanitised matters because the notes are somebody's markdown:
112 // a tag message is pushed content, so it goes through the same renderer as every
113 // other pushed body.
114 func TestReleaseNotesAreSanitised(t *testing.T) {
115 f := newFixture(t)
116 f.publish(f.repo)
117 work := workRepo(t, map[string]string{"readme.md": "x\n"})
118 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
119 t.Fatalf("push: %s", errs)
120 }
121 notes := "<script>alert(1)</script>\n\nand *some* markdown\n"
122 if _, errs, err := runGit(t, work, "tag", "-a", "v2.0.0", "-m", notes); err != nil {
123 t.Fatalf("tag: %s", errs)
124 }
125 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "refs/tags/v2.0.0:refs/tags/v2.0.0"); err != nil {
126 t.Fatalf("push tag: %s", errs)
127 }
128
129 _, page := f.get("/r/" + f.repo + "/releases/v2.0.0")
130 if strings.Contains(page, "<script>alert(1)</script>") {
131 t.Fatalf("release notes execute as markup:\n%s", page)
132 }
133 if !strings.Contains(page, "<em>some</em>") {
134 t.Fatalf("release notes are not rendered as markdown:\n%s", page)
135 }
136 }
137
138 // TestReleasePageWarnsAboutLFSPointers pins the finding from spike D: git archive
139 // copies pointer files verbatim, so a tarball of an LFS repository is not the
140 // bytes the reader expects. The page has to say so.
141 func TestReleasePageWarnsAboutLFSPointers(t *testing.T) {
142 f := newFixture(t)
143 f.publish(f.repo)
144 work := workRepo(t, map[string]string{
145 ".gitattributes": "*.bin filter=lfs diff=lfs merge=lfs -text\n",
146 "readme.md": "x\n",
147 })
148 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
149 t.Fatalf("push: %s", errs)
150 }
151 if _, errs, err := runGit(t, work, "tag", "-a", "v1.0.0", "-m", "with lfs\n"); err != nil {
152 t.Fatalf("tag: %s", errs)
153 }
154 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "refs/tags/v1.0.0:refs/tags/v1.0.0"); err != nil {
155 t.Fatalf("push tag: %s", errs)
156 }
157
158 code, page := f.get("/r/" + f.repo + "/releases/v1.0.0")
159 if code != 200 {
160 t.Fatalf("release page = %d", code)
161 }
162 if !strings.Contains(page, "pointer files") {
163 t.Fatalf("release page does not warn about LFS pointers:\n%s", page)
164 }
165
166 }
167
168 func TestReleaseArchiveIsGeneratedFromTheTag(t *testing.T) {
169 f := releaseFixture(t)
170
171 code, body := f.get("/r/" + f.repo + "/releases/v1.0.0/archive.tar")
172 if code != 200 {
173 t.Fatalf("archive.tar = %d", code)
174 }
175 files := tarContents(t, strings.NewReader(body), false)
176 if got := files[f.repo+"-v1.0.0/readme.md"]; got != "first\nsecond\n" {
177 t.Fatalf("tarball readme = %q, want the second commit's content", got)
178 }
179 // The prefix is one directory, so extracting does not scatter the tree.
180 for name := range files {
181 if !strings.HasPrefix(name, f.repo+"-v1.0.0/") {
182 t.Fatalf("tarball entry %q is not under the release prefix", name)
183 }
184 }
185
186 // The first tag's tarball has the older content: this is what proves the
187 // archive is built from the tag rather than from the branch head.
188 _, older := f.get("/r/" + f.repo + "/releases/v0.9.0/archive.tar")
189 if got := tarContents(t, strings.NewReader(older), false)[f.repo+"-v0.9.0/readme.md"]; got != "first\n" {
190 t.Fatalf("v0.9.0 tarball readme = %q", got)
191 }
192
193 // A gzipped variant, decoded, holds the same file.
194 code, gz := f.get("/r/" + f.repo + "/releases/v1.0.0/archive.tar.gz")
195 if code != 200 {
196 t.Fatalf("archive.tar.gz = %d", code)
197 }
198 if got := tarContents(t, strings.NewReader(gz), true)[f.repo+"-v1.0.0/readme.md"]; got != "first\nsecond\n" {
199 t.Fatalf("gzipped tarball readme = %q", got)
200 }
201
202 // A lightweight tag has a tarball too: the archive is the tree, and the
203 // absence of notes does not change what the tree is.
204 if code, _ := f.get("/r/" + f.repo + "/releases/scratch-tag/archive.tar"); code != 200 {
205 t.Fatalf("lightweight tag archive = %d", code)
206 }
207 if code, _ := f.get("/r/" + f.repo + "/releases/nope/archive.tar"); code != 404 {
208 t.Fatalf("unknown tag archive = %d, want 404", code)
209 }
210 }
211
212 // TestReleaseArchiveValidators checks the two headers that make a generated
213 // tarball cacheable without a store behind it: an ETag derived from the tag
214 // object, and a 304 when the client already has those bytes.
215 func TestReleaseArchiveValidators(t *testing.T) {
216 f := releaseFixture(t)
217
218 url := f.http.URL + "/r/" + f.repo + "/releases/v1.0.0/archive.tar"
219 resp, err := http.Get(url)
220 if err != nil {
221 t.Fatal(err)
222 }
223 io.Copy(io.Discard, resp.Body)
224 resp.Body.Close()
225 etag := resp.Header.Get("ETag")
226 if etag == "" {
227 t.Fatal("archive has no ETag")
228 }
229 if cd := resp.Header.Get("Content-Disposition"); !strings.Contains(cd, "attachment") {
230 t.Fatalf("archive Content-Disposition = %q, want attachment", cd)
231 }
232 if resp.Header.Get("X-Content-Type-Options") != "nosniff" {
233 t.Fatal("archive is served without nosniff")
234 }
235
236 req, err := http.NewRequest(http.MethodGet, url, nil)
237 if err != nil {
238 t.Fatal(err)
239 }
240 req.Header.Set("If-None-Match", etag)
241 again, err := http.DefaultClient.Do(req)
242 if err != nil {
243 t.Fatal(err)
244 }
245 defer again.Body.Close()
246 if again.StatusCode != http.StatusNotModified {
247 t.Fatalf("conditional archive request = %d, want 304", again.StatusCode)
248 }
249 }
250
251 func TestReleasePagesAreNotPublicForPrivateRepos(t *testing.T) {
252 f := newFixture(t)
253 // Deliberately not published: an anonymous reader may not see it exists.
254 work := workRepo(t, map[string]string{"readme.md": "secret\n"})
255 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
256 t.Fatalf("push: %s", errs)
257 }
258 for _, path := range []string{
259 "/r/" + f.repo + "/releases",
260 "/r/" + f.repo + "/releases/main",
261 "/r/" + f.repo + "/releases/main/archive.tar",
262 } {
263 if code, _ := f.get(path); code != 404 {
264 t.Fatalf("%s on a private repo = %d, want 404", path, code)
265 }
266 }
267 }
268
269 // tarContents reads a tar stream into path -> body. Paths keep their prefix, so
270 // the caller can assert on the extraction directory too.
271 func tarContents(t *testing.T, r io.Reader, gzipped bool) map[string]string {
272 t.Helper()
273 if gzipped {
274 zr, err := gzip.NewReader(r)
275 if err != nil {
276 t.Fatalf("gzip: %v", err)
277 }
278 defer zr.Close()
279 r = zr
280 }
281 out := map[string]string{}
282 tr := tar.NewReader(r)
283 for {
284 hdr, err := tr.Next()
285 if err == io.EOF {
286 break
287 }
288 if err != nil {
289 t.Fatalf("tar: %v", err)
290 }
291 if hdr.Typeflag != tar.TypeReg {
292 continue
293 }
294 var buf bytes.Buffer
295 if _, err := io.Copy(&buf, tr); err != nil {
296 t.Fatalf("tar body %s: %v", hdr.Name, err)
297 }
298 out[hdr.Name] = buf.String()
299 }
300 if len(out) == 0 {
301 t.Fatal("tarball is empty")
302 }
303 return out
304 }