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