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