324 lines
9.7 KiB
Go
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
|
|
}
|