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:
2026-07-26 20:13:09 +02:00
co-authored by Claude Opus 5
parent f3d8cbe281
commit 8c4d8da4e9
6 changed files with 725 additions and 42 deletions
+65 -25
View File
@@ -56,46 +56,68 @@ func (r *remoteAPI) releaseByTagPath(owner, repo, tag string) string {
return r.releasePath(owner, repo) + "/tags/" + url.PathEscape(tag)
}
// findRelease returns the provider's id for the release of tag and whether it
// exists at all. GitLab addresses releases by tag, so there the tag is the id.
func (r *remoteAPI) findRelease(owner, repo, tag string) (id string, found bool, err error) {
// releaseRef identifies a release for the calls that follow creating it.
type releaseRef struct {
tag string
id string // provider id; the tag itself on GitLab
// GitHub uploads assets to a different host than its API, and names it in
// the release object as an RFC 6570 template
uploadURL string
}
// parseReleaseRef reads the identifying fields out of a release object.
func (r *remoteAPI) parseReleaseRef(tag string, data []byte) releaseRef {
ref := releaseRef{tag: tag, id: tag}
if r.kind == kindGitLab {
return ref // GitLab addresses a release by its tag throughout
}
var res struct {
ID int64 `json:"id"`
UploadURL string `json:"upload_url"`
}
json.Unmarshal(data, &res)
ref.id = strconv.FormatInt(res.ID, 10)
// ".../assets{?name,label}" -> ".../assets"
if i := strings.IndexByte(res.UploadURL, '{'); i >= 0 {
ref.uploadURL = res.UploadURL[:i]
} else {
ref.uploadURL = res.UploadURL
}
return ref
}
// findRelease returns the release of tag and whether it exists at all.
func (r *remoteAPI) findRelease(owner, repo, tag string) (ref releaseRef, found bool, err error) {
code, data, err := r.do("GET", r.releaseByTagPath(owner, repo, tag), nil)
if err != nil {
return "", false, err
return releaseRef{}, false, err
}
switch code {
case 404:
return "", false, nil
return releaseRef{}, false, nil
case 200:
if r.kind == kindGitLab {
return tag, true, nil
}
var res struct {
ID int64 `json:"id"`
}
json.Unmarshal(data, &res)
return strconv.FormatInt(res.ID, 10), true, nil
return r.parseReleaseRef(tag, data), true, nil
default:
return "", false, fmt.Errorf("checking release failed (HTTP %d): %s", code, firstLine(data))
return releaseRef{}, false, fmt.Errorf("checking release failed (HTTP %d): %s", code, firstLine(data))
}
}
// createRelease publishes a new release for an already pushed tag.
func (r *remoteAPI) createRelease(owner, repo string, rel release) error {
func (r *remoteAPI) createRelease(owner, repo string, rel release) (releaseRef, error) {
code, data, err := r.do("POST", r.releasePath(owner, repo), r.releaseBody(rel, true))
if err != nil {
return err
return releaseRef{}, err
}
if code != 200 && code != 201 {
return fmt.Errorf("creating release failed (HTTP %d): %s", code, firstLine(data))
return releaseRef{}, fmt.Errorf("creating release failed (HTTP %d): %s", code, firstLine(data))
}
return nil
return r.parseReleaseRef(rel.Tag, data), nil
}
// updateRelease rewrites the name and notes of an existing release. Gitea and
// GitHub patch it by numeric id, GitLab puts it by tag.
func (r *remoteAPI) updateRelease(owner, repo, id string, rel release) error {
method, ep := "PATCH", r.releasePath(owner, repo)+"/"+id
func (r *remoteAPI) updateRelease(owner, repo string, ref releaseRef, rel release) error {
method, ep := "PATCH", r.releasePath(owner, repo)+"/"+ref.id
if r.kind == kindGitLab {
method, ep = "PUT", r.releaseByTagPath(owner, repo, rel.Tag)
}
@@ -164,9 +186,20 @@ func handleRelease(args string) {
body = releaseNotes(DIR, tag)
}
// ./bin and ./assets ride along when they are there; nothing to configure,
// and nothing happens for a project that has neither
assets, skipped := collectAssets(DIR)
for _, s := range skipped {
errorln("skipping " + s + ": another directory already contributes that name")
}
if len(assets) > 0 {
fmt.Printf("%s %s %s\n", col(cGray, "attaching"), col(cYellow, assetSummary(assets)),
col(cGray, "from "+strings.Join(assetDirList(DIR), " and ")))
}
done := 0
for _, t := range targets {
if publishRelease(t, PRJ, tag, body) {
if publishRelease(t, PRJ, tag, body, assets) {
done++
}
}
@@ -223,7 +256,7 @@ func releaseNotes(dir, tag string) string {
// publishRelease pushes the tag to one mirror target and turns it into a
// release there. It returns success, so one unreachable server does not stop
// the remaining ones.
func publishRelease(t RemoteTarget, repo, tag, body string) bool {
func publishRelease(t RemoteTarget, repo, tag, body string, assets []releaseAsset) bool {
api := newRemoteAPI(t.URL, t.Key, t.Type)
owner, err := api.authUser()
@@ -248,17 +281,17 @@ func publishRelease(t RemoteTarget, repo, tag, body string) bool {
}
rel := release{Tag: tag, Name: tag, Body: body, Prerelease: preReleaseRe.MatchString(tag)}
id, found, err := api.findRelease(owner, repo, tag)
ref, found, err := api.findRelease(owner, repo, tag)
if err != nil {
errorln(t.Name + ": " + err.Error())
return false
}
if found {
if err := api.updateRelease(owner, repo, id, rel); err != nil {
if err := api.updateRelease(owner, repo, ref, rel); err != nil {
errorln(t.Name + ": " + err.Error())
return false
}
} else if err := api.createRelease(owner, repo, rel); err != nil {
} else if ref, err = api.createRelease(owner, repo, rel); err != nil {
errorln(t.Name + ": " + err.Error())
return false
}
@@ -272,5 +305,12 @@ func publishRelease(t RemoteTarget, repo, tag, body string) bool {
}
fmt.Printf("%s %s %s %s\n", col(cGray, "remote"), col(cYellow, t.Name),
col(cGreen, what), col(cCyan, api.repoWebURL(owner, repo)))
if len(assets) > 0 {
if err := api.uploadAssets(owner, repo, ref, assets); err != nil {
errorln(t.Name + ": " + err.Error())
return false
}
}
return true
}