2016-07-14 10:55:04 +00:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bytes"
|
2023-11-11 14:27:37 +00:00
|
|
|
"errors"
|
2016-07-14 10:55:04 +00:00
|
|
|
"fmt"
|
2023-11-11 14:27:37 +00:00
|
|
|
"io/fs"
|
2016-07-14 10:55:04 +00:00
|
|
|
"os"
|
|
|
|
"os/exec"
|
2016-07-21 15:51:08 +00:00
|
|
|
"regexp"
|
2016-07-14 10:55:04 +00:00
|
|
|
"strings"
|
2016-10-08 21:25:18 +00:00
|
|
|
"text/template"
|
2016-07-14 10:55:04 +00:00
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/mitchellh/go-homedir"
|
2023-11-11 14:27:37 +00:00
|
|
|
"github.com/sirupsen/logrus"
|
2020-08-07 13:44:47 +00:00
|
|
|
|
2023-11-12 12:11:41 +00:00
|
|
|
"github.com/Luzifer/go_helpers/v2/env"
|
2020-08-07 13:44:47 +00:00
|
|
|
"github.com/Luzifer/rconfig/v2"
|
2016-07-14 10:55:04 +00:00
|
|
|
)
|
|
|
|
|
2023-11-11 14:27:37 +00:00
|
|
|
const (
|
|
|
|
fileModeChangelog = 0o644
|
|
|
|
fileModeConfig = 0o600
|
|
|
|
)
|
|
|
|
|
2016-07-14 10:55:04 +00:00
|
|
|
var (
|
|
|
|
cfg = struct {
|
|
|
|
ChangelogFile string `flag:"changelog" default:"History.md" description:"File to write the changelog to"`
|
|
|
|
ConfigFile string `flag:"config" default:"~/.git_changerelease.yaml" description:"Location of the configuration file"`
|
2018-07-03 07:46:37 +00:00
|
|
|
LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"`
|
2016-07-14 10:55:04 +00:00
|
|
|
MkConfig bool `flag:"create-config" default:"false" description:"Copy an example configuration file to the location of --config"`
|
|
|
|
NoEdit bool `flag:"no-edit" default:"false" description:"Do not open the $EDITOR to modify the changelog"`
|
|
|
|
|
2023-11-11 14:27:37 +00:00
|
|
|
PreRelease string `flag:"pre-release" default:"" description:"Pre-Release information to append to the version (i.e. 'beta' or 'alpha.1')"`
|
|
|
|
ReleaseMeta string `flag:"release-meta" default:"" description:"Release metadata to append to the version (i.e. 'exp.sha.5114f85' or '20130313144700')"`
|
2016-07-14 10:55:04 +00:00
|
|
|
|
|
|
|
VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"`
|
|
|
|
}{}
|
|
|
|
|
2018-07-03 07:46:37 +00:00
|
|
|
config *configFile
|
2016-07-14 10:55:04 +00:00
|
|
|
version = "dev"
|
2016-07-21 15:51:08 +00:00
|
|
|
|
|
|
|
matchers = make(map[*regexp.Regexp]semVerBump)
|
2023-11-11 14:27:37 +00:00
|
|
|
|
|
|
|
errExitZero = errors.New("should exit zero now")
|
2016-07-14 10:55:04 +00:00
|
|
|
)
|
|
|
|
|
2023-11-11 14:27:37 +00:00
|
|
|
func initApp() (err error) {
|
|
|
|
rconfig.AutoEnv(true)
|
|
|
|
if err = rconfig.Parse(&cfg); err != nil {
|
|
|
|
return fmt.Errorf("parsing cli options: %w", err)
|
2020-08-07 13:55:10 +00:00
|
|
|
}
|
|
|
|
|
2023-11-11 14:27:37 +00:00
|
|
|
if cfg.VersionAndExit {
|
|
|
|
fmt.Printf("git-changerelease %s\n", version) //nolint:forbidigo // Fine in this case
|
|
|
|
return errExitZero
|
2020-08-07 14:18:50 +00:00
|
|
|
}
|
2020-08-07 13:55:10 +00:00
|
|
|
|
2023-11-11 14:27:37 +00:00
|
|
|
cfg.ConfigFile, err = homedir.Expand(cfg.ConfigFile)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("expanding file path: %w", err)
|
2020-08-07 14:18:50 +00:00
|
|
|
}
|
2016-07-14 10:55:04 +00:00
|
|
|
|
2023-11-11 14:27:37 +00:00
|
|
|
var l logrus.Level
|
|
|
|
if l, err = logrus.ParseLevel(cfg.LogLevel); err != nil {
|
|
|
|
return fmt.Errorf("parsing log-level: %w", err)
|
2016-07-14 10:55:04 +00:00
|
|
|
}
|
2023-11-11 14:27:37 +00:00
|
|
|
logrus.SetLevel(l)
|
2016-07-14 10:55:04 +00:00
|
|
|
|
2023-11-11 14:27:37 +00:00
|
|
|
if cfg.MkConfig {
|
|
|
|
if err = os.WriteFile(cfg.ConfigFile, mustAsset("assets/git_changerelease.yaml"), fileModeConfig); err != nil {
|
|
|
|
return fmt.Errorf("writing example config to %q: %w", cfg.ConfigFile, err)
|
|
|
|
}
|
|
|
|
logrus.Infof("wrote an example configuration to %q", cfg.ConfigFile)
|
|
|
|
return errExitZero
|
2016-07-14 10:55:04 +00:00
|
|
|
}
|
|
|
|
|
2023-11-11 14:27:37 +00:00
|
|
|
if !cfg.NoEdit && os.Getenv("EDITOR") == "" {
|
|
|
|
return errors.New("tried to open the changelog in the editor but there is no $EDITOR in your env")
|
2018-07-03 07:46:37 +00:00
|
|
|
}
|
|
|
|
|
2023-11-11 15:15:41 +00:00
|
|
|
projectConfig, err := filenameInGitRoot(".git_changerelease.yaml")
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("building filename for project config: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
if config, err = loadConfig(cfg.ConfigFile, projectConfig); err != nil {
|
|
|
|
return fmt.Errorf("loading config file(s): %w", err)
|
2023-11-11 14:27:37 +00:00
|
|
|
}
|
2016-07-14 10:55:04 +00:00
|
|
|
|
2023-11-11 14:27:37 +00:00
|
|
|
// Collect matchers
|
|
|
|
if err = loadMatcherRegex(config.MatchPatch, semVerBumpPatch); err != nil {
|
|
|
|
return fmt.Errorf("loading patch matcher: %w", err)
|
2016-07-14 10:55:04 +00:00
|
|
|
}
|
2016-07-21 15:51:08 +00:00
|
|
|
|
2023-11-11 14:27:37 +00:00
|
|
|
if err = loadMatcherRegex(config.MatchMajor, semVerBumpMajor); err != nil {
|
|
|
|
return fmt.Errorf("loading major matcher: %w", err)
|
|
|
|
}
|
2020-08-07 13:55:10 +00:00
|
|
|
|
2023-11-11 14:27:37 +00:00
|
|
|
if cfg.ChangelogFile, err = filenameInGitRoot(cfg.ChangelogFile); err != nil {
|
|
|
|
return fmt.Errorf("getting absolute path to changelog file: %w", err)
|
2020-08-07 13:55:10 +00:00
|
|
|
}
|
2020-08-07 14:18:50 +00:00
|
|
|
|
2023-11-11 14:27:37 +00:00
|
|
|
return nil
|
2016-07-14 10:55:04 +00:00
|
|
|
}
|
|
|
|
|
2018-07-03 07:46:37 +00:00
|
|
|
func loadMatcherRegex(matches []string, bump semVerBump) error {
|
|
|
|
for _, match := range matches {
|
|
|
|
r, err := regexp.Compile(match)
|
|
|
|
if err != nil {
|
2023-11-11 14:27:37 +00:00
|
|
|
return fmt.Errorf("parsing regex %q: %w", match, err)
|
2018-07-03 07:46:37 +00:00
|
|
|
}
|
|
|
|
matchers[r] = bump
|
2016-07-14 10:55:04 +00:00
|
|
|
}
|
|
|
|
|
2018-07-03 07:46:37 +00:00
|
|
|
return nil
|
2016-07-14 10:55:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func main() {
|
2023-11-11 14:27:37 +00:00
|
|
|
var err error
|
|
|
|
if err = initApp(); err != nil {
|
|
|
|
if errors.Is(err, errExitZero) {
|
|
|
|
os.Exit(0)
|
|
|
|
}
|
|
|
|
logrus.WithError(err).Fatal("initializing app")
|
|
|
|
}
|
2016-07-14 12:41:32 +00:00
|
|
|
|
2016-07-14 10:55:04 +00:00
|
|
|
// Get last tag
|
2017-05-31 09:48:13 +00:00
|
|
|
lastTag, err := gitSilent("describe", "--tags", "--abbrev=0", `--match=v[0-9]*\.[0-9]*\.[0-9]*`)
|
2018-07-03 07:46:37 +00:00
|
|
|
if err != nil {
|
|
|
|
lastTag = "0.0.0"
|
|
|
|
}
|
|
|
|
|
|
|
|
logs, err := fetchGitLogs(lastTag, err != nil)
|
|
|
|
if err != nil {
|
2023-11-11 14:27:37 +00:00
|
|
|
logrus.WithError(err).Fatal("fetching git logs")
|
2018-07-03 07:46:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if len(logs) == 0 {
|
2023-11-11 14:27:37 +00:00
|
|
|
logrus.Info("found no changes since last tag, stopping now.")
|
2018-07-03 07:46:37 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Generate new version
|
|
|
|
newVersion, err := newVersionFromLogs(lastTag, logs)
|
|
|
|
if err != nil {
|
2023-11-11 14:27:37 +00:00
|
|
|
logrus.WithError(err).Fatal("bumping version")
|
2018-07-03 07:46:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Render log
|
2023-11-11 14:27:37 +00:00
|
|
|
if newVersion, err = renderLogAndGetVersion(newVersion, logs); err != nil {
|
|
|
|
logrus.WithError(err).Fatal("writing changelog")
|
2018-07-03 07:46:37 +00:00
|
|
|
}
|
|
|
|
|
2023-11-12 12:11:41 +00:00
|
|
|
for _, pc := range config.PreCommitCommands {
|
|
|
|
if err = runUserCommand(pc, map[string]string{"TAG_VERSION": newVersion.String()}); err != nil {
|
|
|
|
logrus.WithError(err).WithField("cmd", pc).Fatal("executing pre-commit-commands")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-07-03 07:46:37 +00:00
|
|
|
// Write the tag
|
|
|
|
if err = applyTag("v" + newVersion.String()); err != nil {
|
2023-11-11 14:27:37 +00:00
|
|
|
logrus.WithError(err).Fatal("applying tag")
|
2018-07-03 07:46:37 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-08-07 14:18:50 +00:00
|
|
|
func newVersionFromLogs(lastTag string, logs []commit) (*semVer, error) {
|
|
|
|
// Tetermine increase type
|
|
|
|
semVerBumpType, err := selectBumpType(logs)
|
|
|
|
if err != nil {
|
2023-11-11 14:27:37 +00:00
|
|
|
return nil, fmt.Errorf("determining bump type: %w", err)
|
2020-08-07 14:18:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Generate new version
|
|
|
|
newVersion, err := parseSemVer(lastTag)
|
|
|
|
if err != nil {
|
2023-11-11 14:27:37 +00:00
|
|
|
return nil, fmt.Errorf("parsing previous version: %w", err)
|
2020-08-07 14:18:50 +00:00
|
|
|
}
|
2023-11-11 14:27:37 +00:00
|
|
|
newVersion.Bump(semVerBumpType)
|
|
|
|
if err = newVersion.SetPrerelease(cfg.PreRelease); err != nil {
|
|
|
|
return newVersion, fmt.Errorf("setting prerelease: %w", err)
|
|
|
|
}
|
|
|
|
if err = newVersion.SetMetadata(cfg.ReleaseMeta); err != nil {
|
|
|
|
return newVersion, fmt.Errorf("setting metadata: %w", err)
|
2020-08-07 14:18:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return newVersion, nil
|
|
|
|
}
|
|
|
|
|
2023-11-11 14:27:37 +00:00
|
|
|
func readChangelog() (string, error) {
|
|
|
|
if _, err := os.Stat(cfg.ChangelogFile); err != nil {
|
|
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
|
|
logrus.Warn("changelog file does not yet exist, creating one")
|
|
|
|
return "", nil
|
2020-08-07 14:18:50 +00:00
|
|
|
}
|
2023-11-11 14:27:37 +00:00
|
|
|
return "", fmt.Errorf("getting file stat: %w", err)
|
2018-07-03 07:46:37 +00:00
|
|
|
}
|
|
|
|
|
2023-11-11 14:27:37 +00:00
|
|
|
d, err := os.ReadFile(cfg.ChangelogFile)
|
2020-08-07 14:18:50 +00:00
|
|
|
if err != nil {
|
2023-11-11 14:27:37 +00:00
|
|
|
return "", fmt.Errorf("reading file: %w", err)
|
2020-08-07 14:18:50 +00:00
|
|
|
}
|
2023-11-11 14:27:37 +00:00
|
|
|
return string(d), nil
|
2020-08-07 14:18:50 +00:00
|
|
|
}
|
2018-09-26 08:55:29 +00:00
|
|
|
|
2023-11-11 14:27:37 +00:00
|
|
|
func renderLogAndGetVersion(newVersion *semVer, logs []commit) (*semVer, error) {
|
|
|
|
oldLog, err := readChangelog()
|
2020-08-07 14:18:50 +00:00
|
|
|
if err != nil {
|
2023-11-11 14:27:37 +00:00
|
|
|
return nil, fmt.Errorf("reading old changelog: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
c, err := renderTemplate("log_template", mustAsset("assets/log_template.md"), struct {
|
|
|
|
NextVersion *semVer
|
|
|
|
Now time.Time
|
|
|
|
LogLines []commit
|
|
|
|
OldLog string
|
|
|
|
}{
|
|
|
|
NextVersion: newVersion,
|
|
|
|
Now: time.Now(),
|
|
|
|
LogLines: logs,
|
2023-11-11 14:29:38 +00:00
|
|
|
OldLog: strings.TrimSpace(oldLog),
|
2016-07-14 10:55:04 +00:00
|
|
|
})
|
2016-07-14 12:22:26 +00:00
|
|
|
if err != nil {
|
2023-11-11 14:27:37 +00:00
|
|
|
return nil, fmt.Errorf("rendering log: %w", err)
|
2016-07-14 12:22:26 +00:00
|
|
|
}
|
2016-07-14 10:55:04 +00:00
|
|
|
|
2023-11-11 14:27:37 +00:00
|
|
|
if err = os.WriteFile(cfg.ChangelogFile, c, fileModeChangelog); err != nil {
|
|
|
|
return nil, fmt.Errorf("writing changelog: %w", err)
|
2016-07-14 10:55:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Spawning editor
|
|
|
|
if !cfg.NoEdit {
|
2023-11-11 14:27:37 +00:00
|
|
|
editor := exec.Command(os.Getenv("EDITOR"), cfg.ChangelogFile) //#nosec:G204 // This is intended to use OS editor with configured changelog file
|
2016-07-14 10:55:04 +00:00
|
|
|
editor.Stdin = os.Stdin
|
|
|
|
editor.Stdout = os.Stdout
|
|
|
|
editor.Stderr = os.Stderr
|
2018-07-03 07:46:37 +00:00
|
|
|
if err = editor.Run(); err != nil {
|
2023-11-11 14:27:37 +00:00
|
|
|
return nil, fmt.Errorf("editor process caused error: %w", err)
|
2016-07-14 10:55:04 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Read back version from changelog file
|
2023-11-11 14:27:37 +00:00
|
|
|
changelog := strings.Split(string(c), "\n")
|
2016-07-14 10:55:04 +00:00
|
|
|
if len(changelog) < 1 {
|
2023-11-11 14:27:37 +00:00
|
|
|
return nil, errors.New("changelog is empty, no way to read back the version")
|
2016-07-14 10:55:04 +00:00
|
|
|
}
|
2023-11-11 14:27:37 +00:00
|
|
|
|
2016-07-14 11:44:21 +00:00
|
|
|
newVersion, err = parseSemVer(strings.Split(changelog[0], " ")[1])
|
|
|
|
if err != nil {
|
2023-11-11 14:27:37 +00:00
|
|
|
return nil, fmt.Errorf("parsing new version from log: %w", err)
|
2016-07-14 11:44:21 +00:00
|
|
|
}
|
2016-07-14 10:55:04 +00:00
|
|
|
|
2018-07-03 07:46:37 +00:00
|
|
|
return newVersion, nil
|
|
|
|
}
|
2023-11-11 14:27:37 +00:00
|
|
|
|
|
|
|
func renderTemplate(name string, tplSrc []byte, values any) ([]byte, error) {
|
|
|
|
tpl, err := template.New(name).Parse(string(tplSrc))
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("parsing template: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
buf := new(bytes.Buffer)
|
|
|
|
if err := tpl.Execute(buf, values); err != nil {
|
|
|
|
return nil, fmt.Errorf("executing template: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
return buf.Bytes(), nil
|
|
|
|
}
|
2023-11-12 12:11:41 +00:00
|
|
|
|
|
|
|
func runUserCommand(command string, extraEnv map[string]string) error {
|
|
|
|
if len(command) == 0 {
|
|
|
|
return errors.New("empty command specified")
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check whether command is prefixed with a minus: If so the error
|
|
|
|
// will only be logged and is not fatal to the changerelease run
|
|
|
|
fatal := command[0] != '-'
|
|
|
|
if !fatal {
|
|
|
|
command = command[1:]
|
|
|
|
}
|
|
|
|
|
|
|
|
logrus.WithField("fatal", fatal).WithField("command", command).Trace("running pre_commit_commands")
|
|
|
|
|
|
|
|
envVars := env.ListToMap(os.Environ())
|
|
|
|
for k, v := range extraEnv {
|
|
|
|
envVars[k] = v
|
|
|
|
}
|
|
|
|
|
|
|
|
wd, err := filenameInGitRoot(".")
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("determining workdir for commands: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
cmd := exec.Command("/usr/bin/env", "bash", "-ec", command)
|
|
|
|
cmd.Stderr = os.Stderr
|
|
|
|
cmd.Stdout = os.Stdout
|
|
|
|
cmd.Env = env.MapToList(envVars)
|
|
|
|
cmd.Dir = wd
|
|
|
|
|
|
|
|
if err = cmd.Run(); err != nil {
|
|
|
|
if fatal {
|
|
|
|
return fmt.Errorf("command had error: %w", err)
|
|
|
|
}
|
|
|
|
logrus.WithError(err).WithField("cmd", command).Warn("command had error, marked as non-fatal")
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|