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