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}