Repository: goreleaser/goreleaser
Pull request #2683 (2021-11-22 12:32:18)

feat: allow to use .PreviousTag on templates
Previous tag will be available as a template field. It will be empty if there are no previous tag (e.g. first ever release of a repo).

Changelog still works by using the first commit on those cases (as it already does today).

caarlos0: one problem with this: on the first tag, this will actually be a commit... 🤔 which per-se is not that bad, its just that the name will not convey what it actually is at some point 🤔
codecov[bot]: # [Codecov](https://codecov.io/gh/goreleaser/goreleaser/pull/2683?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) Report
> Merging [#2683](https://codecov.io/gh/goreleaser/goreleaser/pull/2683?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) (b06be12) into [main](https://codecov.io/gh/goreleaser/goreleaser/commit/419b6fc7d26265dcf3ff8382c67d064b74535347?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) (419b6fc) will **increase** coverage by `0.01%`.
> The diff coverage is `85.00%`.

[![Impacted file tree graph](https://codecov.io/gh/goreleaser/goreleaser/pull/2683/graphs/tree.svg?width=650&height=150&src=pr&token=3bJvv6b7hN&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser)](https://codecov.io/gh/goreleaser/goreleaser/pull/2683?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser)

```diff
@@            Coverage Diff             @@
##             main    #2683      +/-   ##
==========================================
+ Coverage   84.76%   84.77%   +0.01%     
==========================================
  Files         103      103              
  Lines        7883     7890       +7     
==========================================
+ Hits         6682     6689       +7     
  Misses        985      985              
  Partials      216      216              
```


| [Impacted Files](https://codecov.io/gh/goreleaser/goreleaser/pull/2683?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) | Coverage Δ | |
|---|---|---|
| [pkg/context/context.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2683/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-cGtnL2NvbnRleHQvY29udGV4dC5nbw==) | `100.00% <ø> (ø)` | |
| [internal/pipe/changelog/changelog.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2683/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-aW50ZXJuYWwvcGlwZS9jaGFuZ2Vsb2cvY2hhbmdlbG9nLmdv) | `91.21% <62.50%> (-0.19%)` | :arrow_down: |
| [internal/pipe/git/git.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2683/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-aW50ZXJuYWwvcGlwZS9naXQvZ2l0Lmdv) | `87.97% <100.00%> (+0.89%)` | :arrow_up: |
| [internal/tmpl/tmpl.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2683/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-aW50ZXJuYWwvdG1wbC90bXBsLmdv) | `97.56% <100.00%> (+0.01%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/goreleaser/goreleaser/pull/2683?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/goreleaser/goreleaser/pull/2683?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser). Last update [419b6fc...b06be12](https://codecov.io/gh/goreleaser/goreleaser/pull/2683?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser).

blacktop: > one problem with this: on the first tag, this will actually be a commit... 🤔 which per-se is not that bad, its just that the name will not convey what it actually is at some point 🤔

Maybe a default `v0.0.0` would make sense?  I tried `v0.0.0` and a commit hash in github's URL and both 404ed (I know that is not the only purpose of this feature, however.  I kinda feel like a zero-ed out version would make more sense than a commit hash?
blacktop: I'm not intimately familiar with your code, but everything looked good to me. 👍 
caarlos0: > Maybe a default `v0.0.0` would make sense? I tried `v0.0.0` and a commit hash in github's URL and both 404ed (I know that is not the only purpose of this feature, however. I kinda feel like a zero-ed out version would make more sense than a commit hash?

my comment was outdated, it will now be empty as stated in the docs... which makes sense, as if there is no previous tag, that's what I would expect...

Patch:
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 43b264e3..34f858e0 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -48,6 +48,7 @@ changelog:
     - '^test:'
     - '^chore'
     - Merge pull request
+    - Merge remote-tracking branch
     - Merge branch
     - go mod tidy
   groups:
@@ -60,7 +61,6 @@ changelog:
     - title: Other work
       order: 999
 
-
 dockers:
 - image_templates:
   - 'goreleaser/goreleaser:{{ .Tag }}-amd64'
@@ -226,6 +226,8 @@ release:
   extra_files:
     - glob: ./cosign.pub
   footer: |
+    **Full Changelog**: https://github.com/goreleaser/goreleaser/{{ .PreviousTag }}...{{ .Tag }}
+
     ## What to do next?
 
     - Check out the [GoReleaser Pro](https://goreleaser.com/pro) distribution;
diff --git a/internal/pipe/changelog/changelog.go b/internal/pipe/changelog/changelog.go
index 655ab0ee..ca0d7f28 100644
--- a/internal/pipe/changelog/changelog.go
+++ b/internal/pipe/changelog/changelog.go
@@ -20,6 +20,8 @@ import (
 // ErrInvalidSortDirection happens when the sort order is invalid.
 var ErrInvalidSortDirection = errors.New("invalid sort direction")
 
+const li = "* "
+
 // Pipe for checksums.
 type Pipe struct{}
 
@@ -76,20 +78,24 @@ func (Pipe) Run(ctx *context.Context) error {
 		sort.Slice(groups, func(i, j int) bool { return groups[i].Order < groups[j].Order })
 		for _, group := range groups {
 			items := make([]string, 0)
-			if len(group.Regexp) > 0 {
+			if group.Regexp == "" {
+				// If no regexp is provided, we purge all strikethrough entries and add remaining entries to the list
+				items = getAllNonEmpty(entries)
+				// clear array
+				entries = nil
+			} else {
+				regex, err := regexp.Compile(group.Regexp)
+				if err != nil {
+					return fmt.Errorf("failed to group into %q: %w", group.Title, err)
+				}
 				for i, entry := range entries {
-					match := checkEntryType(group.Regexp, entry)
+					match := regex.MatchString(entry)
 					if match {
-						items = append(items, entry)
+						items = append(items, li+entry)
 						// Striking out the matched entry
 						entries[i] = ""
 					}
 				}
-			} else {
-				// If no regexp is provided, we purge all strikethrough entries and add remaining entries to the list
-				items = deleteEmptyElement(entries)
-				// clear array
-				entries = nil
 			}
 			if len(items) > 0 {
 				changelogElements = append(changelogElements, fmt.Sprintf("### %s", group.Title))
@@ -98,7 +104,7 @@ func (Pipe) Run(ctx *context.Context) error {
 		}
 	} else {
 		log.Debug("not grouping entries")
-		changelogElements = append(changelogElements, strings.Join(entries, changelogStringJoiner))
+		changelogElements = append(changelogElements, strings.Join(getAllNonEmpty(entries), changelogStringJoiner))
 	}
 
 	if header != "" {
@@ -118,22 +124,16 @@ func (Pipe) Run(ctx *context.Context) error {
 	return os.WriteFile(path, []byte(ctx.ReleaseNotes), 0o644) //nolint: gosec
 }
 
-func deleteEmptyElement(s []string) []string {
+func getAllNonEmpty(ss []string) []string {
 	var r []string
-	for _, str := range s {
-		if str != "" {
-			r = append(r, str)
+	for _, s := range ss {
+		if s != "" {
+			r = append(r, li+s)
 		}
 	}
 	return r
 }
 
-func checkEntryType(expr, entry string) bool {
-	regex, _ := regexp.Compile(expr)
-	match := regex.Match([]byte(entry))
-	return match
-}
-
 func loadFromFile(file string) (string, error) {
 	bts, err := os.ReadFile(file)
 	if err != nil {
@@ -214,9 +214,14 @@ func extractCommitInfo(line string) string {
 }
 
 func getChangelog(ctx *context.Context, tag string) (string, error) {
-	prev, err := previous(tag)
-	if err != nil {
-		return "", err
+	prev := ctx.Git.PreviousTag
+	if prev == "" {
+		// get first commit
+		result, err := git.Clean(git.Run("rev-list", "--max-parents=0", "HEAD"))
+		if err != nil {
+			return "", err
+		}
+		prev = result
 	}
 	return doGetChangelog(ctx, prev, tag)
 }
@@ -282,18 +287,6 @@ func newSCMChangeloger(ctx *context.Context) (changeloger, error) {
 	}, nil
 }
 
-func previous(tag string) (result string, err error) {
-	if tag := os.Getenv("GORELEASER_PREVIOUS_TAG"); tag != "" {
-		return tag, nil
-	}
-
-	result, err = git.Clean(git.Run("describe", "--tags", "--abbrev=0", fmt.Sprintf("tags/%s^", tag)))
-	if err != nil {
-		result, err = git.Clean(git.Run("rev-list", "--max-parents=0", "HEAD"))
-	}
-	return
-}
-
 func loadContent(ctx *context.Context, fileName, tmplName string) (string, error) {
 	if tmplName != "" {
 		log.Debugf("loading template %s", tmplName)
diff --git a/internal/pipe/git/git.go b/internal/pipe/git/git.go
index bf6d1cb3..943f1612 100644
--- a/internal/pipe/git/git.go
+++ b/internal/pipe/git/git.go
@@ -109,9 +109,17 @@ func getGitInfo() (context.GitInfo, error) {
 			CurrentTag:  "v0.0.0",
 		}, ErrNoTag
 	}
+
+	previous, err := getPreviousTag(tag)
+	if err != nil {
+		// shouldn't error, will only affect templates
+		log.Warnf("couldn't find any tags before %q", tag)
+	}
+
 	return context.GitInfo{
 		Branch:      branch,
 		CurrentTag:  tag,
+		PreviousTag: previous,
 		Commit:      full,
 		FullCommit:  full,
 		ShortCommit: short,
@@ -203,6 +211,14 @@ func getTag() (string, error) {
 	return tag, err
 }
 
+func getPreviousTag(current string) (string, error) {
+	if tag := os.Getenv("GORELEASER_PREVIOUS_TAG"); tag != "" {
+		return tag, nil
+	}
+
+	return git.Clean(git.Run("describe", "--tags", "--abbrev=0", fmt.Sprintf("tags/%s^", current)))
+}
+
 func getURL() (string, error) {
 	return git.Clean(git.Run("ls-remote", "--get-url"))
 }
diff --git a/internal/tmpl/tmpl.go b/internal/tmpl/tmpl.go
index 1926c273..46407423 100644
--- a/internal/tmpl/tmpl.go
+++ b/internal/tmpl/tmpl.go
@@ -31,6 +31,7 @@ const (
 	version         = "Version"
 	rawVersion      = "RawVersion"
 	tag             = "Tag"
+	previousTag     = "PreviousTag"
 	branch          = "Branch"
 	commit          = "Commit"
 	shortCommit     = "ShortCommit"
@@ -78,6 +79,7 @@ func New(ctx *context.Context) *Template {
 			version:         ctx.Version,
 			rawVersion:      rawVersionV,
 			tag:             ctx.Git.CurrentTag,
+			previousTag:     ctx.Git.PreviousTag,
 			branch:          ctx.Git.Branch,
 			commit:          ctx.Git.Commit,
 			shortCommit:     ctx.Git.ShortCommit,
diff --git a/pkg/context/context.go b/pkg/context/context.go
index 6435ef00..415ca8d2 100644
--- a/pkg/context/context.go
+++ b/pkg/context/context.go
@@ -20,6 +20,7 @@ import (
 type GitInfo struct {
 	Branch      string
 	CurrentTag  string
+	PreviousTag string
 	Commit      string
 	ShortCommit string
 	FullCommit  string
diff --git a/www/docs/customization/templates.md b/www/docs/customization/templates.md
index 14660be0..979b70fe 100644
--- a/www/docs/customization/templates.md
+++ b/www/docs/customization/templates.md
@@ -17,6 +17,7 @@ On fields that support templating, these fields are always available:
 | `.Branch`           | the current git branch                                                                                 |
 | `.PrefixedTag`      | the current git tag prefixed with the monorepo config tag prefix (if any)                              |
 | `.Tag`              | the current git tag                                                                                    |
+| `.PreviousTag`      | the previous git tag, or empty if no previous tags                                                     |
 | `.ShortCommit`      | the git commit short hash                                                                              |
 | `.FullCommit`       | the git commit full hash                                                                               |
 | `.Commit`           | the git commit hash (deprecated)                                                                       |


Test patch:
diff --git a/internal/pipe/changelog/changelog_test.go b/internal/pipe/changelog/changelog_test.go
index d04d7438..28eefd5a 100644
--- a/internal/pipe/changelog/changelog_test.go
+++ b/internal/pipe/changelog/changelog_test.go
@@ -3,6 +3,7 @@ package changelog
 import (
 	"os"
 	"path/filepath"
+	"strings"
 	"testing"
 
 	"github.com/stretchr/testify/require"
@@ -78,6 +79,7 @@ func TestChangelog(t *testing.T) {
 			},
 		},
 	})
+	ctx.Git.PreviousTag = "v0.0.1"
 	ctx.Git.CurrentTag = "v0.0.2"
 	require.NoError(t, Pipe{}.Run(ctx))
 	require.Contains(t, ctx.ReleaseNotes, "## Changelog")
@@ -89,37 +91,18 @@ func TestChangelog(t *testing.T) {
 	require.NotContains(t, ctx.ReleaseNotes, "cArs")
 	require.NotContains(t, ctx.ReleaseNotes, "from goreleaser/some-branch")
 
+	for _, line := range strings.Split(ctx.ReleaseNotes, "\n")[1:] {
+		if line == "" {
+			continue
+		}
+		require.Truef(t, strings.HasPrefix(line, "* "), "%q: changelog commit must be a list item", line)
+	}
+
 	bts, err := os.ReadFile(filepath.Join(folder, "CHANGELOG.md"))
 	require.NoError(t, err)
 	require.NotEmpty(t, string(bts))
 }
 
-func TestChangelogPreviousTagEnv(t *testing.T) {
-	folder := testlib.Mktmp(t)
-	testlib.GitInit(t)
-	testlib.GitCommit(t, "first")
-	testlib.GitTag(t, "v0.0.1")
-	testlib.GitCommit(t, "second")
-	testlib.GitTag(t, "v0.0.2")
-	testlib.GitCommit(t, "third")
-	testlib.GitTag(t, "v0.0.3")
-	ctx := context.New(config.Project{
-		Dist: folder,
-		Changelog: config.Changelog{
-			Use:     "git",
-			Filters: config.Filters{},
-		},
-	})
-	ctx.Git.CurrentTag = "v0.0.3"
-	require.NoError(t, os.Setenv("GORELEASER_PREVIOUS_TAG", "v0.0.1"))
-	require.NoError(t, Pipe{}.Run(ctx))
-	require.NoError(t, os.Setenv("GORELEASER_PREVIOUS_TAG", ""))
-	require.Contains(t, ctx.ReleaseNotes, "## Changelog")
-	require.NotContains(t, ctx.ReleaseNotes, "first")
-	require.Contains(t, ctx.ReleaseNotes, "second")
-	require.Contains(t, ctx.ReleaseNotes, "third")
-}
-
 func TestChangelogForGitlab(t *testing.T) {
 	folder := testlib.Mktmp(t)
 	testlib.GitInit(t)
@@ -148,6 +131,7 @@ func TestChangelogForGitlab(t *testing.T) {
 		},
 	})
 	ctx.TokenType = context.TokenTypeGitLab
+	ctx.Git.PreviousTag = "v0.0.1"
 	ctx.Git.CurrentTag = "v0.0.2"
 	require.NoError(t, Pipe{}.Run(ctx))
 	require.Contains(t, ctx.ReleaseNotes, "## Changelog")
@@ -176,6 +160,7 @@ func TestChangelogSort(t *testing.T) {
 	ctx := context.New(config.Project{
 		Changelog: config.Changelog{},
 	})
+	ctx.Git.PreviousTag = "v0.9.9"
 	ctx.Git.CurrentTag = "v1.0.0"
 
 	for _, cfg := range []struct {
@@ -268,6 +253,7 @@ func TestChangelogFilterInvalidRegex(t *testing.T) {
 			},
 		},
 	})
+	ctx.Git.PreviousTag = "v0.0.3"
 	ctx.Git.CurrentTag = "v0.0.4"
 	require.EqualError(t, Pipe{}.Run(ctx), "error parsing regexp: invalid or unsupported Perl syntax: `(?ia`")
 }
@@ -609,3 +595,24 @@ func TestGroup(t *testing.T) {
 	require.NotContains(t, ctx.ReleaseNotes, "### Catch nothing")
 	require.Contains(t, ctx.ReleaseNotes, "### Others")
 }
+
+func TestGroupBadRegex(t *testing.T) {
+	folder := testlib.Mktmp(t)
+	testlib.GitInit(t)
+	testlib.GitCommit(t, "first")
+	testlib.GitTag(t, "v0.0.1")
+	testlib.GitTag(t, "v0.0.2")
+	ctx := context.New(config.Project{
+		Dist: folder,
+		Changelog: config.Changelog{
+			Groups: []config.ChangeLogGroup{
+				{
+					Title:  "Something",
+					Regexp: "^.*feat[(\\w", // unterminated regex
+				},
+			},
+		},
+	})
+	ctx.Git.CurrentTag = "v0.0.2"
+	require.EqualError(t, Pipe{}.Run(ctx), `failed to group into "Something": error parsing regexp: missing closing ]: `+"`"+`[(\w`+"`")
+}
diff --git a/internal/pipe/git/git_test.go b/internal/pipe/git/git_test.go
index 0ab32307..cddfe8b4 100644
--- a/internal/pipe/git/git_test.go
+++ b/internal/pipe/git/git_test.go
@@ -201,6 +201,7 @@ func TestSnapshotNoTags(t *testing.T) {
 	ctx.Snapshot = true
 	testlib.AssertSkipped(t, Pipe{}.Run(ctx))
 	require.Equal(t, fakeInfo.CurrentTag, ctx.Git.CurrentTag)
+	require.Empty(t, ctx.Git.PreviousTag)
 }
 
 func TestSnapshotNoCommits(t *testing.T) {
@@ -277,6 +278,57 @@ func TestTagFromCI(t *testing.T) {
 	}
 }
 
+func TestNoPreviousTag(t *testing.T) {
+	testlib.Mktmp(t)
+	testlib.GitInit(t)
+	testlib.GitRemoteAdd(t, "git@github.com:foo/bar.git")
+	testlib.GitCommit(t, "commit1")
+	testlib.GitTag(t, "v0.0.1")
+	ctx := &context.Context{
+		Config: config.Project{},
+	}
+	require.NoError(t, Pipe{}.Run(ctx))
+	require.Equal(t, "v0.0.1", ctx.Git.CurrentTag)
+	require.Empty(t, ctx.Git.PreviousTag, "should be empty")
+}
+
+func TestPreviousTagFromCI(t *testing.T) {
+	testlib.Mktmp(t)
+	testlib.GitInit(t)
+	testlib.GitRemoteAdd(t, "git@github.com:foo/bar.git")
+	testlib.GitCommit(t, "commit1")
+	testlib.GitTag(t, "v0.0.1")
+	testlib.GitCommit(t, "commit2")
+	testlib.GitTag(t, "v0.0.2")
+
+	for _, tc := range []struct {
+		envs     map[string]string
+		expected string
+	}{
+		{expected: "v0.0.1"},
+		{
+			envs:     map[string]string{"GORELEASER_PREVIOUS_TAG": "v0.0.2"},
+			expected: "v0.0.2",
+		},
+	} {
+		t.Run(tc.expected, func(t *testing.T) {
+			for name, value := range tc.envs {
+				require.NoError(t, os.Setenv(name, value))
+			}
+
+			ctx := &context.Context{
+				Config: config.Project{},
+			}
+			require.NoError(t, Pipe{}.Run(ctx))
+			require.Equal(t, tc.expected, ctx.Git.PreviousTag)
+
+			for name := range tc.envs {
+				require.NoError(t, os.Setenv(name, ""))
+			}
+		})
+	}
+}
+
 func TestCommitDate(t *testing.T) {
 	// round to seconds since this is expressed in a Unix timestamp
 	commitDate := time.Now().AddDate(-1, 0, 0).Round(1 * time.Second)
@@ -291,5 +343,6 @@ func TestCommitDate(t *testing.T) {
 	}
 	require.NoError(t, Pipe{}.Run(ctx))
 	require.Equal(t, "v0.0.1", ctx.Git.CurrentTag)
+	require.Empty(t, ctx.Git.PreviousTag)
 	require.True(t, commitDate.Equal(ctx.Git.CommitDate), "commit date does not match expected")
 }
diff --git a/internal/tmpl/tmpl_test.go b/internal/tmpl/tmpl_test.go
index 13f76354..605ba1d0 100644
--- a/internal/tmpl/tmpl_test.go
+++ b/internal/tmpl/tmpl_test.go
@@ -22,6 +22,7 @@ func TestWithArtifact(t *testing.T) {
 		"FOO": "bar",
 	}
 	ctx.Version = "1.2.3"
+	ctx.Git.PreviousTag = "v1.2.2"
 	ctx.Git.CurrentTag = "v1.2.3"
 	ctx.Semver = context.Semver{
 		Major: 1,
@@ -56,6 +57,7 @@ func TestWithArtifact(t *testing.T) {
 		"v1.2.4":                           "{{.Tag | incpatch }}",
 		"1.2.4":                            "{{.Version | incpatch }}",
 		"test release notes":               "{{ .ReleaseNotes }}",
+		"v1.2.2":                           "{{ .PreviousTag }}",
 	} {
 		tmpl := tmpl
 		expect := expect