internal/control/release.go

9df917e73a67d15adecc3f45976690f6fcd4e47a
gitbay/internal/control/release.go history · blame · raw

449 lines · 14339 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	})
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 := c.parseArgs(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			v.section("assets")
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}