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 3: artifacts attach over SSH and are one pointer deep
85cc89fe9eafd1793702d143ea2eb41db635fbe2 · Keenan Nemetz · 17d ago · parent c56911e2
Phase 7 slice 3: artifacts attach over SSH and are one pointer deep A released binary is a pointer blob at releases/<tag>/<name> on the metadata branch. That is the whole record, and it was chosen because the machinery that already exists does the work: gc marks LFS objects named by pointers in any tree reachable from any ref (refs/heads/vole is one), and pre-receive's pointer hygiene already refuses a pointer whose bytes are missing. A JSON manifest of sha256 strings would have parsed as no pointer and been swept once the grace period passed. - cmd/vole attach speaks the pure-SSH LFS transfer protocol itself (version, batch, put-object, verify-object), so bytes move over the key that already writes: no token, no second endpoint, no new write surface - upload happens before the pointer commit, because the reverse order publishes a download that 404s and receive-pack cannot see bytes that were never in the pack. A test pushes a pointer with no object and asserts the push is refused - the artifact list is read from the pointer tree (internal/releases), which the metadata validator now knows about; a file under releases/ that is not a pointer is refused at push instead of silently unservable - downloads: /r/<repo>/releases/<tag>/artifacts/<name> out of the store, ETag = object id, always Content-Disposition: attachment with nosniff, ServeContent for Range and 304, clone read rule so private artifacts are 404 - the "not on this server" hook message now names vole release attach as well as git lfs push, because it fires for both now Measured end to end through the binary: two 2.9 MiB files attached over SSH in 390 ms, the identical second file deduplicating to one object; both downloaded in 13.9 ms with matching sha256, 304 on If-None-Match, 206 on Range; vole gc --dry-run reports the published artifact reachable and removes nothing. The planned releases/<tag>.json record and drafts are dropped with it: the pointer directory is the artifact list and being in the ref is what published means, so the file would only have duplicated the tag.
internal/releases/releases.go +142
| @@ -0,0 +1,142 @@ | |||
| 1 | // Package releases reads the artifacts attached to a release. | ||
| 2 | // | ||
| 3 | // The artifact list is a directory on the metadata branch: releases/<tag>/<name> | ||
| 4 | // holds a standard git-lfs pointer blob, and the pointer is the whole record. | ||
| 5 | // Nothing else names the bytes, which is deliberate: | ||
| 6 | // | ||
| 7 | // - gc marks LFS objects named by pointer blobs in any tree reachable from any | ||
| 8 | // ref, and refs/heads/vole is one of those refs, so a published artifact is | ||
| 9 | // reachable without teaching the sweep a second file format; | ||
| 10 | // - pre-receive's pointer hygiene runs on the metadata branch like any other, | ||
| 11 | // so a pointer whose object was never uploaded cannot be published at all; | ||
| 12 | // - `git clone` of the metadata branch carries the artifact list, which is what | ||
| 13 | // makes the repository a complete backup of its own releases. | ||
| 14 | // | ||
| 15 | // A JSON manifest listing sha256 strings would have been none of those things: it | ||
| 16 | // parses as no pointer, so the sweep would treat every published artifact as | ||
| 17 | // garbage once the grace period passed. | ||
| 18 | package releases | ||
| 19 | |||
| 20 | import ( | ||
| 21 | "fmt" | ||
| 22 | "sort" | ||
| 23 | "strings" | ||
| 24 | |||
| 25 | "github.com/rodent-software/vole/internal/gitx" | ||
| 26 | "github.com/rodent-software/vole/internal/lfs" | ||
| 27 | ) | ||
| 28 | |||
| 29 | // Dir is the metadata-branch directory releases live under. | ||
| 30 | const Dir = "releases" | ||
| 31 | |||
| 32 | // Artifact is one file attached to a release. | ||
| 33 | type Artifact struct { | ||
| 34 | Name string | ||
| 35 | OID string | ||
| 36 | Size int64 | ||
| 37 | } | ||
| 38 | |||
| 39 | // Path is where an artifact's pointer lives on the metadata branch. | ||
| 40 | func Path(tag, name string) string { return Dir + "/" + tag + "/" + name } | ||
| 41 | |||
| 42 | // ValidName rejects artifact names that would not be one path segment under the | ||
| 43 | // release's directory. A name is a filename, not a path: allowing "/" would let | ||
| 44 | // an attach write outside releases/<tag>/, and the download route could not name | ||
| 45 | // the result. | ||
| 46 | func ValidName(name string) error { | ||
| 47 | switch { | ||
| 48 | case name == "": | ||
| 49 | return fmt.Errorf("an artifact needs a name") | ||
| 50 | case name == "." || name == "..": | ||
| 51 | return fmt.Errorf("%q is not a filename", name) | ||
| 52 | case strings.ContainsAny(name, "/\\"): | ||
| 53 | return fmt.Errorf("artifact name %q contains a path separator; attach a file, not a path tree", name) | ||
| 54 | case len(name) > 200: | ||
| 55 | return fmt.Errorf("artifact name is %d bytes, limit 200", len(name)) | ||
| 56 | case strings.ContainsAny(name, "\x00\n\r\t"): | ||
| 57 | return fmt.Errorf("artifact name %q contains a control character", name) | ||
| 58 | } | ||
| 59 | return nil | ||
| 60 | } | ||
| 61 | |||
| 62 | // List reads the artifacts of one tag, sorted by name. A tag with nothing | ||
| 63 | // attached is an empty list rather than an error: the directory only exists once | ||
| 64 | // something is attached to it. | ||
| 65 | func List(g *gitx.Repo, tag string) ([]Artifact, error) { | ||
| 66 | entries, err := g.LsTree("refs/heads/vole:" + Dir + "/" + tag) | ||
| 67 | if err != nil { | ||
| 68 | return nil, nil // no releases/<tag>/ directory: no artifacts | ||
| 69 | } | ||
| 70 | out := make([]Artifact, 0, len(entries)) | ||
| 71 | for _, e := range entries { | ||
| 72 | if e.Type != "blob" { | ||
| 73 | continue | ||
| 74 | } | ||
| 75 | data, err := g.BlobAt("refs/heads/vole", Path(tag, e.Name)) | ||
| 76 | if err != nil { | ||
| 77 | return nil, fmt.Errorf("read artifact pointer %s: %w", e.Name, err) | ||
| 78 | } | ||
| 79 | p, ok := lfs.ParsePointer(data) | ||
| 80 | if !ok { | ||
| 81 | // A file that is not a pointer is not an artifact: it may be a note a | ||
| 82 | // human put there. Serving it would mean serving something no upload | ||
| 83 | // ever vouched for. | ||
| 84 | continue | ||
| 85 | } | ||
| 86 | out = append(out, Artifact{Name: e.Name, OID: p.OID, Size: p.Size}) | ||
| 87 | } | ||
| 88 | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | ||
| 89 | return out, nil | ||
| 90 | } | ||
| 91 | |||
| 92 | // Problems validates the releases/ portion of the metadata tree at commit. The | ||
| 93 | // layout is closed for the same reason the rest of the metadata branch is: a file | ||
| 94 | // that is not releases/<tag>/<name> pointing at an object nobody uploaded is | ||
| 95 | // either a mistake or a download that will 404, and both are cheaper to refuse | ||
| 96 | // here than to discover later. | ||
| 97 | func Problems(g *gitx.Repo, commit string) []string { | ||
| 98 | if commit == "" { | ||
| 99 | return nil | ||
| 100 | } | ||
| 101 | blobs, err := g.LsTreeR(commit) | ||
| 102 | if err != nil { | ||
| 103 | return []string{fmt.Sprintf("cannot read the metadata tree: %v", err)} | ||
| 104 | } | ||
| 105 | var problems []string | ||
| 106 | for _, e := range blobs { | ||
| 107 | if !strings.HasPrefix(e.Name, Dir+"/") { | ||
| 108 | continue | ||
| 109 | } | ||
| 110 | parts := strings.Split(e.Name, "/") | ||
| 111 | switch { | ||
| 112 | case len(parts) != 3: | ||
| 113 | problems = append(problems, fmt.Sprintf( | ||
| 114 | "%s: releases are %s/<tag>/<name>; a release directory holds files, not more directories", | ||
| 115 | e.Name, Dir)) | ||
| 116 | continue | ||
| 117 | case parts[1] == "": | ||
| 118 | problems = append(problems, fmt.Sprintf("%s: missing tag name", e.Name)) | ||
| 119 | continue | ||
| 120 | } | ||
| 121 | if err := ValidName(parts[2]); err != nil { | ||
| 122 | problems = append(problems, e.Name+": "+err.Error()) | ||
| 123 | continue | ||
| 124 | } | ||
| 125 | data, err := g.BlobBySHA(e.SHA) | ||
| 126 | if err != nil { | ||
| 127 | return append(problems, fmt.Sprintf("cannot read %s: %v", e.Name, err)) | ||
| 128 | } | ||
| 129 | if _, ok := lfs.ParsePointer(data); !ok { | ||
| 130 | problems = append(problems, fmt.Sprintf( | ||
| 131 | "%s: an artifact must be a git-lfs pointer; write it with vole release attach", e.Name)) | ||
| 132 | } | ||
| 133 | } | ||
| 134 | return problems | ||
| 135 | } | ||
| 136 | |||
| 137 | // TagWithArtifacts reports whether a tag has an artifacts directory. The release | ||
| 138 | // page uses it to say "no artifacts" without a second tree read. | ||
| 139 | func TagWithArtifacts(g *gitx.Repo, tag string) bool { | ||
| 140 | _, err := g.LsTree("refs/heads/vole:" + Dir + "/" + tag) | ||
| 141 | return err == nil | ||
| 142 | } | ||