Files
mgsh/release_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

353 lines
12 KiB
Go

package main
// release_test.go — the mirror-server REST API, against a recording stand-in.
//
// The three providers differ in path shape and field names, and those
// differences are invisible until a real server rejects the request. A fake
// server that records every request lets the differences be asserted directly —
// and covers authUser/repoExists/createRepo, which had no test at all.
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// 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
}
// fakeProvider stands in for a Gitea/GitHub/GitLab API. routes maps
// "METHOD /path" to a status code and JSON body; anything unrouted answers 404,
// which is what the "does it exist?" probes expect.
type fakeProvider struct {
*httptest.Server
got []recordedReq
routes map[string]struct {
code int
body string
}
}
func newFakeProvider(t *testing.T) *fakeProvider {
t.Helper()
f := &fakeProvider{routes: map[string]struct {
code int
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,
ctype: r.Header.Get("Content-Type")}
if r.Body != nil {
raw, _ := io.ReadAll(r.Body)
rec.raw = raw
json.Unmarshal(raw, &rec.body)
}
f.got = append(f.got, rec)
if route, ok := f.routes[r.Method+" "+r.URL.EscapedPath()]; ok {
w.WriteHeader(route.code)
w.Write([]byte(route.body))
return
}
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"message":"Not Found"}`))
}))
t.Cleanup(f.Close)
return f
}
func (f *fakeProvider) route(methodPath string, code int, body string) {
f.routes[methodPath] = struct {
code int
body string
}{code, body}
}
// find returns the first recorded request for "METHOD /path", or nil.
func (f *fakeProvider) find(methodPath string) *recordedReq {
for i, r := range f.got {
if r.method+" "+r.path == methodPath {
return &f.got[i]
}
}
return nil
}
func (f *fakeProvider) paths() []string {
var out []string
for _, r := range f.got {
out = append(out, r.method+" "+r.path)
}
return out
}
// TestCreateReleasePerProvider pins down the three API dialects: where the
// release collection lives, and what the notes field is called.
func TestCreateReleasePerProvider(t *testing.T) {
rel := release{Tag: "v1.2", Name: "v1.2", Body: "- did a thing", Prerelease: false}
cases := []struct {
typ string
wantPath string
bodyField string // provider's name for the notes
}{
{"gitea", "/api/v1/repos/mike/mgsh/releases", "body"},
{"github", "/api/v3/repos/mike/mgsh/releases", "body"},
{"gitlab", "/api/v4/projects/mike%2Fmgsh/releases", "description"},
}
for _, c := range cases {
f := newFakeProvider(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 {
t.Fatalf("%s: createRelease: %v (requests: %v)", c.typ, err, f.paths())
}
req := f.find("POST " + c.wantPath)
if req == nil {
t.Fatalf("%s: expected POST %s, got %v", c.typ, c.wantPath, f.paths())
}
if req.body["tag_name"] != "v1.2" || req.body["name"] != "v1.2" {
t.Errorf("%s: body = %v, want tag_name/name v1.2", c.typ, req.body)
}
if req.body[c.bodyField] != "- did a thing" {
t.Errorf("%s: notes not in %q: %v", c.typ, c.bodyField, req.body)
}
// GitLab has no pre-release flag; the others must carry it
if _, ok := req.body["prerelease"]; ok != (c.typ != "gitlab") {
t.Errorf("%s: prerelease presence = %v, want %v", c.typ, ok, c.typ != "gitlab")
}
}
}
// TestFindReleaseAndUpdate covers the existing-release path: how each provider
// is asked whether a release for a tag exists, and how it is then rewritten.
func TestFindReleaseAndUpdate(t *testing.T) {
cases := []struct {
typ string
lookup, wantUpdate string
wantMethod string
}{
{"gitea", "/api/v1/repos/mike/mgsh/releases/tags/v1.2", "/api/v1/repos/mike/mgsh/releases/7", "PATCH"},
{"github", "/api/v3/repos/mike/mgsh/releases/tags/v1.2", "/api/v3/repos/mike/mgsh/releases/7", "PATCH"},
{"gitlab", "/api/v4/projects/mike%2Fmgsh/releases/v1.2", "/api/v4/projects/mike%2Fmgsh/releases/v1.2", "PUT"},
}
for _, c := range cases {
f := newFakeProvider(t)
f.route("GET "+c.lookup, 200, `{"id":7,"tag_name":"v1.2"}`)
f.route(c.wantMethod+" "+c.wantUpdate, 200, `{}`)
api := newRemoteAPI(f.URL, "tok", c.typ)
ref, found, err := api.findRelease("mike", "mgsh", "v1.2")
if err != nil || !found {
t.Fatalf("%s: findRelease = %+v,%v,%v", c.typ, ref, found, err)
}
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)
if req == nil {
t.Fatalf("%s: expected %s %s, got %v", c.typ, c.wantMethod, c.wantUpdate, f.paths())
}
// an update must not try to move the tag
if _, ok := req.body["tag_name"]; ok {
t.Errorf("%s: update sent tag_name: %v", c.typ, req.body)
}
}
}
// TestFindReleaseMissing: an absent release is "not found", not an error — that
// distinction decides between create and update.
func TestFindReleaseMissing(t *testing.T) {
f := newFakeProvider(t) // everything 404s
api := newRemoteAPI(f.URL, "tok", "gitea")
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)
}
}
// TestFindReleaseServerError: anything else must surface, so a broken server is
// never mistaken for "no release yet" and silently re-created.
func TestFindReleaseServerError(t *testing.T) {
f := newFakeProvider(t)
f.route("GET /api/v1/repos/mike/mgsh/releases/tags/v1", 500, `{"message":"boom"}`)
api := newRemoteAPI(f.URL, "tok", "gitea")
if _, found, err := api.findRelease("mike", "mgsh", "v1"); err == nil || found {
t.Fatalf("a 500 must be an error, got found=%v err=%v", found, err)
}
}
// TestAuthUserAndRepoExists covers the calls pushremote has always made and
// that had no test: who the token belongs to, and whether the repo is there.
func TestAuthUserAndRepoExists(t *testing.T) {
// Gitea/GitHub report "login", GitLab "username"
for _, c := range []struct{ typ, root, field string }{
{"gitea", "/api/v1", "login"},
{"gitlab", "/api/v4", "username"},
} {
f := newFakeProvider(t)
f.route("GET "+c.root+"/user", 200, `{"`+c.field+`":"mike"}`)
api := newRemoteAPI(f.URL, "tok", c.typ)
owner, err := api.authUser()
if err != nil || owner != "mike" {
t.Fatalf("%s: authUser = %q, %v", c.typ, owner, err)
}
}
f := newFakeProvider(t)
f.route("GET /api/v1/repos/mike/mgsh", 200, `{}`)
api := newRemoteAPI(f.URL, "tok", "gitea")
if ok, err := api.repoExists("mike", "mgsh"); err != nil || !ok {
t.Errorf("repoExists(existing) = %v, %v", ok, err)
}
if ok, err := api.repoExists("mike", "gone"); err != nil || ok {
t.Errorf("repoExists(missing) = %v, %v, want false,nil", ok, err)
}
// a bad token must be reported, not treated as "no such user"
bad := newFakeProvider(t)
bad.route("GET /api/v1/user", 401, `{"message":"token required"}`)
if _, err := newRemoteAPI(bad.URL, "tok", "gitea").authUser(); err == nil {
t.Error("authUser accepted a 401")
}
}
// TestAuthHeaderReachesTheServer: each provider gets its own header form.
func TestAuthHeaderReachesTheServer(t *testing.T) {
for _, c := range []struct{ typ, header, value string }{
{"gitea", "Authorization", "token s3cret"},
{"github", "Authorization", "Bearer s3cret"},
{"gitlab", "PRIVATE-TOKEN", "s3cret"},
} {
var got http.Header
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Clone()
w.Write([]byte(`{"login":"mike","username":"mike"}`))
}))
newRemoteAPI(srv.URL, "s3cret", c.typ).authUser()
srv.Close()
if got.Get(c.header) != c.value {
t.Errorf("%s: %s = %q, want %q", c.typ, c.header, got.Get(c.header), c.value)
}
}
}
func TestPreReleaseDetection(t *testing.T) {
pre := []string{"v1.0-rc1", "v1.0-RC.2", "v2-beta", "v2-alpha3", "1.0-pre"}
final := []string{"v1.0", "v1.0.1", "2026-01-17", "v1.0-final", "release-1"}
for _, s := range pre {
if !preReleaseRe.MatchString(s) {
t.Errorf("%q should be a pre-release", s)
}
}
for _, s := range final {
if preReleaseRe.MatchString(s) {
t.Errorf("%q should not be a pre-release", s)
}
}
}
// TestReleaseNotesFromLog: without an annotation, the notes are the commits
// since the previous tag — not the whole history.
func TestReleaseNotesFromLog(t *testing.T) {
dir := t.TempDir()
mustGit(t, dir, "init", "-q")
mustGit(t, dir, "config", "user.name", "t")
mustGit(t, dir, "config", "user.email", "t@e")
commit := func(msg string) {
if err := os.WriteFile(filepath.Join(dir, "f"), []byte(msg), 0644); err != nil {
t.Fatal(err)
}
mustGit(t, dir, "add", ".")
mustGit(t, dir, "commit", "-q", "-m", msg)
}
commit("old work")
mustGit(t, dir, "tag", "-a", "v1.0", "-m", "v1.0") // annotation == tag name
commit("fix the thing")
commit("add the other thing")
mustGit(t, dir, "tag", "-a", "v1.1", "-m", "v1.1")
notes := releaseNotes(dir, "v1.1")
if !strings.Contains(notes, "- fix the thing") || !strings.Contains(notes, "- add the other thing") {
t.Errorf("notes missing the new commits: %q", notes)
}
if strings.Contains(notes, "old work") {
t.Errorf("notes reach back past the previous tag: %q", notes)
}
// the default annotation (= the tag name) must not become the body
if strings.TrimSpace(notes) == "v1.1" {
t.Errorf("notes fell back to the placeholder annotation: %q", notes)
}
// a real annotation wins over the log
mustGit(t, dir, "tag", "-a", "v1.2", "-m", "Handpicked notes")
if got := releaseNotes(dir, "v1.2"); got != "Handpicked notes" {
t.Errorf("annotated notes = %q, want %q", got, "Handpicked notes")
}
}
// TestEnsureTagRefusesToMoveATag: a published tag that would move is the one
// case where releasing must stop.
func TestEnsureTagRefusesToMoveATag(t *testing.T) {
dir := t.TempDir()
mustGit(t, dir, "init", "-q")
mustGit(t, dir, "config", "user.name", "t")
mustGit(t, dir, "config", "user.email", "t@e")
mustGit(t, dir, "commit", "-q", "--allow-empty", "-m", "one")
mustGit(t, dir, "tag", "-a", "v1", "-m", "v1")
if !ensureTag(dir, "v1", "") {
t.Error("re-publishing the same commit should be allowed")
}
if !ensureTag(dir, "v2", "notes here") {
t.Fatal("ensureTag did not create a new tag")
}
if out, _ := gitCapture(dir, "tag", "-l", "--format=%(contents:subject)", "v2"); strings.TrimSpace(out) != "notes here" {
t.Errorf("new tag annotation = %q, want %q", strings.TrimSpace(out), "notes here")
}
mustGit(t, dir, "commit", "-q", "--allow-empty", "-m", "two")
if ensureTag(dir, "v1", "") {
t.Error("ensureTag moved a tag that already points at another commit")
}
}
// TestReleaseTagsAndPushesToOrigin drives the local half of `release` against a
// real bare origin: the tag is created with the given notes as its annotation
// and lands on the internal server, before any mirror is involved.
func TestReleaseTagsAndPushesToOrigin(t *testing.T) {
dir := useProject(t, "proj")
bare := filepath.Join(t.TempDir(), "origin.git")
mustGit(t, "", "init", "--bare", "-q", bare)
mustGit(t, dir, "init", "-q")
mustGit(t, dir, "config", "user.name", "t")
mustGit(t, dir, "config", "user.email", "t@e")
mustGit(t, dir, "commit", "-q", "--allow-empty", "-m", "work")
mustGit(t, dir, "remote", "add", "origin", bare)
mustGit(t, dir, "push", "-q", "-u", "origin", "HEAD")
cfg.Remotes = nil // no mirror targets: stop after the internal server
runCommand("release v1.0 first public build")
out, err := gitCapture(bare, "tag", "-l", "--format=%(refname:short) %(contents:subject)")
if err != nil {
t.Fatal(err)
}
if got := strings.TrimSpace(out); got != "v1.0 first public build" {
t.Errorf("tag on origin = %q, want %q", got, "v1.0 first public build")
}
}