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/web/releases.go +203

@@ -0,0 +1,203 @@
1 package web
2
3 // Release pages. A release is an annotated tag, so this file reads refs/tags and
4 // nothing else: the notes are the tag message and the tarball is generated from
5 // the tag's tree when it is asked for. There is no release table to keep in sync
6 // with the repository, and no stored tarball that can disagree with the tag.
7
8 import (
9 "fmt"
10 "html/template"
11 "net/http"
12 "strings"
13
14 "github.com/rodent-software/vole/internal/gitx"
15 )
16
17 // archiveConc bounds concurrent compressed archives. Plain tar streams at
18 // ~1 GiB/s and needs no bound; gzip is single-threaded per stream, and eight
19 // concurrent gzipped archives of a 100 MiB tree cost 13 s of CPU (see
20 // docs/spikes/archive-streaming). Queueing a request is better than making every
21 // request late.
22 const archiveConc = 4
23
24 // maxReleaseCommits bounds the "since the previous release" list. A release a
25 // thousand commits wide is a report, not a page.
26 const maxReleaseCommits = 100
27
28 func (w *Web) Releases(rw http.ResponseWriter, r *http.Request) {
29 m, ok := w.open(rw, r)
30 if !ok {
31 return
32 }
33 tags, err := m.Git().Tags()
34 if err != nil {
35 http.Error(rw, "vole: cannot list tags", http.StatusInternalServerError)
36 return
37 }
38 light := 0
39 for _, t := range tags {
40 if !t.Annotated {
41 light++
42 }
43 }
44 w.render(rw, "releases", struct {
45 Page
46 Name string
47 Tags []gitx.Tag
48 Lightweight int
49 }{w.base(m.Name + " · releases"), m.Name, tags, light}, 0)
50 }
51
52 func (w *Web) ReleaseView(rw http.ResponseWriter, r *http.Request) {
53 m, ok := w.open(rw, r)
54 if !ok {
55 return
56 }
57 g := m.Git()
58 t, err := g.Tag(r.PathValue("tag"))
59 if err != nil {
60 http.NotFound(rw, r)
61 return
62 }
63 // The previous release is the nearest one behind this commit, not the next
64 // row in a list whose order depends on tie-breaking timestamps.
65 prevName := g.PreviousTag(t.Commit)
66 from := ""
67 if prevName != "" {
68 if prev, err := g.Tag(prevName); err == nil {
69 from = prev.Commit
70 } else {
71 prevName = ""
72 }
73 }
74 commits, _ := g.CommitsBetween(from, t.Commit, maxReleaseCommits)
75 count, _ := g.CountBetween(from, t.Commit)
76
77 // git archive copies LFS pointer files verbatim (measured in spike D), so a
78 // tarball of an LFS repository holds shopping lists where the reader expects
79 // bytes. Saying so is cheaper than resolving pointers, and a silent pointer is
80 // the kind of surprise that costs an afternoon.
81 lfsPointers := usesLFS(g, t.Commit)
82
83 // Only an annotated tag has notes of its own; a lightweight tag's message is
84 // the commit's subject, which the reader can already see.
85 var notes template.HTML
86 if t.Annotated && strings.TrimSpace(t.Message) != "" {
87 notes = w.renderMarkdown(m.Name, t.Message)
88 }
89
90 w.render(rw, "release", struct {
91 Page
92 Name string
93 Tag gitx.Tag
94 Notes template.HTML
95 Prev string
96 Count int
97 Commits []gitx.Commit
98 Capped bool
99 CappedAt int
100 LFSPointers bool
101 }{
102 Page: w.base(m.Name + " · " + t.Name),
103 Name: m.Name,
104 Tag: t,
105 Notes: notes,
106 Prev: prevName,
107 Count: count,
108 Commits: commits,
109 Capped: count > len(commits),
110 CappedAt: maxReleaseCommits,
111 LFSPointers: lfsPointers,
112 }, 0)
113 }
114
115 func (w *Web) ReleaseTar(rw http.ResponseWriter, r *http.Request) {
116 w.releaseArchive(rw, r, "tar")
117 }
118
119 func (w *Web) ReleaseTarGz(rw http.ResponseWriter, r *http.Request) {
120 w.releaseArchive(rw, r, "tar.gz")
121 }
122
123 func (w *Web) releaseArchive(rw http.ResponseWriter, r *http.Request, format string) {
124 m, ok := w.open(rw, r)
125 if !ok {
126 return
127 }
128 g := m.Git()
129 t, err := g.Tag(r.PathValue("tag"))
130 if err != nil {
131 http.NotFound(rw, r)
132 return
133 }
134
135 contentType, ext := "application/x-tar", ".tar"
136 if format == "tar.gz" {
137 contentType, ext = "application/gzip", ".tar.gz"
138 }
139 // The bytes are a pure function of the tag object and the format, so the tag
140 // object id is the validator: a re-tagged name changes the ETag, and nothing
141 // else can change the bytes.
142 etag := `"` + releaseSafe(t.OID) + "-" + strings.ReplaceAll(format, ".", "") + `"`
143 rw.Header().Set("ETag", etag)
144 rw.Header().Set("Cache-Control", "public, max-age=300")
145 rw.Header().Set("Content-Type", contentType)
146 // Always an attachment: an archive is not a document, and serving one that a
147 // browser might render would put other people's bytes on this origin.
148 rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q",
149 releaseSafe(m.Name)+"-"+releaseSafe(t.Name)+ext))
150 rw.Header().Set("X-Content-Type-Options", "nosniff")
151 if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, etag) {
152 rw.WriteHeader(http.StatusNotModified)
153 return
154 }
155 if r.Method == http.MethodHead {
156 return
157 }
158
159 if format == "tar.gz" {
160 w.sem <- struct{}{}
161 defer func() { <-w.sem }()
162 }
163
164 prefix := releaseSafe(m.Name) + "-" + releaseSafe(t.Name) + "/"
165 if err := g.Archive(rw, t.Commit, format, prefix); err != nil {
166 // Headers are already on the wire, so the reader gets a cut archive; the
167 // log is the only place left to say so.
168 w.Logger("archive %s %s: %v", m.Name, t.Name, err)
169 }
170 }
171
172 // usesLFS reports whether the tree tracks anything through Git LFS. It reads the
173 // root .gitattributes, which is where `git lfs track` writes its patterns; a
174 // pattern declared in a subdirectory is not detected, and the notice is worded so
175 // that the omission cannot make it false.
176 func usesLFS(g *gitx.Repo, commit string) bool {
177 data, err := g.BlobAt(commit, ".gitattributes")
178 if err != nil {
179 return false
180 }
181 return strings.Contains(string(data), "filter=lfs")
182 }
183
184 // releaseSafe reduces a repository or tag name to characters that are safe in a
185 // filename and in a path prefix. A tag may contain "/" and spaces; a download
186 // filename may not.
187 func releaseSafe(s string) string {
188 var b strings.Builder
189 for _, c := range s {
190 switch {
191 case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9',
192 c == '.', c == '-', c == '_', c == '+':
193 b.WriteRune(c)
194 default:
195 b.WriteByte('-')
196 }
197 }
198 out := strings.Trim(b.String(), "-")
199 if out == "" || out == "." || out == ".." {
200 return "release"
201 }
202 return out
203 }