Files
mgsh/assets.go
2026-07-28 15:25:54 +02:00

324 lines
9.7 KiB
Go

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/<id>/assets?name=<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(cDark, "uploading"),
col(cGreen, padRight(a.name, 28)), col(cDark, 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
}