Files
mgsh/assets.go
T
mikeandClaude Opus 5 8c4d8da4e9 Attach ./bin and ./assets to a release
`release` now uploads every file in the project's ./bin and ./assets when
those directories exist. Nothing to configure, and nothing happens for a
project that has neither.

This is the part deliberately left out when `release` was written,
because it is where the three providers stop resembling each other:

  Gitea   multipart POST to .../releases/<id>/assets?name=<name>
  GitHub  raw POST to the separate upload host the release object names
          in upload_url, whose RFC 6570 template suffix has to go first
  GitLab  a release stores links, not files: the file goes into the
          project's generic package registry and the release gets a
          package link pointing at it

So findRelease and createRelease now return a releaseRef carrying the id
and, for GitHub, that upload host -- the id alone cannot address an
upload. Uploads stream from disk rather than buffering: these are whole
binaries, and the Gitea multipart body is assembled through a pipe.

Only regular files directly in those directories are taken. Symlinks are
skipped, which matters here: build.sh leaves bin/mgsh pointing at one of
its siblings, and uploading the same 9M twice under two names helps
nobody. A name present in both directories is used from bin and reported
for assets. Re-releasing a tag replaces same-named assets rather than
failing on them, since rebuilding and publishing again is the normal
reason to do it, and a file that fails does not stop the rest.

Each provider's request shape is pinned down against the recording
stand-in, and the whole chain was run once end to end -- real repository,
real binaries, a fake Gitea that also serves git-http-backend so the tag
push is real too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 20:13:09 +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(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
}