github.com/triarius/goreleaser@v1.12.5/internal/pipe/changelog/changelog.go (about) 1 // Package changelog provides the release changelog to goreleaser. 2 package changelog 3 4 import ( 5 "errors" 6 "fmt" 7 "os" 8 "path/filepath" 9 "regexp" 10 "sort" 11 "strings" 12 13 "github.com/caarlos0/log" 14 "github.com/triarius/goreleaser/internal/client" 15 "github.com/triarius/goreleaser/internal/git" 16 "github.com/triarius/goreleaser/internal/tmpl" 17 "github.com/triarius/goreleaser/pkg/context" 18 ) 19 20 // ErrInvalidSortDirection happens when the sort order is invalid. 21 var ErrInvalidSortDirection = errors.New("invalid sort direction") 22 23 const li = "* " 24 25 type useChangelog string 26 27 func (u useChangelog) formatable() bool { 28 return u != "github-native" 29 } 30 31 const ( 32 useGit = "git" 33 useGitHub = "github" 34 useGitLab = "gitlab" 35 useGitHubNative = "github-native" 36 ) 37 38 // Pipe for checksums. 39 type Pipe struct{} 40 41 func (Pipe) String() string { return "generating changelog" } 42 func (Pipe) Skip(ctx *context.Context) bool { return ctx.Config.Changelog.Skip || ctx.Snapshot } 43 44 // Run the pipe. 45 func (Pipe) Run(ctx *context.Context) error { 46 notes, err := loadContent(ctx, ctx.ReleaseNotesFile, ctx.ReleaseNotesTmpl) 47 if err != nil { 48 return err 49 } 50 ctx.ReleaseNotes = notes 51 52 if ctx.ReleaseNotesFile != "" || ctx.ReleaseNotesTmpl != "" { 53 return nil 54 } 55 56 footer, err := loadContent(ctx, ctx.ReleaseFooterFile, ctx.ReleaseFooterTmpl) 57 if err != nil { 58 return err 59 } 60 61 header, err := loadContent(ctx, ctx.ReleaseHeaderFile, ctx.ReleaseHeaderTmpl) 62 if err != nil { 63 return err 64 } 65 66 if err := checkSortDirection(ctx.Config.Changelog.Sort); err != nil { 67 return err 68 } 69 70 entries, err := buildChangelog(ctx) 71 if err != nil { 72 return err 73 } 74 75 changes, err := formatChangelog(ctx, entries) 76 if err != nil { 77 return err 78 } 79 changelogElements := []string{changes} 80 81 if header != "" { 82 changelogElements = append([]string{header}, changelogElements...) 83 } 84 if footer != "" { 85 changelogElements = append(changelogElements, footer) 86 } 87 88 ctx.ReleaseNotes = strings.Join(changelogElements, "\n\n") 89 if !strings.HasSuffix(ctx.ReleaseNotes, "\n") { 90 ctx.ReleaseNotes += "\n" 91 } 92 93 path := filepath.Join(ctx.Config.Dist, "CHANGELOG.md") 94 log.WithField("changelog", path).Info("writing") 95 return os.WriteFile(path, []byte(ctx.ReleaseNotes), 0o644) //nolint: gosec 96 } 97 98 type changelogGroup struct { 99 title string 100 entries []string 101 order int 102 } 103 104 func formatChangelog(ctx *context.Context, entries []string) (string, error) { 105 newLine := "\n" 106 if ctx.TokenType == context.TokenTypeGitLab || ctx.TokenType == context.TokenTypeGitea { 107 // We need two or more whitespace to let markdown interpret 108 // it as newline. See https://docs.gitlab.com/ee/user/markdown.html#newlines for details 109 log.Debug("is gitlab or gitea changelog") 110 newLine = " \n" 111 } 112 113 if !useChangelog(ctx.Config.Changelog.Use).formatable() { 114 return strings.Join(entries, newLine), nil 115 } 116 117 for i := range entries { 118 entry := entries[i] 119 abbr := ctx.Config.Changelog.Abbrev 120 switch abbr { 121 case 0: 122 continue 123 case -1: 124 _, rest, _ := strings.Cut(entry, " ") 125 entries[i] = rest 126 default: 127 commit, rest, _ := strings.Cut(entry, " ") 128 if abbr > len(commit) { 129 continue 130 } 131 entries[i] = fmt.Sprintf("%s %s", commit[:abbr], rest) 132 } 133 } 134 135 result := []string{"## Changelog"} 136 if len(ctx.Config.Changelog.Groups) == 0 { 137 log.Debug("not grouping entries") 138 return strings.Join(append(result, filterAndPrefixItems(entries)...), newLine), nil 139 } 140 141 log.Debug("grouping entries") 142 var groups []changelogGroup 143 for _, group := range ctx.Config.Changelog.Groups { 144 item := changelogGroup{ 145 title: group.Title, 146 order: group.Order, 147 } 148 if group.Regexp == "" { 149 // If no regexp is provided, we purge all strikethrough entries and add remaining entries to the list 150 item.entries = filterAndPrefixItems(entries) 151 // clear array 152 entries = nil 153 } else { 154 regex, err := regexp.Compile(group.Regexp) 155 if err != nil { 156 return "", fmt.Errorf("failed to group into %q: %w", group.Title, err) 157 } 158 for i, entry := range entries { 159 match := regex.MatchString(entry) 160 if match { 161 item.entries = append(item.entries, li+entry) 162 // Striking out the matched entry 163 entries[i] = "" 164 } 165 } 166 } 167 groups = append(groups, item) 168 } 169 170 sort.Slice(groups, func(i, j int) bool { return groups[i].order < groups[j].order }) 171 for _, group := range groups { 172 if len(group.entries) > 0 { 173 result = append(result, fmt.Sprintf("\n### %s", group.title)) 174 result = append(result, group.entries...) 175 } 176 } 177 return strings.Join(result, newLine), nil 178 } 179 180 func filterAndPrefixItems(ss []string) []string { 181 var r []string 182 for _, s := range ss { 183 if s != "" { 184 r = append(r, li+s) 185 } 186 } 187 return r 188 } 189 190 func loadFromFile(file string) (string, error) { 191 bts, err := os.ReadFile(file) 192 if err != nil { 193 return "", err 194 } 195 log.WithField("file", file).Debugf("read %d bytes", len(bts)) 196 return string(bts), nil 197 } 198 199 func checkSortDirection(mode string) error { 200 switch mode { 201 case "": 202 fallthrough 203 case "asc": 204 fallthrough 205 case "desc": 206 return nil 207 } 208 return ErrInvalidSortDirection 209 } 210 211 func buildChangelog(ctx *context.Context) ([]string, error) { 212 log, err := getChangelog(ctx, ctx.Git.CurrentTag) 213 if err != nil { 214 return nil, err 215 } 216 entries := strings.Split(log, "\n") 217 if lastLine := entries[len(entries)-1]; strings.TrimSpace(lastLine) == "" { 218 entries = entries[0 : len(entries)-1] 219 } 220 if !useChangelog(ctx.Config.Changelog.Use).formatable() { 221 return entries, nil 222 } 223 entries, err = filterEntries(ctx, entries) 224 if err != nil { 225 return entries, err 226 } 227 return sortEntries(ctx, entries), nil 228 } 229 230 func filterEntries(ctx *context.Context, entries []string) ([]string, error) { 231 for _, filter := range ctx.Config.Changelog.Filters.Exclude { 232 r, err := regexp.Compile(filter) 233 if err != nil { 234 return entries, err 235 } 236 entries = remove(r, entries) 237 } 238 return entries, nil 239 } 240 241 func sortEntries(ctx *context.Context, entries []string) []string { 242 direction := ctx.Config.Changelog.Sort 243 if direction == "" { 244 return entries 245 } 246 result := make([]string, len(entries)) 247 copy(result, entries) 248 sort.Slice(result, func(i, j int) bool { 249 imsg := extractCommitInfo(result[i]) 250 jmsg := extractCommitInfo(result[j]) 251 if direction == "asc" { 252 return strings.Compare(imsg, jmsg) < 0 253 } 254 return strings.Compare(imsg, jmsg) > 0 255 }) 256 return result 257 } 258 259 func remove(filter *regexp.Regexp, entries []string) (result []string) { 260 for _, entry := range entries { 261 if !filter.MatchString(extractCommitInfo(entry)) { 262 result = append(result, entry) 263 } 264 } 265 return result 266 } 267 268 func extractCommitInfo(line string) string { 269 return strings.Join(strings.Split(line, " ")[1:], " ") 270 } 271 272 func getChangelog(ctx *context.Context, tag string) (string, error) { 273 prev := ctx.Git.PreviousTag 274 if prev == "" { 275 // get first commit 276 result, err := git.Clean(git.Run(ctx, "rev-list", "--max-parents=0", "HEAD")) 277 if err != nil { 278 return "", err 279 } 280 prev = result 281 } 282 return doGetChangelog(ctx, prev, tag) 283 } 284 285 func doGetChangelog(ctx *context.Context, prev, tag string) (string, error) { 286 l, err := getChangeloger(ctx) 287 if err != nil { 288 return "", err 289 } 290 return l.Log(ctx, prev, tag) 291 } 292 293 func getChangeloger(ctx *context.Context) (changeloger, error) { 294 switch ctx.Config.Changelog.Use { 295 case useGit: 296 fallthrough 297 case "": 298 return gitChangeloger{}, nil 299 case useGitHub: 300 fallthrough 301 case useGitLab: 302 return newSCMChangeloger(ctx) 303 case useGitHubNative: 304 return newGithubChangeloger(ctx) 305 default: 306 return nil, fmt.Errorf("invalid changelog.use: %q", ctx.Config.Changelog.Use) 307 } 308 } 309 310 func newGithubChangeloger(ctx *context.Context) (changeloger, error) { 311 cli, err := client.NewGitHub(ctx, ctx.Token) 312 if err != nil { 313 return nil, err 314 } 315 repo, err := git.ExtractRepoFromConfig(ctx) 316 if err != nil { 317 return nil, err 318 } 319 if err := repo.CheckSCM(); err != nil { 320 return nil, err 321 } 322 return &githubNativeChangeloger{ 323 client: cli, 324 repo: client.Repo{ 325 Owner: repo.Owner, 326 Name: repo.Name, 327 }, 328 }, nil 329 } 330 331 func newSCMChangeloger(ctx *context.Context) (changeloger, error) { 332 cli, err := client.New(ctx) 333 if err != nil { 334 return nil, err 335 } 336 repo, err := git.ExtractRepoFromConfig(ctx) 337 if err != nil { 338 return nil, err 339 } 340 if err := repo.CheckSCM(); err != nil { 341 return nil, err 342 } 343 return &scmChangeloger{ 344 client: cli, 345 repo: client.Repo{ 346 Owner: repo.Owner, 347 Name: repo.Name, 348 }, 349 }, nil 350 } 351 352 func loadContent(ctx *context.Context, fileName, tmplName string) (string, error) { 353 if tmplName != "" { 354 log.Debugf("loading template %q", tmplName) 355 content, err := loadFromFile(tmplName) 356 if err != nil { 357 return "", err 358 } 359 content, err = tmpl.New(ctx).Apply(content) 360 if strings.TrimSpace(content) == "" && err == nil { 361 log.Warnf("loaded %q, but it evaluates to an empty string", tmplName) 362 } 363 return content, err 364 } 365 366 if fileName != "" { 367 log.Debugf("loading file %q", fileName) 368 content, err := loadFromFile(fileName) 369 if strings.TrimSpace(content) == "" && err == nil { 370 log.Warnf("loaded %q, but it is empty", fileName) 371 } 372 return content, err 373 } 374 375 return "", nil 376 } 377 378 type changeloger interface { 379 Log(ctx *context.Context, prev, current string) (string, error) 380 } 381 382 type gitChangeloger struct{} 383 384 var validSHA1 = regexp.MustCompile(`^[a-fA-F0-9]{40}$`) 385 386 func (g gitChangeloger) Log(ctx *context.Context, prev, current string) (string, error) { 387 args := []string{"log", "--pretty=oneline", "--abbrev-commit", "--no-decorate", "--no-color"} 388 if validSHA1.MatchString(prev) { 389 args = append(args, prev, current) 390 } else { 391 args = append(args, fmt.Sprintf("tags/%s..tags/%s", prev, current)) 392 } 393 return git.Run(ctx, args...) 394 } 395 396 type scmChangeloger struct { 397 client client.Client 398 repo client.Repo 399 } 400 401 func (c *scmChangeloger) Log(ctx *context.Context, prev, current string) (string, error) { 402 return c.client.Changelog(ctx, c.repo, prev, current) 403 } 404 405 type githubNativeChangeloger struct { 406 client client.GitHubClient 407 repo client.Repo 408 } 409 410 func (c *githubNativeChangeloger) Log(ctx *context.Context, prev, current string) (string, error) { 411 return c.client.GenerateReleaseNotes(ctx, c.repo, prev, current) 412 }