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>
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user