Add release: publish tagged releases on the mirror servers

`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>
This commit is contained in:
2026-07-26 15:47:05 +02:00
co-authored by Claude Opus 5
parent fa44a4056a
commit b1024148c1
9 changed files with 716 additions and 25 deletions
+47 -5
View File
@@ -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 <project>` 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 <repo>` | 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] <tag> [notes]` | tag and publish a release on the mirrors |
| `alias [name [cmd]]` | list, show or define a command alias |
| `unalias <name>` | 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 ...] <tag> [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
(`- <subject>` 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
+1 -1
View File
@@ -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] }
+13 -2
View File
@@ -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 <tag> [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 <tag>", "add tag"},
{"tag add <tag> [msg]", "add tag (msg becomes the annotation)"},
{"tag checkout <tag>", "checkout tag"},
{"tag delete <tag>", "delete tag"},
{"alias [name [cmd]]", "list, show or define an alias ($1..$N, $* args)"},
+1
View File
@@ -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")),
+1 -1
View File
@@ -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
+276
View File
@@ -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 ...] <tag> [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/<tag>, GitLab directly under .../releases/<tag>.
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 ...] <tag> [notes]`.
func handleRelease(args string) {
if !requireRepo() {
return
}
names, rest := parsePushRemoteArgs(args)
fields := strings.Fields(rest)
if len(fields) == 0 {
errorln("usage: release [@remote ...] <tag> [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
}
+348
View File
@@ -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")
}
}
+28 -15
View File
@@ -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 <remote> <args...>` with an extra HTTP auth
// header, disabling interactive credential prompts.
//
+1 -1
View File
@@ -1 +1 @@
4.0.23
4.0.24