Files
mgsh/assets_test.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

285 lines
10 KiB
Go

package main
// assets_test.go — attaching files to a release.
//
// The three providers upload assets in three unrelated ways, and the difference
// is invisible until a real server rejects the request. The recording stand-in
// lets each one be pinned down: where the bytes go, how they are wrapped, and
// what else has to happen around them.
import (
"os"
"path/filepath"
"strings"
"testing"
)
// assetTree builds a project with ./bin and ./assets and returns its path.
func assetTree(t *testing.T, files map[string]string) string {
t.Helper()
dir := t.TempDir()
for rel, content := range files {
p := filepath.Join(dir, rel)
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0755); err != nil {
t.Fatal(err)
}
}
return dir
}
func TestCollectAssets(t *testing.T) {
dir := assetTree(t, map[string]string{
"bin/mgsh-linux-amd64": "ELF...",
"bin/mgsh-darwin-arm64": "MACH...",
"assets/logo.png": "PNG",
"assets/notes.txt": "text",
"bin/sub/nested": "not descended into",
"src/main.go": "not an asset directory",
})
// bin/mgsh is a symlink to one of its siblings, as build.sh leaves it
if err := os.Symlink("mgsh-linux-amd64", filepath.Join(dir, "bin", "mgsh")); err != nil {
t.Fatal(err)
}
assets, skipped := collectAssets(dir)
var names []string
for _, a := range assets {
names = append(names, a.name)
}
want := "logo.png,mgsh-darwin-arm64,mgsh-linux-amd64,notes.txt"
if strings.Join(names, ",") != want {
t.Errorf("collected %v, want %s", names, want)
}
if len(skipped) != 0 {
t.Errorf("skipped = %v, want none", skipped)
}
for _, a := range assets {
if a.size == 0 {
t.Errorf("%s has no size", a.name)
}
if _, err := os.Stat(a.path); err != nil {
t.Errorf("%s: %v", a.name, err)
}
}
// a project with neither directory contributes nothing at all
if got, _ := collectAssets(t.TempDir()); len(got) != 0 {
t.Errorf("a project without bin/ or assets/ produced %v", got)
}
}
// TestCollectAssetsNameCollision: one asset name can only mean one file, so the
// second directory's copy is reported rather than silently overwriting.
func TestCollectAssetsNameCollision(t *testing.T) {
dir := assetTree(t, map[string]string{
"bin/tool": "the binary",
"assets/tool": "something else with the same name",
})
assets, skipped := collectAssets(dir)
if len(assets) != 1 || assets[0].name != "tool" ||
!strings.HasSuffix(assets[0].path, "bin/tool") {
t.Errorf("assets = %+v, want only bin/tool", assets)
}
if len(skipped) != 1 || !strings.Contains(skipped[0], "tool") {
t.Errorf("skipped = %v, want the assets/ copy", skipped)
}
}
// TestUploadAssetGitea: a multipart POST under the field name Gitea expects,
// with the asset name in the query.
func TestUploadAssetGitea(t *testing.T) {
dir := assetTree(t, map[string]string{"bin/mgsh-linux-amd64": "binary-bytes-here"})
assets, _ := collectAssets(dir)
f := newFakeProvider(t)
f.route("GET /api/v1/repos/mike/mgsh/releases/7/assets", 200, `[]`)
f.route("POST /api/v1/repos/mike/mgsh/releases/7/assets", 201, `{"id":1}`)
api := newRemoteAPI(f.URL, "tok", "gitea")
ref := releaseRef{tag: "v1.0", id: "7"}
if err := api.uploadAsset("mike", "mgsh", ref, assets[0]); err != nil {
t.Fatalf("uploadAsset: %v (requests %v)", err, f.paths())
}
req := f.find("POST /api/v1/repos/mike/mgsh/releases/7/assets")
if req == nil {
t.Fatalf("no upload, requests: %v", f.paths())
}
if !strings.Contains(req.query, "name=mgsh-linux-amd64") {
t.Errorf("query = %q, want the asset name", req.query)
}
if !strings.HasPrefix(req.ctype, "multipart/form-data") {
t.Errorf("content type = %q, want multipart", req.ctype)
}
body := string(req.raw)
if !strings.Contains(body, `name="attachment"`) {
t.Errorf("form field is not 'attachment': %q", body)
}
if !strings.Contains(body, "binary-bytes-here") {
t.Errorf("the file contents did not make it: %q", body)
}
}
// TestUploadAssetGitHub: the bytes go raw to the host the release object named,
// not to the API host.
func TestUploadAssetGitHub(t *testing.T) {
dir := assetTree(t, map[string]string{"bin/tool": "raw-bytes"})
assets, _ := collectAssets(dir)
f := newFakeProvider(t)
f.route("GET /api/v3/repos/mike/mgsh/releases/7/assets", 200, `[]`)
f.route("POST /uploads/repos/mike/mgsh/releases/7/assets", 201, `{"id":1}`)
api := newRemoteAPI(f.URL, "tok", "github")
// the template suffix from the release object has to be stripped
ref := api.parseReleaseRef("v1.0", []byte(`{"id":7,"upload_url":"`+
f.URL+`/uploads/repos/mike/mgsh/releases/7/assets{?name,label}"}`))
if ref.id != "7" || strings.Contains(ref.uploadURL, "{") {
t.Fatalf("release ref = %+v, want id 7 and a bare upload url", ref)
}
if err := api.uploadAsset("mike", "mgsh", ref, assets[0]); err != nil {
t.Fatalf("uploadAsset: %v (requests %v)", err, f.paths())
}
req := f.find("POST /uploads/repos/mike/mgsh/releases/7/assets")
if req == nil {
t.Fatalf("no upload to the upload host, requests: %v", f.paths())
}
if string(req.raw) != "raw-bytes" {
t.Errorf("body = %q, want the file verbatim", req.raw)
}
if !strings.Contains(req.query, "name=tool") {
t.Errorf("query = %q, want the asset name", req.query)
}
}
// TestUploadAssetGitHubWithoutUploadURL: without one there is nowhere to put
// the bytes, and that has to be said rather than guessed at.
func TestUploadAssetGitHubWithoutUploadURL(t *testing.T) {
dir := assetTree(t, map[string]string{"bin/tool": "x"})
assets, _ := collectAssets(dir)
f := newFakeProvider(t)
f.route("GET /api/v3/repos/mike/mgsh/releases/7/assets", 200, `[]`)
api := newRemoteAPI(f.URL, "tok", "github")
err := api.uploadAsset("mike", "mgsh", releaseRef{tag: "v1", id: "7"}, assets[0])
if err == nil || !strings.Contains(err.Error(), "upload_url") {
t.Errorf("error = %v, want it to name the missing upload_url", err)
}
}
// TestUploadAssetGitLab: a GitLab release stores links, not files, so the file
// goes into the generic package registry first and the release then points at
// it.
func TestUploadAssetGitLab(t *testing.T) {
dir := assetTree(t, map[string]string{"bin/tool": "package-bytes"})
assets, _ := collectAssets(dir)
const pkg = "/api/v4/projects/mike%2Fmgsh/packages/generic/mgsh/v1.0/tool"
const links = "/api/v4/projects/mike%2Fmgsh/releases/v1.0/assets/links"
f := newFakeProvider(t)
f.route("GET "+links, 200, `[]`)
f.route("PUT "+pkg, 201, `{"message":"201 Created"}`)
f.route("POST "+links, 201, `{"id":1}`)
api := newRemoteAPI(f.URL, "tok", "gitlab")
if err := api.uploadAsset("mike", "mgsh", releaseRef{tag: "v1.0", id: "v1.0"}, assets[0]); err != nil {
t.Fatalf("uploadAsset: %v (requests %v)", err, f.paths())
}
up := f.find("PUT " + pkg)
if up == nil {
t.Fatalf("no package upload, requests: %v", f.paths())
}
if string(up.raw) != "package-bytes" {
t.Errorf("package body = %q, want the file verbatim", up.raw)
}
link := f.find("POST " + links)
if link == nil {
t.Fatalf("the release was not linked to the package, requests: %v", f.paths())
}
if link.body["name"] != "tool" || link.body["link_type"] != "package" {
t.Errorf("link body = %v", link.body)
}
if u, _ := link.body["url"].(string); !strings.HasSuffix(u, pkg) {
t.Errorf("link url = %q, want it to point at the package", u)
}
}
// TestUploadAssetReplacesPrevious: re-releasing after a rebuild has to replace
// the old file, not fail on it or leave two of each.
func TestUploadAssetReplacesPrevious(t *testing.T) {
dir := assetTree(t, map[string]string{"bin/tool": "v2"})
assets, _ := collectAssets(dir)
f := newFakeProvider(t)
f.route("GET /api/v1/repos/mike/mgsh/releases/7/assets", 200,
`[{"id":41,"name":"other"},{"id":42,"name":"tool"}]`)
f.route("DELETE /api/v1/repos/mike/mgsh/releases/7/assets/42", 204, ``)
f.route("POST /api/v1/repos/mike/mgsh/releases/7/assets", 201, `{"id":43}`)
api := newRemoteAPI(f.URL, "tok", "gitea")
if err := api.uploadAsset("mike", "mgsh", releaseRef{tag: "v1", id: "7"}, assets[0]); err != nil {
t.Fatalf("uploadAsset: %v (requests %v)", err, f.paths())
}
if f.find("DELETE /api/v1/repos/mike/mgsh/releases/7/assets/42") == nil {
t.Errorf("the previous asset was not removed: %v", f.paths())
}
// only the one with the matching name
if f.find("DELETE /api/v1/repos/mike/mgsh/releases/7/assets/41") != nil {
t.Errorf("an unrelated asset was deleted: %v", f.paths())
}
if f.find("POST /api/v1/repos/mike/mgsh/releases/7/assets") == nil {
t.Errorf("the replacement was not uploaded: %v", f.paths())
}
}
// TestUploadAssetsReportsFailures: one bad file must not stop the others, and
// the caller has to hear about it.
func TestUploadAssetsReportsFailures(t *testing.T) {
dir := assetTree(t, map[string]string{"bin/good": "ok", "bin/bad": "boom"})
assets, _ := collectAssets(dir)
f := newFakeProvider(t)
f.route("GET /api/v1/repos/mike/mgsh/releases/7/assets", 200, `[]`)
// the fake answers 404 for anything unrouted, so the POST fails
api := newRemoteAPI(f.URL, "tok", "gitea")
out := captureStdout(t, func() {
if err := api.uploadAssets("mike", "mgsh", releaseRef{tag: "v1", id: "7"}, assets); err == nil {
t.Error("failed uploads were not reported")
}
})
// both were attempted, and each was announced before it started
for _, name := range []string{"good", "bad"} {
if !strings.Contains(out, name) {
t.Errorf("%s was not attempted or not announced:\n%s", name, out)
}
}
}
func TestAssetSummaryAndDirList(t *testing.T) {
dir := assetTree(t, map[string]string{
"bin/a": strings.Repeat("x", 1024),
"assets/b": strings.Repeat("y", 512),
"src/main.go": "not counted",
})
assets, _ := collectAssets(dir)
if got := assetSummary(assets); got != "2 assets, 1.5K" {
t.Errorf("assetSummary = %q, want \"2 assets, 1.5K\"", got)
}
if got := strings.Join(assetDirList(dir), ","); got != "./bin,./assets" {
t.Errorf("assetDirList = %q", got)
}
// singular reads properly, and a project without the directories lists none
if got := assetSummary(assets[:1]); !strings.HasPrefix(got, "1 asset,") {
t.Errorf("assetSummary(one) = %q", got)
}
if got := assetDirList(t.TempDir()); len(got) != 0 {
t.Errorf("assetDirList of a bare project = %v", got)
}
}