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.

Code

396 commit(s) on main

Turn off the disk reserve for test-spawned runners Keenan Nemetz · 20m ago c284cb2c
history blame raw 7.2 KiB
1// Release artifacts are the files 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.
18package metadata
19
20import (
21 "fmt"
22 "sort"
23 "strings"
24
25 "rodent.software/vole/core/git"
26 "rodent.software/vole/core/lfs"
27)
28
29// Artifact is one file attached to a release.
30type Artifact struct {
31 Name string
32 OID string
33 Size int64
34}
35
36// Path is where an artifact's pointer lives on the metadata branch.
37func ReleasePath(tag, name string) string { return ReleasesDir + "/" + tag + "/" + name }
38
39// ValidName rejects artifact names that would not be one path segment under the
40// release's directory. A name is a filename, not a path: allowing "/" would let
41// an attach write outside releases/<tag>/, and the download route could not name
42// the result.
43func ValidReleaseName(name string) error {
44 switch {
45 case name == "":
46 return fmt.Errorf("an artifact needs a name")
47 case name == "." || name == "..":
48 return fmt.Errorf("%q is not a filename", name)
49 case strings.ContainsAny(name, "/\\"):
50 return fmt.Errorf("artifact name %q contains a path separator; attach a file, not a path tree", name)
51 case len(name) > 200:
52 return fmt.Errorf("artifact name is %d bytes, limit 200", len(name))
53 case strings.ContainsAny(name, "\x00\n\r\t"):
54 return fmt.Errorf("artifact name %q contains a control character", name)
55 }
56 return nil
57}
58
59// List reads the artifacts of one tag, sorted by name. A tag with nothing
60// attached is an empty list rather than an error: the directory only exists once
61// something is attached to it.
62func ListReleaseArtifacts(g *git.Repo, tag string) ([]Artifact, error) {
63 entries, err := g.LsTree("refs/heads/vole:" + ReleasesDir + "/" + tag)
64 if err != nil {
65 return nil, nil // no releases/<tag>/ directory: no artifacts
66 }
67 out := make([]Artifact, 0, len(entries))
68 for _, e := range entries {
69 if e.Type != "blob" {
70 continue
71 }
72 data, err := g.BlobAt("refs/heads/vole", ReleasePath(tag, e.Name))
73 if err != nil {
74 return nil, fmt.Errorf("read artifact pointer %s: %w", e.Name, err)
75 }
76 p, ok := lfs.ParsePointer(data)
77 if !ok {
78 // A file that is not a pointer is not an artifact: it may be a note a
79 // human put there. Serving it would mean serving something no upload
80 // ever vouched for.
81 continue
82 }
83 out = append(out, Artifact{Name: e.Name, OID: p.OID, Size: p.Size})
84 }
85 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
86 return out, nil
87}
88
89// Problems validates the releases/ portion of the metadata tree at commit. The
90// layout is closed for the same reason the rest of the metadata branch is: a file
91// that is not releases/<tag>/<name> pointing at an object nobody uploaded is
92// either a mistake or a download that will 404, and both are cheaper to refuse
93// here than to discover later.
94func ReleaseProblems(g *git.Repo, commit string) []string {
95 if commit == "" {
96 return nil
97 }
98 blobs, err := g.LsTreeR(commit)
99 if err != nil {
100 return []string{fmt.Sprintf("cannot read the metadata tree: %v", err)}
101 }
102 var problems []string
103 var paths, shas []string
104 for _, e := range blobs {
105 if !strings.HasPrefix(e.Name, ReleasesDir+"/") {
106 continue
107 }
108 parts := strings.Split(e.Name, "/")
109 switch {
110 case len(parts) != 3:
111 problems = append(problems, fmt.Sprintf(
112 "%s: releases are %s/<tag>/<name>; a release directory holds files, not more directories",
113 e.Name, ReleasesDir))
114 continue
115 case parts[1] == "":
116 problems = append(problems, fmt.Sprintf("%s: missing tag name", e.Name))
117 continue
118 }
119 if err := ValidReleaseName(parts[2]); err != nil {
120 problems = append(problems, e.Name+": "+err.Error())
121 continue
122 }
123 paths = append(paths, e.Name)
124 shas = append(shas, e.SHA)
125 }
126 files, err := readBlobs(g, paths, shas)
127 if err != nil {
128 return append(problems, err.Error())
129 }
130 // The pointers are read together: a tree with many artifacts is one call, not
131 // one process per file.
132 for _, path := range paths {
133 if _, ok := lfs.ParsePointer([]byte(files[path])); !ok {
134 problems = append(problems, fmt.Sprintf(
135 "%s: an artifact must be a git-lfs pointer; write it with vole release attach", path))
136 }
137 }
138 return problems
139}
140
141// TagWithArtifacts reports whether a tag has an artifacts directory. The release
142// page uses it to say "no artifacts" without a second tree read.
143func TagWithArtifacts(g *git.Repo, tag string) bool {
144 _, err := g.LsTree("refs/heads/vole:" + ReleasesDir + "/" + tag)
145 return err == nil
146}
147
148// WritePointers records artifacts on the metadata branch, one literal LFS
149// pointer per name. It starts from the branch's current tree, so applying it
150// again after a lost push race is idempotent.
151func WriteReleasePointers(g *git.Repo, ref, tag string, arts []Artifact, message, author string) error {
152 _, err := git.CommitFiles(g, ref, func(files map[string][]byte) error {
153 for _, a := range arts {
154 files[ReleasePath(tag, a.Name)] = lfs.Pointer{OID: a.OID, Size: a.Size}.Encode()
155 }
156 return nil
157 }, message, author)
158 return err
159}
160
161// LoadArtifactsAt reads every release artifact on the metadata branch at commit,
162// keyed by tag. It is one tree listing and one blob read for the whole set: a
163// release with fifty files is not fifty processes, and the index reads the set to
164// answer the artifact list without touching git.
165func LoadArtifactsAt(g *git.Repo, commit string) (map[string][]Artifact, error) {
166 out := map[string][]Artifact{}
167 if commit == "" {
168 return out, nil
169 }
170 entries, err := g.LsTreeR(commit)
171 if err != nil {
172 return nil, err
173 }
174 var paths, shas []string
175 for _, e := range entries {
176 if e.Type != "blob" || !strings.HasPrefix(e.Name, ReleasesDir+"/") {
177 continue
178 }
179 // releases/<tag>/<name>; anything deeper is not an artifact.
180 if parts := strings.Split(e.Name, "/"); len(parts) != 3 {
181 continue
182 }
183 paths = append(paths, e.Name)
184 shas = append(shas, e.SHA)
185 }
186 files, err := readBlobs(g, paths, shas)
187 if err != nil {
188 return nil, err
189 }
190 for _, path := range paths {
191 parts := strings.Split(path, "/")
192 p, ok := lfs.ParsePointer([]byte(files[path]))
193 if !ok {
194 // A file that is not a pointer is not an artifact; the release
195 // validator refuses those at push time, and a reader skips one a
196 // human put there by hand.
197 continue
198 }
199 out[parts[1]] = append(out[parts[1]], Artifact{Name: parts[2], OID: p.OID, Size: p.Size})
200 }
201 for _, list := range out {
202 sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
203 }
204 return out, nil
205}
206