mirror of
https://github.com/goreleaser/goreleaser.git
synced 2025-03-17 20:47:50 +02:00
482 lines
12 KiB
Go
482 lines
12 KiB
Go
// Package changelog provides the release changelog to goreleaser.
|
|
package changelog
|
|
|
|
import (
|
|
"cmp"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/caarlos0/log"
|
|
"github.com/goreleaser/goreleaser/v2/internal/client"
|
|
"github.com/goreleaser/goreleaser/v2/internal/git"
|
|
"github.com/goreleaser/goreleaser/v2/internal/tmpl"
|
|
"github.com/goreleaser/goreleaser/v2/pkg/context"
|
|
)
|
|
|
|
// ErrInvalidSortDirection happens when the sort order is invalid.
|
|
var ErrInvalidSortDirection = errors.New("invalid sort direction")
|
|
|
|
const li = "* "
|
|
|
|
type useChangelog string
|
|
|
|
func (u useChangelog) formatable() bool {
|
|
return u != "github-native"
|
|
}
|
|
|
|
const (
|
|
useGit = "git"
|
|
useGitHub = "github"
|
|
useGitea = "gitea"
|
|
useGitLab = "gitlab"
|
|
useGitHubNative = "github-native"
|
|
)
|
|
|
|
// Pipe for checksums.
|
|
type Pipe struct{}
|
|
|
|
func (Pipe) String() string { return "generating changelog" }
|
|
|
|
func (Pipe) Skip(ctx *context.Context) (bool, error) {
|
|
if ctx.Snapshot {
|
|
return true, nil
|
|
}
|
|
|
|
return tmpl.New(ctx).Bool(ctx.Config.Changelog.Disable)
|
|
}
|
|
|
|
func (Pipe) Default(ctx *context.Context) error {
|
|
if ctx.Config.Changelog.Format == "" {
|
|
ctx.Config.Changelog.Format = "{{ .SHA }}: {{ .Message }} ({{ with .AuthorUsername }}@{{ . }}{{ else }}{{ .AuthorName }} <{{ .AuthorEmail }}>{{ end }})"
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Run the pipe.
|
|
func (Pipe) Run(ctx *context.Context) error {
|
|
notes, err := loadContent(ctx, ctx.ReleaseNotesFile, ctx.ReleaseNotesTmpl)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ctx.ReleaseNotes = notes
|
|
|
|
if ctx.ReleaseNotesFile != "" || ctx.ReleaseNotesTmpl != "" {
|
|
return nil
|
|
}
|
|
|
|
footer, err := loadContent(ctx, ctx.ReleaseFooterFile, ctx.ReleaseFooterTmpl)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
header, err := loadContent(ctx, ctx.ReleaseHeaderFile, ctx.ReleaseHeaderTmpl)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := checkSortDirection(ctx.Config.Changelog.Sort); err != nil {
|
|
return err
|
|
}
|
|
|
|
entries, err := buildChangelog(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
changes, err := formatChangelog(ctx, entries)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
changelogElements := []string{changes}
|
|
|
|
if header != "" {
|
|
changelogElements = append([]string{header}, changelogElements...)
|
|
}
|
|
if footer != "" {
|
|
changelogElements = append(changelogElements, footer)
|
|
}
|
|
|
|
ctx.ReleaseNotes = strings.Join(changelogElements, "\n\n")
|
|
if !strings.HasSuffix(ctx.ReleaseNotes, "\n") {
|
|
ctx.ReleaseNotes += "\n"
|
|
}
|
|
|
|
path := filepath.Join(ctx.Config.Dist, "CHANGELOG.md")
|
|
log.WithField("path", path).Debug("writing changelog")
|
|
return os.WriteFile(path, []byte(ctx.ReleaseNotes), 0o644) //nolint:gosec
|
|
}
|
|
|
|
type changelogGroup struct {
|
|
title string
|
|
entries []string
|
|
order int
|
|
}
|
|
|
|
func title(s string, level int) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%s %s", strings.Repeat("#", level), s)
|
|
}
|
|
|
|
func newLineFor(ctx *context.Context) string {
|
|
if ctx.TokenType == context.TokenTypeGitLab || ctx.TokenType == context.TokenTypeGitea {
|
|
// We need two or more whitespace to let markdown interpret
|
|
// it as newline. See https://docs.gitlab.com/ee/user/markdown.html#newlines for details
|
|
log.Debug("is gitlab or gitea changelog")
|
|
return " \n"
|
|
}
|
|
|
|
return "\n"
|
|
}
|
|
|
|
func abbrevEntry(s string, abbr int) string {
|
|
switch abbr {
|
|
case 0:
|
|
return s
|
|
case -1:
|
|
_, rest, _ := strings.Cut(s, " ")
|
|
return rest
|
|
default:
|
|
commit, rest, _ := strings.Cut(s, " ")
|
|
if abbr > len(commit) {
|
|
return s
|
|
}
|
|
return fmt.Sprintf("%s %s", commit[:abbr], rest)
|
|
}
|
|
}
|
|
|
|
func abbrev(entries []string, abbr int) []string {
|
|
result := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
result = append(result, abbrevEntry(entry, abbr))
|
|
}
|
|
return result
|
|
}
|
|
|
|
func formatChangelog(ctx *context.Context, entries []string) (string, error) {
|
|
if !useChangelog(ctx.Config.Changelog.Use).formatable() {
|
|
return strings.Join(entries, newLineFor(ctx)), nil
|
|
}
|
|
|
|
entries = abbrev(entries, ctx.Config.Changelog.Abbrev)
|
|
|
|
result := []string{title("Changelog", 2)}
|
|
if len(ctx.Config.Changelog.Groups) == 0 {
|
|
log.Debug("not grouping entries")
|
|
return strings.Join(append(result, filterAndPrefixItems(entries)...), newLineFor(ctx)), nil
|
|
}
|
|
|
|
log.Debug("grouping entries")
|
|
var groups []changelogGroup
|
|
for _, group := range ctx.Config.Changelog.Groups {
|
|
item := changelogGroup{
|
|
title: title(group.Title, 3),
|
|
order: group.Order,
|
|
}
|
|
if group.Regexp == "" {
|
|
// If no regexp is provided, we purge all strikethrough entries and add remaining entries to the list
|
|
item.entries = filterAndPrefixItems(entries)
|
|
// clear array
|
|
entries = nil
|
|
} else {
|
|
re, err := regexp.Compile(group.Regexp)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to group into %q: %w", group.Title, err)
|
|
}
|
|
|
|
log.Debugf("group: %#v", group)
|
|
i := 0
|
|
for _, entry := range entries {
|
|
match := re.MatchString(entry)
|
|
log.Debugf("entry: %s match: %b\n", entry, match)
|
|
if match {
|
|
item.entries = append(item.entries, li+entry)
|
|
} else {
|
|
// Keep unmatched entry.
|
|
entries[i] = entry
|
|
i++
|
|
}
|
|
}
|
|
entries = entries[:i]
|
|
}
|
|
groups = append(groups, item)
|
|
|
|
if len(entries) == 0 {
|
|
break // No more entries to process.
|
|
}
|
|
}
|
|
|
|
slices.SortFunc(groups, groupSort)
|
|
for _, group := range groups {
|
|
if len(group.entries) > 0 {
|
|
result = append(result, group.title)
|
|
result = append(result, group.entries...)
|
|
}
|
|
}
|
|
return strings.Join(result, newLineFor(ctx)), nil
|
|
}
|
|
|
|
func groupSort(i, j changelogGroup) int {
|
|
return cmp.Compare(i.order, j.order)
|
|
}
|
|
|
|
func filterAndPrefixItems(ss []string) []string {
|
|
var r []string
|
|
for _, s := range ss {
|
|
if s != "" {
|
|
r = append(r, li+s)
|
|
}
|
|
}
|
|
return r
|
|
}
|
|
|
|
func loadFromFile(file string) (string, error) {
|
|
bts, err := os.ReadFile(file)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
log.WithField("file", file).Debugf("read %d bytes", len(bts))
|
|
return string(bts), nil
|
|
}
|
|
|
|
func checkSortDirection(mode string) error {
|
|
switch mode {
|
|
case "", "asc", "desc":
|
|
return nil
|
|
default:
|
|
return ErrInvalidSortDirection
|
|
}
|
|
}
|
|
|
|
func buildChangelog(ctx *context.Context) ([]string, error) {
|
|
l, err := getChangeloger(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
log, err := l.Log(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entries := strings.Split(log, "\n")
|
|
if lastLine := entries[len(entries)-1]; strings.TrimSpace(lastLine) == "" {
|
|
entries = entries[0 : len(entries)-1]
|
|
}
|
|
if !useChangelog(ctx.Config.Changelog.Use).formatable() {
|
|
return entries, nil
|
|
}
|
|
entries, err = filterEntries(ctx, entries)
|
|
if err != nil {
|
|
return entries, err
|
|
}
|
|
return sortEntries(ctx, entries), nil
|
|
}
|
|
|
|
func filterEntries(ctx *context.Context, entries []string) ([]string, error) {
|
|
filters := ctx.Config.Changelog.Filters
|
|
if len(filters.Include) > 0 {
|
|
var newEntries []string
|
|
for _, filter := range filters.Include {
|
|
r, err := regexp.Compile(filter)
|
|
if err != nil {
|
|
return entries, err
|
|
}
|
|
newEntries = append(newEntries, keep(r, entries)...)
|
|
}
|
|
return newEntries, nil
|
|
}
|
|
for _, filter := range filters.Exclude {
|
|
r, err := regexp.Compile(filter)
|
|
if err != nil {
|
|
return entries, err
|
|
}
|
|
entries = remove(r, entries)
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func sortEntries(ctx *context.Context, entries []string) []string {
|
|
direction := ctx.Config.Changelog.Sort
|
|
if direction == "" {
|
|
return entries
|
|
}
|
|
slices.SortFunc(entries, func(i, j string) int {
|
|
imsg := extractCommitInfo(i)
|
|
jmsg := extractCommitInfo(j)
|
|
compareRes := strings.Compare(imsg, jmsg)
|
|
if direction == "asc" {
|
|
return compareRes
|
|
}
|
|
return -compareRes
|
|
})
|
|
return entries
|
|
}
|
|
|
|
func keep(filter *regexp.Regexp, entries []string) (result []string) {
|
|
for _, entry := range entries {
|
|
if filter.MatchString(extractCommitInfo(entry)) {
|
|
result = append(result, entry)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func remove(filter *regexp.Regexp, entries []string) (result []string) {
|
|
for _, entry := range entries {
|
|
if !filter.MatchString(extractCommitInfo(entry)) {
|
|
result = append(result, entry)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func extractCommitInfo(line string) string {
|
|
return strings.Join(strings.Split(line, " ")[1:], " ")
|
|
}
|
|
|
|
func getChangeloger(ctx *context.Context) (changeloger, error) {
|
|
switch ctx.Config.Changelog.Use {
|
|
case useGit, "":
|
|
return gitChangeloger{}, nil
|
|
case useGitLab, useGitea, useGitHub:
|
|
if ctx.Git.PreviousTag == "" {
|
|
log.Warnf("there's no previous tag, using 'git' instead of '%s'", ctx.Config.Changelog.Use)
|
|
return gitChangeloger{}, nil
|
|
}
|
|
return newSCMChangeloger(ctx)
|
|
case useGitHubNative:
|
|
return newGithubChangeloger(ctx)
|
|
default:
|
|
return nil, fmt.Errorf("invalid changelog.use: %q", ctx.Config.Changelog.Use)
|
|
}
|
|
}
|
|
|
|
func newGithubChangeloger(ctx *context.Context) (changeloger, error) {
|
|
cli, err := client.NewGitHubReleaseNotesGenerator(ctx, ctx.Token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
repo, err := git.ExtractRepoFromConfig(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := repo.CheckSCM(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &githubNativeChangeloger{
|
|
client: cli,
|
|
repo: client.Repo{
|
|
Owner: repo.Owner,
|
|
Name: repo.Name,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func newSCMChangeloger(ctx *context.Context) (changeloger, error) {
|
|
cli, err := client.New(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
repo, err := git.ExtractRepoFromConfig(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := repo.CheckSCM(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &scmChangeloger{
|
|
client: cli,
|
|
repo: client.Repo{
|
|
Owner: repo.Owner,
|
|
Name: repo.Name,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func loadContent(ctx *context.Context, fileName, tmplName string) (string, error) {
|
|
if tmplName != "" {
|
|
log.Debugf("loading template %q", tmplName)
|
|
content, err := loadFromFile(tmplName)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
content, err = tmpl.New(ctx).Apply(content)
|
|
if strings.TrimSpace(content) == "" && err == nil {
|
|
log.Warnf("loaded %q, but it evaluates to an empty string", tmplName)
|
|
}
|
|
return content, err
|
|
}
|
|
|
|
if fileName != "" {
|
|
log.Debugf("loading file %q", fileName)
|
|
content, err := loadFromFile(fileName)
|
|
if strings.TrimSpace(content) == "" && err == nil {
|
|
log.Warnf("loaded %q, but it is empty", fileName)
|
|
}
|
|
return content, err
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
type changeloger interface {
|
|
Log(ctx *context.Context) (string, error)
|
|
}
|
|
|
|
type gitChangeloger struct{}
|
|
|
|
func (g gitChangeloger) Log(ctx *context.Context) (string, error) {
|
|
args := []string{"log", "--pretty=oneline", "--no-decorate", "--no-color"}
|
|
// if prev is empty, it means we don't have a previous tag, so we don't
|
|
// pass any more args, which should everything.
|
|
// if current is empty, it shouldn't matter, as it will then log
|
|
// `{prev}..`, which should log everything from prev to HEAD.
|
|
prev, current := ctx.Git.PreviousTag, ctx.Git.CurrentTag
|
|
if prev != "" {
|
|
args = append(args, fmt.Sprintf("%s..%s", prev, current))
|
|
}
|
|
return git.Run(ctx, args...)
|
|
}
|
|
|
|
type scmChangeloger struct {
|
|
client client.Client
|
|
repo client.Repo
|
|
}
|
|
|
|
func (c *scmChangeloger) Log(ctx *context.Context) (string, error) {
|
|
prev, current := ctx.Git.PreviousTag, ctx.Git.CurrentTag
|
|
items, err := c.client.Changelog(ctx, c.repo, prev, current)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var lines []string
|
|
for _, item := range items {
|
|
line, err := tmpl.New(ctx).WithExtraFields(tmpl.Fields{
|
|
"SHA": item.SHA,
|
|
"Message": item.Message,
|
|
"AuthorUsername": item.AuthorUsername,
|
|
"AuthorName": item.AuthorName,
|
|
"AuthorEmail": item.AuthorEmail,
|
|
}).Apply(ctx.Config.Changelog.Format)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
lines = append(lines, line)
|
|
}
|
|
return strings.Join(lines, "\n"), nil
|
|
}
|
|
|
|
type githubNativeChangeloger struct {
|
|
client client.ReleaseNotesGenerator
|
|
repo client.Repo
|
|
}
|
|
|
|
func (c *githubNativeChangeloger) Log(ctx *context.Context) (string, error) {
|
|
return c.client.GenerateReleaseNotes(ctx, c.repo, ctx.Git.PreviousTag, ctx.Git.CurrentTag)
|
|
}
|