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:
+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
|
||||
}
|
||||
Reference in New Issue
Block a user