Files
mgsh/release.go
2026-08-11 11:40:32 +02:00

315 lines
9.9 KiB
Go

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)
}
// releaseRef identifies a release for the calls that follow creating it.
type releaseRef struct {
tag string
id string // provider id; the tag itself on GitLab
// GitHub uploads assets to a different host than its API, and names it in
// the release object as an RFC 6570 template
uploadURL string
}
// parseReleaseRef reads the identifying fields out of a release object.
func (r *remoteAPI) parseReleaseRef(tag string, data []byte) releaseRef {
ref := releaseRef{tag: tag, id: tag}
if r.kind == kindGitLab {
return ref // GitLab addresses a release by its tag throughout
}
var res struct {
ID int64 `json:"id"`
UploadURL string `json:"upload_url"`
}
json.Unmarshal(data, &res)
ref.id = strconv.FormatInt(res.ID, 10)
// ".../assets{?name,label}" -> ".../assets"
if i := strings.IndexByte(res.UploadURL, '{'); i >= 0 {
ref.uploadURL = res.UploadURL[:i]
} else {
ref.uploadURL = res.UploadURL
}
return ref
}
// findRelease returns the release of tag and whether it exists at all.
func (r *remoteAPI) findRelease(owner, repo, tag string) (ref releaseRef, found bool, err error) {
code, data, err := r.do("GET", r.releaseByTagPath(owner, repo, tag), nil)
if err != nil {
return releaseRef{}, false, err
}
switch code {
case 404:
return releaseRef{}, false, nil
case 200:
return r.parseReleaseRef(tag, data), true, nil
default:
return releaseRef{}, 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) (releaseRef, error) {
code, data, err := r.do("POST", r.releasePath(owner, repo), r.releaseBody(rel, true))
if err != nil {
return releaseRef{}, err
}
if code != 200 && code != 201 {
return releaseRef{}, fmt.Errorf("creating release failed (HTTP %d): %s", code, firstLine(data))
}
return r.parseReleaseRef(rel.Tag, data), 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 string, ref releaseRef, rel release) error {
method, ep := "PATCH", r.releasePath(owner, repo)+"/"+ref.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
}
configured, incomplete := cfg.mirrorTargets()
for _, n := range incomplete {
errorln("remote " + n + ": url or key missing — skipped")
}
targets := pickRemotes(configured, names)
if len(targets) == 0 {
reportNoTargets("release", configured, names)
return
}
body := notes
if body == "" {
body = releaseNotes(DIR, tag)
}
// ./bin and ./assets ride along when they are there; nothing to configure,
// and nothing happens for a project that has neither
assets, skipped := collectAssets(DIR)
for _, s := range skipped {
errorln("skipping " + s + ": another directory already contributes that name")
}
if len(assets) > 0 {
fmt.Printf("%s %s %s\n", col(cDark, "attaching"), col(cYellow, assetSummary(assets)),
col(cDark, "from "+strings.Join(assetDirList(DIR), " and ")))
}
done := 0
for _, t := range targets {
if publishRelease(t, PRJ, tag, body, assets) {
done++
}
}
if len(targets) > 1 {
fmt.Printf("%s %d/%d remotes released\n", col(cDark, "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, assets []releaseAsset) 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)}
ref, 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, ref, rel); err != nil {
errorln(t.Name + ": " + err.Error())
return false
}
} else if ref, 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(cDark, "remote"), col(cYellow, t.Name),
col(cGreen, what), col(cBlue, api.repoWebURL(owner, repo)))
if len(assets) > 0 {
if err := api.uploadAssets(owner, repo, ref, assets); err != nil {
errorln(t.Name + ": " + err.Error())
return false
}
}
return true
}