package main // assets.go — attaching files to a release. // // `release` uploads everything in the project's ./bin and ./assets when those // directories exist. This is where the three providers stop resembling each // other, so each gets its own path: // // Gitea multipart POST to .../releases//assets?name= // GitHub raw POST to the upload host named by the release object's upload_url // GitLab no asset hosting on a release at all: the file goes into the generic // package registry, and the release gets a link pointing at it // // Re-releasing the same tag replaces same-named assets rather than failing or // piling up duplicates, because rebuilding and publishing again is the normal // reason to do it. import ( "encoding/json" "fmt" "io" "mime" "mime/multipart" "net/http" "net/url" "os" "path/filepath" "sort" "strconv" ) // assetDirs are the project directories whose files a release carries. var assetDirs = []string{"bin", "assets"} // releaseAsset is one file to attach to a release. type releaseAsset struct { name string path string size int64 } // collectAssets gathers the files to attach: every regular file directly in // the project's ./bin and ./assets. Subdirectories are not descended into, and // symlinks are skipped — bin/mgsh points at one of its own siblings, and // uploading the same binary twice under two names helps nobody. func collectAssets(dir string) (assets []releaseAsset, skipped []string) { seen := map[string]bool{} for _, sub := range assetDirs { path := filepath.Join(dir, sub) entries, err := os.ReadDir(path) if err != nil { continue // the directory simply is not there } for _, e := range entries { if e.IsDir() || e.Type()&os.ModeSymlink != 0 { continue } fi, err := e.Info() if err != nil || !fi.Mode().IsRegular() { continue } if seen[e.Name()] { // bin/x and assets/x would fight over one asset name skipped = append(skipped, filepath.Join(sub, e.Name())) continue } seen[e.Name()] = true assets = append(assets, releaseAsset{ name: e.Name(), path: filepath.Join(path, e.Name()), size: fi.Size(), }) } } sort.Slice(assets, func(i, j int) bool { return assets[i].name < assets[j].name }) return assets, skipped } // contentType guesses a type from the file name, falling back to the one for // "some bytes" — which is what a compiled binary is. func contentType(name string) string { if t := mime.TypeByExtension(filepath.Ext(name)); t != "" { return t } return "application/octet-stream" } // doUpload sends a request with a raw body of a known size. The JSON helper // cannot express these: an asset upload is bytes, not an object, and the // providers insist on a Content-Length rather than a chunked body. func (r *remoteAPI) doUpload(method, endpoint, ctype string, body io.Reader, size int64) (int, []byte, error) { req, err := http.NewRequest(method, endpoint, body) if err != nil { return 0, nil, err } req.ContentLength = size req.Header.Set("Content-Type", ctype) hk, hv := r.authHeader() req.Header.Set(hk, hv) req.Header.Set("Accept", "application/json") resp, err := r.http.Do(req) if err != nil { return 0, nil, err } defer resp.Body.Close() data, _ := io.ReadAll(resp.Body) return resp.StatusCode, data, nil } // uploadAssets attaches every file to the release, reporting each one as it // goes: these are megabytes over a network, and silence for a minute looks like // a hang. A file that fails does not stop the rest. func (r *remoteAPI) uploadAssets(owner, repo string, ref releaseRef, assets []releaseAsset) error { failed := 0 for _, a := range assets { fmt.Printf(" %s %s %s\n", col(cGray, "uploading"), col(cGreen, padRight(a.name, 28)), col(cGray, humanSize(a.size))) if err := r.uploadAsset(owner, repo, ref, a); err != nil { errorln(" " + a.name + ": " + err.Error()) failed++ } } if failed > 0 { return fmt.Errorf("%d of %d assets failed", failed, len(assets)) } return nil } // uploadAsset attaches one file, replacing an asset of the same name that is // already on the release. func (r *remoteAPI) uploadAsset(owner, repo string, ref releaseRef, a releaseAsset) error { if err := r.removeAsset(owner, repo, ref, a.name); err != nil { return err } switch r.kind { case kindGitLab: return r.uploadAssetGitLab(owner, repo, ref, a) case kindGitHub: return r.uploadAssetGitHub(ref, a) default: return r.uploadAssetGitea(owner, repo, ref, a) } } // uploadAssetGitea posts the file as a multipart form, streamed from disk // rather than buffered: these are whole binaries. func (r *remoteAPI) uploadAssetGitea(owner, repo string, ref releaseRef, a releaseAsset) error { f, err := os.Open(a.path) if err != nil { return err } defer f.Close() pr, pw := io.Pipe() mw := multipart.NewWriter(pw) go func() { part, err := mw.CreateFormFile("attachment", a.name) if err == nil { _, err = io.Copy(part, f) } if err != nil { pw.CloseWithError(err) return } pw.CloseWithError(mw.Close()) }() ep := r.releasePath(owner, repo) + "/" + ref.id + "/assets?name=" + url.QueryEscape(a.name) // the multipart length is not known up front, so this one is chunked code, data, err := r.doUpload("POST", ep, mw.FormDataContentType(), pr, -1) if err != nil { return err } if code != 200 && code != 201 { return fmt.Errorf("HTTP %d: %s", code, firstLine(data)) } return nil } // uploadAssetGitHub posts the raw bytes to the host the release object named. func (r *remoteAPI) uploadAssetGitHub(ref releaseRef, a releaseAsset) error { if ref.uploadURL == "" { return fmt.Errorf("the release carries no upload_url") } f, err := os.Open(a.path) if err != nil { return err } defer f.Close() ep := ref.uploadURL + "?name=" + url.QueryEscape(a.name) code, data, err := r.doUpload("POST", ep, contentType(a.name), f, a.size) if err != nil { return err } if code != 200 && code != 201 { return fmt.Errorf("HTTP %d: %s", code, firstLine(data)) } return nil } // uploadAssetGitLab puts the file into the project's generic package registry // and links the release to it, since a GitLab release stores links, not files. func (r *remoteAPI) uploadAssetGitLab(owner, repo string, ref releaseRef, a releaseAsset) error { f, err := os.Open(a.path) if err != nil { return err } defer f.Close() pkg := r.packagePath(owner, repo, ref.tag, a.name) code, data, err := r.doUpload("PUT", pkg, contentType(a.name), f, a.size) if err != nil { return err } if code != 200 && code != 201 { return fmt.Errorf("package upload failed (HTTP %d): %s", code, firstLine(data)) } code, data, err = r.do("POST", r.releaseByTagPath(owner, repo, ref.tag)+"/assets/links", map[string]any{"name": a.name, "url": pkg, "link_type": "package"}) if err != nil { return err } if code != 200 && code != 201 { return fmt.Errorf("linking the package failed (HTTP %d): %s", code, firstLine(data)) } return nil } // packagePath is where a release asset lives in GitLab's generic registry: one // package named after the repository, one version per tag. func (r *remoteAPI) packagePath(owner, repo, tag, name string) string { return r.repoPath(owner, repo) + "/packages/generic/" + url.PathEscape(repo) + "/" + url.PathEscape(tag) + "/" + url.PathEscape(name) } // removeAsset deletes an asset of the given name from the release when one is // there, so re-releasing a tag after a rebuild replaces the files instead of // failing or leaving two of each. func (r *remoteAPI) removeAsset(owner, repo string, ref releaseRef, name string) error { listEP, delEP := r.assetEndpoints(owner, repo, ref) code, data, err := r.do("GET", listEP, nil) if err != nil { return err } if code == 404 { return nil // nothing uploaded yet } if code != 200 { return fmt.Errorf("listing assets failed (HTTP %d): %s", code, firstLine(data)) } for _, id := range assetIDsNamed(data, name) { if code, data, err := r.do("DELETE", delEP+"/"+id, nil); err != nil { return err } else if code != 200 && code != 202 && code != 204 { return fmt.Errorf("deleting the previous %s failed (HTTP %d): %s", name, code, firstLine(data)) } } return nil } // assetEndpoints returns where a release's assets are listed and deleted. // GitHub deletes an asset through the repository rather than the release. func (r *remoteAPI) assetEndpoints(owner, repo string, ref releaseRef) (list, del string) { switch r.kind { case kindGitLab: base := r.releaseByTagPath(owner, repo, ref.tag) + "/assets/links" return base, base case kindGitHub: return r.releasePath(owner, repo) + "/" + ref.id + "/assets", r.releasePath(owner, repo) + "/assets" default: // Gitea base := r.releasePath(owner, repo) + "/" + ref.id + "/assets" return base, base } } // assetIDsNamed picks the ids of the listed assets called name. All three // providers answer with an array of objects carrying "id" and "name". func assetIDsNamed(data []byte, name string) []string { var list []struct { ID json.Number `json:"id"` Name string `json:"name"` } if err := json.Unmarshal(data, &list); err != nil { return nil } var ids []string for _, a := range list { if a.Name == name && a.ID.String() != "" { ids = append(ids, a.ID.String()) } } return ids } // assetSummary describes what will be uploaded, for the line `release` prints // before it starts. func assetSummary(assets []releaseAsset) string { var total int64 for _, a := range assets { total += a.size } unit := "assets" if len(assets) == 1 { unit = "asset" } return strconv.Itoa(len(assets)) + " " + unit + ", " + humanSize(total) } // assetDirList names the directories that were actually found, for the same line. func assetDirList(dir string) []string { var found []string for _, sub := range assetDirs { if isDir(filepath.Join(dir, sub)) { found = append(found, "./"+sub) } } return found }