diff --git a/README.md b/README.md index 64fe00b..859ff16 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ directory. Go port of the original Perl `mgsh` (`mgsh.perl`). ## Contents - [Build](#build) · [Usage](#usage) · [Commands](#commands) · [Aliases](#aliases) -- [Public mirror (`pushremote`)](#public-mirror-pushremote) +- [Public mirror (`pushremote`)](#public-mirror-pushremote) · + [Releases](#releases) - [Configuration](#configuration) · [Settings reference](#settings-reference) · [Per-project configuration](#per-project-configuration) - [Git server layout](#git-server-layout) @@ -38,9 +39,9 @@ stand in. Outside `base` no project is selected. `mgsh ` starts the interactive shell with that project preselected. The commands available directly from the shell are `clone`, `init`, `log`, -`push`, `pushremote`, `list`, `tag`, `archive`, `show`, `open`, `pull`, `fetch`, -`status`, `diff`, `overview`, `config`, `count`, `login` and `cloneall`; every -other command is interactive-only. +`push`, `pushremote`, `release`, `list`, `tag`, `archive`, `show`, `open`, +`pull`, `fetch`, `status`, `diff`, `overview`, `config`, `count`, `login` and +`cloneall`; every other command is interactive-only. The interactive prompt is colored (Catppuccin Mocha) and shows the active project, its git branch and a `*` dirty marker: @@ -87,7 +88,8 @@ Run `help` for the full list. Highlights: | `show ` | show a repository log directly on the server | | `archive [comment]` | snapshot the server-side repo into `./archive` | | `init` | make a new repository from the current directory | -| `tag [add/checkout/delete]` | manage tags | +| `tag [add/checkout/delete]` | manage tags (`tag add v1.0 "why"` annotates) | +| `release [@name] [notes]` | tag and publish a release on the mirrors | | `alias [name [cmd]]` | list, show or define a command alias | | `unalias ` | remove a command alias | | `config [-k]` | show the effective configuration and its sources | @@ -200,6 +202,46 @@ existing file is readable by others. The provider is auto-detected from `remoteu GitHub, `gitlab*` → GitLab, otherwise Gitea) and can be forced with `remotetype`. Set `mirror = true` to have every `push` mirror automatically. +### Releases + +`release` turns a commit into a published release on the mirror servers, in one +step — creating the tag, getting it onto the internal server, and then onto each +mirror as a release object: + +``` +release [@name ...] [notes] +``` + +``` +< src/mgsh > release v4.1.0 first public build +remote hub released https://github.com/mike/mgsh.git +``` + +Without `@name` it releases to every configured mirror target, exactly like +`pushremote`. Everything after the tag becomes the release notes *and* the tag's +annotation. + +**Notes are generated when you do not write any**: the tag's own annotation if +it has a real one, otherwise the commit subjects since the previous tag +(`- ` per line, at most 50). So a plain `release v4.1.0` already +produces a usable changelog. + +A tag ending in `-rc`, `-alpha`, `-beta` or `-pre` (optionally with digits) is +marked as a **pre-release** on Gitea and GitHub; GitLab has no such flag. + +Releasing the same tag twice updates the existing release rather than failing. +But a tag that already exists **on a different commit** stops the command — +moving a published tag is how one version quietly comes to mean different things +on different servers. `release` also refuses when the repository is not on the +mirror yet and tells you to run `pushremote` first, rather than creating it as a +side effect. + +**No binary assets.** The three providers handle uploads in three incompatible +ways — Gitea attaches them to the release, GitHub uses a separate upload host, +and GitLab does not host them at all but expects a link into its package +registry. mgsh publishes source releases with notes; if you need binaries, +upload them with the provider's own tooling. + ## Configuration mgsh has **no built-in defaults**. Settings are resolved in three steps, each diff --git a/alias.go b/alias.go index fd7310b..c258b36 100644 --- a/alias.go +++ b/alias.go @@ -47,7 +47,7 @@ var builtinCmds = map[string]bool{ "pushremote": true, "overview": true, "archive": true, "init": true, "login": true, "cd": true, "checkout": true, "clone": true, "cloneall": true, "open": true, "view": true, "count": true, "tag": true, "alias": true, - "unalias": true, "config": true, + "unalias": true, "config": true, "release": true, } func isBuiltin(name string) bool { return builtinCmds[name] } diff --git a/commands.go b/commands.go index a3334cd..d6fad0a 100644 --- a/commands.go +++ b/commands.go @@ -285,6 +285,9 @@ func runCommandDepth(line string, depth int) bool { case "pushremote": // mirror the repo to a public git server via its API handlePushRemote(strings.Join(fields[1:], " ")) + case "release": // tag a commit and publish it as a release on the mirrors + handleRelease(strings.Join(fields[1:], " ")) + case "edit": // interactively edit the last N commits if !requireRepo() { break @@ -516,7 +519,14 @@ func runCommandDepth(line string, depth int) bool { sub := word(words, 1) switch { case sub == "add" && word(words, 2) != "": - if gitOK(DIR, "tag", "-a", words[2], "-m", words[2]) { + // `tag add v1.0 why this exists` annotates with that text; without + // one the tag name is the message, as before. The annotation is + // what `release` falls back to for its notes. + msg := words[2] + if len(fields) > 3 { + msg = strings.Join(fields[3:], " ") + } + if gitOK(DIR, "tag", "-a", words[2], "-m", msg) { gitOK(DIR, "push", "origin", words[2]) } case sub == "checkout" && word(words, 2) != "": @@ -697,6 +707,7 @@ var helpItems = []struct{ cmd, desc string }{ {"init", "make new repository from current directory"}, {"push [comment]", "push changes to git server"}, {"pushremote [@name] [desc]", "mirror repo to the public server(s) (gitea/github/gitlab)"}, + {"release [notes]", "tag and publish a release on the public server(s)"}, {"pull", "pull changes from git server"}, {"fetch", "fetch changes from git server"}, {"status [-a]", "short git status (-a: overview of all projects)"}, @@ -714,7 +725,7 @@ var helpItems = []struct{ cmd, desc string }{ {"login", "connect to git server"}, {"count", "count lines in project"}, {"tag", "show tags"}, - {"tag add ", "add tag"}, + {"tag add [msg]", "add tag (msg becomes the annotation)"}, {"tag checkout ", "checkout tag"}, {"tag delete ", "delete tag"}, {"alias [name [cmd]]", "list, show or define an alias ($1..$N, $* args)"}, diff --git a/completion.go b/completion.go index e7354fb..bc261f6 100644 --- a/completion.go +++ b/completion.go @@ -26,6 +26,7 @@ func completer() *readline.PrefixCompleter { readline.PcItem("list", readline.PcItem("-a")), readline.PcItem("push"), readline.PcItem("pushremote", readline.PcItemDynamic(dynRemoteNames)), + readline.PcItem("release", readline.PcItemDynamic(dynRemoteNames)), readline.PcItem("pull"), readline.PcItem("fetch"), readline.PcItem("status", readline.PcItem("-a")), diff --git a/main.go b/main.go index e99ea55..dd18c14 100644 --- a/main.go +++ b/main.go @@ -147,7 +147,7 @@ func parseArgs() (int, string, bool) { "clone": 2, "init": 2, "log": 2, "push": 1, "pushremote": 1, "list": 1, "tag": 1, "archive": 1, "show": 1, "open": 1, "pull": 1, "fetch": 1, "status": 1, "diff": 1, "overview": 1, - "config": 1, "count": 1, "login": 1, "cloneall": 1, + "config": 1, "count": 1, "login": 1, "cloneall": 1, "release": 1, } if c, ok := cls[a0]; ok { return c, strings.Join(os.Args[1:], " "), false diff --git a/release.go b/release.go new file mode 100644 index 0000000..8ca81d8 --- /dev/null +++ b/release.go @@ -0,0 +1,276 @@ +package main + +// release.go — the `release` command: tag a commit and publish it as a release +// on the public mirror servers. +// +// release [@name ...] [notes] +// +// It does the whole chain in one step, because doing it in two is where the +// second half gets forgotten: create the annotated tag, push it to the internal +// git server, then push it to each selected mirror target and turn it into a +// release object there via the provider's REST API. +// +// Release notes come from the text after the tag; without it, from the tag's own +// annotation, and failing that from the commit subjects since the previous tag. +// +// Binary assets are deliberately not supported: Gitea uploads them to the +// release, GitHub to a separate host, and GitLab does not host them at all but +// wants a link into its package registry. A source release with notes is what +// this needs; assets would be three implementations instead of one. + +import ( + "encoding/json" + "fmt" + "net/url" + "regexp" + "strconv" + "strings" +) + +// preReleaseRe marks a tag as a pre-release by its usual suffixes. +var preReleaseRe = regexp.MustCompile(`(?i)-(rc|alpha|beta|pre)[.\-0-9]*$`) + +// maxAutoNotes caps generated release notes, so a first release on a long +// history does not paste the entire log into the release body. +const maxAutoNotes = 50 + +// release is one release as mgsh publishes it. +type release struct { + Tag string + Name string + Body string + Prerelease bool +} + +// releasePath is the API path of a repository's release collection. +func (r *remoteAPI) releasePath(owner, repo string) string { + return r.repoPath(owner, repo) + "/releases" +} + +// releaseByTagPath addresses one existing release: Gitea and GitHub look it up +// under .../releases/tags/, GitLab directly under .../releases/. +func (r *remoteAPI) releaseByTagPath(owner, repo, tag string) string { + if r.kind == kindGitLab { + return r.releasePath(owner, repo) + "/" + url.PathEscape(tag) + } + 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) { + code, data, err := r.do("GET", r.releaseByTagPath(owner, repo, tag), nil) + if err != nil { + return "", false, err + } + switch code { + case 404: + return "", 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 + default: + return "", 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 { + code, data, err := r.do("POST", r.releasePath(owner, repo), r.releaseBody(rel, true)) + if err != nil { + return err + } + if code != 200 && code != 201 { + return fmt.Errorf("creating release failed (HTTP %d): %s", code, firstLine(data)) + } + return 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 + if r.kind == kindGitLab { + method, ep = "PUT", r.releaseByTagPath(owner, repo, rel.Tag) + } + code, data, err := r.do(method, ep, r.releaseBody(rel, false)) + if err != nil { + return err + } + if code != 200 && code != 201 { + return fmt.Errorf("updating release failed (HTTP %d): %s", code, firstLine(data)) + } + return nil +} + +// releaseBody maps a release onto the provider's field names. GitLab calls the +// notes "description" and has no pre-release flag; the tag is only sent when +// creating, since none of the three lets an existing release change tags. +func (r *remoteAPI) releaseBody(rel release, create bool) map[string]any { + body := map[string]any{"name": rel.Name} + if r.kind == kindGitLab { + body["description"] = rel.Body + } else { + body["body"] = rel.Body + body["prerelease"] = rel.Prerelease + } + if create { + body["tag_name"] = rel.Tag + } + return body +} + +// handleRelease implements `release [@name ...] [notes]`. +func handleRelease(args string) { + if !requireRepo() { + return + } + names, rest := parsePushRemoteArgs(args) + fields := strings.Fields(rest) + if len(fields) == 0 { + errorln("usage: release [@remote ...] [notes]") + return + } + tag := fields[0] + notes := strings.Join(fields[1:], " ") + + if !ensureTag(DIR, tag, notes) { + return + } + if !gitOK(DIR, "push", "origin", tag) { + return + } + + targets, incomplete := cfg.mirrorTargets() + for _, n := range incomplete { + errorln("remote " + n + ": url or key missing — skipped") + } + targets = pickRemotes(targets, names) + if len(targets) == 0 { + if len(names) == 0 { // an unknown @name already reported itself + errorln("release needs a mirror target — see 'config'") + } + return + } + + body := notes + if body == "" { + body = releaseNotes(DIR, tag) + } + + done := 0 + for _, t := range targets { + if publishRelease(t, PRJ, tag, body) { + done++ + } + } + if len(targets) > 1 { + fmt.Printf("%s %d/%d remotes released\n", col(cGray, "release:"), done, len(targets)) + } +} + +// ensureTag creates the annotated tag when it is missing. It refuses to move a +// tag that already points somewhere else: re-pointing a published tag is how +// the same version silently ends up meaning different things per server. +func ensureTag(dir, tag, message string) bool { + if _, err := gitCapture(dir, "rev-parse", "--verify", "--quiet", "refs/tags/"+tag); err == nil { + head, _ := gitCapture(dir, "rev-parse", "HEAD^{commit}") + tagged, _ := gitCapture(dir, "rev-parse", tag+"^{commit}") + if strings.TrimSpace(head) != strings.TrimSpace(tagged) { + errorln("tag " + tag + " exists on another commit — publishing it would move it; pick a new tag") + return false + } + return true // same commit: re-publishing an existing release is fine + } + msg := message + if msg == "" { + msg = tag + } + return gitOK(dir, "tag", "-a", tag, "-m", msg) +} + +// releaseNotes builds the body for a release: the tag's own annotation when it +// carries more than the default (`tag add` annotates with the tag name), else +// the commit subjects since the previous tag. +func releaseNotes(dir, tag string) string { + if out, err := gitCapture(dir, "tag", "-l", + "--format=%(contents:subject)%0a%0a%(contents:body)", tag); err == nil { + if msg := strings.TrimSpace(out); msg != "" && msg != tag { + return msg + } + } + + rng := tag + if prev, err := gitCapture(dir, "describe", "--tags", "--abbrev=0", tag+"^"); err == nil { + if p := strings.TrimSpace(prev); p != "" { + rng = p + ".." + tag + } + } + out, err := gitCapture(dir, "log", "--reverse", "-n", strconv.Itoa(maxAutoNotes), + "--format=- %s", rng) + if err != nil { + return "" + } + return strings.TrimSpace(out) +} + +// 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 { + api := newRemoteAPI(t.URL, t.Key, t.Type) + + owner, err := api.authUser() + if err != nil { + errorln(t.Name + ": " + err.Error()) + return false + } + exists, err := api.repoExists(owner, repo) + if err != nil { + errorln(t.Name + ": " + err.Error()) + return false + } + if !exists { + errorln(t.Name + ": " + owner + "/" + repo + " does not exist there — run 'pushremote' first") + return false + } + + // a release refers to a tag, so the tag has to reach the server first + ensureGitRemote(t.Name, api.repoWebURL(owner, repo)) + if !gitPushHeader(DIR, t.Name, api.pushHeader(owner), "tag", tag) { + return false + } + + rel := release{Tag: tag, Name: tag, Body: body, Prerelease: preReleaseRe.MatchString(tag)} + id, 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 { + errorln(t.Name + ": " + err.Error()) + return false + } + } else if err := api.createRelease(owner, repo, rel); err != nil { + errorln(t.Name + ": " + err.Error()) + return false + } + + what := "released" + if found { + what = "updated release" + } + if rel.Prerelease { + what += " (pre-release)" + } + fmt.Printf("%s %s %s %s\n", col(cGray, "remote"), col(cYellow, t.Name), + col(cGreen, what), col(cCyan, api.repoWebURL(owner, repo))) + return true +} diff --git a/release_test.go b/release_test.go new file mode 100644 index 0000000..cc92e76 --- /dev/null +++ b/release_test.go @@ -0,0 +1,348 @@ +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") + } +} diff --git a/remote.go b/remote.go index bec73f1..06ade8c 100644 --- a/remote.go +++ b/remote.go @@ -162,15 +162,18 @@ func (r *remoteAPI) authUser() (string, error) { return "", fmt.Errorf("could not determine remote user") } +// repoPath is the API path of one repository. GitLab addresses a project by its +// URL-encoded "owner/repo" path, the others by two path elements. +func (r *remoteAPI) repoPath(owner, repo string) string { + if r.kind == kindGitLab { + return r.apiRoot() + "/projects/" + url.PathEscape(owner+"/"+repo) + } + return r.apiRoot() + "/repos/" + owner + "/" + repo +} + // repoExists reports whether owner/repo already exists on the server. func (r *remoteAPI) repoExists(owner, repo string) (bool, error) { - var ep string - if r.kind == kindGitLab { - ep = r.apiRoot() + "/projects/" + url.PathEscape(owner+"/"+repo) - } else { - ep = r.apiRoot() + "/repos/" + owner + "/" + repo - } - code, data, err := r.do("GET", ep, nil) + code, data, err := r.do("GET", r.repoPath(owner, repo), nil) if err != nil { return false, err } @@ -338,15 +341,9 @@ func pushToRemote(t RemoteTarget, repo, description string) bool { // keep a credential-free git remote named after the target web := api.repoWebURL(owner, repo) - if _, err := gitCapture(DIR, "remote", "get-url", t.Name); err == nil { - gitOK(DIR, "remote", "set-url", t.Name, web) - } else { - gitOK(DIR, "remote", "add", t.Name, web) - } + ensureGitRemote(t.Name, web) - // authenticate the push with a one-shot Basic auth header, so the token is - // neither persisted in the repository's git config nor visible in `ps` - header := "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(owner+":"+api.key)) + header := api.pushHeader(owner) if !gitPushHeader(DIR, t.Name, header, "--all") { return false } @@ -355,6 +352,22 @@ func pushToRemote(t RemoteTarget, repo, description string) bool { return true } +// ensureGitRemote points a credential-free git remote named name at web, +// adding it when the repository does not have it yet. +func ensureGitRemote(name, web string) bool { + if _, err := gitCapture(DIR, "remote", "get-url", name); err == nil { + return gitOK(DIR, "remote", "set-url", name, web) + } + return gitOK(DIR, "remote", "add", name, web) +} + +// pushHeader builds the one-shot HTTP Basic auth header used for pushes, so the +// token is neither persisted in the repository's git config nor visible in `ps`. +func (r *remoteAPI) pushHeader(owner string) string { + return "Authorization: Basic " + + base64.StdEncoding.EncodeToString([]byte(owner+":"+r.key)) +} + // gitPushHeader runs `git push ` with an extra HTTP auth // header, disabling interactive credential prompts. // diff --git a/version.txt b/version.txt index f05f0cb..0227d4a 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.0.23 +4.0.24