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) } // 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 ...] [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) } // ./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 }