internal/control/release.go
487 lines · 15874 bytes
16 symbols in this file
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.emitPageView(p, ds, next, func(w io.Writer) {
286 tb := c.table(w, "TAG", "TITLE", "ASSETS")
287 for _, d := range ds {
288 title := d.Title
289 if title == d.Tag {
290 title = ""
291 }
292 tb.row(cRef(d.Tag), cFlex(title), cText(fmt.Sprintf("%d asset(s)", len(d.Assets))))
293 }
294 tb.flush()
295 }, func() screen {
296 rows := make([]row, len(ds))
297 for i, d := range ds {
298 // "v1.2.0 — the forge speaks first" reads as its subtitle
299 // beside the tag.
300 title := strings.TrimPrefix(strings.TrimPrefix(d.Title, d.Tag), " — ")
301 assets := ""
302 switch n := len(d.Assets); n {
303 case 0:
304 case 1:
305 assets = "1 asset"
306 default:
307 assets = fmt.Sprintf("%d assets", n)
308 }
309 rows[i] = rowOf(cLink(d.Tag, c.siteURL(repo.Path(), "releases")), cFlex(title), cMeta(assets, relAge(d.CreatedAt, termNow())))
310 }
311 s := listScreen("Releases", rows)
312 if len(ds) > 0 {
313 s.actions = []action{{"Read", []string{"release", "show", repo.Path(), ds[0].Tag}}}
314 }
315 return s
316 })
317}
318
319func runReleaseShow(c *Ctx, args []string) int {
320 repo, rel, code := releaseRef(c, args, policy.CanRead)
321 if code >= 0 {
322 return code
323 }
324 d := releaseToOut(rel, true)
325 return c.emitView(d, func(w io.Writer) {
326 title := d.Title
327 if title == d.Tag {
328 title = ""
329 }
330 v := c.view(w)
331 v.title(d.Tag, title, "")
332 v.fields(
333 "author", d.Author+", "+c.when(d.CreatedAt),
334 )
335 v.body(d.Notes, d.NotesFormat)
336 if len(d.Assets) > 0 {
337 v.section("assets")
338 tb := c.table(w, "NAME", "SIZE", "SHA256")
339 for _, a := range d.Assets {
340 tb.row(cRef(a.Name), cSize(a.Size), cFlex(a.SHA256))
341 }
342 tb.flush()
343 }
344 }, func() screen {
345 assets := section{title: "Assets", n: len(d.Assets)}
346 for _, a := range d.Assets {
347 assets.rows = append(assets.rows, rowOf(cRef(a.Name), cSize(a.Size), cMeta(a.SHA256[:min(12, len(a.SHA256))])))
348 }
349 s := screen{body: d.Notes, format: d.NotesFormat, sections: []section{assets}, fields: []field{
350 {"Release", []cell{cLink(d.Tag, c.siteURL(repo.Path(), "releases")), cText(d.Title)}},
351 {"Author", []cell{cText(d.Author), cAge(d.CreatedAt)}},
352 }}
353 if len(d.Assets) > 0 {
354 s.actions = append(s.actions, action{"Get", []string{"release", "asset", "get", repo.Path(), d.Tag, d.Assets[0].Name}})
355 }
356 s.actions = append(s.actions, action{"Edit", []string{"release", "edit", repo.Path(), d.Tag, "--title", "<title>"}})
357 return s
358 })
359}
360
361func runReleaseDelete(c *Ctx, args []string) int {
362 var rest []string
363 var yes bool
364 for _, a := range args {
365 if a == "--yes" {
366 yes = true
367 } else {
368 rest = append(rest, a)
369 }
370 }
371 repo, rel, code := releaseRef(c, rest, policy.CanAdmin)
372 if code >= 0 {
373 return code
374 }
375 if !yes {
376 return c.fail(protocol.ExitUsage, "release delete is permanent (assets included); re-run with --yes")
377 }
378 if err := c.Store.DeleteRelease(rel.ID); err != nil {
379 return c.fail(protocol.ExitFailure, "%v", err)
380 }
381 os.RemoveAll(assetDir(c.Cfg.Server.Root, repo, rel.ID))
382 c.Store.RecordEvent(repo.ID, c.User.ID, "release.deleted", fmt.Sprintf(`{"tag":%q}`, rel.Tag))
383 return c.emit(map[string]string{"deleted": rel.Tag}, func(w io.Writer) {
384 fmt.Fprintf(w, "deleted release %s\n", rel.Tag)
385 })
386}
387
388func runAssetAdd(c *Ctx, args []string) int {
389 if len(args) != 3 {
390 return c.usage()
391 }
392 repo, rel, code := releaseRef(c, args[:2], policy.CanWrite)
393 if code >= 0 {
394 return code
395 }
396 if code := refuseArchived(c, repo); code >= 0 {
397 return code
398 }
399 name := args[2]
400 if !assetNamePat.MatchString(name) {
401 return c.fail(protocol.ExitUsage, "invalid asset name %q: letters, digits, '._+-'; must not start with '.'", name)
402 }
403 dir := assetDir(c.Cfg.Server.Root, repo, rel.ID)
404 if err := os.MkdirAll(dir, 0o750); err != nil {
405 return c.fail(protocol.ExitFailure, "%v", err)
406 }
407 tmp, err := os.CreateTemp(dir, ".upload-*")
408 if err != nil {
409 return c.fail(protocol.ExitFailure, "%v", err)
410 }
411 defer os.Remove(tmp.Name())
412 h := sha256.New()
413 limit := c.Cfg.Limits.MaxAssetBytes
414 n, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(c.Stdin, limit+1))
415 if err != nil {
416 return c.fail(protocol.ExitFailure, "reading asset: %v", err)
417 }
418 if n > limit {
419 return c.fail(protocol.ExitUsage, "asset exceeds max_asset_bytes (%d)", limit)
420 }
421 if n == 0 {
422 return c.fail(protocol.ExitUsage, "empty asset: pipe the file on stdin")
423 }
424 if err := tmp.Close(); err != nil {
425 return c.fail(protocol.ExitFailure, "%v", err)
426 }
427 sum := hex.EncodeToString(h.Sum(nil))
428 if err := c.Store.AddReleaseAsset(rel.ID, name, n, sum); err != nil {
429 return c.failErr(err)
430 }
431 if err := os.Rename(tmp.Name(), filepath.Join(dir, name)); err != nil {
432 c.Store.RemoveReleaseAsset(rel.ID, name)
433 return c.fail(protocol.ExitFailure, "%v", err)
434 }
435 return c.emit(assetOut{name, n, sum}, func(w io.Writer) {
436 fmt.Fprintf(w, "uploaded %s (%d bytes, sha256 %s)\n", name, n, sum)
437 })
438}
439
440func runAssetGet(c *Ctx, args []string) int {
441 if len(args) != 3 {
442 return c.usage()
443 }
444 repo, rel, code := releaseRef(c, args[:2], policy.CanRead)
445 if code >= 0 {
446 return code
447 }
448 name := args[2]
449 if !assetNamePat.MatchString(name) {
450 return c.fail(protocol.ExitNotFound, "no asset %q", name)
451 }
452 f, err := os.Open(filepath.Join(assetDir(c.Cfg.Server.Root, repo, rel.ID), name))
453 if err != nil {
454 return c.fail(protocol.ExitNotFound, "no asset %q on release %s", name, rel.Tag)
455 }
456 defer f.Close()
457 if _, err := io.Copy(c.Stdout, f); err != nil {
458 return protocol.ExitFailure
459 }
460 return protocol.ExitOK
461}
462
463func runAssetRemove(c *Ctx, args []string) int {
464 if len(args) != 3 {
465 return c.usage()
466 }
467 repo, rel, code := releaseRef(c, args[:2], policy.CanWrite)
468 if code >= 0 {
469 return code
470 }
471 if code := refuseArchived(c, repo); code >= 0 {
472 return code
473 }
474 name := args[2]
475 if err := c.Store.RemoveReleaseAsset(rel.ID, name); err != nil {
476 if errors.Is(err, store.ErrNotFound) {
477 return c.fail(protocol.ExitNotFound, "no asset %q on release %s", name, rel.Tag)
478 }
479 return c.fail(protocol.ExitFailure, "%v", err)
480 }
481 if assetNamePat.MatchString(name) {
482 os.Remove(filepath.Join(assetDir(c.Cfg.Server.Root, repo, rel.ID), name))
483 }
484 return c.emit(map[string]string{"removed": name}, func(w io.Writer) {
485 fmt.Fprintf(w, "removed %s\n", name)
486 })
487}