v0.34.0 7c1886f

cli_release.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"bufio"
7	"context"
8	"crypto/sha256"
9	"encoding/hex"
10	"errors"
11	"fmt"
12	"io"
13	"mime"
14	"net/http"
15	"net/url"
16	"os"
17	"path/filepath"
18	"slices"
19	"strconv"
20	"strings"
21
22	"connectrpc.com/connect"
23	"github.com/spf13/cobra"
24	"golang.org/x/term"
25	"google.golang.org/protobuf/types/known/fieldmaskpb"
26
27	"bnema.dev/ermite-api/gen/ermite"
28	"bnema.dev/ermite/internal/adapters/client"
29)
30
31var errNoReleases = errors.New("the server has no releases (it lacks the releases feature)")
32
33func requireReleases(ctx context.Context, c *client.Client) error {
34	res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
35	if err != nil {
36		return c.Describe(err)
37	}
38	if !slices.Contains(res.Msg.GetFeatures(), client.FeatureReleases) {
39		return errNoReleases
40	}
41	return nil
42}
43
44func releaseClient(env *cliEnv, cmd *cobra.Command) (*client.Client, error) {
45	c, err := env.client(cmd)
46	if err != nil {
47		return nil, err
48	}
49	if err := requireReleases(env.ctx(cmd), c); err != nil {
50		return nil, err
51	}
52	return c, nil
53}
54
55// jsonRelease and jsonReleaseAsset keep the CLI shape independent of protobuf JSON.
56type jsonReleaseAsset struct {
57	ID          int64   `json:"id"`
58	ReleaseID   int64   `json:"release_id"`
59	Name        string  `json:"name"`
60	Label       string  `json:"label"`
61	ContentType string  `json:"content_type"`
62	Size        int64   `json:"size"`
63	Digest      string  `json:"digest"`
64	Uploader    string  `json:"uploader"`
65	CreatedAt   *string `json:"created_at"`
66	UpdatedAt   *string `json:"updated_at"`
67}
68type jsonRelease struct {
69	ID              int64              `json:"id"`
70	Repo            string             `json:"repo"`
71	Tag             string             `json:"tag"`
72	Title           string             `json:"title"`
73	Notes           string             `json:"notes"`
74	Draft           bool               `json:"draft"`
75	Prerelease      bool               `json:"prerelease"`
76	Latest          bool               `json:"latest"`
77	TargetCommitish string             `json:"target_commitish"`
78	Author          string             `json:"author"`
79	CreatedAt       *string            `json:"created_at"`
80	PublishedAt     *string            `json:"published_at"`
81	UpdatedAt       *string            `json:"updated_at"`
82	Assets          []jsonReleaseAsset `json:"assets"`
83}
84
85func toJSONRelease(r *ermite.Release) jsonRelease {
86	out := jsonRelease{ID: r.GetId(), Repo: r.GetRepo(), Tag: r.GetTag(), Title: r.GetTitle(), Notes: r.GetNotes(), Draft: r.GetDraft(), Prerelease: r.GetPrerelease(), Latest: r.GetLatest(), TargetCommitish: r.GetTargetCommitish(), Author: r.GetAuthor(), CreatedAt: jsonTime(r.GetCreatedAt()), PublishedAt: jsonTime(r.GetPublishedAt()), UpdatedAt: jsonTime(r.GetUpdatedAt()), Assets: []jsonReleaseAsset{}}
87	for _, a := range r.GetAssets() {
88		out.Assets = append(out.Assets, jsonReleaseAsset{a.GetId(), a.GetReleaseId(), a.GetName(), a.GetLabel(), a.GetContentType(), a.GetSize(), a.GetDigest(), a.GetUploader(), jsonTime(a.GetCreatedAt()), jsonTime(a.GetUpdatedAt())})
89	}
90	return out
91}
92
93const releaseJSONHelp = "\nJSON fields: id, repo, tag, title, notes, draft, prerelease, latest, target_commitish, author,\ncreated_at, published_at, updated_at (RFC 3339 UTC or null), assets (id, release_id, name, label,\ncontent_type, size, digest, uploader, created_at, updated_at). list prints an array."
94
95func newReleaseCmd(env *cliEnv) *cobra.Command {
96	cmd := &cobra.Command{Use: "release", Short: "Manage releases and their assets", Long: "Create and manage tagged releases. Reads follow repository visibility; writes need release:write.\nNeeds a server with the releases feature. Select releases by tag, numeric id, or latest (view/download)."}
97	cmd.AddCommand(newReleaseCreateCmd(env), newReleaseListCmd(env), newReleaseViewCmd(env), newReleaseEditCmd(env), newReleaseDeleteCmd(env), newReleaseUploadCmd(env), newReleaseDownloadCmd(env))
98	return cmd
99}
100func releaseCommand(env *cliEnv, use, short string, n int) *cobra.Command {
101	return &cobra.Command{Use: use, Short: short, Args: cobra.ExactArgs(n), ValidArgsFunction: completeRepoNames(env)}
102}
103
104func getRelease(ctx context.Context, c *client.Client, repo, selector string, latest bool) (*ermite.Release, error) {
105	req := &ermite.GetReleaseRequest{Repo: repo}
106	if selector == "latest" && latest {
107		req.Selector = &ermite.GetReleaseRequest_Latest{Latest: true}
108	} else if selector != "" && strings.Trim(selector, "0123456789") == "" {
109		id, err := strconv.ParseInt(selector, 10, 64)
110		if err != nil || id <= 0 {
111			return nil, fmt.Errorf("invalid release id %q", selector)
112		}
113		req.Selector = &ermite.GetReleaseRequest_Id{Id: id}
114	} else {
115		req.Selector = &ermite.GetReleaseRequest_Tag{Tag: selector}
116	}
117	res, err := c.Releases().GetRelease(ctx, connect.NewRequest(req))
118	if err != nil {
119		return nil, c.Describe(err)
120	}
121	return res.Msg.GetRelease(), nil
122}
123func releaseState(r *ermite.Release) string {
124	if r.GetDraft() {
125		return "draft"
126	}
127	if r.GetPrerelease() {
128		return "prerelease"
129	}
130	return "published"
131}
132func releaseURL(base, repo, tag string) string {
133	return strings.TrimRight(base, "/") + "/" + url.PathEscape(repo) + "/releases/tag/" + url.PathEscape(tag)
134}
135func outputRelease(cmd *cobra.Command, c *client.Client, r *ermite.Release, asJSON bool) error {
136	if asJSON {
137		return writeJSON(cmd.OutOrStdout(), toJSONRelease(r))
138	}
139	_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\nid: %d\n", releaseURL(c.URL(), r.GetRepo(), r.GetTag()), r.GetId())
140	return err
141}
142
143type releaseFields struct {
144	title, notes, notesFile, target, tag             string
145	draft, publish, prerelease, noPrerelease, latest bool
146}
147
148func (f *releaseFields) flags(cmd *cobra.Command, edit bool) {
149	cmd.Flags().StringVar(&f.title, "title", "", "release title")
150	cmd.Flags().StringVar(&f.notes, "notes", "", "Markdown release notes")
151	cmd.Flags().StringVar(&f.notesFile, "notes-file", "", "read Markdown release notes from a file (- for stdin)")
152	cmd.MarkFlagsMutuallyExclusive("notes", "notes-file")
153	cmd.Flags().StringVar(&f.target, "target", "", "target commit or branch for a missing tag")
154	cmd.Flags().BoolVar(&f.draft, "draft", false, "keep as a draft")
155	cmd.Flags().BoolVar(&f.prerelease, "prerelease", false, "mark as a prerelease")
156	cmd.Flags().BoolVar(&f.latest, "latest", false, "pin as latest")
157	if edit {
158		cmd.Flags().StringVar(&f.tag, "tag", "", "new tag")
159		cmd.Flags().BoolVar(&f.publish, "publish", false, "publish a draft")
160		cmd.Flags().BoolVar(&f.noPrerelease, "no-prerelease", false, "clear the prerelease flag")
161		cmd.MarkFlagsMutuallyExclusive("draft", "publish")
162		cmd.MarkFlagsMutuallyExclusive("prerelease", "no-prerelease")
163	}
164}
165func (f *releaseFields) readNotes(cmd *cobra.Command) error {
166	if !cmd.Flags().Changed("notes-file") {
167		return nil
168	}
169	var data []byte
170	var err error
171	if f.notesFile == "-" {
172		data, err = io.ReadAll(cmd.InOrStdin())
173	} else {
174		data, err = os.ReadFile(f.notesFile)
175	}
176	f.notes = string(data)
177	return err
178}
179func newReleaseCreateCmd(env *cliEnv) *cobra.Command {
180	var f releaseFields
181	var assets []string
182	cmd := releaseCommand(env, "create <repo> <tag>", "Create a release", 2)
183	cmd.Long = "Create a release, then upload each --asset FILE[#LABEL] (repeatable; shell expands globs)." + releaseJSONHelp
184	f.flags(cmd, false)
185	cmd.Flags().StringArrayVar(&assets, "asset", nil, "upload FILE[#LABEL] (repeatable)")
186	asJSON := addJSONFlag(cmd)
187	cmd.RunE = func(cmd *cobra.Command, args []string) error {
188		if err := f.readNotes(cmd); err != nil {
189			return err
190		}
191		c, err := releaseClient(env, cmd)
192		if err != nil {
193			return err
194		}
195		ctx := env.ctx(cmd)
196		res, err := c.Releases().CreateRelease(ctx, connect.NewRequest(&ermite.CreateReleaseRequest{Repo: args[0], Tag: args[1], Title: f.title, Notes: f.notes, TargetCommitish: f.target, Draft: f.draft, Prerelease: f.prerelease, MakeLatest: f.latest}))
197		if err != nil {
198			return c.Describe(err)
199		}
200		r := res.Msg.GetRelease()
201		for _, spec := range assets {
202			a, err := uploadReleaseFile(ctx, c, args[0], r.GetId(), spec, "", false)
203			if err != nil {
204				return fmt.Errorf("release %d was created, but asset upload failed: %w", r.GetId(), err)
205			}
206			r.Assets = append(r.Assets, a)
207		}
208		return outputRelease(cmd, c, r, *asJSON)
209	}
210	return cmd
211}
212func newReleaseListCmd(env *cliEnv) *cobra.Command {
213	var limit int32
214	cmd := releaseCommand(env, "list <repo>", "List releases, newest first", 1)
215	cmd.Long = "List releases, including drafts visible to writers." + releaseJSONHelp
216	cmd.Flags().Int32Var(&limit, "limit", 30, "maximum number of releases")
217	asJSON := addJSONFlag(cmd)
218	cmd.RunE = func(cmd *cobra.Command, args []string) error {
219		if limit <= 0 {
220			return errors.New("--limit must be positive")
221		}
222		c, err := releaseClient(env, cmd)
223		if err != nil {
224			return err
225		}
226		all := []*ermite.Release{}
227		page := ""
228		for int32(len(all)) < limit {
229			res, err := c.Releases().ListReleases(env.ctx(cmd), connect.NewRequest(&ermite.ListReleasesRequest{Repo: args[0], PageSize: limit - int32(len(all)), PageToken: page}))
230			if err != nil {
231				return c.Describe(err)
232			}
233			all = append(all, res.Msg.GetReleases()...)
234			next := res.Msg.GetNextPageToken()
235			if next == "" {
236				break
237			}
238			if next == page {
239				return errors.New("the server repeated a release page token")
240			}
241			page = next
242		}
243		if int32(len(all)) > limit {
244			all = all[:limit]
245		}
246		if *asJSON {
247			out := make([]jsonRelease, 0, len(all))
248			for _, r := range all {
249				out = append(out, toJSONRelease(r))
250			}
251			return writeJSON(cmd.OutOrStdout(), out)
252		}
253		rows := [][]string{}
254		for _, r := range all {
255			rows = append(rows, []string{strconv.FormatInt(r.GetId(), 10), r.GetTag(), r.GetTitle(), releaseState(r), shortTime(r.GetPublishedAt(), "-"), strconv.Itoa(len(r.GetAssets()))})
256		}
257		return table(cmd.OutOrStdout(), []string{"ID", "TAG", "TITLE", "STATE", "PUBLISHED", "ASSETS"}, rows)
258	}
259	return cmd
260}
261func newReleaseViewCmd(env *cliEnv) *cobra.Command {
262	var notes bool
263	cmd := releaseCommand(env, "view <repo> <tag|id|latest>", "View a release and its assets", 2)
264	cmd.Long = "View release fields, Markdown notes and assets. --notes prints only Markdown." + releaseJSONHelp
265	asJSON := addJSONFlag(cmd)
266	cmd.Flags().BoolVar(&notes, "notes", false, "print only Markdown notes")
267	cmd.MarkFlagsMutuallyExclusive("json", "notes")
268	cmd.RunE = func(cmd *cobra.Command, args []string) error {
269		c, err := releaseClient(env, cmd)
270		if err != nil {
271			return err
272		}
273		r, err := getRelease(env.ctx(cmd), c, args[0], args[1], true)
274		if err != nil {
275			return err
276		}
277		if *asJSON {
278			return writeJSON(cmd.OutOrStdout(), toJSONRelease(r))
279		}
280		if notes {
281			_, err := fmt.Fprint(cmd.OutOrStdout(), r.GetNotes())
282			return err
283		}
284		if err := fields(cmd.OutOrStdout(), [][2]string{{"id", strconv.FormatInt(r.GetId(), 10)}, {"tag", r.GetTag()}, {"title", r.GetTitle()}, {"state", releaseState(r)}, {"latest", strconv.FormatBool(r.GetLatest())}, {"target", r.GetTargetCommitish()}, {"author", r.GetAuthor()}, {"created", shortTime(r.GetCreatedAt(), "-")}, {"published", shortTime(r.GetPublishedAt(), "-")}, {"updated", shortTime(r.GetUpdatedAt(), "-")}}); err != nil {
285			return err
286		}
287		if _, err := fmt.Fprintln(cmd.OutOrStdout(), clean(r.GetNotes())); err != nil {
288			return err
289		}
290		rows := [][]string{}
291		for _, a := range r.GetAssets() {
292			rows = append(rows, []string{a.GetName(), humanBytes(a.GetSize()), a.GetContentType()})
293		}
294		return table(cmd.OutOrStdout(), []string{"NAME", "SIZE", "TYPE"}, rows)
295	}
296	return cmd
297}
298func newReleaseEditCmd(env *cliEnv) *cobra.Command {
299	var f releaseFields
300	cmd := releaseCommand(env, "edit <repo> <tag|id>", "Edit a release", 2)
301	f.flags(cmd, true)
302	cmd.RunE = func(cmd *cobra.Command, args []string) error {
303		if err := f.readNotes(cmd); err != nil {
304			return err
305		}
306		paths := []string{}
307		for _, p := range [][2]string{{"title", "title"}, {"notes", "notes"}, {"notes-file", "notes"}, {"tag", "tag"}, {"target", "target_commitish"}, {"draft", "draft"}, {"publish", "draft"}, {"prerelease", "prerelease"}, {"no-prerelease", "prerelease"}, {"latest", "make_latest"}} {
308			if cmd.Flags().Changed(p[0]) {
309				paths = append(paths, p[1])
310			}
311		}
312		if len(paths) == 0 {
313			return errors.New("give at least one field to edit")
314		}
315		c, err := releaseClient(env, cmd)
316		if err != nil {
317			return err
318		}
319		ctx := env.ctx(cmd)
320		r, err := getRelease(ctx, c, args[0], args[1], false)
321		if err != nil {
322			return err
323		}
324		res, err := c.Releases().UpdateRelease(ctx, connect.NewRequest(&ermite.UpdateReleaseRequest{Repo: args[0], Id: r.GetId(), Tag: f.tag, Title: f.title, Notes: f.notes, TargetCommitish: f.target, Draft: f.draft && !f.publish, Prerelease: f.prerelease && !f.noPrerelease, MakeLatest: f.latest, UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}}))
325		if err != nil {
326			return c.Describe(err)
327		}
328		return outputRelease(cmd, c, res.Msg.GetRelease(), false)
329	}
330	return cmd
331}
332func newReleaseDeleteCmd(env *cliEnv) *cobra.Command {
333	var yes bool
334	cmd := releaseCommand(env, "delete <repo> <tag|id>", "Delete a release and its assets (keeps the tag)", 2)
335	cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip confirmation")
336	cmd.RunE = func(cmd *cobra.Command, args []string) error {
337		c, err := releaseClient(env, cmd)
338		if err != nil {
339			return err
340		}
341		ctx := env.ctx(cmd)
342		r, err := getRelease(ctx, c, args[0], args[1], false)
343		if err != nil {
344			return err
345		}
346		if in, ok := cmd.InOrStdin().(*os.File); !yes && ok && term.IsTerminal(int(in.Fd())) {
347			_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Delete release %q and all its assets? [y/N] ", clean(r.GetTag()))
348			answer, err := bufio.NewReader(in).ReadString('\n')
349			if err != nil || !strings.EqualFold(strings.TrimSpace(answer), "y") {
350				return errors.New("aborted")
351			}
352		}
353		_, err = c.Releases().DeleteRelease(ctx, connect.NewRequest(&ermite.DeleteReleaseRequest{Repo: args[0], Id: r.GetId()}))
354		if err != nil {
355			return c.Describe(err)
356		}
357		_, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted release %d\n", r.GetId())
358		return err
359	}
360	return cmd
361}
362func newReleaseUploadCmd(env *cliEnv) *cobra.Command {
363	var clobber bool
364	var contentType string
365	cmd := releaseCommand(env, "upload <repo> <tag|id> FILE[#LABEL]...", "Upload release assets", 3)
366	cmd.Args = cobra.MinimumNArgs(3)
367	cmd.Flags().BoolVar(&clobber, "clobber", false, "replace assets with the same name")
368	cmd.Flags().StringVar(&contentType, "content-type", "", "override detected media type")
369	cmd.RunE = func(cmd *cobra.Command, args []string) error {
370		c, err := releaseClient(env, cmd)
371		if err != nil {
372			return err
373		}
374		ctx := env.ctx(cmd)
375		r, err := getRelease(ctx, c, args[0], args[1], false)
376		if err != nil {
377			return err
378		}
379		for _, spec := range args[2:] {
380			a, err := uploadReleaseFile(ctx, c, args[0], r.GetId(), spec, contentType, clobber)
381			if err != nil {
382				return err
383			}
384			noticef(cmd, "uploaded %s (%s)", clean(a.GetName()), humanBytes(a.GetSize()))
385		}
386		return nil
387	}
388	return cmd
389}
390func uploadReleaseFile(ctx context.Context, c *client.Client, repo string, id int64, spec, contentType string, replace bool) (*ermite.ReleaseAsset, error) {
391	filename, label, _ := strings.Cut(spec, "#")
392	f, err := os.Open(filename)
393	if err != nil {
394		return nil, err
395	}
396	defer func() { _ = f.Close() }()
397	info, err := f.Stat()
398	if err != nil {
399		return nil, err
400	}
401	if !info.Mode().IsRegular() {
402		return nil, fmt.Errorf("%q is not a regular file", filename)
403	}
404	if contentType == "" {
405		contentType = mime.TypeByExtension(filepath.Ext(filename))
406		if contentType == "" {
407			buf := make([]byte, 512)
408			n, err := f.Read(buf)
409			if err != nil && !errors.Is(err, io.EOF) {
410				return nil, err
411			}
412			contentType = http.DetectContentType(buf[:n])
413			if _, err := f.Seek(0, io.SeekStart); err != nil {
414				return nil, err
415			}
416		}
417	}
418	if _, _, err := mime.ParseMediaType(contentType); err != nil {
419		return nil, fmt.Errorf("invalid --content-type: %w", err)
420	}
421	name := filepath.Base(filename)
422	stream := c.Releases().UploadReleaseAsset(ctx)
423	// Always close the stream, including a local read or Send failure.
424	defer func() {
425		if conn, err := stream.Conn(); err == nil {
426			_ = conn.CloseRequest()
427			_ = conn.CloseResponse()
428		}
429	}()
430	err = stream.Send(&ermite.UploadReleaseAssetRequest{Repo: repo, ReleaseId: id, Name: name, Label: label, ContentType: contentType, Size: info.Size(), Replace: replace})
431	buf := make([]byte, 1<<20)
432	for err == nil {
433		n, rerr := f.Read(buf)
434		if n > 0 {
435			err = stream.Send(&ermite.UploadReleaseAssetRequest{Data: buf[:n]})
436		}
437		if rerr != nil {
438			if !errors.Is(rerr, io.EOF) {
439				return nil, rerr
440			}
441			break
442		}
443	}
444	// CloseAndReceive exposes a server error even when Send returned EOF.
445	res, closeErr := stream.CloseAndReceive()
446	if closeErr != nil {
447		err = closeErr
448	}
449	if err != nil {
450		if connect.CodeOf(err) == connect.CodeAlreadyExists && !replace {
451			return nil, fmt.Errorf("asset %q already exists; use --clobber to replace it", clean(name))
452		}
453		return nil, c.Describe(err)
454	}
455	return res.Msg.GetAsset(), nil
456}
457func newReleaseDownloadCmd(env *cliEnv) *cobra.Command {
458	var names, patterns []string
459	var dir string
460	cmd := releaseCommand(env, "download <repo> <tag|id|latest>", "Download assets without overwriting existing files", 2)
461	cmd.Flags().StringArrayVar(&names, "name", nil, "asset name (repeatable)")
462	cmd.Flags().StringArrayVar(&patterns, "pattern", nil, "asset name glob (repeatable)")
463	cmd.Flags().StringVarP(&dir, "dir", "D", ".", "download directory")
464	cmd.RunE = func(cmd *cobra.Command, args []string) error {
465		for _, p := range patterns {
466			if _, err := filepath.Match(p, ""); err != nil {
467				return fmt.Errorf("--pattern: %w", err)
468			}
469		}
470		c, err := releaseClient(env, cmd)
471		if err != nil {
472			return err
473		}
474		ctx := env.ctx(cmd)
475		r, err := getRelease(ctx, c, args[0], args[1], true)
476		if err != nil {
477			return err
478		}
479		picked, err := pickReleaseAssets(r.GetAssets(), names, patterns)
480		if err != nil {
481			return err
482		}
483		if len(picked) == 0 {
484			return errors.New("no matching release assets")
485		}
486		for _, a := range picked {
487			if err := checkArtifactName(a.GetName()); err != nil {
488				return err
489			}
490		}
491		if err := os.MkdirAll(dir, 0o755); err != nil {
492			return err
493		}
494		root, err := os.OpenRoot(dir)
495		if err != nil {
496			return err
497		}
498		defer func() { _ = root.Close() }()
499		for _, a := range picked {
500			if err := downloadReleaseAsset(ctx, c, args[0], a, root); err != nil {
501				return fmt.Errorf("asset %q: %w", clean(a.GetName()), err)
502			}
503			noticef(cmd, "downloaded %s to %s", clean(a.GetName()), clean(filepath.Join(dir, a.GetName())))
504		}
505		return nil
506	}
507	return cmd
508}
509func pickReleaseAssets(all []*ermite.ReleaseAsset, names, patterns []string) ([]*ermite.ReleaseAsset, error) {
510	for _, name := range names {
511		if !slices.ContainsFunc(all, func(a *ermite.ReleaseAsset) bool { return a.GetName() == name }) {
512			return nil, fmt.Errorf("the release has no asset %q", clean(name))
513		}
514	}
515	out := []*ermite.ReleaseAsset{}
516	for _, a := range all {
517		match := len(names) == 0 && len(patterns) == 0 || slices.Contains(names, a.GetName())
518		for _, p := range patterns {
519			ok, err := filepath.Match(p, a.GetName())
520			if err != nil {
521				return nil, err
522			}
523			match = match || ok
524		}
525		if match {
526			out = append(out, a)
527		}
528	}
529	return out, nil
530}
531
532// downloadReleaseAsset owns the destination exclusively; errors remove it. Only
533// transport failures resume, once, using the immutable asset id and kept offset.
534func downloadReleaseAsset(ctx context.Context, c *client.Client, repo string, a *ermite.ReleaseAsset, root *os.Root) (err error) {
535	f, err := root.OpenFile(a.GetName(), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
536	if err != nil {
537		return err
538	}
539	defer func() {
540		cerr := f.Close()
541		if err == nil {
542			err = cerr
543		}
544		if err != nil {
545			_ = root.Remove(a.GetName())
546		}
547	}()
548	hash := sha256.New()
549	w := io.MultiWriter(f, hash)
550	var kept int64
551	for attempt := 0; attempt < 2; attempt++ {
552		stream, openErr := c.Releases().DownloadReleaseAsset(ctx, connect.NewRequest(&ermite.DownloadReleaseAssetRequest{Repo: repo, Selector: &ermite.DownloadReleaseAssetRequest_AssetId{AssetId: a.GetId()}, Offset: kept}))
553		if openErr != nil {
554			err = openErr
555		} else {
556			described := false
557			for stream.Receive() {
558				msg := stream.Msg()
559				if !described {
560					meta := msg.GetAsset()
561					if meta == nil || meta.GetId() != a.GetId() || meta.GetSize() != a.GetSize() || meta.GetDigest() != a.GetDigest() {
562						_ = stream.Close()
563						return errors.New("the server did not describe the expected asset")
564					}
565					described = true
566				}
567				chunk := msg.GetChunk()
568				if kept+int64(len(chunk)) > a.GetSize() {
569					_ = stream.Close()
570					return errors.New("the server sent more than the asset size")
571				}
572				n, werr := w.Write(chunk)
573				kept += int64(n)
574				if werr != nil {
575					_ = stream.Close()
576					return werr
577				}
578			}
579			err = stream.Err()
580			_ = stream.Close()
581			if err == nil && (!described || kept != a.GetSize()) {
582				err = io.ErrUnexpectedEOF
583			}
584		}
585		if err == nil {
586			break
587		}
588		if ctx.Err() != nil {
589			return ctx.Err()
590		}
591		code := connect.CodeOf(err)
592		if attempt == 1 || (code != connect.CodeUnavailable && code != connect.CodeInternal && !errors.Is(err, io.ErrUnexpectedEOF)) {
593			return c.Describe(err)
594		}
595	}
596	if err != nil {
597		return c.Describe(err)
598	}
599	if a.GetDigest() != "sha256:"+hex.EncodeToString(hash.Sum(nil)) {
600		return errors.New("asset digest mismatch")
601	}
602	return f.Sync()
603}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close