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 915ef1783a
9 changed files with 716 additions and 25 deletions
+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
}