internal/control/release.go
475 lines · 15078 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 := c.parseArgs(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 if c.Term.Cols > 0 {
155 fmt.Fprintln(w, c.siteURL(repo.Path(), "releases"))
156 }
157 })
158}
159
160type assetOut struct {
161 Name string `json:"name"`
162 Size int64 `json:"size"`
163 SHA256 string `json:"sha256"`
164}
165
166type releaseOut struct {
167 Tag string `json:"tag"`
168 Title string `json:"title"`
169 Notes string `json:"notes,omitempty"`
170 NotesFormat string `json:"notes_format,omitempty"`
171 Author string `json:"author,omitempty"`
172 CreatedAt string `json:"created_at"`
173 Assets []assetOut `json:"assets,omitempty"`
174}
175
176func releaseToOut(r store.Release, withNotes bool) releaseOut {
177 o := releaseOut{Tag: r.Tag, Title: r.Title, Author: r.Author, CreatedAt: r.CreatedAt}
178 if withNotes {
179 o.Notes = r.Notes
180 o.NotesFormat = r.NotesFormat
181 }
182 for _, a := range r.Assets {
183 o.Assets = append(o.Assets, assetOut{a.Name, a.Size, a.SHA256})
184 }
185 return o
186}
187
188func runReleaseEdit(c *Ctx, args []string) int {
189 f, err := c.parseArgs(args, flagSpec{Values: []string{"--title", "--notes", "--file", "--format"}, MaxPos: 2, Usage: c.Cmd.Usage})
190 if err != nil {
191 return c.fail(protocol.ExitUsage, "%v", err)
192 }
193 path, tag := f.pos(0), f.pos(1)
194 title, notes, file, format := f.Value("--title"), f.Value("--notes"), f.Value("--file"), f.Value("--format")
195 setTitle, setNotes := f.Has("--title"), f.Has("--notes") || f.Has("--file")
196 fmtName, err := markupFormat(format)
197 if err != nil {
198 return c.failInput(err)
199 }
200 if path == "" || tag == "" || (!setTitle && !setNotes && fmtName == "") {
201 return c.usage()
202 }
203 repo, code := resolveRepo(c, path, policy.CanWrite)
204 if code >= 0 {
205 return code
206 }
207 if code := refuseArchived(c, repo); code >= 0 {
208 return code
209 }
210 rel, err := c.Store.ReleaseByTag(repo.ID, tag)
211 if err != nil {
212 return c.fail(protocol.ExitNotFound, "no release %q in %s", tag, repo.Path())
213 }
214 // Absent flags keep what the release already says.
215 if !setTitle {
216 title = rel.Title
217 } else if title == "" {
218 title = tag
219 }
220 body := rel.Notes
221 if setNotes {
222 if body, err = bodyFrom(c, notes, file); err != nil {
223 return c.failInput(err)
224 }
225 }
226 if fmtName == "" {
227 fmtName = rel.NotesFormat
228 }
229 if err := c.Store.UpdateRelease(repo.ID, tag, title, body, fmtName); err != nil {
230 return c.fail(protocol.ExitFailure, "%v", err)
231 }
232 return c.emit(map[string]string{"tag": tag, "title": title}, func(w io.Writer) {
233 fmt.Fprintf(w, "updated release %s\n", tag)
234 })
235}
236
237// splitReleaseCursor pulls "<created_at>|<id>" apart. The id is what a
238// deleted release loses, so the created_at half carries the sort
239// position even when the row the cursor names is gone.
240func splitReleaseCursor(key string) (created string, id int64, ok bool) {
241 i := strings.LastIndex(key, "|")
242 if i < 0 {
243 return "", 0, false
244 }
245 created = key[:i]
246 n, err := strconv.ParseInt(key[i+1:], 10, 64)
247 if err != nil || created == "" {
248 return "", 0, false
249 }
250 return created, n, true
251}
252
253func runReleaseList(c *Ctx, args []string) int {
254 rest, p, code := parsePageFlags(c, args, "release", false)
255 if code >= 0 {
256 return code
257 }
258 if len(rest) != 1 {
259 return c.usage()
260 }
261 repo, code := resolveRepo(c, rest[0], policy.CanRead)
262 if code >= 0 {
263 return code
264 }
265 var afterCreated string
266 var afterID int64
267 if p.key != "" {
268 var ok bool
269 afterCreated, afterID, ok = splitReleaseCursor(p.key)
270 if !ok {
271 return c.fail(protocol.ExitUsage, "bad cursor")
272 }
273 }
274 rels, err := c.Store.ListReleasesPage(repo.ID, p.queryLimit(), afterCreated, afterID)
275 if err != nil {
276 return c.fail(protocol.ExitFailure, "%v", err)
277 }
278 rels, next := trimPage(p, rels, "release", func(r store.Release) string {
279 return r.CreatedAt + "|" + strconv.FormatInt(r.ID, 10)
280 })
281 var ds []releaseOut
282 for _, r := range rels {
283 ds = append(ds, releaseToOut(r, false))
284 }
285 return c.emitPage(p, ds, next, func(w io.Writer) {
286 if c.Term.Cols > 0 {
287 tb := c.table(w, "TAG", "TITLE", "ASSETS", "RELEASED")
288 for _, d := range ds {
289 // "v1.2.0 — the forge speaks first" reads as its
290 // subtitle beside the tag.
291 title := strings.TrimPrefix(strings.TrimPrefix(d.Title, d.Tag), " — ")
292 assets := ""
293 switch n := len(d.Assets); n {
294 case 0:
295 case 1:
296 assets = "1 asset"
297 default:
298 assets = fmt.Sprintf("%d assets", n)
299 }
300 tb.row(cLink(d.Tag, c.siteURL(repo.Path(), "releases")), cFlex(title), cText(assets), cAge(d.CreatedAt))
301 }
302 tb.flush()
303 return
304 }
305 tb := c.table(w, "TAG", "TITLE", "ASSETS")
306 for _, d := range ds {
307 title := d.Title
308 if title == d.Tag {
309 title = ""
310 }
311 tb.row(cRef(d.Tag), cFlex(title), cText(fmt.Sprintf("%d asset(s)", len(d.Assets))))
312 }
313 tb.flush()
314 })
315}
316
317func runReleaseShow(c *Ctx, args []string) int {
318 _, rel, code := releaseRef(c, args, policy.CanRead)
319 if code >= 0 {
320 return code
321 }
322 d := releaseToOut(rel, true)
323 return c.emit(d, func(w io.Writer) {
324 title := d.Title
325 if title == d.Tag {
326 title = ""
327 }
328 v := c.view(w)
329 v.title(d.Tag, title, "")
330 v.fields(
331 "author", d.Author+", "+c.when(d.CreatedAt),
332 )
333 v.body(d.Notes, d.NotesFormat)
334 if len(d.Assets) > 0 {
335 v.section("assets")
336 tb := c.table(w, "NAME", "SIZE", "SHA256")
337 for _, a := range d.Assets {
338 sum := a.SHA256
339 if c.Term.Cols > 0 {
340 sum = sum[:min(12, len(sum))]
341 }
342 tb.row(cRef(a.Name), cSize(a.Size), cFlex(sum))
343 }
344 tb.flush()
345 }
346 })
347}
348
349func runReleaseDelete(c *Ctx, args []string) int {
350 var rest []string
351 var yes bool
352 for _, a := range args {
353 if a == "--yes" {
354 yes = true
355 } else {
356 rest = append(rest, a)
357 }
358 }
359 repo, rel, code := releaseRef(c, rest, policy.CanAdmin)
360 if code >= 0 {
361 return code
362 }
363 if !yes {
364 return c.fail(protocol.ExitUsage, "release delete is permanent (assets included); re-run with --yes")
365 }
366 if err := c.Store.DeleteRelease(rel.ID); err != nil {
367 return c.fail(protocol.ExitFailure, "%v", err)
368 }
369 os.RemoveAll(assetDir(c.Cfg.Server.Root, repo, rel.ID))
370 c.Store.RecordEvent(repo.ID, c.User.ID, "release.deleted", fmt.Sprintf(`{"tag":%q}`, rel.Tag))
371 return c.emit(map[string]string{"deleted": rel.Tag}, func(w io.Writer) {
372 fmt.Fprintf(w, "deleted release %s\n", rel.Tag)
373 })
374}
375
376func runAssetAdd(c *Ctx, args []string) int {
377 if len(args) != 3 {
378 return c.usage()
379 }
380 repo, rel, code := releaseRef(c, args[:2], policy.CanWrite)
381 if code >= 0 {
382 return code
383 }
384 if code := refuseArchived(c, repo); code >= 0 {
385 return code
386 }
387 name := args[2]
388 if !assetNamePat.MatchString(name) {
389 return c.fail(protocol.ExitUsage, "invalid asset name %q: letters, digits, '._+-'; must not start with '.'", name)
390 }
391 dir := assetDir(c.Cfg.Server.Root, repo, rel.ID)
392 if err := os.MkdirAll(dir, 0o750); err != nil {
393 return c.fail(protocol.ExitFailure, "%v", err)
394 }
395 tmp, err := os.CreateTemp(dir, ".upload-*")
396 if err != nil {
397 return c.fail(protocol.ExitFailure, "%v", err)
398 }
399 defer os.Remove(tmp.Name())
400 h := sha256.New()
401 limit := c.Cfg.Limits.MaxAssetBytes
402 n, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(c.Stdin, limit+1))
403 if err != nil {
404 return c.fail(protocol.ExitFailure, "reading asset: %v", err)
405 }
406 if n > limit {
407 return c.fail(protocol.ExitUsage, "asset exceeds max_asset_bytes (%d)", limit)
408 }
409 if n == 0 {
410 return c.fail(protocol.ExitUsage, "empty asset: pipe the file on stdin")
411 }
412 if err := tmp.Close(); err != nil {
413 return c.fail(protocol.ExitFailure, "%v", err)
414 }
415 sum := hex.EncodeToString(h.Sum(nil))
416 if err := c.Store.AddReleaseAsset(rel.ID, name, n, sum); err != nil {
417 return c.failErr(err)
418 }
419 if err := os.Rename(tmp.Name(), filepath.Join(dir, name)); err != nil {
420 c.Store.RemoveReleaseAsset(rel.ID, name)
421 return c.fail(protocol.ExitFailure, "%v", err)
422 }
423 return c.emit(assetOut{name, n, sum}, func(w io.Writer) {
424 fmt.Fprintf(w, "uploaded %s (%d bytes, sha256 %s)\n", name, n, sum)
425 })
426}
427
428func runAssetGet(c *Ctx, args []string) int {
429 if len(args) != 3 {
430 return c.usage()
431 }
432 repo, rel, code := releaseRef(c, args[:2], policy.CanRead)
433 if code >= 0 {
434 return code
435 }
436 name := args[2]
437 if !assetNamePat.MatchString(name) {
438 return c.fail(protocol.ExitNotFound, "no asset %q", name)
439 }
440 f, err := os.Open(filepath.Join(assetDir(c.Cfg.Server.Root, repo, rel.ID), name))
441 if err != nil {
442 return c.fail(protocol.ExitNotFound, "no asset %q on release %s", name, rel.Tag)
443 }
444 defer f.Close()
445 if _, err := io.Copy(c.Stdout, f); err != nil {
446 return protocol.ExitFailure
447 }
448 return protocol.ExitOK
449}
450
451func runAssetRemove(c *Ctx, args []string) int {
452 if len(args) != 3 {
453 return c.usage()
454 }
455 repo, rel, code := releaseRef(c, args[:2], policy.CanWrite)
456 if code >= 0 {
457 return code
458 }
459 if code := refuseArchived(c, repo); code >= 0 {
460 return code
461 }
462 name := args[2]
463 if err := c.Store.RemoveReleaseAsset(rel.ID, name); err != nil {
464 if errors.Is(err, store.ErrNotFound) {
465 return c.fail(protocol.ExitNotFound, "no asset %q on release %s", name, rel.Tag)
466 }
467 return c.fail(protocol.ExitFailure, "%v", err)
468 }
469 if assetNamePat.MatchString(name) {
470 os.Remove(filepath.Join(assetDir(c.Cfg.Server.Root, repo, rel.ID), name))
471 }
472 return c.emit(map[string]string{"removed": name}, func(w io.Writer) {
473 fmt.Fprintf(w, "removed %s\n", name)
474 })
475}