Set up Hugo release flow on CircleCI
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Sun, 10 Sep 2017 15:14:02 +0000 (17:14 +0200)
committerGitHub <noreply@github.com>
Sun, 10 Sep 2017 15:14:02 +0000 (17:14 +0200)
This rewrites the release logic to use CircleCI 2.0 and its approve workflow in combination with the state of the release notes to determine what to do next.

Fixes #3779

.circleci/config.yml [new file with mode: 0644]
commands/release.go
helpers/hugo.go
helpers/hugo_test.go
releaser/git.go
releaser/git_test.go
releaser/github.go
releaser/github_test.go
releaser/releasenotes_writer.go
releaser/releasenotes_writer_test.go
releaser/releaser.go

diff --git a/.circleci/config.yml b/.circleci/config.yml
new file mode 100644 (file)
index 0000000..b189707
--- /dev/null
@@ -0,0 +1,49 @@
+defaults: &defaults
+  working_directory: /go/src/github.com/gohugoio
+  docker:
+      - image: bepsays/ci-goreleaser:0.30.5-2
+    
+version: 2
+jobs:
+  build:
+    <<: *defaults
+    steps:
+      - checkout:
+          path: hugo
+      - run:
+            command: |
+                git clone git@github.com:gohugoio/hugoDocs.git
+                cd hugo
+                make vendor
+                make check
+      - persist_to_workspace:
+          root: .
+          paths: .
+  release:
+    <<: *defaults
+    steps:
+      - attach_workspace:
+          at: /go/src/github.com/gohugoio
+      - run:
+            command: |
+                    cd hugo
+                    git config --global user.email "bjorn.erik.pedersen+hugoreleaser@gmail.com"
+                    git config --global user.name "hugoreleaser"
+                    go run -tags release main.go release -r ${CIRCLE_BRANCH}
+
+workflows:
+  version: 2
+  release:
+      jobs:  
+        - build:
+            filters:
+              branches:
+                only: /release-.*/
+        - hold:
+            type: approval
+            requires:
+              - build
+        - release:
+            context: org-global
+            requires:
+              - hold
index 0764685f0e420ddc59f05bbeffd302fa09233f82..8ccf8bcc27c3fa14f345e60605cab61671d3e477 100644 (file)
@@ -33,8 +33,6 @@ type releaseCommandeer struct {
 
        skipPublish bool
        try         bool
-
-       step int
 }
 
 func createReleaser() *releaseCommandeer {
@@ -53,7 +51,6 @@ func createReleaser() *releaseCommandeer {
        }
 
        r.cmd.PersistentFlags().StringVarP(&r.version, "rel", "r", "", "new release version, i.e. 0.25.1")
-       r.cmd.PersistentFlags().IntVarP(&r.step, "step", "s", -1, "release step, defaults to -1 for all steps.")
        r.cmd.PersistentFlags().BoolVarP(&r.skipPublish, "skip-publish", "", false, "skip all publishing pipes of the release")
        r.cmd.PersistentFlags().BoolVarP(&r.try, "try", "", false, "simulate a release, i.e. no changes")
 
@@ -64,5 +61,5 @@ func (r *releaseCommandeer) release() error {
        if r.version == "" {
                return errors.New("must set the --rel flag to the relevant version number")
        }
-       return releaser.New(r.version, r.step, r.skipPublish, r.try).Run()
+       return releaser.New(r.version, r.skipPublish, r.try).Run()
 }
index da000093740f50dae46e88c8ae8cf27b818dcbc8..f5b7f64318447167d7023307ee33be43d94bbbff 100644 (file)
@@ -41,6 +41,10 @@ func (v HugoVersion) String() string {
 // ParseHugoVersion parses a version string.
 func ParseHugoVersion(s string) (HugoVersion, error) {
        var vv HugoVersion
+       if strings.HasSuffix(s, "-test") {
+               vv.Suffix = "-test"
+               s = strings.TrimSuffix(s, "-test")
+       }
 
        if strings.Contains(s, "DEV") {
                return vv, errors.New("DEV versions not supported by parse")
index a59d8ee162c67cb23b145c760df8171b7babd618..1f5e5193f91048e3626ff5e94ba6b8c66035d7b9 100644 (file)
@@ -53,7 +53,7 @@ func TestCompareVersions(t *testing.T) {
 func TestParseHugoVersion(t *testing.T) {
        require.Equal(t, "0.25", MustParseHugoVersion("0.25").String())
        require.Equal(t, "0.25.2", MustParseHugoVersion("0.25.2").String())
-
+       require.Equal(t, "0.25-test", MustParseHugoVersion("0.25-test").String())
        _, err := ParseHugoVersion("0.25-DEV")
        require.Error(t, err)
 }
index cfef434dd289de27dbb0dd5e01c6a6fa1442c3d5..8d8bbd68de2c99aa2496f9512b9f81387d63eff0 100644 (file)
@@ -156,8 +156,8 @@ func git(args ...string) (string, error) {
        return string(out), nil
 }
 
-func getGitInfos(tag, repoPath string, remote bool) (gitInfos, error) {
-       return getGitInfosBefore("HEAD", tag, repoPath, remote)
+func getGitInfos(tag, repo, repoPath string, remote bool) (gitInfos, error) {
+       return getGitInfosBefore("HEAD", tag, repo, repoPath, remote)
 }
 
 type countribCount struct {
@@ -213,8 +213,8 @@ func (g gitInfos) ContribCountPerAuthor() contribCounts {
        return c
 }
 
-func getGitInfosBefore(ref, tag, repoPath string, remote bool) (gitInfos, error) {
-
+func getGitInfosBefore(ref, tag, repo, repoPath string, remote bool) (gitInfos, error) {
+       client := newGitHubAPI(repo)
        var g gitInfos
 
        log, err := gitLogBefore(ref, tag, repoPath)
@@ -234,7 +234,7 @@ func getGitInfosBefore(ref, tag, repoPath string, remote bool) (gitInfos, error)
                        Body:    items[3],
                }
                if remote {
-                       gc, err := fetchCommit(gi.Hash)
+                       gc, err := client.fetchCommit(gi.Hash)
                        if err == nil {
                                gi.GitHubCommit = &gc
                        }
index 8053f7702577328b5260dcb43c71178aff992d8f..f0d6fd24b51529613bf63fe80d5dd51c526ec9b6 100644 (file)
@@ -14,7 +14,6 @@
 package releaser
 
 import (
-       "os"
        "testing"
 
        "github.com/stretchr/testify/require"
@@ -22,7 +21,7 @@ import (
 
 func TestGitInfos(t *testing.T) {
        skipIfCI(t)
-       infos, err := getGitInfos("v0.20", "", false)
+       infos, err := getGitInfos("v0.20", "hugo", "", false)
 
        require.NoError(t, err)
        require.True(t, len(infos) > 0)
@@ -68,7 +67,7 @@ func TestTagExists(t *testing.T) {
 }
 
 func skipIfCI(t *testing.T) {
-       if os.Getenv("CI") != "" {
+       if isCI() {
                // Travis has an ancient git with no --invert-grep: https://github.com/travis-ci/travis-ci/issues/6328
                // Also Travis clones very shallowly, making some of the tests above shaky.
                t.Skip("Skip git test on Linux to make Travis happy.")
index c1e7691b8deb9902e3d9b2efbb7a6cfcf6de45b6..11f617007fb6daa7d53794512a1ecf0f9cff46f2 100644 (file)
@@ -6,14 +6,29 @@ import (
        "io/ioutil"
        "net/http"
        "os"
+       "strings"
 )
 
 var (
-       gitHubCommitsApi      = "https://api.github.com/repos/gohugoio/hugo/commits/%s"
-       gitHubRepoApi         = "https://api.github.com/repos/gohugoio/hugo"
-       gitHubContributorsApi = "https://api.github.com/repos/gohugoio/hugo/contributors"
+       gitHubCommitsAPI      = "https://api.github.com/repos/gohugoio/REPO/commits/%s"
+       gitHubRepoAPI         = "https://api.github.com/repos/gohugoio/REPO"
+       gitHubContributorsAPI = "https://api.github.com/repos/gohugoio/REPO/contributors"
 )
 
+type gitHubAPI struct {
+       commitsAPITemplate      string
+       repoAPI                 string
+       contributorsAPITemplate string
+}
+
+func newGitHubAPI(repo string) *gitHubAPI {
+       return &gitHubAPI{
+               commitsAPITemplate:      strings.Replace(gitHubCommitsAPI, "REPO", repo, -1),
+               repoAPI:                 strings.Replace(gitHubRepoAPI, "REPO", repo, -1),
+               contributorsAPITemplate: strings.Replace(gitHubContributorsAPI, "REPO", repo, -1),
+       }
+}
+
 type gitHubCommit struct {
        Author  gitHubAuthor `json:"author"`
        HtmlURL string       `json:"html_url"`
@@ -42,10 +57,10 @@ type gitHubContributor struct {
        Contributions int    `json:"contributions"`
 }
 
-func fetchCommit(ref string) (gitHubCommit, error) {
+func (g *gitHubAPI) fetchCommit(ref string) (gitHubCommit, error) {
        var commit gitHubCommit
 
-       u := fmt.Sprintf(gitHubCommitsApi, ref)
+       u := fmt.Sprintf(g.commitsAPITemplate, ref)
 
        req, err := http.NewRequest("GET", u, nil)
        if err != nil {
@@ -57,10 +72,10 @@ func fetchCommit(ref string) (gitHubCommit, error) {
        return commit, err
 }
 
-func fetchRepo() (gitHubRepo, error) {
+func (g *gitHubAPI) fetchRepo() (gitHubRepo, error) {
        var repo gitHubRepo
 
-       req, err := http.NewRequest("GET", gitHubRepoApi, nil)
+       req, err := http.NewRequest("GET", g.repoAPI, nil)
        if err != nil {
                return repo, err
        }
@@ -75,7 +90,7 @@ func fetchRepo() (gitHubRepo, error) {
        for {
                page++
                var currPage []gitHubContributor
-               url := fmt.Sprintf(gitHubContributorsApi+"?page=%d", page)
+               url := fmt.Sprintf(g.contributorsAPITemplate+"?page=%d", page)
 
                req, err = http.NewRequest("GET", url, nil)
                if err != nil {
index 7feae75f5de92190e82283b9bf06fe0599dff6b1..1187cbb2c3cdad6b4b793e0bf20f7157313984a5 100644 (file)
@@ -23,14 +23,16 @@ import (
 
 func TestGitHubLookupCommit(t *testing.T) {
        skipIfNoToken(t)
-       commit, err := fetchCommit("793554108763c0984f1a1b1a6ee5744b560d78d0")
+       client := newGitHubAPI("hugo")
+       commit, err := client.fetchCommit("793554108763c0984f1a1b1a6ee5744b560d78d0")
        require.NoError(t, err)
        fmt.Println(commit)
 }
 
 func TestFetchRepo(t *testing.T) {
        skipIfNoToken(t)
-       repo, err := fetchRepo()
+       client := newGitHubAPI("hugo")
+       repo, err := client.fetchRepo()
        require.NoError(t, err)
        fmt.Println(">>", len(repo.Contributors))
 }
index 0c6f297a4fb18415a6f09dc14b96a7231b87159e..e94ed25e1141c67d9b0acfba157d64b5b0c9da96 100644 (file)
@@ -139,9 +139,10 @@ var templateFuncs = template.FuncMap{
 }
 
 func writeReleaseNotes(version string, infosMain, infosDocs gitInfos, to io.Writer) error {
+       client := newGitHubAPI("hugo")
        changes := gitInfosToChangeLog(infosMain, infosDocs)
        changes.Version = version
-       repo, err := fetchRepo()
+       repo, err := client.fetchRepo()
        if err == nil {
                changes.Repo = &repo
        }
@@ -190,17 +191,43 @@ func writeReleaseNotesToTmpFile(version string, infosMain, infosDocs gitInfos) (
        return f.Name(), nil
 }
 
-func getReleaseNotesDocsTempDirAndName(version string) (string, string) {
+func getReleaseNotesDocsTempDirAndName(version string, final bool) (string, string) {
+       if final {
+               return hugoFilepath("temp"), fmt.Sprintf("%s-relnotes-ready.md", version)
+       }
        return hugoFilepath("temp"), fmt.Sprintf("%s-relnotes.md", version)
 }
 
-func getReleaseNotesDocsTempFilename(version string) string {
-       return filepath.Join(getReleaseNotesDocsTempDirAndName(version))
+func getReleaseNotesDocsTempFilename(version string, final bool) string {
+       return filepath.Join(getReleaseNotesDocsTempDirAndName(version, final))
+}
+
+func (r *ReleaseHandler) releaseNotesState(version string) (releaseNotesState, error) {
+       docsTempPath, name := getReleaseNotesDocsTempDirAndName(version, false)
+       _, err := os.Stat(filepath.Join(docsTempPath, name))
+
+       if err == nil {
+               return releaseNotesCreated, nil
+       }
+
+       docsTempPath, name = getReleaseNotesDocsTempDirAndName(version, true)
+       _, err = os.Stat(filepath.Join(docsTempPath, name))
+
+       if err == nil {
+               return releaseNotesReady, nil
+       }
+
+       if !os.IsNotExist(err) {
+               return releaseNotesNone, err
+       }
+
+       return releaseNotesNone, nil
+
 }
 
 func (r *ReleaseHandler) writeReleaseNotesToTemp(version string, infosMain, infosDocs gitInfos) (string, error) {
 
-       docsTempPath, name := getReleaseNotesDocsTempDirAndName(version)
+       docsTempPath, name := getReleaseNotesDocsTempDirAndName(version, false)
 
        var (
                w io.WriteCloser
index f3e984d55bf5275191bff4e53f679b348dc58bec..f5b7a87d3c1c5d081366698288258de266e89b47 100644 (file)
@@ -34,7 +34,7 @@ func _TestReleaseNotesWriter(t *testing.T) {
        var b bytes.Buffer
 
        // TODO(bep) consider to query GitHub directly for the gitlog with author info, probably faster.
-       infos, err := getGitInfosBefore("HEAD", "v0.20", "", false)
+       infos, err := getGitInfosBefore("HEAD", "v0.20", "hugo", "", false)
        require.NoError(t, err)
 
        require.NoError(t, writeReleaseNotes("0.21", infos, infos, &b))
index d056830336203dc9cd60850391798f94230635a5..1271cf179b2322a525c440483d219626cafb3dd3 100644 (file)
@@ -31,15 +31,18 @@ import (
 
 const commitPrefix = "releaser:"
 
+type releaseNotesState int
+
+const (
+       releaseNotesNone = iota
+       releaseNotesCreated
+       releaseNotesReady
+)
+
 // ReleaseHandler provides functionality to release a new version of Hugo.
 type ReleaseHandler struct {
        cliVersion string
 
-       // If set, we do the releases in 3 steps:
-       // 1: Create and write a draft release note
-       // 2: Prepare files for new version
-       // 3: Release
-       step        int
        skipPublish bool
 
        // Just simulate, no actual changes.
@@ -48,29 +51,14 @@ type ReleaseHandler struct {
        git func(args ...string) (string, error)
 }
 
-func (r ReleaseHandler) shouldRelease() bool {
-       return r.step < 1 || r.shouldContinue()
-}
-
-func (r ReleaseHandler) shouldContinue() bool {
-       return r.step >= 3
-}
-
-func (r ReleaseHandler) shouldPrepareReleasenotes() bool {
-       return r.step < 1 || r.step == 1
-}
-
-func (r ReleaseHandler) shouldPrepareVersions() bool {
-       return r.step < 1 || r.step == 2 || r.step > 3
-}
-
 func (r ReleaseHandler) calculateVersions() (helpers.HugoVersion, helpers.HugoVersion) {
-
        newVersion := helpers.MustParseHugoVersion(r.cliVersion)
        finalVersion := newVersion
        finalVersion.PatchLevel = 0
 
-       newVersion.Suffix = ""
+       if newVersion.Suffix != "-test" {
+               newVersion.Suffix = ""
+       }
 
        if newVersion.PatchLevel == 0 {
                finalVersion = finalVersion.Next()
@@ -82,8 +70,11 @@ func (r ReleaseHandler) calculateVersions() (helpers.HugoVersion, helpers.HugoVe
 }
 
 // New initialises a ReleaseHandler.
-func New(version string, step int, skipPublish, try bool) *ReleaseHandler {
-       rh := &ReleaseHandler{cliVersion: version, step: step, skipPublish: skipPublish, try: try}
+func New(version string, skipPublish, try bool) *ReleaseHandler {
+       // When triggered from CI release branch
+       version = strings.TrimPrefix(version, "release-")
+       version = strings.TrimPrefix(version, "v")
+       rh := &ReleaseHandler{cliVersion: version, skipPublish: skipPublish, try: try}
 
        if try {
                rh.git = func(args ...string) (string, error) {
@@ -133,20 +124,38 @@ func (r *ReleaseHandler) Run() error {
        var (
                gitCommits     gitInfos
                gitCommitsDocs gitInfos
+               relNotesState  releaseNotesState
        )
 
-       if r.shouldPrepareReleasenotes() || r.shouldRelease() {
-               gitCommits, err = getGitInfos(changeLogFromTag, "", !r.try)
+       relNotesState, err = r.releaseNotesState(version)
+       if err != nil {
+               return err
+       }
+
+       prepareRelaseNotes := relNotesState == releaseNotesNone
+       shouldRelease := relNotesState == releaseNotesReady
+
+       defer r.gitPush() // TODO(bep)
+
+       if prepareRelaseNotes || shouldRelease {
+               gitCommits, err = getGitInfos(changeLogFromTag, "hugo", "", !r.try)
                if err != nil {
                        return err
                }
-               gitCommitsDocs, err = getGitInfos(changeLogFromTag, "../hugoDocs", !r.try)
+
+               // TODO(bep) explicit tag?
+               gitCommitsDocs, err = getGitInfos("", "hugoDocs", "../hugoDocs", !r.try)
                if err != nil {
                        return err
                }
        }
 
-       if r.shouldPrepareReleasenotes() {
+       if relNotesState == releaseNotesCreated {
+               fmt.Println("Release notes created, but not ready. Reneame to *-ready.md to continue ...")
+               return nil
+       }
+
+       if prepareRelaseNotes {
                releaseNotesFile, err := r.writeReleaseNotesToTemp(version, gitCommits, gitCommitsDocs)
                if err != nil {
                        return err
@@ -155,33 +164,30 @@ func (r *ReleaseHandler) Run() error {
                if _, err := r.git("add", releaseNotesFile); err != nil {
                        return err
                }
-               if _, err := r.git("commit", "-m", fmt.Sprintf("%s Add release notes draft for %s\n\n[ci skip]", commitPrefix, newVersion)); err != nil {
+               if _, err := r.git("commit", "-m", fmt.Sprintf("%s Add release notes draft for %s\n\nRename to *-ready.md to continue. [ci skip]", commitPrefix, newVersion)); err != nil {
                        return err
                }
        }
 
-       if r.shouldPrepareVersions() {
-
-               // For docs, for now we assume that:
-               // The /docs subtree is up to date and ready to go.
-               // The hugoDocs/dev and hugoDocs/master must be merged manually after release.
-               // TODO(bep) improve this when we see how it works.
+       if !shouldRelease {
+               fmt.Printf("Skip release ... ")
+               return nil
+       }
 
-               if err := r.bumpVersions(newVersion); err != nil {
-                       return err
-               }
+       // For docs, for now we assume that:
+       // The /docs subtree is up to date and ready to go.
+       // The hugoDocs/dev and hugoDocs/master must be merged manually after release.
+       // TODO(bep) improve this when we see how it works.
 
-               if _, err := r.git("commit", "-a", "-m", fmt.Sprintf("%s Bump versions for release of %s\n\n[ci skip]", commitPrefix, newVersion)); err != nil {
-                       return err
-               }
+       if err := r.bumpVersions(newVersion); err != nil {
+               return err
        }
 
-       if !r.shouldRelease() {
-               fmt.Printf("Skip release ... Use --state=%d for next or --state=4 to finish\n", r.step+1)
-               return nil
+       if _, err := r.git("commit", "-a", "-m", fmt.Sprintf("%s Bump versions for release of %s\n\n[ci skip]", commitPrefix, newVersion)); err != nil {
+               return err
        }
 
-       releaseNotesFile := getReleaseNotesDocsTempFilename(version)
+       releaseNotesFile := getReleaseNotesDocsTempFilename(version, true)
 
        // Write the release notes to the docs site as well.
        docFile, err := r.writeReleaseNotesToDocs(version, releaseNotesFile)
@@ -196,12 +202,14 @@ func (r *ReleaseHandler) Run() error {
                return err
        }
 
-       if _, err := r.git("tag", "-a", tag, "-m", fmt.Sprintf("%s %s [ci deploy]", commitPrefix, newVersion)); err != nil {
+       if _, err := r.git("tag", "-a", tag, "-m", fmt.Sprintf("%s %s [ci skip]", commitPrefix, newVersion)); err != nil {
                return err
        }
 
-       if _, err := r.git("push", "origin", tag); err != nil {
-               return err
+       if !r.skipPublish {
+               if _, err := r.git("push", "origin", tag); err != nil {
+                       return err
+               }
        }
 
        if err := r.release(releaseNotesFile); err != nil {
@@ -226,6 +234,15 @@ func (r *ReleaseHandler) Run() error {
        return nil
 }
 
+func (r *ReleaseHandler) gitPush() {
+       if r.skipPublish {
+               return
+       }
+       if _, err := r.git("push", "origin", "HEAD"); err != nil {
+               log.Fatal("push failed:", err)
+       }
+}
+
 func (r *ReleaseHandler) release(releaseNotesFile string) error {
        if r.try {
                fmt.Println("Skip goreleaser...")
@@ -243,19 +260,16 @@ func (r *ReleaseHandler) release(releaseNotesFile string) error {
 }
 
 func (r *ReleaseHandler) bumpVersions(ver helpers.HugoVersion) error {
-       fromDev := ""
        toDev := ""
 
        if ver.Suffix != "" {
-               toDev = "-DEV"
-       } else {
-               fromDev = "-DEV"
+               toDev = ver.Suffix
        }
 
        if err := r.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 {
+               `Suffix:(\s{4,})".*",`, fmt.Sprintf(`Suffix:${1}"%s",`, toDev)); err != nil {
                return err
        }
 
@@ -325,3 +339,7 @@ func hugoFilepath(filename string) string {
        }
        return filepath.Join(pwd, filename)
 }
+
+func isCI() bool {
+       return os.Getenv("CI") != ""
+}