315 lines
9.9 KiB
Go
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
|
|
}
|