package main // release.go — the `release` command: tag a commit and publish it as a release // on the public mirror servers. // // release [@name ...] [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/, GitLab directly under .../releases/. 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 ...] [notes]`. func handleRelease(args string) { if !requireRepo() { return } names, rest := parsePushRemoteArgs(args) fields := strings.Fields(rest) if len(fields) == 0 { errorln("usage: release [@remote ...] [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 }