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
+15 -11
View File
@@ -9,6 +9,7 @@ package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
@@ -20,6 +21,8 @@ import (
// recordedReq is one request the fake provider received.
type recordedReq struct {
method, path, query string
ctype string
raw []byte // the body as sent, for the asset uploads
body map[string]any
}
@@ -42,11 +45,12 @@ func newFakeProvider(t *testing.T) *fakeProvider {
body string
}{}}
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec := recordedReq{method: r.Method, path: r.URL.EscapedPath(), query: r.URL.RawQuery}
rec := recordedReq{method: r.Method, path: r.URL.EscapedPath(), query: r.URL.RawQuery,
ctype: r.Header.Get("Content-Type")}
if r.Body != nil {
var m map[string]any
json.NewDecoder(r.Body).Decode(&m)
rec.body = m
raw, _ := io.ReadAll(r.Body)
rec.raw = raw
json.Unmarshal(raw, &rec.body)
}
f.got = append(f.got, rec)
@@ -106,7 +110,7 @@ func TestCreateReleasePerProvider(t *testing.T) {
f.route("POST "+c.wantPath, 201, `{"id":7}`)
api := newRemoteAPI(f.URL, "tok", c.typ)
if err := api.createRelease("mike", "mgsh", rel); err != nil {
if _, err := api.createRelease("mike", "mgsh", rel); err != nil {
t.Fatalf("%s: createRelease: %v (requests: %v)", c.typ, err, f.paths())
}
@@ -145,11 +149,11 @@ func TestFindReleaseAndUpdate(t *testing.T) {
f.route(c.wantMethod+" "+c.wantUpdate, 200, `{}`)
api := newRemoteAPI(f.URL, "tok", c.typ)
id, found, err := api.findRelease("mike", "mgsh", "v1.2")
ref, found, err := api.findRelease("mike", "mgsh", "v1.2")
if err != nil || !found {
t.Fatalf("%s: findRelease = %q,%v,%v", c.typ, id, found, err)
t.Fatalf("%s: findRelease = %+v,%v,%v", c.typ, ref, found, err)
}
if err := api.updateRelease("mike", "mgsh", id, release{Tag: "v1.2", Name: "v1.2", Body: "new"}); err != nil {
if err := api.updateRelease("mike", "mgsh", ref, release{Tag: "v1.2", Name: "v1.2", Body: "new"}); err != nil {
t.Fatalf("%s: updateRelease: %v (requests: %v)", c.typ, err, f.paths())
}
req := f.find(c.wantMethod + " " + c.wantUpdate)
@@ -168,9 +172,9 @@ func TestFindReleaseAndUpdate(t *testing.T) {
func TestFindReleaseMissing(t *testing.T) {
f := newFakeProvider(t) // everything 404s
api := newRemoteAPI(f.URL, "tok", "gitea")
id, found, err := api.findRelease("mike", "mgsh", "v9")
if err != nil || found || id != "" {
t.Fatalf("findRelease on empty server = %q,%v,%v", id, found, err)
ref, found, err := api.findRelease("mike", "mgsh", "v9")
if err != nil || found || ref.id != "" {
t.Fatalf("findRelease on empty server = %+v,%v,%v", ref, found, err)
}
}