github.com/olliephillips/hugo@v0.42.2/releaser/releaser.go (about)

     1  // Copyright 2017-present The Hugo Authors. All rights reserved.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  // http://www.apache.org/licenses/LICENSE-2.0
     7  //
     8  // Unless required by applicable law or agreed to in writing, software
     9  // distributed under the License is distributed on an "AS IS" BASIS,
    10  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    11  // See the License for the specific language governing permissions and
    12  // limitations under the License.
    13  
    14  // Package releaser implements a set of utilities and a wrapper around Goreleaser
    15  // to help automate the Hugo release process.
    16  package releaser
    17  
    18  import (
    19  	"errors"
    20  	"fmt"
    21  	"io/ioutil"
    22  	"log"
    23  	"os"
    24  	"os/exec"
    25  	"path/filepath"
    26  	"regexp"
    27  	"strings"
    28  
    29  	"github.com/gohugoio/hugo/helpers"
    30  )
    31  
    32  const commitPrefix = "releaser:"
    33  
    34  type releaseNotesState int
    35  
    36  const (
    37  	releaseNotesNone = iota
    38  	releaseNotesCreated
    39  	releaseNotesReady
    40  )
    41  
    42  // ReleaseHandler provides functionality to release a new version of Hugo.
    43  type ReleaseHandler struct {
    44  	cliVersion string
    45  
    46  	skipPublish bool
    47  
    48  	// Just simulate, no actual changes.
    49  	try bool
    50  
    51  	git func(args ...string) (string, error)
    52  }
    53  
    54  func (r ReleaseHandler) calculateVersions() (helpers.HugoVersion, helpers.HugoVersion) {
    55  	newVersion := helpers.MustParseHugoVersion(r.cliVersion)
    56  	finalVersion := newVersion.Next()
    57  	finalVersion.PatchLevel = 0
    58  
    59  	if newVersion.Suffix != "-test" {
    60  		newVersion.Suffix = ""
    61  	}
    62  
    63  	finalVersion.Suffix = "-DEV"
    64  
    65  	return newVersion, finalVersion
    66  }
    67  
    68  // New initialises a ReleaseHandler.
    69  func New(version string, skipPublish, try bool) *ReleaseHandler {
    70  	// When triggered from CI release branch
    71  	version = strings.TrimPrefix(version, "release-")
    72  	version = strings.TrimPrefix(version, "v")
    73  	rh := &ReleaseHandler{cliVersion: version, skipPublish: skipPublish, try: try}
    74  
    75  	if try {
    76  		rh.git = func(args ...string) (string, error) {
    77  			fmt.Println("git", strings.Join(args, " "))
    78  			return "", nil
    79  		}
    80  	} else {
    81  		rh.git = git
    82  	}
    83  
    84  	return rh
    85  }
    86  
    87  // Run creates a new release.
    88  func (r *ReleaseHandler) Run() error {
    89  	if os.Getenv("GITHUB_TOKEN") == "" {
    90  		return errors.New("GITHUB_TOKEN not set, create one here with the repo scope selected: https://github.com/settings/tokens/new")
    91  	}
    92  
    93  	newVersion, finalVersion := r.calculateVersions()
    94  
    95  	version := newVersion.String()
    96  	tag := "v" + version
    97  
    98  	// Exit early if tag already exists
    99  	exists, err := tagExists(tag)
   100  	if err != nil {
   101  		return err
   102  	}
   103  
   104  	if exists {
   105  		return fmt.Errorf("Tag %q already exists", tag)
   106  	}
   107  
   108  	var changeLogFromTag string
   109  
   110  	if newVersion.PatchLevel == 0 {
   111  		// There may have been patch releases between, so set the tag explicitly.
   112  		changeLogFromTag = "v" + newVersion.Prev().String()
   113  		exists, _ := tagExists(changeLogFromTag)
   114  		if !exists {
   115  			// fall back to one that exists.
   116  			changeLogFromTag = ""
   117  		}
   118  	}
   119  
   120  	var (
   121  		gitCommits     gitInfos
   122  		gitCommitsDocs gitInfos
   123  		relNotesState  releaseNotesState
   124  	)
   125  
   126  	relNotesState, err = r.releaseNotesState(version)
   127  	if err != nil {
   128  		return err
   129  	}
   130  
   131  	prepareRelaseNotes := relNotesState == releaseNotesNone
   132  	shouldRelease := relNotesState == releaseNotesReady
   133  
   134  	defer r.gitPush() // TODO(bep)
   135  
   136  	if prepareRelaseNotes || shouldRelease {
   137  		gitCommits, err = getGitInfos(changeLogFromTag, "hugo", "", !r.try)
   138  		if err != nil {
   139  			return err
   140  		}
   141  
   142  		// TODO(bep) explicit tag?
   143  		gitCommitsDocs, err = getGitInfos("", "hugoDocs", "../hugoDocs", !r.try)
   144  		if err != nil {
   145  			return err
   146  		}
   147  	}
   148  
   149  	if relNotesState == releaseNotesCreated {
   150  		fmt.Println("Release notes created, but not ready. Reneame to *-ready.md to continue ...")
   151  		return nil
   152  	}
   153  
   154  	if prepareRelaseNotes {
   155  		releaseNotesFile, err := r.writeReleaseNotesToTemp(version, gitCommits, gitCommitsDocs)
   156  		if err != nil {
   157  			return err
   158  		}
   159  
   160  		if _, err := r.git("add", releaseNotesFile); err != nil {
   161  			return err
   162  		}
   163  		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 {
   164  			return err
   165  		}
   166  	}
   167  
   168  	if !shouldRelease {
   169  		fmt.Printf("Skip release ... ")
   170  		return nil
   171  	}
   172  
   173  	// For docs, for now we assume that:
   174  	// The /docs subtree is up to date and ready to go.
   175  	// The hugoDocs/dev and hugoDocs/master must be merged manually after release.
   176  	// TODO(bep) improve this when we see how it works.
   177  
   178  	if err := r.bumpVersions(newVersion); err != nil {
   179  		return err
   180  	}
   181  
   182  	if _, err := r.git("commit", "-a", "-m", fmt.Sprintf("%s Bump versions for release of %s\n\n[ci skip]", commitPrefix, newVersion)); err != nil {
   183  		return err
   184  	}
   185  
   186  	releaseNotesFile := getReleaseNotesDocsTempFilename(version, true)
   187  
   188  	// Write the release notes to the docs site as well.
   189  	docFile, err := r.writeReleaseNotesToDocs(version, releaseNotesFile)
   190  	if err != nil {
   191  		return err
   192  	}
   193  
   194  	if _, err := r.git("add", docFile); err != nil {
   195  		return err
   196  	}
   197  	if _, err := r.git("commit", "-m", fmt.Sprintf("%s Add release notes to /docs for release of %s\n\n[ci skip]", commitPrefix, newVersion)); err != nil {
   198  		return err
   199  	}
   200  
   201  	if _, err := r.git("tag", "-a", tag, "-m", fmt.Sprintf("%s %s [ci skip]", commitPrefix, newVersion)); err != nil {
   202  		return err
   203  	}
   204  
   205  	if !r.skipPublish {
   206  		if _, err := r.git("push", "origin", tag); err != nil {
   207  			return err
   208  		}
   209  	}
   210  
   211  	if err := r.release(releaseNotesFile); err != nil {
   212  		return err
   213  	}
   214  
   215  	if err := r.bumpVersions(finalVersion); err != nil {
   216  		return err
   217  	}
   218  
   219  	if !r.try {
   220  		// No longer needed.
   221  		if err := os.Remove(releaseNotesFile); err != nil {
   222  			return err
   223  		}
   224  	}
   225  
   226  	if _, err := r.git("commit", "-a", "-m", fmt.Sprintf("%s Prepare repository for %s\n\n[ci skip]", commitPrefix, finalVersion)); err != nil {
   227  		return err
   228  	}
   229  
   230  	return nil
   231  }
   232  
   233  func (r *ReleaseHandler) gitPush() {
   234  	if r.skipPublish {
   235  		return
   236  	}
   237  	if _, err := r.git("push", "origin", "HEAD"); err != nil {
   238  		log.Fatal("push failed:", err)
   239  	}
   240  }
   241  
   242  func (r *ReleaseHandler) release(releaseNotesFile string) error {
   243  	if r.try {
   244  		fmt.Println("Skip goreleaser...")
   245  		return nil
   246  	}
   247  
   248  	cmd := exec.Command("goreleaser", "--rm-dist", "--release-notes", releaseNotesFile, "--skip-publish="+fmt.Sprint(r.skipPublish))
   249  	cmd.Stdout = os.Stdout
   250  	cmd.Stderr = os.Stderr
   251  	err := cmd.Run()
   252  	if err != nil {
   253  		return fmt.Errorf("goreleaser failed: %s", err)
   254  	}
   255  	return nil
   256  }
   257  
   258  func (r *ReleaseHandler) bumpVersions(ver helpers.HugoVersion) error {
   259  	toDev := ""
   260  
   261  	if ver.Suffix != "" {
   262  		toDev = ver.Suffix
   263  	}
   264  
   265  	if err := r.replaceInFile("helpers/hugo.go",
   266  		`Number:(\s{4,})(.*),`, fmt.Sprintf(`Number:${1}%.2f,`, ver.Number),
   267  		`PatchLevel:(\s*)(.*),`, fmt.Sprintf(`PatchLevel:${1}%d,`, ver.PatchLevel),
   268  		`Suffix:(\s{4,})".*",`, fmt.Sprintf(`Suffix:${1}"%s",`, toDev)); err != nil {
   269  		return err
   270  	}
   271  
   272  	snapcraftGrade := "stable"
   273  	if ver.Suffix != "" {
   274  		snapcraftGrade = "devel"
   275  	}
   276  	if err := r.replaceInFile("snapcraft.yaml",
   277  		`version: "(.*)"`, fmt.Sprintf(`version: "%s"`, ver),
   278  		`grade: (.*) #`, fmt.Sprintf(`grade: %s #`, snapcraftGrade)); err != nil {
   279  		return err
   280  	}
   281  
   282  	var minVersion string
   283  	if ver.Suffix != "" {
   284  		// People use the DEV version in daily use, and we cannot create new themes
   285  		// with the next version before it is released.
   286  		minVersion = ver.Prev().String()
   287  	} else {
   288  		minVersion = ver.String()
   289  	}
   290  
   291  	if err := r.replaceInFile("commands/new.go",
   292  		`min_version = "(.*)"`, fmt.Sprintf(`min_version = "%s"`, minVersion)); err != nil {
   293  		return err
   294  	}
   295  
   296  	// docs/config.toml
   297  	if err := r.replaceInFile("docs/config.toml",
   298  		`release = "(.*)"`, fmt.Sprintf(`release = "%s"`, ver)); err != nil {
   299  		return err
   300  	}
   301  
   302  	return nil
   303  }
   304  
   305  func (r *ReleaseHandler) replaceInFile(filename string, oldNew ...string) error {
   306  	fullFilename := hugoFilepath(filename)
   307  	fi, err := os.Stat(fullFilename)
   308  	if err != nil {
   309  		return err
   310  	}
   311  
   312  	if r.try {
   313  		fmt.Printf("Replace in %q: %q\n", filename, oldNew)
   314  		return nil
   315  	}
   316  
   317  	b, err := ioutil.ReadFile(fullFilename)
   318  	if err != nil {
   319  		return err
   320  	}
   321  	newContent := string(b)
   322  
   323  	for i := 0; i < len(oldNew); i += 2 {
   324  		re := regexp.MustCompile(oldNew[i])
   325  		newContent = re.ReplaceAllString(newContent, oldNew[i+1])
   326  	}
   327  
   328  	return ioutil.WriteFile(fullFilename, []byte(newContent), fi.Mode())
   329  }
   330  
   331  func hugoFilepath(filename string) string {
   332  	pwd, err := os.Getwd()
   333  	if err != nil {
   334  		log.Fatal(err)
   335  	}
   336  	return filepath.Join(pwd, filename)
   337  }
   338  
   339  func isCI() bool {
   340  	return os.Getenv("CI") != ""
   341  }