github.com/goreleaser/goreleaser@v1.25.1/internal/client/github.go (about) 1 package client 2 3 import ( 4 "crypto/tls" 5 "errors" 6 "fmt" 7 "net/http" 8 "net/url" 9 "os" 10 "reflect" 11 "strconv" 12 "strings" 13 "time" 14 15 "github.com/caarlos0/log" 16 "github.com/charmbracelet/x/exp/ordered" 17 "github.com/google/go-github/v61/github" 18 "github.com/goreleaser/goreleaser/internal/artifact" 19 "github.com/goreleaser/goreleaser/internal/tmpl" 20 "github.com/goreleaser/goreleaser/pkg/config" 21 "github.com/goreleaser/goreleaser/pkg/context" 22 "golang.org/x/oauth2" 23 ) 24 25 const DefaultGitHubDownloadURL = "https://github.com" 26 27 var ( 28 _ Client = &githubClient{} 29 _ ReleaseNotesGenerator = &githubClient{} 30 _ PullRequestOpener = &githubClient{} 31 _ ForkSyncer = &githubClient{} 32 ) 33 34 type githubClient struct { 35 client *github.Client 36 } 37 38 // NewGitHubReleaseNotesGenerator returns a GitHub client that can generate 39 // changelogs. 40 func NewGitHubReleaseNotesGenerator(ctx *context.Context, token string) (ReleaseNotesGenerator, error) { 41 return newGitHub(ctx, token) 42 } 43 44 // newGitHub returns a github client implementation. 45 func newGitHub(ctx *context.Context, token string) (*githubClient, error) { 46 ts := oauth2.StaticTokenSource( 47 &oauth2.Token{AccessToken: token}, 48 ) 49 50 httpClient := oauth2.NewClient(ctx, ts) 51 base := httpClient.Transport.(*oauth2.Transport).Base 52 if base == nil || reflect.ValueOf(base).IsNil() { 53 base = http.DefaultTransport 54 } 55 // nolint: gosec 56 base.(*http.Transport).TLSClientConfig = &tls.Config{ 57 InsecureSkipVerify: ctx.Config.GitHubURLs.SkipTLSVerify, 58 } 59 base.(*http.Transport).Proxy = http.ProxyFromEnvironment 60 httpClient.Transport.(*oauth2.Transport).Base = base 61 62 client := github.NewClient(httpClient) 63 err := overrideGitHubClientAPI(ctx, client) 64 if err != nil { 65 return &githubClient{}, err 66 } 67 68 return &githubClient{client: client}, nil 69 } 70 71 func (c *githubClient) checkRateLimit(ctx *context.Context) { 72 limits, _, err := c.client.RateLimit.Get(ctx) 73 if err != nil { 74 log.Warn("could not check rate limits, hoping for the best...") 75 return 76 } 77 if limits.Core.Remaining > 100 { // 100 should be safe enough 78 return 79 } 80 sleep := limits.Core.Reset.UTC().Sub(time.Now().UTC()) 81 if sleep <= 0 { 82 // it seems that sometimes, after the rate limit just reset, it might 83 // still get <100 remaining and a reset time in the past... in such 84 // cases we can probably sleep a bit more before trying again... 85 sleep = 15 * time.Second 86 } 87 log.Warnf("token too close to rate limiting, will sleep for %s before continuing...", sleep) 88 time.Sleep(sleep) 89 c.checkRateLimit(ctx) 90 } 91 92 func (c *githubClient) GenerateReleaseNotes(ctx *context.Context, repo Repo, prev, current string) (string, error) { 93 c.checkRateLimit(ctx) 94 notes, _, err := c.client.Repositories.GenerateReleaseNotes(ctx, repo.Owner, repo.Name, &github.GenerateNotesOptions{ 95 TagName: current, 96 PreviousTagName: github.String(prev), 97 }) 98 if err != nil { 99 return "", err 100 } 101 return notes.Body, err 102 } 103 104 func (c *githubClient) Changelog(ctx *context.Context, repo Repo, prev, current string) (string, error) { 105 c.checkRateLimit(ctx) 106 var log []string 107 opts := &github.ListOptions{PerPage: 100} 108 109 for { 110 result, resp, err := c.client.Repositories.CompareCommits(ctx, repo.Owner, repo.Name, prev, current, opts) 111 if err != nil { 112 return "", err 113 } 114 for _, commit := range result.Commits { 115 log = append(log, fmt.Sprintf( 116 "%s: %s (@%s)", 117 commit.GetSHA(), 118 strings.Split(commit.Commit.GetMessage(), "\n")[0], 119 commit.GetAuthor().GetLogin(), 120 )) 121 } 122 if resp.NextPage == 0 { 123 break 124 } 125 opts.Page = resp.NextPage 126 } 127 128 return strings.Join(log, "\n"), nil 129 } 130 131 // getDefaultBranch returns the default branch of a github repo 132 func (c *githubClient) getDefaultBranch(ctx *context.Context, repo Repo) (string, error) { 133 c.checkRateLimit(ctx) 134 p, res, err := c.client.Repositories.Get(ctx, repo.Owner, repo.Name) 135 if err != nil { 136 log := log.WithField("projectID", repo.String()) 137 if res != nil { 138 log = log.WithField("statusCode", res.StatusCode) 139 } 140 log. 141 WithError(err). 142 Warn("error checking for default branch") 143 return "", err 144 } 145 return p.GetDefaultBranch(), nil 146 } 147 148 // CloseMilestone closes a given milestone. 149 func (c *githubClient) CloseMilestone(ctx *context.Context, repo Repo, title string) error { 150 c.checkRateLimit(ctx) 151 milestone, err := c.getMilestoneByTitle(ctx, repo, title) 152 if err != nil { 153 return err 154 } 155 156 if milestone == nil { 157 return ErrNoMilestoneFound{Title: title} 158 } 159 160 closedState := "closed" 161 milestone.State = &closedState 162 163 _, _, err = c.client.Issues.EditMilestone( 164 ctx, 165 repo.Owner, 166 repo.Name, 167 *milestone.Number, 168 milestone, 169 ) 170 171 return err 172 } 173 174 func headString(base, head Repo) string { 175 return strings.Join([]string{ 176 ordered.First(head.Owner, base.Owner), 177 ordered.First(head.Name, base.Name), 178 ordered.First(head.Branch, base.Branch), 179 }, ":") 180 } 181 182 func (c *githubClient) getPRTemplate(ctx *context.Context, repo Repo) (string, error) { 183 content, _, _, err := c.client.Repositories.GetContents( 184 ctx, repo.Owner, repo.Name, 185 ".github/PULL_REQUEST_TEMPLATE.md", 186 &github.RepositoryContentGetOptions{ 187 Ref: repo.Branch, 188 }, 189 ) 190 if err != nil { 191 return "", err 192 } 193 return content.GetContent() 194 } 195 196 const prFooter = "###### Automated with [GoReleaser](https://goreleaser.com)" 197 198 func (c *githubClient) OpenPullRequest( 199 ctx *context.Context, 200 base, head Repo, 201 title string, 202 draft bool, 203 ) error { 204 c.checkRateLimit(ctx) 205 base.Owner = ordered.First(base.Owner, head.Owner) 206 base.Name = ordered.First(base.Name, head.Name) 207 if base.Branch == "" { 208 def, err := c.getDefaultBranch(ctx, base) 209 if err != nil { 210 return err 211 } 212 base.Branch = def 213 } 214 tpl, err := c.getPRTemplate(ctx, base) 215 if err != nil { 216 log.WithError(err).Debug("no pull request template found...") 217 } 218 if len(tpl) > 0 { 219 log.Info("got a pr template") 220 } 221 222 log := log. 223 WithField("base", headString(base, Repo{})). 224 WithField("head", headString(base, head)). 225 WithField("draft", draft) 226 log.Info("opening pull request") 227 pr, res, err := c.client.PullRequests.Create( 228 ctx, 229 base.Owner, 230 base.Name, 231 &github.NewPullRequest{ 232 Title: github.String(title), 233 Base: github.String(base.Branch), 234 Head: github.String(headString(base, head)), 235 Body: github.String(strings.Join([]string{tpl, prFooter}, "\n")), 236 Draft: github.Bool(draft), 237 }, 238 ) 239 if err != nil { 240 if res.StatusCode == http.StatusUnprocessableEntity { 241 log.WithError(err).Warn("pull request validation failed") 242 return nil 243 } 244 return fmt.Errorf("could not create pull request: %w", err) 245 } 246 log.WithField("url", pr.GetHTMLURL()).Info("pull request created") 247 return nil 248 } 249 250 func (c *githubClient) SyncFork(ctx *context.Context, head, base Repo) error { 251 branch := base.Branch 252 if branch == "" { 253 def, err := c.getDefaultBranch(ctx, base) 254 if err != nil { 255 return err 256 } 257 branch = def 258 } 259 res, _, err := c.client.Repositories.MergeUpstream( 260 ctx, 261 head.Owner, 262 head.Name, 263 &github.RepoMergeUpstreamRequest{ 264 Branch: github.String(branch), 265 }, 266 ) 267 if res != nil { 268 log.WithField("merge_type", res.GetMergeType()). 269 WithField("base_branch", res.GetBaseBranch()). 270 Info(res.GetMessage()) 271 } 272 return err 273 } 274 275 func (c *githubClient) CreateFile( 276 ctx *context.Context, 277 commitAuthor config.CommitAuthor, 278 repo Repo, 279 content []byte, 280 path, 281 message string, 282 ) error { 283 c.checkRateLimit(ctx) 284 defBranch, err := c.getDefaultBranch(ctx, repo) 285 if err != nil { 286 return fmt.Errorf("could not get default branch: %w", err) 287 } 288 289 branch := repo.Branch 290 if branch == "" { 291 branch = defBranch 292 } 293 294 options := &github.RepositoryContentFileOptions{ 295 Committer: &github.CommitAuthor{ 296 Name: github.String(commitAuthor.Name), 297 Email: github.String(commitAuthor.Email), 298 }, 299 Content: content, 300 Message: github.String(message), 301 } 302 303 // Set the branch if we got it above...otherwise, just default to 304 // whatever the SDK does auto-magically 305 if branch != "" { 306 options.Branch = &branch 307 } 308 309 log. 310 WithField("repository", repo.String()). 311 WithField("branch", repo.Branch). 312 WithField("file", path). 313 Info("pushing") 314 315 if defBranch != branch && branch != "" { 316 _, res, err := c.client.Repositories.GetBranch(ctx, repo.Owner, repo.Name, branch, 100) 317 if err != nil && (res == nil || res.StatusCode != http.StatusNotFound) { 318 return fmt.Errorf("could not get branch %q: %w", branch, err) 319 } 320 321 if res.StatusCode == http.StatusNotFound { 322 defRef, _, err := c.client.Git.GetRef(ctx, repo.Owner, repo.Name, "refs/heads/"+defBranch) 323 if err != nil { 324 return fmt.Errorf("could not get ref %q: %w", "refs/heads/"+defBranch, err) 325 } 326 327 if _, _, err := c.client.Git.CreateRef(ctx, repo.Owner, repo.Name, &github.Reference{ 328 Ref: github.String("refs/heads/" + branch), 329 Object: &github.GitObject{ 330 SHA: defRef.Object.SHA, 331 }, 332 }); err != nil { 333 rerr := new(github.ErrorResponse) 334 if !errors.As(err, &rerr) || rerr.Message != "Reference already exists" { 335 return fmt.Errorf("could not create ref %q from %q: %w", "refs/heads/"+branch, defRef.Object.GetSHA(), err) 336 } 337 } 338 } 339 } 340 341 file, _, res, err := c.client.Repositories.GetContents( 342 ctx, 343 repo.Owner, 344 repo.Name, 345 path, 346 &github.RepositoryContentGetOptions{ 347 Ref: branch, 348 }, 349 ) 350 if err != nil && (res == nil || res.StatusCode != http.StatusNotFound) { 351 return fmt.Errorf("could not get %q: %w", path, err) 352 } 353 354 options.SHA = github.String(file.GetSHA()) 355 if _, _, err := c.client.Repositories.UpdateFile( 356 ctx, 357 repo.Owner, 358 repo.Name, 359 path, 360 options, 361 ); err != nil { 362 return fmt.Errorf("could not update %q: %w", path, err) 363 } 364 return nil 365 } 366 367 func (c *githubClient) CreateRelease(ctx *context.Context, body string) (string, error) { 368 c.checkRateLimit(ctx) 369 title, err := tmpl.New(ctx).Apply(ctx.Config.Release.NameTemplate) 370 if err != nil { 371 return "", err 372 } 373 374 if ctx.Config.Release.Draft && ctx.Config.Release.ReplaceExistingDraft { 375 if err := c.deleteExistingDraftRelease(ctx, title); err != nil { 376 return "", err 377 } 378 } 379 380 // Truncate the release notes if it's too long (github doesn't allow more than 125000 characters) 381 body = truncateReleaseBody(body) 382 383 data := &github.RepositoryRelease{ 384 Name: github.String(title), 385 TagName: github.String(ctx.Git.CurrentTag), 386 Body: github.String(body), 387 // Always start with a draft release while uploading artifacts. 388 // PublishRelease will undraft it. 389 Draft: github.Bool(true), 390 Prerelease: github.Bool(ctx.PreRelease), 391 MakeLatest: github.String("true"), 392 } 393 394 if ctx.Config.Release.DiscussionCategoryName != "" { 395 data.DiscussionCategoryName = github.String(ctx.Config.Release.DiscussionCategoryName) 396 } 397 398 if target := ctx.Config.Release.TargetCommitish; target != "" { 399 target, err := tmpl.New(ctx).Apply(target) 400 if err != nil { 401 return "", err 402 } 403 if target != "" { 404 data.TargetCommitish = github.String(target) 405 } 406 } 407 408 if latest := strings.TrimSpace(ctx.Config.Release.MakeLatest); latest == "false" { 409 data.MakeLatest = github.String(latest) 410 } 411 412 release, err := c.createOrUpdateRelease(ctx, data, body) 413 if err != nil { 414 return "", fmt.Errorf("could not release: %w", err) 415 } 416 417 return strconv.FormatInt(release.GetID(), 10), nil 418 } 419 420 func (c *githubClient) PublishRelease(ctx *context.Context, releaseID string) error { 421 draft := ctx.Config.Release.Draft 422 if draft { 423 return nil 424 } 425 releaseIDInt, err := strconv.ParseInt(releaseID, 10, 64) 426 if err != nil { 427 return fmt.Errorf("non-numeric release ID %q: %w", releaseID, err) 428 } 429 if _, err := c.updateRelease(ctx, releaseIDInt, &github.RepositoryRelease{ 430 Draft: github.Bool(draft), 431 }); err != nil { 432 return fmt.Errorf("could not update existing release: %w", err) 433 } 434 return nil 435 } 436 437 func (c *githubClient) createOrUpdateRelease(ctx *context.Context, data *github.RepositoryRelease, body string) (*github.RepositoryRelease, error) { 438 c.checkRateLimit(ctx) 439 release, _, err := c.client.Repositories.GetReleaseByTag( 440 ctx, 441 ctx.Config.Release.GitHub.Owner, 442 ctx.Config.Release.GitHub.Name, 443 data.GetTagName(), 444 ) 445 if err != nil { 446 release, resp, err := c.client.Repositories.CreateRelease( 447 ctx, 448 ctx.Config.Release.GitHub.Owner, 449 ctx.Config.Release.GitHub.Name, 450 data, 451 ) 452 if err == nil { 453 log.WithField("name", data.GetName()). 454 WithField("release-id", release.GetID()). 455 WithField("request-id", resp.Header.Get("X-GitHub-Request-Id")). 456 Info("release created") 457 } 458 return release, err 459 } 460 461 data.Draft = release.Draft 462 data.Body = github.String(getReleaseNotes(release.GetBody(), body, ctx.Config.Release.ReleaseNotesMode)) 463 return c.updateRelease(ctx, release.GetID(), data) 464 } 465 466 func (c *githubClient) updateRelease(ctx *context.Context, id int64, data *github.RepositoryRelease) (*github.RepositoryRelease, error) { 467 c.checkRateLimit(ctx) 468 release, resp, err := c.client.Repositories.EditRelease( 469 ctx, 470 ctx.Config.Release.GitHub.Owner, 471 ctx.Config.Release.GitHub.Name, 472 id, 473 data, 474 ) 475 if err == nil { 476 log.WithField("name", data.GetName()). 477 WithField("release-id", release.GetID()). 478 WithField("request-id", resp.Header.Get("X-GitHub-Request-Id")). 479 Info("release updated") 480 } 481 return release, err 482 } 483 484 func (c *githubClient) ReleaseURLTemplate(ctx *context.Context) (string, error) { 485 downloadURL, err := tmpl.New(ctx).Apply(ctx.Config.GitHubURLs.Download) 486 if err != nil { 487 return "", fmt.Errorf("templating GitHub download URL: %w", err) 488 } 489 490 return fmt.Sprintf( 491 "%s/%s/%s/releases/download/{{ .Tag }}/{{ .ArtifactName }}", 492 downloadURL, 493 ctx.Config.Release.GitHub.Owner, 494 ctx.Config.Release.GitHub.Name, 495 ), nil 496 } 497 498 func (c *githubClient) deleteReleaseArtifact(ctx *context.Context, releaseID int64, name string, page int) error { 499 c.checkRateLimit(ctx) 500 log.WithField("name", name).Info("delete pre-existing asset from the release") 501 assets, resp, err := c.client.Repositories.ListReleaseAssets( 502 ctx, 503 ctx.Config.Release.GitHub.Owner, 504 ctx.Config.Release.GitHub.Name, 505 releaseID, 506 &github.ListOptions{ 507 PerPage: 100, 508 Page: page, 509 }, 510 ) 511 if err != nil { 512 githubErrLogger(resp, err). 513 WithField("release-id", releaseID). 514 Warn("could not list release assets") 515 return err 516 } 517 for _, asset := range assets { 518 if asset.GetName() != name { 519 continue 520 } 521 resp, err := c.client.Repositories.DeleteReleaseAsset( 522 ctx, 523 ctx.Config.Release.GitHub.Owner, 524 ctx.Config.Release.GitHub.Name, 525 asset.GetID(), 526 ) 527 if err != nil { 528 githubErrLogger(resp, err). 529 WithField("release-id", releaseID). 530 WithField("id", asset.GetID()). 531 WithField("name", name). 532 Warn("could not delete asset") 533 } 534 return err 535 } 536 if next := resp.NextPage; next > 0 { 537 return c.deleteReleaseArtifact(ctx, releaseID, name, next) 538 } 539 return nil 540 } 541 542 func (c *githubClient) Upload( 543 ctx *context.Context, 544 releaseID string, 545 artifact *artifact.Artifact, 546 file *os.File, 547 ) error { 548 c.checkRateLimit(ctx) 549 githubReleaseID, err := strconv.ParseInt(releaseID, 10, 64) 550 if err != nil { 551 return err 552 } 553 _, resp, err := c.client.Repositories.UploadReleaseAsset( 554 ctx, 555 ctx.Config.Release.GitHub.Owner, 556 ctx.Config.Release.GitHub.Name, 557 githubReleaseID, 558 &github.UploadOptions{ 559 Name: artifact.Name, 560 }, 561 file, 562 ) 563 if err != nil { 564 githubErrLogger(resp, err). 565 WithField("name", artifact.Name). 566 WithField("release-id", releaseID). 567 Warn("upload failed") 568 } 569 if err == nil { 570 return nil 571 } 572 // this status means the asset already exists 573 if resp != nil && resp.StatusCode == http.StatusUnprocessableEntity { 574 if !ctx.Config.Release.ReplaceExistingArtifacts { 575 return err 576 } 577 // if the user allowed to delete assets, we delete it, and return a 578 // retriable error. 579 if err := c.deleteReleaseArtifact(ctx, githubReleaseID, artifact.Name, 1); err != nil { 580 return err 581 } 582 return RetriableError{err} 583 } 584 return RetriableError{err} 585 } 586 587 // getMilestoneByTitle returns a milestone by title. 588 func (c *githubClient) getMilestoneByTitle(ctx *context.Context, repo Repo, title string) (*github.Milestone, error) { 589 c.checkRateLimit(ctx) 590 // The GitHub API/SDK does not provide lookup by title functionality currently. 591 opts := &github.MilestoneListOptions{ 592 ListOptions: github.ListOptions{PerPage: 100}, 593 } 594 595 for { 596 milestones, resp, err := c.client.Issues.ListMilestones( 597 ctx, 598 repo.Owner, 599 repo.Name, 600 opts, 601 ) 602 if err != nil { 603 return nil, err 604 } 605 606 for _, m := range milestones { 607 if m != nil && m.Title != nil && *m.Title == title { 608 return m, nil 609 } 610 } 611 612 if resp.NextPage == 0 { 613 break 614 } 615 616 opts.Page = resp.NextPage 617 } 618 619 return nil, nil 620 } 621 622 func overrideGitHubClientAPI(ctx *context.Context, client *github.Client) error { 623 if ctx.Config.GitHubURLs.API == "" { 624 return nil 625 } 626 627 apiURL, err := tmpl.New(ctx).Apply(ctx.Config.GitHubURLs.API) 628 if err != nil { 629 return fmt.Errorf("templating GitHub API URL: %w", err) 630 } 631 api, err := url.Parse(apiURL) 632 if err != nil { 633 return err 634 } 635 636 uploadURL, err := tmpl.New(ctx).Apply(ctx.Config.GitHubURLs.Upload) 637 if err != nil { 638 return fmt.Errorf("templating GitHub upload URL: %w", err) 639 } 640 upload, err := url.Parse(uploadURL) 641 if err != nil { 642 return err 643 } 644 645 client.BaseURL = api 646 client.UploadURL = upload 647 648 return nil 649 } 650 651 func (c *githubClient) deleteExistingDraftRelease(ctx *context.Context, name string) error { 652 c.checkRateLimit(ctx) 653 opt := github.ListOptions{PerPage: 50} 654 for { 655 releases, resp, err := c.client.Repositories.ListReleases( 656 ctx, 657 ctx.Config.Release.GitHub.Owner, 658 ctx.Config.Release.GitHub.Name, 659 &opt, 660 ) 661 if err != nil { 662 return fmt.Errorf("could not delete existing drafts: %w", err) 663 } 664 for _, r := range releases { 665 if r.GetDraft() && r.GetName() == name { 666 if _, err := c.client.Repositories.DeleteRelease( 667 ctx, 668 ctx.Config.Release.GitHub.Owner, 669 ctx.Config.Release.GitHub.Name, 670 r.GetID(), 671 ); err != nil { 672 return fmt.Errorf("could not delete previous draft release: %w", err) 673 } 674 675 log.WithField("commit", r.GetTargetCommitish()). 676 WithField("tag", r.GetTagName()). 677 WithField("name", r.GetName()). 678 Info("deleted previous draft release") 679 680 // in theory, there should be only 1 release matching, so we can just return 681 return nil 682 } 683 } 684 if resp.NextPage == 0 { 685 return nil 686 } 687 opt.Page = resp.NextPage 688 } 689 } 690 691 func githubErrLogger(resp *github.Response, err error) *log.Entry { 692 requestID := "" 693 if resp != nil { 694 requestID = resp.Header.Get("X-GitHub-Request-Id") 695 } 696 return log.WithField("request-id", requestID).WithError(err) 697 }