`release [@name ...] <tag> [notes]` does the whole chain in one step — create the annotated tag, push it to the internal server, then push it to each selected mirror and turn it into a release object there. Target selection reuses pushremote's @name mechanism, so the two behave alike. Notes are generated when none are given: the tag's own annotation when it carries more than the default, otherwise the commit subjects since the previous tag, capped at 50 lines. `tag add v1.0 "why this exists"` now takes a message, which is what that fallback reads; before, the annotation was always just the tag name. Tags ending in -rc/-alpha/-beta/-pre are marked as pre-releases on Gitea and GitHub. Releasing the same tag twice updates the existing release; a tag that already points at a different commit stops the command, since moving a published tag makes one version mean different things per server. A repository that is not on the mirror yet is reported instead of being created as a side effect. Binary assets are deliberately out of scope: Gitea attaches them to the release, GitHub uses a separate upload host, and GitLab does not host them at all but wants a link into its package registry. The providers differ in path shape and field names -- GitLab addresses projects by URL-encoded path, calls the notes "description" and has no pre-release flag -- so this comes with a recording httptest stand-in that asserts the exact requests for all three. That harness also covers authUser, repoExists and the auth header forms, which had no test at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
349 lines
12 KiB
Go
349 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"
|
|
"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
|
|
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}
|
|
if r.Body != nil {
|
|
var m map[string]any
|
|
json.NewDecoder(r.Body).Decode(&m)
|
|
rec.body = m
|
|
}
|
|
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)
|
|
id, found, err := api.findRelease("mike", "mgsh", "v1.2")
|
|
if err != nil || !found {
|
|
t.Fatalf("%s: findRelease = %q,%v,%v", c.typ, id, found, err)
|
|
}
|
|
if err := api.updateRelease("mike", "mgsh", id, 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")
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|