internal/control/release.go
449 lines · 14341 bytes
1package control
2
3import (
4 "crypto/sha256"
5 "encoding/hex"
6 "errors"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "regexp"
12 "strconv"
13 "strings"
14
15 "gitbay.org/gitbay/internal/gitutil"
16 "gitbay.org/gitbay/internal/policy"
17 "gitbay.org/gitbay/internal/protocol"
18 "gitbay.org/gitbay/internal/store"
19)
20
21func init() {
22 register(Command{Path: []string{"release", "create"},
23 Summary: "create a release on a tag",
24 Usage: "release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -] [--format md|org]",
25 Flags: []Flag{
26 {"--title", "<t>", "the release's title", "the tag"},
27 {"--notes", "<n>", "the release notes", ""},
28 {"--file", "-", "read the release notes from stdin", ""},
29 {"--format", "md|org", "the notes' markup", "md"},
30 },
31 Examples: []string{
32 `release create krz/gitbay v1.31.0 --title "v1.31.0" --notes "flag help"`,
33 "release create krz/gitbay v1.31.0 --file - < notes.md",
34 },
35 ReadsStdin: true, Run: runReleaseCreate})
36 register(Command{Path: []string{"release", "edit"},
37 Summary: "update a release's title and notes",
38 Usage: "release edit <owner/name> <tag> [--title <t>] [--notes <n> | --file -] [--format md|org]",
39 Flags: []Flag{
40 {"--title", "<t>", "the release's new title", ""},
41 {"--notes", "<n>", "the release's new notes", ""},
42 {"--file", "-", "read the new release notes from stdin", ""},
43 {"--format", "md|org", "the notes' markup", ""},
44 },
45 Examples: []string{`release edit krz/gitbay v1.31.0 --title "v1.31.0"`},
46 ReadsStdin: true, Run: runReleaseEdit})
47 register(Command{Path: []string{"release", "list"},
48 Summary: "list releases",
49 Usage: "release list <owner/name> [--limit <n>] [--cursor <c>]",
50 Flags: []Flag{
51 {"--limit", "<n>", "rows per page", ""},
52 {"--cursor", "<c>", "continue from the previous page", ""},
53 },
54 Examples: []string{"release list krz/gitbay --limit 10"},
55 ReadOnly: true, Run: runReleaseList})
56 register(Command{Path: []string{"release", "show"},
57 Summary: "show a release with assets",
58 Usage: "release show <owner/name> <tag>",
59 Examples: []string{"release show krz/gitbay v1.30.0"},
60 ReadOnly: true, Run: runReleaseShow})
61 register(Command{Path: []string{"release", "delete"},
62 Summary: "delete a release and its assets",
63 Usage: "release delete <owner/name> <tag> --yes",
64 Flags: []Flag{
65 {"--yes", "", "confirm the permanent delete", ""},
66 },
67 Examples: []string{"release delete krz/gitbay v1.30.0 --yes"},
68 Run: runReleaseDelete})
69 register(Command{Path: []string{"release", "asset", "add"},
70 Summary: "upload an asset from stdin",
71 Usage: "release asset add <owner/name> <tag> <filename> < file",
72 Examples: []string{"release asset add krz/gitbay v1.30.0 gitbay-darwin-arm64 < gitbay-darwin-arm64"},
73 ReadsStdin: true, Run: runAssetAdd})
74 register(Command{Path: []string{"release", "asset", "get"},
75 Summary: "write an asset to stdout",
76 Usage: "release asset get <owner/name> <tag> <filename> > file",
77 Examples: []string{"release asset get krz/gitbay v1.30.0 gitbay-darwin-arm64 > gitbay-darwin-arm64"},
78 ReadOnly: true, Run: runAssetGet})
79 register(Command{Path: []string{"release", "asset", "remove"},
80 Summary: "remove an asset",
81 Usage: "release asset remove <owner/name> <tag> <filename>",
82 Examples: []string{"release asset remove krz/gitbay v1.30.0 gitbay-darwin-arm64"},
83 Run: runAssetRemove})
84}
85
86var assetNamePat = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,199}$`)
87
88// assetDir holds a release's uploaded files inside the bare repo directory,
89// so backup, transfer, and delete all carry them automatically.
90func assetDir(root string, repo store.Repo, releaseID int64) string {
91 return filepath.Join(RepoDir(root, repo.OwnerName, repo.Name), "gitbay-releases", strconv.FormatInt(releaseID, 10))
92}
93
94// releaseRef loads a release for "<owner/name> <tag>" with the permission.
95func releaseRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.Release, int) {
96 if len(args) < 2 {
97 return store.Repo{}, store.Release{}, c.usageWith("expected <owner/name> <tag>")
98 }
99 repo, code := resolveRepo(c, args[0], perm)
100 if code >= 0 {
101 return repo, store.Release{}, code
102 }
103 rel, err := c.Store.ReleaseByTag(repo.ID, args[1])
104 if errors.Is(err, store.ErrNotFound) {
105 return repo, rel, c.fail(protocol.ExitNotFound, "no release for tag %q in %s", args[1], repo.Path())
106 }
107 if err != nil {
108 return repo, rel, c.fail(protocol.ExitFailure, "%v", err)
109 }
110 return repo, rel, -1
111}
112
113func runReleaseCreate(c *Ctx, args []string) int {
114 f, err := parseFlags(args, flagSpec{Values: []string{"--title", "--notes", "--file", "--format"}, MaxPos: 2, Usage: c.Cmd.Usage})
115 if err != nil {
116 return c.fail(protocol.ExitUsage, "%v", err)
117 }
118 path, tag := f.pos(0), f.pos(1)
119 title, notes, file, format := f.Value("--title"), f.Value("--notes"), f.Value("--file"), f.Value("--format")
120 if path == "" || tag == "" {
121 return c.usage()
122 }
123 fmtName, err := markupFormat(format)
124 if err != nil {
125 return c.failInput(err)
126 }
127 if fmtName == "" {
128 fmtName = "md"
129 }
130 repo, code := resolveRepo(c, path, policy.CanWrite)
131 if code >= 0 {
132 return code
133 }
134 if code := refuseArchived(c, repo); code >= 0 {
135 return code
136 }
137 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
138 if _, err := gitutil.ResolveRef(dir, "refs/tags/"+tag); err != nil {
139 return c.fail(protocol.ExitNotFound, "no tag %q in %s — push the tag first", tag, repo.Path())
140 }
141 body, err := bodyFrom(c, notes, file)
142 if err != nil {
143 return c.failInput(err)
144 }
145 if title == "" {
146 title = tag
147 }
148 if _, err := c.Store.CreateRelease(repo.ID, tag, title, body, c.User.ID, fmtName); err != nil {
149 return c.failErr(err)
150 }
151 c.Store.RecordEvent(repo.ID, c.User.ID, "release.created", fmt.Sprintf(`{"tag":%q}`, tag))
152 return c.emit(map[string]string{"tag": tag, "title": title}, func(w io.Writer) {
153 fmt.Fprintf(w, "created release %s on %s\n", tag, repo.Path())
154 })
155}
156
157type assetOut struct {
158 Name string `json:"name"`
159 Size int64 `json:"size"`
160 SHA256 string `json:"sha256"`
161}
162
163type releaseOut struct {
164 Tag string `json:"tag"`
165 Title string `json:"title"`
166 Notes string `json:"notes,omitempty"`
167 NotesFormat string `json:"notes_format,omitempty"`
168 Author string `json:"author,omitempty"`
169 CreatedAt string `json:"created_at"`
170 Assets []assetOut `json:"assets,omitempty"`
171}
172
173func releaseToOut(r store.Release, withNotes bool) releaseOut {
174 o := releaseOut{Tag: r.Tag, Title: r.Title, Author: r.Author, CreatedAt: r.CreatedAt}
175 if withNotes {
176 o.Notes = r.Notes
177 o.NotesFormat = r.NotesFormat
178 }
179 for _, a := range r.Assets {
180 o.Assets = append(o.Assets, assetOut{a.Name, a.Size, a.SHA256})
181 }
182 return o
183}
184
185func runReleaseEdit(c *Ctx, args []string) int {
186 f, err := parseFlags(args, flagSpec{Values: []string{"--title", "--notes", "--file", "--format"}, MaxPos: 2, Usage: c.Cmd.Usage})
187 if err != nil {
188 return c.fail(protocol.ExitUsage, "%v", err)
189 }
190 path, tag := f.pos(0), f.pos(1)
191 title, notes, file, format := f.Value("--title"), f.Value("--notes"), f.Value("--file"), f.Value("--format")
192 setTitle, setNotes := f.Has("--title"), f.Has("--notes") || f.Has("--file")
193 fmtName, err := markupFormat(format)
194 if err != nil {
195 return c.failInput(err)
196 }
197 if path == "" || tag == "" || (!setTitle && !setNotes && fmtName == "") {
198 return c.usage()
199 }
200 repo, code := resolveRepo(c, path, policy.CanWrite)
201 if code >= 0 {
202 return code
203 }
204 if code := refuseArchived(c, repo); code >= 0 {
205 return code
206 }
207 rel, err := c.Store.ReleaseByTag(repo.ID, tag)
208 if err != nil {
209 return c.fail(protocol.ExitNotFound, "no release %q in %s", tag, repo.Path())
210 }
211 // Absent flags keep what the release already says.
212 if !setTitle {
213 title = rel.Title
214 } else if title == "" {
215 title = tag
216 }
217 body := rel.Notes
218 if setNotes {
219 if body, err = bodyFrom(c, notes, file); err != nil {
220 return c.failInput(err)
221 }
222 }
223 if fmtName == "" {
224 fmtName = rel.NotesFormat
225 }
226 if err := c.Store.UpdateRelease(repo.ID, tag, title, body, fmtName); err != nil {
227 return c.fail(protocol.ExitFailure, "%v", err)
228 }
229 return c.emit(map[string]string{"tag": tag, "title": title}, func(w io.Writer) {
230 fmt.Fprintf(w, "updated release %s\n", tag)
231 })
232}
233
234// splitReleaseCursor pulls "<created_at>|<id>" apart. The id is what a
235// deleted release loses, so the created_at half carries the sort
236// position even when the row the cursor names is gone.
237func splitReleaseCursor(key string) (created string, id int64, ok bool) {
238 i := strings.LastIndex(key, "|")
239 if i < 0 {
240 return "", 0, false
241 }
242 created = key[:i]
243 n, err := strconv.ParseInt(key[i+1:], 10, 64)
244 if err != nil || created == "" {
245 return "", 0, false
246 }
247 return created, n, true
248}
249
250func runReleaseList(c *Ctx, args []string) int {
251 rest, p, code := parsePageFlags(c, args, "release", false)
252 if code >= 0 {
253 return code
254 }
255 if len(rest) != 1 {
256 return c.usage()
257 }
258 repo, code := resolveRepo(c, rest[0], policy.CanRead)
259 if code >= 0 {
260 return code
261 }
262 var afterCreated string
263 var afterID int64
264 if p.key != "" {
265 var ok bool
266 afterCreated, afterID, ok = splitReleaseCursor(p.key)
267 if !ok {
268 return c.fail(protocol.ExitUsage, "bad cursor")
269 }
270 }
271 rels, err := c.Store.ListReleasesPage(repo.ID, p.queryLimit(), afterCreated, afterID)
272 if err != nil {
273 return c.fail(protocol.ExitFailure, "%v", err)
274 }
275 rels, next := trimPage(p, rels, "release", func(r store.Release) string {
276 return r.CreatedAt + "|" + strconv.FormatInt(r.ID, 10)
277 })
278 var ds []releaseOut
279 for _, r := range rels {
280 ds = append(ds, releaseToOut(r, false))
281 }
282 return c.emitPage(p, ds, next, func(w io.Writer) {
283 tb := c.table(w, "TAG", "TITLE", "ASSETS")
284 for _, d := range ds {
285 title := d.Title
286 if title == d.Tag {
287 title = ""
288 }
289 tb.row(cRef(d.Tag), cFlex(title), cText(fmt.Sprintf("%d asset(s)", len(d.Assets))))
290 }
291 tb.flush()
292 })
293}
294
295func runReleaseShow(c *Ctx, args []string) int {
296 _, rel, code := releaseRef(c, args, policy.CanRead)
297 if code >= 0 {
298 return code
299 }
300 d := releaseToOut(rel, true)
301 return c.emit(d, func(w io.Writer) {
302 title := d.Title
303 if title == d.Tag {
304 title = ""
305 }
306 v := c.view(w)
307 v.title(d.Tag, title, "")
308 v.fields(
309 "author", d.Author+", "+c.when(d.CreatedAt),
310 )
311 v.body(d.Notes, d.NotesFormat)
312 if len(d.Assets) > 0 {
313 io.WriteString(w, "\n")
314 tb := c.table(w, "NAME", "SIZE", "SHA256")
315 for _, a := range d.Assets {
316 tb.row(cRef(a.Name), cNum(a.Size), cFlex(a.SHA256))
317 }
318 tb.flush()
319 }
320 })
321}
322
323func runReleaseDelete(c *Ctx, args []string) int {
324 var rest []string
325 var yes bool
326 for _, a := range args {
327 if a == "--yes" {
328 yes = true
329 } else {
330 rest = append(rest, a)
331 }
332 }
333 repo, rel, code := releaseRef(c, rest, policy.CanAdmin)
334 if code >= 0 {
335 return code
336 }
337 if !yes {
338 return c.fail(protocol.ExitUsage, "release delete is permanent (assets included); re-run with --yes")
339 }
340 if err := c.Store.DeleteRelease(rel.ID); err != nil {
341 return c.fail(protocol.ExitFailure, "%v", err)
342 }
343 os.RemoveAll(assetDir(c.Cfg.Server.Root, repo, rel.ID))
344 c.Store.RecordEvent(repo.ID, c.User.ID, "release.deleted", fmt.Sprintf(`{"tag":%q}`, rel.Tag))
345 return c.emit(map[string]string{"deleted": rel.Tag}, func(w io.Writer) {
346 fmt.Fprintf(w, "deleted release %s\n", rel.Tag)
347 })
348}
349
350func runAssetAdd(c *Ctx, args []string) int {
351 if len(args) != 3 {
352 return c.usage()
353 }
354 repo, rel, code := releaseRef(c, args[:2], policy.CanWrite)
355 if code >= 0 {
356 return code
357 }
358 if code := refuseArchived(c, repo); code >= 0 {
359 return code
360 }
361 name := args[2]
362 if !assetNamePat.MatchString(name) {
363 return c.fail(protocol.ExitUsage, "invalid asset name %q: letters, digits, '._+-'; must not start with '.'", name)
364 }
365 dir := assetDir(c.Cfg.Server.Root, repo, rel.ID)
366 if err := os.MkdirAll(dir, 0o750); err != nil {
367 return c.fail(protocol.ExitFailure, "%v", err)
368 }
369 tmp, err := os.CreateTemp(dir, ".upload-*")
370 if err != nil {
371 return c.fail(protocol.ExitFailure, "%v", err)
372 }
373 defer os.Remove(tmp.Name())
374 h := sha256.New()
375 limit := c.Cfg.Limits.MaxAssetBytes
376 n, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(c.Stdin, limit+1))
377 if err != nil {
378 return c.fail(protocol.ExitFailure, "reading asset: %v", err)
379 }
380 if n > limit {
381 return c.fail(protocol.ExitUsage, "asset exceeds max_asset_bytes (%d)", limit)
382 }
383 if n == 0 {
384 return c.fail(protocol.ExitUsage, "empty asset: pipe the file on stdin")
385 }
386 if err := tmp.Close(); err != nil {
387 return c.fail(protocol.ExitFailure, "%v", err)
388 }
389 sum := hex.EncodeToString(h.Sum(nil))
390 if err := c.Store.AddReleaseAsset(rel.ID, name, n, sum); err != nil {
391 return c.failErr(err)
392 }
393 if err := os.Rename(tmp.Name(), filepath.Join(dir, name)); err != nil {
394 c.Store.RemoveReleaseAsset(rel.ID, name)
395 return c.fail(protocol.ExitFailure, "%v", err)
396 }
397 return c.emit(assetOut{name, n, sum}, func(w io.Writer) {
398 fmt.Fprintf(w, "uploaded %s (%d bytes, sha256 %s)\n", name, n, sum)
399 })
400}
401
402func runAssetGet(c *Ctx, args []string) int {
403 if len(args) != 3 {
404 return c.usage()
405 }
406 repo, rel, code := releaseRef(c, args[:2], policy.CanRead)
407 if code >= 0 {
408 return code
409 }
410 name := args[2]
411 if !assetNamePat.MatchString(name) {
412 return c.fail(protocol.ExitNotFound, "no asset %q", name)
413 }
414 f, err := os.Open(filepath.Join(assetDir(c.Cfg.Server.Root, repo, rel.ID), name))
415 if err != nil {
416 return c.fail(protocol.ExitNotFound, "no asset %q on release %s", name, rel.Tag)
417 }
418 defer f.Close()
419 if _, err := io.Copy(c.Stdout, f); err != nil {
420 return protocol.ExitFailure
421 }
422 return protocol.ExitOK
423}
424
425func runAssetRemove(c *Ctx, args []string) int {
426 if len(args) != 3 {
427 return c.usage()
428 }
429 repo, rel, code := releaseRef(c, args[:2], policy.CanWrite)
430 if code >= 0 {
431 return code
432 }
433 if code := refuseArchived(c, repo); code >= 0 {
434 return code
435 }
436 name := args[2]
437 if err := c.Store.RemoveReleaseAsset(rel.ID, name); err != nil {
438 if errors.Is(err, store.ErrNotFound) {
439 return c.fail(protocol.ExitNotFound, "no asset %q on release %s", name, rel.Tag)
440 }
441 return c.fail(protocol.ExitFailure, "%v", err)
442 }
443 if assetNamePat.MatchString(name) {
444 os.Remove(filepath.Join(assetDir(c.Cfg.Server.Root, repo, rel.ID), name))
445 }
446 return c.emit(map[string]string{"removed": name}, func(w io.Writer) {
447 fmt.Fprintf(w, "removed %s\n", name)
448 })
449}