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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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)"},
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1 +1 @@
|
||||
4.0.23
|
||||
4.0.24
|
||||
|
||||
Reference in New Issue
Block a user