diff options
author | Bjørn Erik Pedersen <[email protected]> | 2017-04-13 16:59:05 +0200 |
---|---|---|
committer | Bjørn Erik Pedersen <[email protected]> | 2017-04-24 11:08:56 +0200 |
commit | 7f6430d84d68765536b8758b1331a4b84db04c84 (patch) | |
tree | 7c2b1450c2d2789fbbc0323fc6027dac586ef6df /releaser | |
parent | 0e87b18b66d2c8ba9e2abc429630cb03f5b093d6 (diff) | |
download | hugo-7f6430d84d68765536b8758b1331a4b84db04c84.tar.gz hugo-7f6430d84d68765536b8758b1331a4b84db04c84.zip |
Automate the Hugo release process
This commit adds a work flow aroung GoReleaser to get the Hugo release process automated and more uniform:
* It can be run fully automated or in two steps to allow for manual edits of the relase notes.
* It supports both patch and full releases.
* It fetches author, issue, repo info. etc. for the release notes from GitHub.
* The file names produced are mainly the same as before, but we no use tar.gz as archive for all Unix versions.
* There isn't a fully automated CI setup in place yet, but the release tag is marked in the commit message with "[ci deploy]"
Fixes #3358
Diffstat (limited to 'releaser')
-rw-r--r-- | releaser/git.go | 265 | ||||
-rw-r--r-- | releaser/git_test.go | 53 | ||||
-rw-r--r-- | releaser/github.go | 129 | ||||
-rw-r--r-- | releaser/github_test.go | 42 | ||||
-rw-r--r-- | releaser/releasenotes_writer.go | 245 | ||||
-rw-r--r-- | releaser/releasenotes_writer_test.go | 44 | ||||
-rw-r--r-- | releaser/releaser.go | 267 | ||||
-rw-r--r-- | releaser/releaser_test.go | 78 |
8 files changed, 1123 insertions, 0 deletions
diff --git a/releaser/git.go b/releaser/git.go new file mode 100644 index 000000000..d8b5bef31 --- /dev/null +++ b/releaser/git.go @@ -0,0 +1,265 @@ +// Copyright 2017-present The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package releaser + +import ( + "fmt" + "os/exec" + "regexp" + "sort" + "strconv" + "strings" +) + +var issueRe = regexp.MustCompile(`(?i)[Updates?|Closes?|Fix.*|See] #(\d+)`) + +const ( + templateChanges = "templateChanges" + coreChanges = "coreChanges" + outChanges = "outChanges" + docsChanges = "docsChanges" + otherChanges = "otherChanges" +) + +type changeLog struct { + Version string + Enhancements map[string]gitInfos + Fixes map[string]gitInfos + All gitInfos + + // Overall stats + Repo *gitHubRepo + ContributorCount int + ThemeCount int +} + +func newChangeLog(infos gitInfos) changeLog { + return changeLog{ + Enhancements: make(map[string]gitInfos), + Fixes: make(map[string]gitInfos), + All: infos, + } +} + +func (l changeLog) addGitInfo(isFix bool, info gitInfo, category string) { + var ( + infos gitInfos + found bool + segment map[string]gitInfos + ) + + if isFix { + segment = l.Fixes + } else { + segment = l.Enhancements + } + + infos, found = segment[category] + if !found { + infos = gitInfos{} + } + + infos = append(infos, info) + segment[category] = infos +} + +func gitInfosToChangeLog(infos gitInfos) changeLog { + log := newChangeLog(infos) + for _, info := range infos { + los := strings.ToLower(info.Subject) + isFix := strings.Contains(los, "fix") + var category = otherChanges + + // TODO(bep) improve + if regexp.MustCompile("(?i)tpl:|tplimpl:|layout").MatchString(los) { + category = templateChanges + } else if regexp.MustCompile("(?i)docs?:|documentation:").MatchString(los) { + category = docsChanges + } else if regexp.MustCompile("(?i)hugolib:").MatchString(los) { + category = coreChanges + } else if regexp.MustCompile("(?i)out(put)?:|media:|Output|Media").MatchString(los) { + category = outChanges + } + + // Trim package prefix. + colonIdx := strings.Index(info.Subject, ":") + if colonIdx != -1 && colonIdx < (len(info.Subject)/2) { + info.Subject = info.Subject[colonIdx+1:] + } + + info.Subject = strings.TrimSpace(info.Subject) + + log.addGitInfo(isFix, info, category) + } + + return log +} + +type gitInfo struct { + Hash string + Author string + Subject string + Body string + + GitHubCommit *gitHubCommit +} + +func (g gitInfo) Issues() []int { + return extractIssues(g.Body) +} + +func (g gitInfo) AuthorID() string { + if g.GitHubCommit != nil { + return g.GitHubCommit.Author.Login + } + return g.Author +} + +func extractIssues(body string) []int { + var i []int + m := issueRe.FindAllStringSubmatch(body, -1) + for _, mm := range m { + issueID, err := strconv.Atoi(mm[1]) + if err != nil { + continue + } + i = append(i, issueID) + } + return i +} + +type gitInfos []gitInfo + +func git(args ...string) (string, error) { + cmd := exec.Command("git", args...) + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git failed: %q: %q", err, out) + } + return string(out), nil +} + +func getGitInfos(remote bool) (gitInfos, error) { + return getGitInfosBefore("HEAD", remote) +} + +type countribCount struct { + Author string + GitHubAuthor gitHubAuthor + Count int +} + +func (c countribCount) AuthorLink() string { + if c.GitHubAuthor.HtmlURL != "" { + return fmt.Sprintf("[@%s](%s)", c.GitHubAuthor.Login, c.GitHubAuthor.HtmlURL) + } + + if !strings.Contains(c.Author, "@") { + return c.Author + } + + return c.Author[:strings.Index(c.Author, "@")] + +} + +type contribCounts []countribCount + +func (c contribCounts) Less(i, j int) bool { return c[i].Count > c[j].Count } +func (c contribCounts) Len() int { return len(c) } +func (c contribCounts) Swap(i, j int) { c[i], c[j] = c[j], c[i] } + +func (g gitInfos) ContribCountPerAuthor() contribCounts { + var c contribCounts + + counters := make(map[string]countribCount) + + for _, gi := range g { + authorID := gi.AuthorID() + if count, ok := counters[authorID]; ok { + count.Count = count.Count + 1 + counters[authorID] = count + } else { + var ghA gitHubAuthor + if gi.GitHubCommit != nil { + ghA = gi.GitHubCommit.Author + } + authorCount := countribCount{Count: 1, Author: gi.Author, GitHubAuthor: ghA} + counters[authorID] = authorCount + } + } + + for _, v := range counters { + c = append(c, v) + } + + sort.Sort(c) + return c +} + +func getGitInfosBefore(ref string, remote bool) (gitInfos, error) { + + var g gitInfos + + log, err := gitLogBefore(ref) + if err != nil { + return g, err + } + + log = strings.Trim(log, "\n\x1e'") + entries := strings.Split(log, "\x1e") + + for _, entry := range entries { + items := strings.Split(entry, "\x1f") + gi := gitInfo{ + Hash: items[0], + Author: items[1], + Subject: items[2], + Body: items[3], + } + if remote { + gc, err := fetchCommit(gi.Hash) + if err == nil { + gi.GitHubCommit = &gc + } + } + g = append(g, gi) + } + + return g, nil +} + +// Ignore autogenerated commits etc. in change log. This is a regexp. +const ignoredCommits = "release:|vendor:|snapcraft:" + +func gitLogBefore(ref string) (string, error) { + prevTag, err := gitShort("describe", "--tags", "--abbrev=0", "--always", ref+"^") + if err != nil { + return "", err + } + log, err := git("log", "-E", fmt.Sprintf("--grep=%s", ignoredCommits), "--invert-grep", "--pretty=format:%x1e%h%x1f%aE%x1f%s%x1f%b", "--abbrev-commit", prevTag+".."+ref) + if err != nil { + return ",", err + } + + return log, err +} + +func gitLog() (string, error) { + return gitLogBefore("HEAD") +} + +func gitShort(args ...string) (output string, err error) { + output, err = git(args...) + return strings.Replace(strings.Split(output, "\n")[0], "'", "", -1), err +} diff --git a/releaser/git_test.go b/releaser/git_test.go new file mode 100644 index 000000000..dc1db5dc7 --- /dev/null +++ b/releaser/git_test.go @@ -0,0 +1,53 @@ +// Copyright 2017-present The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package releaser + +import ( + "testing" + + "runtime" + + "github.com/stretchr/testify/require" +) + +func TestGitInfos(t *testing.T) { + if runtime.GOOS == "linux" { + // Travis has an ancient git with no --invert-grep: https://github.com/travis-ci/travis-ci/issues/6328 + t.Skip("Skip git test on Linux to make Travis happy.") + } + infos, err := getGitInfos(false) + + require.NoError(t, err) + require.True(t, len(infos) > 0) + +} + +func TestIssuesRe(t *testing.T) { + + body := ` +This is a commit message. + +Updates #123 +Fix #345 +closes #543 +See #456 + ` + + issues := extractIssues(body) + + require.Len(t, issues, 4) + require.Equal(t, 123, issues[0]) + require.Equal(t, 543, issues[2]) + +} diff --git a/releaser/github.go b/releaser/github.go new file mode 100644 index 000000000..0dbb1bca1 --- /dev/null +++ b/releaser/github.go @@ -0,0 +1,129 @@ +package releaser + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" +) + +var ( + gitHubCommitsApi = "https://api.github.com/repos/spf13/hugo/commits/%s" + gitHubRepoApi = "https://api.github.com/repos/spf13/hugo" + gitHubContributorsApi = "https://api.github.com/repos/spf13/hugo/contributors" +) + +type gitHubCommit struct { + Author gitHubAuthor `json:"author"` + HtmlURL string `json:"html_url"` +} + +type gitHubAuthor struct { + ID int `json:"id"` + Login string `json:"login"` + HtmlURL string `json:"html_url"` + AvatarURL string `json:"avatar_url"` +} + +type gitHubRepo struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + HtmlURL string `json:"html_url"` + Stars int `json:"stargazers_count"` + Contributors []gitHubContributor +} + +type gitHubContributor struct { + ID int `json:"id"` + Login string `json:"login"` + HtmlURL string `json:"html_url"` + Contributions int `json:"contributions"` +} + +func fetchCommit(ref string) (gitHubCommit, error) { + var commit gitHubCommit + + u := fmt.Sprintf(gitHubCommitsApi, ref) + + req, err := http.NewRequest("GET", u, nil) + if err != nil { + return commit, err + } + + err = doGitHubRequest(req, &commit) + + return commit, err +} + +func fetchRepo() (gitHubRepo, error) { + var repo gitHubRepo + + req, err := http.NewRequest("GET", gitHubRepoApi, nil) + if err != nil { + return repo, err + } + + err = doGitHubRequest(req, &repo) + if err != nil { + return repo, err + } + + var contributors []gitHubContributor + page := 0 + for { + page++ + var currPage []gitHubContributor + url := fmt.Sprintf(gitHubContributorsApi+"?page=%d", page) + + req, err = http.NewRequest("GET", url, nil) + if err != nil { + return repo, err + } + + err = doGitHubRequest(req, &currPage) + if err != nil { + return repo, err + } + if len(currPage) == 0 { + break + } + + contributors = append(contributors, currPage...) + + } + + repo.Contributors = contributors + + return repo, err + +} + +func doGitHubRequest(req *http.Request, v interface{}) error { + addGitHubToken(req) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if isError(resp) { + b, _ := ioutil.ReadAll(resp.Body) + return fmt.Errorf("GitHub lookup failed: %s", string(b)) + } + + return json.NewDecoder(resp.Body).Decode(v) +} + +func isError(resp *http.Response) bool { + return resp.StatusCode < 200 || resp.StatusCode > 299 +} + +func addGitHubToken(req *http.Request) { + gitHubToken := os.Getenv("GITHUB_TOKEN") + if gitHubToken != "" { + req.Header.Add("Authorization", "token "+gitHubToken) + } +} diff --git a/releaser/github_test.go b/releaser/github_test.go new file mode 100644 index 000000000..7feae75f5 --- /dev/null +++ b/releaser/github_test.go @@ -0,0 +1,42 @@ +// Copyright 2017-present The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package releaser + +import ( + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGitHubLookupCommit(t *testing.T) { + skipIfNoToken(t) + commit, err := fetchCommit("793554108763c0984f1a1b1a6ee5744b560d78d0") + require.NoError(t, err) + fmt.Println(commit) +} + +func TestFetchRepo(t *testing.T) { + skipIfNoToken(t) + repo, err := fetchRepo() + require.NoError(t, err) + fmt.Println(">>", len(repo.Contributors)) +} + +func skipIfNoToken(t *testing.T) { + if os.Getenv("GITHUB_TOKEN") == "" { + t.Skip("Skip test against GitHub as no GITHUB_TOKEN set.") + } +} diff --git a/releaser/releasenotes_writer.go b/releaser/releasenotes_writer.go new file mode 100644 index 000000000..3d48d9ae8 --- /dev/null +++ b/releaser/releasenotes_writer.go @@ -0,0 +1,245 @@ +// Copyright 2017-present The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package release implements a set of utilities and a wrapper around Goreleaser +// to help automate the Hugo release process. +package releaser + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "strings" + "text/template" + "time" +) + +const ( + issueLinkTemplate = "[#%d](https://github.com/spf13/hugo/issues/%d)" + linkTemplate = "[%s](%s)" + releaseNotesMarkdownTemplate = ` +{{- $patchRelease := isPatch . -}} +{{- $contribsPerAuthor := .All.ContribCountPerAuthor -}} + +{{- if $patchRelease }} +{{ if eq (len .All) 1 }} +This is a bug-fix release with one important fix. +{{ else }} +This is a bug-fix relase with a couple of important fixes. +{{ end }} +{{ else }} +This release represents **{{ len .All }} contributions by {{ len $contribsPerAuthor }} contributors** to the main Hugo code base. +{{ end -}} + +{{- if gt (len $contribsPerAuthor) 3 -}} +{{- $u1 := index $contribsPerAuthor 0 -}} +{{- $u2 := index $contribsPerAuthor 1 -}} +{{- $u3 := index $contribsPerAuthor 2 -}} +{{- $u4 := index $contribsPerAuthor 3 -}} +{{- $u1.AuthorLink }} leads the Hugo development with a significant amount of contributions, but also a big shoutout to {{ $u2.AuthorLink }}, {{ $u3.AuthorLink }}, and {{ $u4.AuthorLink }} for their ongoing contributions. +And as always a big thanks to [@digitalcraftsman](https://github.com/digitalcraftsman) for his relentless work on keeping the documentation and the themes site in pristine condition. +{{ end }} +Hugo now has: + +{{ with .Repo -}} +* {{ .Stars }}+ [stars](https://github.com/spf13/hugo/stargazers) +* {{ len .Contributors }}+ [contributors](https://github.com/spf13/hugo/graphs/contributors) +{{- end -}} +{{ with .ThemeCount }} +* 156+ [themes](http://themes.gohugo.io/) +{{- end }} + +## Enhancements +{{ template "change-headers" .Enhancements -}} +## Fixes +{{ template "change-headers" .Fixes -}} + +{{ define "change-headers" }} +{{ $tmplChanges := index . "templateChanges" -}} +{{- $outChanges := index . "outChanges" -}} +{{- $coreChanges := index . "coreChanges" -}} +{{- $docsChanges := index . "docsChanges" -}} +{{- $otherChanges := index . "otherChanges" -}} +{{- with $tmplChanges -}} +### Templates +{{ template "change-section" . }} +{{- end -}} +{{- with $outChanges -}} +### Output +{{- template "change-section" . }} +{{- end -}} +{{- with $coreChanges -}} +### Core +{{ template "change-section" . }} +{{- end -}} +{{- with $docsChanges -}} +### Docs +{{- template "change-section" . }} +{{- end -}} +{{- with $otherChanges -}} +### Other +{{ template "change-section" . }} +{{- end -}} +{{ end }} + + +{{ define "change-section" }} +{{ range . }} +{{- if .GitHubCommit -}} +* {{ .Subject }} {{ . | commitURL }} {{ . | authorURL }} {{ range .Issues }}{{ . | issue }} {{ end }} +{{ else -}} +* {{ .Subject }} {{ range .Issues }}{{ . | issue }} {{ end }} +{{ end -}} +{{- end }} +{{ end }} +` +) + +var templateFuncs = template.FuncMap{ + "isPatch": func(c changeLog) bool { + return strings.Count(c.Version, ".") > 1 + }, + "issue": func(id int) string { + return fmt.Sprintf(issueLinkTemplate, id, id) + }, + "commitURL": func(info gitInfo) string { + if info.GitHubCommit.HtmlURL == "" { + return "" + } + return fmt.Sprintf(linkTemplate, info.Hash, info.GitHubCommit.HtmlURL) + }, + "authorURL": func(info gitInfo) string { + if info.GitHubCommit.Author.Login == "" { + return "" + } + return fmt.Sprintf(linkTemplate, "@"+info.GitHubCommit.Author.Login, info.GitHubCommit.Author.HtmlURL) + }, +} + +func writeReleaseNotes(version string, infos gitInfos, to io.Writer) error { + changes := gitInfosToChangeLog(infos) + changes.Version = version + repo, err := fetchRepo() + if err == nil { + changes.Repo = &repo + } + themeCount, err := fetchThemeCount() + if err == nil { + changes.ThemeCount = themeCount + } + + tmpl, err := template.New("").Funcs(templateFuncs).Parse(releaseNotesMarkdownTemplate) + if err != nil { + return err + } + + err = tmpl.Execute(to, changes) + if err != nil { + return err + } + + return nil + +} + +func fetchThemeCount() (int, error) { + resp, err := http.Get("https://github.com/spf13/hugoThemes/blob/master/.gitmodules") + if err != nil { + return 0, err + } + defer resp.Body.Close() + + b, _ := ioutil.ReadAll(resp.Body) + return bytes.Count(b, []byte("submodule")), nil +} + +func writeReleaseNotesToTmpFile(version string, infos gitInfos) (string, error) { + f, err := ioutil.TempFile("", "hugorelease") + if err != nil { + return "", err + } + + defer f.Close() + + if err := writeReleaseNotes(version, infos, f); err != nil { + return "", err + } + + return f.Name(), nil +} + +func getRelaseNotesDocsTempDirAndName(version string) (string, string) { + return hugoFilepath("docs/temp"), fmt.Sprintf("%s-relnotes.md", version) +} + +func getRelaseNotesDocsTempFilename(version string) string { + return filepath.Join(getRelaseNotesDocsTempDirAndName(version)) +} + +func writeReleaseNotesToDocsTemp(version string, infos gitInfos) (string, error) { + docsTempPath, name := getRelaseNotesDocsTempDirAndName(version) + os.Mkdir(docsTempPath, os.ModePerm) + + f, err := os.Create(filepath.Join(docsTempPath, name)) + if err != nil { + return "", err + } + + defer f.Close() + + if err := writeReleaseNotes(version, infos, f); err != nil { + return "", err + } + + return f.Name(), nil + +} + +func writeReleaseNotesToDocs(title, sourceFilename string) (string, error) { + targetFilename := filepath.Base(sourceFilename) + contentDir := hugoFilepath("docs/content/release-notes") + targetFullFilename := filepath.Join(contentDir, targetFilename) + os.Mkdir(contentDir, os.ModePerm) + + b, err := ioutil.ReadFile(sourceFilename) + if err != nil { + return "", err + } + + f, err := os.Create(targetFullFilename) + if err != nil { + return "", err + } + defer f.Close() + + if _, err := f.WriteString(fmt.Sprintf(` +--- +date: %s +title: %s +--- + + `, time.Now().Format("2006-01-02"), title)); err != nil { + return "", err + } + + if _, err := f.Write(b); err != nil { + return "", err + } + + return targetFullFilename, nil + +} diff --git a/releaser/releasenotes_writer_test.go b/releaser/releasenotes_writer_test.go new file mode 100644 index 000000000..d1151bffc --- /dev/null +++ b/releaser/releasenotes_writer_test.go @@ -0,0 +1,44 @@ +// Copyright 2017-present The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package commands defines and implements command-line commands and flags +// used by Hugo. Commands and flags are implemented using Cobra. + +package releaser + +import ( + "bytes" + "fmt" + "testing" + + "runtime" + + "github.com/stretchr/testify/require" +) + +func TestReleaseNotesWriter(t *testing.T) { + if runtime.GOOS == "linux" { + // Travis has an ancient git with no --invert-grep: https://github.com/travis-ci/travis-ci/issues/6328 + t.Skip("Skip git test on Linux to make Travis happy.") + } + + var b bytes.Buffer + + // TODO(bep) consider to query GitHub directly for the gitlog with author info, probably faster. + infos, err := getGitInfosBefore("v0.20", false) + require.NoError(t, err) + + require.NoError(t, writeReleaseNotes("0.20", infos, &b)) + + fmt.Println(">>>", b.String()) +} diff --git a/releaser/releaser.go b/releaser/releaser.go new file mode 100644 index 000000000..b6e9bfde1 --- /dev/null +++ b/releaser/releaser.go @@ -0,0 +1,267 @@ +// Copyright 2017-present The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package releaser implements a set of utilities and a wrapper around Goreleaser +// to help automate the Hugo release process. +package releaser + +import ( + "errors" + "fmt" + "io/ioutil" + "log" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/spf13/hugo/helpers" +) + +const commitPrefix = "releaser:" + +type ReleaseHandler struct { + patch int + step int + skipPublish bool +} + +func (r ReleaseHandler) shouldRelease() bool { + return r.step < 1 || r.shouldContinue() +} + +func (r ReleaseHandler) shouldContinue() bool { + return r.step == 2 +} + +func (r ReleaseHandler) shouldPrepare() bool { + return r.step < 1 || r.step == 1 +} + +func (r ReleaseHandler) calculateVersions(current helpers.HugoVersion) (helpers.HugoVersion, helpers.HugoVersion) { + var ( + newVersion = current + finalVersion = current + ) + + newVersion.Suffix = "" + + if r.shouldContinue() { + // The version in the current code base is in the state we want for + // the release. + if r.patch == 0 { + finalVersion = newVersion.Next() + } + } else if r.patch > 0 { + newVersion = helpers.CurrentHugoVersion.NextPatchLevel(r.patch) + } else { + finalVersion = newVersion.Next() + } + + finalVersion.Suffix = "-DEV" + + return newVersion, finalVersion +} + +func New(patch, step int, skipPublish bool) *ReleaseHandler { + return &ReleaseHandler{patch: patch, step: step, skipPublish: skipPublish} +} + +func (r *ReleaseHandler) Run() error { + if os.Getenv("GITHUB_TOKEN") == "" { + return errors.New("GITHUB_TOKEN not set, create one here with the repo scope selected: https://github.com/settings/tokens/new") + } + + newVersion, finalVersion := r.calculateVersions(helpers.CurrentHugoVersion) + + version := newVersion.String() + tag := "v" + version + + // Exit early if tag already exists + out, err := git("tag", "-l", tag) + + if err != nil { + return err + } + + if strings.Contains(out, tag) { + return fmt.Errorf("Tag %q already exists", tag) + } + + var gitCommits gitInfos + + if r.shouldPrepare() || r.shouldRelease() { + gitCommits, err = getGitInfos(true) + if err != nil { + return err + } + } + + if r.shouldPrepare() { + if err := bumpVersions(newVersion); err != nil { + return err + } + + if _, err := git("commit", "-a", "-m", fmt.Sprintf("%s Bump versions for release of %s\n\n[ci skip]", commitPrefix, newVersion)); err != nil { + return err + } + + releaseNotesFile, err := writeReleaseNotesToDocsTemp(version, gitCommits) + if err != nil { + return err + } + + if _, err := git("add", releaseNotesFile); err != nil { + return err + } + if _, err := git("commit", "-m", fmt.Sprintf("%s Add relase notes draft for release of %s\n\n[ci skip]", commitPrefix, newVersion)); err != nil { + return err + } + } + + if !r.shouldRelease() { + fmt.Println("Skip release ... Use --state=2 to continue.") + return nil + } + + releaseNotesFile := getRelaseNotesDocsTempFilename(version) + + // Write the release notes to the docs site as well. + docFile, err := writeReleaseNotesToDocs(version, releaseNotesFile) + if err != nil { + return err + } + + if _, err := git("add", docFile); err != nil { + return err + } + if _, err := git("commit", "-m", fmt.Sprintf("%s Add relase notes to /docs for release of %s\n\n[ci skip]", commitPrefix, newVersion)); err != nil { + return err + } + + if _, err := git("tag", "-a", tag, "-m", fmt.Sprintf("%s %s [ci deploy]", commitPrefix, newVersion)); err != nil { + return err + } + + if err := r.release(releaseNotesFile); err != nil { + return err + } + + if err := bumpVersions(finalVersion); err != nil { + return err + } + + // No longer needed. + if err := os.Remove(releaseNotesFile); err != nil { + return err + } + + if _, err := git("commit", "-a", "-m", fmt.Sprintf("%s Prepare repository for %s\n\n[ci skip]", commitPrefix, finalVersion)); err != nil { + return err + } + + return nil +} + +func (r *ReleaseHandler) release(releaseNotesFile string) error { + cmd := exec.Command("goreleaser", "--release-notes", releaseNotesFile, "--skip-publish="+fmt.Sprint(r.skipPublish)) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + if err != nil { + return fmt.Errorf("goreleaser failed: %s", err) + } + return nil +} + +func bumpVersions(ver helpers.HugoVersion) error { + fromDev := "" + toDev := "" + + if ver.Suffix != "" { + toDev = "-DEV" + } else { + fromDev = "-DEV" + } + + if err := replaceInFile("helpers/hugo.go", + `Number:(\s{4,})(.*),`, fmt.Sprintf(`Number:${1}%.2f,`, ver.Number), + `PatchLevel:(\s*)(.*),`, fmt.Sprintf(`PatchLevel:${1}%d,`, ver.PatchLevel), + fmt.Sprintf(`Suffix:(\s{4,})"%s",`, fromDev), fmt.Sprintf(`Suffix:${1}"%s",`, toDev)); err != nil { + return err + } + + snapcraftGrade := "stable" + if ver.Suffix != "" { + snapcraftGrade = "devel" + } + if err := replaceInFile("snapcraft.yaml", + `version: "(.*)"`, fmt.Sprintf(`version: "%s"`, ver), + `grade: (.*) #`, fmt.Sprintf(`grade: %s #`, snapcraftGrade)); err != nil { + return err + } + + var minVersion string + if ver.Suffix != "" { + // People use the DEV version in daily use, and we cannot create new themes + // with the next version before it is released. + minVersion = ver.Prev().String() + } else { + minVersion = ver.String() + } + + if err := replaceInFile("commands/new.go", + `min_version = "(.*)"`, fmt.Sprintf(`min_version = "%s"`, minVersion)); err != nil { + return err + } + + // docs/config.toml + if err := replaceInFile("docs/config.toml", + `release = "(.*)"`, fmt.Sprintf(`release = "%s"`, ver)); err != nil { + return err + } + + return nil +} + +func replaceInFile(filename string, oldNew ...string) error { + fullFilename := hugoFilepath(filename) + fi, err := os.Stat(fullFilename) + if err != nil { + return err + } + + b, err := ioutil.ReadFile(fullFilename) + if err != nil { + return err + } + newContent := string(b) + + for i := 0; i < len(oldNew); i += 2 { + re := regexp.MustCompile(oldNew[i]) + newContent = re.ReplaceAllString(newContent, oldNew[i+1]) + } + + return ioutil.WriteFile(fullFilename, []byte(newContent), fi.Mode()) + + return nil +} + +func hugoFilepath(filename string) string { + pwd, err := os.Getwd() + if err != nil { + log.Fatal(err) + } + return filepath.Join(pwd, filename) +} diff --git a/releaser/releaser_test.go b/releaser/releaser_test.go new file mode 100644 index 000000000..641600545 --- /dev/null +++ b/releaser/releaser_test.go @@ -0,0 +1,78 @@ +// Copyright 2017-present The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package commands defines and implements command-line commands and flags +// used by Hugo. Commands and flags are implemented using Cobra. + +package releaser + +import ( + "testing" + + "github.com/spf13/hugo/helpers" + "github.com/stretchr/testify/require" +) + +func TestCalculateVersions(t *testing.T) { + startVersion := helpers.HugoVersion{Number: 0.20, Suffix: "-DEV"} + + tests := []struct { + handler *ReleaseHandler + version helpers.HugoVersion + v1 string + v2 string + }{ + { + New(0, 0, true), + startVersion, + "0.20", + "0.21-DEV", + }, + { + New(2, 0, true), + startVersion, + "0.20.2", + "0.20-DEV", + }, + { + New(0, 1, true), + startVersion, + "0.20", + "0.21-DEV", + }, + { + New(0, 2, true), + startVersion, + "0.20", + "0.21-DEV", + }, + { + New(3, 1, true), + startVersion, + "0.20.3", + "0.20-DEV", + }, + { + New(3, 2, true), + startVersion.Next(), + "0.21", + "0.21-DEV", + }, + } + + for _, test := range tests { + v1, v2 := test.handler.calculateVersions(test.version) + require.Equal(t, test.v1, v1.String(), "Release version") + require.Equal(t, test.v2, v2.String(), "Final version") + } +} |