< Summary - go-semantic-release Coverage

Line coverage
100%
Covered lines: 42
Uncovered lines: 0
Coverable lines: 42
Total lines: 714
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
File 1: NewPlugin0%00100%
File 1: resolveToken0%00100%
File 2: NewPublisher0%00100%

File(s)

/home/runner/work/go-semantic-release/go-semantic-release/internal/adapters/github/plugin.go

#LineLine coverage
 1package github
 2
 3import (
 4  "bytes"
 5  "context"
 6  "encoding/json"
 7  "fmt"
 8  "io"
 9  "mime"
 10  "net/http"
 11  neturl "net/url"
 12  "os"
 13  "path/filepath"
 14  "strings"
 15  "time"
 16
 17  "github.com/jedi-knights/go-semantic-release/internal/domain"
 18  "github.com/jedi-knights/go-semantic-release/internal/ports"
 19)
 20
 21// Compile-time interface compliance checks.
 22var (
 23  _ ports.Plugin                 = (*Plugin)(nil)
 24  _ ports.VerifyConditionsPlugin = (*Plugin)(nil)
 25  _ ports.PublishPlugin          = (*Plugin)(nil)
 26  _ ports.AddChannelPlugin       = (*Plugin)(nil)
 27  _ ports.SuccessPlugin          = (*Plugin)(nil)
 28  _ ports.FailPlugin             = (*Plugin)(nil)
 29)
 30
 31// PluginConfig holds configuration for the GitHub plugin.
 32type PluginConfig struct {
 33  Owner                  string               `mapstructure:"owner"`
 34  Repo                   string               `mapstructure:"repo"`
 35  Token                  string               `mapstructure:"token"`
 36  APIURL                 string               `mapstructure:"api_url"`
 37  Assets                 []domain.GitHubAsset `mapstructure:"assets"`
 38  DraftRelease           bool                 `mapstructure:"draft_release"`
 39  DiscussionCategoryName string               `mapstructure:"discussion_category_name"`
 40  SuccessComment         string               `mapstructure:"success_comment"`
 41  FailComment            string               `mapstructure:"fail_comment"`
 42  ReleasedLabels         []string             `mapstructure:"released_labels"`
 43  FailLabels             []string             `mapstructure:"fail_labels"`
 44}
 45
 46// Plugin implements multiple lifecycle interfaces for GitHub integration.
 47type Plugin struct {
 48  config PluginConfig
 49  client *http.Client
 50  logger ports.Logger
 51}
 52
 53// NewPlugin creates a GitHub lifecycle plugin.
 6854func NewPlugin(cfg PluginConfig, logger ports.Logger) *Plugin {
 3555  if cfg.APIURL == "" {
 3556    cfg.APIURL = "https://api.github.com"
 3557  }
 258  if cfg.Token == "" {
 259    cfg.Token = resolveToken()
 260  }
 6861  if cfg.SuccessComment == "" {
 6862    cfg.SuccessComment = "🎉 This issue has been resolved in version {{.Version}} 🎉\n\nThe release is available on [Git
 6863  }
 6864  if cfg.FailComment == "" {
 6865    cfg.FailComment = "The release from branch `{{.Branch}}` has failed.\n\nError: {{.Error}}"
 6866  }
 6867  if len(cfg.ReleasedLabels) == 0 {
 6868    cfg.ReleasedLabels = []string{"released"}
 6869  }
 6870  if len(cfg.FailLabels) == 0 {
 6871    cfg.FailLabels = []string{"semantic-release"}
 6872  }
 6873  return &Plugin{
 6874    config: cfg,
 6875    client: &http.Client{Timeout: 30 * time.Second},
 6876    logger: logger,
 6877  }
 78}
 79
 280func resolveToken() string {
 281  for _, key := range []string{"GH_TOKEN", "GITHUB_TOKEN", "SEMANTIC_RELEASE_GITHUB_TOKEN"} {
 182    if v := os.Getenv(key); v != "" {
 183      return v
 184    }
 85  }
 186  return ""
 87}
 88
 89func (p *Plugin) Name() string { return "github" }
 90
 91// VerifyConditions checks that GitHub credentials and config are valid.
 92func (p *Plugin) VerifyConditions(ctx context.Context, rc *domain.ReleaseContext) error {
 93  if p.config.Token == "" {
 94    return fmt.Errorf("GitHub token not found (set GH_TOKEN, GITHUB_TOKEN, or SEMANTIC_RELEASE_GITHUB_TOKEN)")
 95  }
 96
 97  owner, repo := p.config.Owner, p.config.Repo
 98  if owner == "" || repo == "" {
 99    return fmt.Errorf("GitHub owner and repo must be configured")
 100  }
 101
 102  // Verify token is valid with a lightweight API call.
 103  url := fmt.Sprintf("%s/repos/%s/%s", p.config.APIURL, neturl.PathEscape(owner), neturl.PathEscape(repo))
 104  req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
 105  if err != nil {
 106    return fmt.Errorf("creating request: %w", err)
 107  }
 108  p.setHeaders(req)
 109
 110  resp, err := p.client.Do(req)
 111  if err != nil {
 112    return fmt.Errorf("verifying GitHub access: %w", err)
 113  }
 114  defer func() { _ = resp.Body.Close() }()
 115
 116  if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
 117    return fmt.Errorf("GitHub token is invalid or lacks permissions (HTTP %d)", resp.StatusCode)
 118  }
 119  if resp.StatusCode != http.StatusOK {
 120    return fmt.Errorf("GitHub API returned HTTP %d for repo verification", resp.StatusCode)
 121  }
 122  _, _ = io.Copy(io.Discard, resp.Body)
 123  return nil
 124}
 125
 126// Publish creates a GitHub release, optionally uploads assets.
 127func (p *Plugin) Publish(ctx context.Context, rc *domain.ReleaseContext) (*domain.ProjectReleaseResult, error) {
 128  if rc.CurrentProject == nil {
 129    return nil, nil
 130  }
 131
 132  tagName := rc.TagName
 133  releaseName := tagName
 134  if rc.CurrentProject.Project.Name != "" {
 135    releaseName = fmt.Sprintf("%s %s", rc.CurrentProject.Project.Name, rc.CurrentProject.NextVersion.String())
 136  }
 137
 138  isPrerelease := rc.BranchPolicy != nil && rc.BranchPolicy.Prerelease
 139
 140  reqBody := ghCreateReleaseRequest{
 141    TagName:                tagName,
 142    Name:                   releaseName,
 143    Body:                   rc.Notes,
 144    Prerelease:             isPrerelease,
 145    Draft:                  p.config.DraftRelease,
 146    DiscussionCategoryName: p.config.DiscussionCategoryName,
 147  }
 148
 149  releaseResp, err := p.createGHRelease(ctx, reqBody)
 150  if err != nil {
 151    return nil, err
 152  }
 153
 154  // Upload assets.
 155  for _, asset := range p.config.Assets {
 156    if err := p.uploadAssetGlob(ctx, releaseResp.UploadURL, asset); err != nil {
 157      p.logger.Warn("failed to upload asset", "pattern", asset.Path, "error", err)
 158    }
 159  }
 160
 161  return &domain.ProjectReleaseResult{
 162    Project:    rc.CurrentProject.Project,
 163    Version:    rc.CurrentProject.NextVersion,
 164    TagName:    tagName,
 165    Published:  true,
 166    PublishURL: releaseResp.HTMLURL,
 167    Changelog:  rc.Notes,
 168  }, nil
 169}
 170
 171// AddChannel updates a release's prerelease status based on the channel.
 172func (p *Plugin) AddChannel(ctx context.Context, rc *domain.ReleaseContext) error {
 173  if rc.TagName == "" {
 174    return nil
 175  }
 176
 177  // Find existing release by tag.
 178  release, err := p.getReleaseByTag(ctx, rc.TagName)
 179  if err != nil {
 180    return fmt.Errorf("finding release for tag %s: %w", rc.TagName, err)
 181  }
 182  if release == nil {
 183    return nil
 184  }
 185
 186  isPrerelease := rc.BranchPolicy != nil && rc.BranchPolicy.Prerelease
 187
 188  // Update the prerelease field.
 189  updateBody := map[string]any{
 190    "prerelease": isPrerelease,
 191  }
 192  jsonData, err := json.Marshal(updateBody)
 193  if err != nil {
 194    return fmt.Errorf("marshaling release update: %w", err)
 195  }
 196
 197  url := fmt.Sprintf("%s/repos/%s/%s/releases/%d", p.config.APIURL, neturl.PathEscape(p.config.Owner), neturl.PathEscape
 198  req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(jsonData))
 199  if err != nil {
 200    return err
 201  }
 202  p.setHeaders(req)
 203
 204  resp, err := p.client.Do(req)
 205  if err != nil {
 206    return fmt.Errorf("updating release: %w", err)
 207  }
 208  defer func() { _ = resp.Body.Close() }()
 209
 210  if resp.StatusCode != http.StatusOK {
 211    body, _ := io.ReadAll(resp.Body)
 212    return fmt.Errorf("updating release failed (%d): %s", resp.StatusCode, string(body))
 213  }
 214
 215  p.logger.Info("updated release channel", "tag", rc.TagName, "prerelease", isPrerelease)
 216  return nil
 217}
 218
 219// Success comments on merged PRs and resolved issues.
 220func (p *Plugin) Success(ctx context.Context, rc *domain.ReleaseContext) error {
 221  if rc.CurrentProject == nil || rc.Result == nil {
 222    return nil
 223  }
 224
 225  // Find the publish URL for this project.
 226  releaseURL := ""
 227  for i := range rc.Result.Projects {
 228    if rc.Result.Projects[i].Project.Name == rc.CurrentProject.Project.Name {
 229      releaseURL = rc.Result.Projects[i].PublishURL
 230      break
 231    }
 232  }
 233
 234  comment := strings.NewReplacer(
 235    "{{.Version}}", rc.CurrentProject.NextVersion.String(),
 236    "{{.ReleaseURL}}", releaseURL,
 237    "{{.Branch}}", rc.Branch,
 238    "{{.TagName}}", rc.TagName,
 239  ).Replace(p.config.SuccessComment)
 240
 241  // Comment on commits' associated PRs.
 242  for i := range rc.CurrentProject.Commits {
 243    prs, err := p.getPRsForCommit(ctx, rc.CurrentProject.Commits[i].Hash)
 244    if err != nil {
 245      p.logger.Debug("failed to get PRs for commit", "hash", rc.CurrentProject.Commits[i].Hash, "error", err)
 246      continue
 247    }
 248    for _, pr := range prs {
 249      if err := p.commentOnIssue(ctx, pr.Number, comment); err != nil {
 250        p.logger.Debug("failed to comment on PR", "number", pr.Number, "error", err)
 251      }
 252      if err := p.addLabelsToIssue(ctx, pr.Number, p.config.ReleasedLabels); err != nil {
 253        p.logger.Warn("failed to add labels to PR", "number", pr.Number, "error", err)
 254      }
 255    }
 256  }
 257
 258  return nil
 259}
 260
 261// Fail opens or updates a GitHub issue documenting the failure.
 262func (p *Plugin) Fail(ctx context.Context, rc *domain.ReleaseContext) error {
 263  if rc.Error == nil {
 264    return nil
 265  }
 266
 267  body := strings.NewReplacer(
 268    "{{.Branch}}", rc.Branch,
 269    "{{.Error}}", rc.Error.Error(),
 270  ).Replace(p.config.FailComment)
 271
 272  title := "The automated release is failing"
 273
 274  // Check for existing failure issue.
 275  existing, err := p.findFailureIssue(ctx, title)
 276  if err != nil {
 277    p.logger.Debug("failed to search for existing failure issue", "error", err)
 278  }
 279
 280  if existing != nil {
 281    return p.commentOnIssue(ctx, existing.Number, body)
 282  }
 283
 284  return p.createIssue(ctx, title, body, p.config.FailLabels)
 285}
 286
 287// --- Helper methods ---
 288
 289type ghCreateReleaseRequest struct {
 290  TagName                string `json:"tag_name"`
 291  Name                   string `json:"name"`
 292  Body                   string `json:"body"`
 293  Prerelease             bool   `json:"prerelease"`
 294  Draft                  bool   `json:"draft"`
 295  DiscussionCategoryName string `json:"discussion_category_name,omitempty"`
 296}
 297
 298type ghRelease struct {
 299  ID        int    `json:"id"`
 300  HTMLURL   string `json:"html_url"`
 301  TagName   string `json:"tag_name"`
 302  UploadURL string `json:"upload_url"`
 303}
 304
 305type ghPR struct {
 306  Number int `json:"number"`
 307}
 308
 309type ghIssue struct {
 310  Number int    `json:"number"`
 311  Title  string `json:"title"`
 312  State  string `json:"state"`
 313}
 314
 315func (p *Plugin) setHeaders(req *http.Request) {
 316  req.Header.Set("Authorization", "token "+p.config.Token)
 317  req.Header.Set("Content-Type", "application/json")
 318  req.Header.Set("Accept", "application/vnd.github+json")
 319}
 320
 321func (p *Plugin) createGHRelease(ctx context.Context, reqBody ghCreateReleaseRequest) (*ghRelease, error) {
 322  jsonData, err := json.Marshal(reqBody)
 323  if err != nil {
 324    return nil, fmt.Errorf("marshaling release request: %w", err)
 325  }
 326
 327  url := fmt.Sprintf("%s/repos/%s/%s/releases", p.config.APIURL, neturl.PathEscape(p.config.Owner), neturl.PathEscape(p.
 328  req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData))
 329  if err != nil {
 330    return nil, fmt.Errorf("creating request: %w", err)
 331  }
 332  p.setHeaders(req)
 333
 334  resp, err := p.client.Do(req)
 335  if err != nil {
 336    return nil, fmt.Errorf("publishing release: %w", err)
 337  }
 338  defer func() { _ = resp.Body.Close() }()
 339
 340  if resp.StatusCode != http.StatusCreated {
 341    respBody, _ := io.ReadAll(resp.Body)
 342    return nil, fmt.Errorf("github create release failed (%d): %s", resp.StatusCode, string(respBody))
 343  }
 344
 345  var release ghRelease
 346  if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
 347    return nil, fmt.Errorf("decoding release response: %w", err)
 348  }
 349  return &release, nil
 350}
 351
 352func (p *Plugin) getReleaseByTag(ctx context.Context, tag string) (*ghRelease, error) {
 353  url := fmt.Sprintf("%s/repos/%s/%s/releases/tags/%s", p.config.APIURL, neturl.PathEscape(p.config.Owner), neturl.PathE
 354  req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
 355  if err != nil {
 356    return nil, err
 357  }
 358  p.setHeaders(req)
 359
 360  resp, err := p.client.Do(req)
 361  if err != nil {
 362    return nil, err
 363  }
 364  defer func() { _ = resp.Body.Close() }()
 365
 366  if resp.StatusCode == http.StatusNotFound {
 367    return nil, nil
 368  }
 369  if resp.StatusCode != http.StatusOK {
 370    return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
 371  }
 372
 373  var release ghRelease
 374  if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
 375    return nil, err
 376  }
 377  return &release, nil
 378}
 379
 380func (p *Plugin) uploadAssetGlob(ctx context.Context, uploadURL string, asset domain.GitHubAsset) error {
 381  matches, err := filepath.Glob(asset.Path)
 382  if err != nil {
 383    return fmt.Errorf("globbing %s: %w", asset.Path, err)
 384  }
 385
 386  for _, path := range matches {
 387    if err := p.uploadAsset(ctx, uploadURL, path, asset.Label); err != nil {
 388      return err
 389    }
 390  }
 391  return nil
 392}
 393
 394func (p *Plugin) uploadAsset(ctx context.Context, uploadURL, filePath, label string) error {
 395  file, err := os.Open(filePath)
 396  if err != nil {
 397    return fmt.Errorf("opening %s: %w", filePath, err)
 398  }
 399  defer func() { _ = file.Close() }()
 400
 401  stat, err := file.Stat()
 402  if err != nil {
 403    return fmt.Errorf("stat %s: %w", filePath, err)
 404  }
 405
 406  name := filepath.Base(filePath)
 407  contentType := mime.TypeByExtension(filepath.Ext(filePath))
 408  if contentType == "" {
 409    contentType = "application/octet-stream"
 410  }
 411
 412  // Strip the URI template suffix (e.g. "{?name,label}") that GitHub appends to upload_url.
 413  base := uploadURL
 414  if i := strings.Index(base, "{"); i >= 0 {
 415    base = base[:i]
 416  }
 417  q := neturl.Values{}
 418  q.Set("name", name)
 419  if label != "" {
 420    q.Set("label", label)
 421  }
 422  fullUploadURL := base + "?" + q.Encode()
 423
 424  req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullUploadURL, file)
 425  if err != nil {
 426    return fmt.Errorf("creating upload request: %w", err)
 427  }
 428
 429  req.Header.Set("Authorization", "token "+p.config.Token)
 430  req.Header.Set("Content-Type", contentType)
 431  req.ContentLength = stat.Size()
 432
 433  resp, err := p.client.Do(req)
 434  if err != nil {
 435    return fmt.Errorf("uploading asset %s: %w", name, err)
 436  }
 437  defer func() { _ = resp.Body.Close() }()
 438
 439  if resp.StatusCode != http.StatusCreated {
 440    body, _ := io.ReadAll(resp.Body)
 441    return fmt.Errorf("upload asset failed (%d): %s", resp.StatusCode, string(body))
 442  }
 443
 444  p.logger.Info("uploaded asset", "file", name)
 445  return nil
 446}
 447
 448func (p *Plugin) getPRsForCommit(ctx context.Context, sha string) ([]ghPR, error) {
 449  url := fmt.Sprintf("%s/repos/%s/%s/commits/%s/pulls", p.config.APIURL, neturl.PathEscape(p.config.Owner), neturl.PathE
 450  req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
 451  if err != nil {
 452    return nil, err
 453  }
 454  p.setHeaders(req)
 455
 456  resp, err := p.client.Do(req)
 457  if err != nil {
 458    return nil, err
 459  }
 460  defer func() { _ = resp.Body.Close() }()
 461
 462  if resp.StatusCode != http.StatusOK {
 463    return nil, nil
 464  }
 465
 466  var prs []ghPR
 467  if err := json.NewDecoder(resp.Body).Decode(&prs); err != nil {
 468    return nil, err
 469  }
 470  return prs, nil
 471}
 472
 473func (p *Plugin) commentOnIssue(ctx context.Context, number int, body string) error {
 474  payload := map[string]string{"body": body}
 475  jsonData, err := json.Marshal(payload)
 476  if err != nil {
 477    return fmt.Errorf("marshaling comment: %w", err)
 478  }
 479
 480  url := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments", p.config.APIURL, neturl.PathEscape(p.config.Owner), neturl.Pat
 481  req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData))
 482  if err != nil {
 483    return err
 484  }
 485  p.setHeaders(req)
 486
 487  resp, err := p.client.Do(req)
 488  if err != nil {
 489    return err
 490  }
 491  defer func() { _ = resp.Body.Close() }()
 492
 493  if resp.StatusCode != http.StatusCreated {
 494    return fmt.Errorf("comment failed (%d)", resp.StatusCode)
 495  }
 496  return nil
 497}
 498
 499func (p *Plugin) addLabelsToIssue(ctx context.Context, number int, labels []string) error {
 500  if len(labels) == 0 {
 501    return nil
 502  }
 503  payload := map[string][]string{"labels": labels}
 504  jsonData, err := json.Marshal(payload)
 505  if err != nil {
 506    return fmt.Errorf("marshaling labels: %w", err)
 507  }
 508
 509  url := fmt.Sprintf("%s/repos/%s/%s/issues/%d/labels",
 510    p.config.APIURL,
 511    neturl.PathEscape(p.config.Owner),
 512    neturl.PathEscape(p.config.Repo),
 513    number)
 514  req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData))
 515  if err != nil {
 516    return fmt.Errorf("creating request: %w", err)
 517  }
 518  p.setHeaders(req)
 519
 520  resp, err := p.client.Do(req)
 521  if err != nil {
 522    return fmt.Errorf("adding labels: %w", err)
 523  }
 524  defer func() { _ = resp.Body.Close() }()
 525  if resp.StatusCode != http.StatusOK {
 526    body, _ := io.ReadAll(resp.Body)
 527    return fmt.Errorf("adding labels failed (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
 528  }
 529  _, _ = io.Copy(io.Discard, resp.Body)
 530  return nil
 531}
 532
 533func (p *Plugin) findFailureIssue(ctx context.Context, title string) (*ghIssue, error) {
 534  q := neturl.Values{
 535    "state":  {"open"},
 536    "labels": {strings.Join(p.config.FailLabels, ",")},
 537  }
 538  url := fmt.Sprintf("%s/repos/%s/%s/issues?%s",
 539    p.config.APIURL,
 540    neturl.PathEscape(p.config.Owner),
 541    neturl.PathEscape(p.config.Repo),
 542    q.Encode())
 543  req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
 544  if err != nil {
 545    return nil, err
 546  }
 547  p.setHeaders(req)
 548
 549  resp, err := p.client.Do(req)
 550  if err != nil {
 551    return nil, err
 552  }
 553  defer func() { _ = resp.Body.Close() }()
 554
 555  if resp.StatusCode != http.StatusOK {
 556    body, _ := io.ReadAll(resp.Body)
 557    return nil, fmt.Errorf("listing issues failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
 558  }
 559
 560  var issues []ghIssue
 561  if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil {
 562    return nil, err
 563  }
 564
 565  for _, issue := range issues {
 566    if issue.Title == title {
 567      return &issue, nil
 568    }
 569  }
 570  return nil, nil
 571}
 572
 573func (p *Plugin) createIssue(ctx context.Context, title, body string, labels []string) error {
 574  payload := map[string]any{
 575    "title":  title,
 576    "body":   body,
 577    "labels": labels,
 578  }
 579  jsonData, err := json.Marshal(payload)
 580  if err != nil {
 581    return fmt.Errorf("marshaling issue: %w", err)
 582  }
 583
 584  url := fmt.Sprintf("%s/repos/%s/%s/issues", p.config.APIURL, neturl.PathEscape(p.config.Owner), neturl.PathEscape(p.co
 585  req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData))
 586  if err != nil {
 587    return err
 588  }
 589  p.setHeaders(req)
 590
 591  resp, err := p.client.Do(req)
 592  if err != nil {
 593    return err
 594  }
 595  defer func() { _ = resp.Body.Close() }()
 596
 597  if resp.StatusCode != http.StatusCreated {
 598    respBody, _ := io.ReadAll(resp.Body)
 599    return fmt.Errorf("create issue failed (%d): %s", resp.StatusCode, string(respBody))
 600  }
 601
 602  p.logger.Info("created failure issue", "title", title)
 603  return nil
 604}

/home/runner/work/go-semantic-release/go-semantic-release/internal/adapters/github/publisher.go

#LineLine coverage
 1package github
 2
 3import (
 4  "bytes"
 5  "context"
 6  "encoding/json"
 7  "fmt"
 8  "io"
 9  "net/http"
 10  "os"
 11
 12  "github.com/jedi-knights/go-semantic-release/internal/domain"
 13  "github.com/jedi-knights/go-semantic-release/internal/ports"
 14)
 15
 16// Compile-time interface compliance check.
 17var _ ports.ReleasePublisher = (*Publisher)(nil)
 18
 19// Publisher implements ports.ReleasePublisher for GitHub Releases.
 20type Publisher struct {
 21  owner  string
 22  repo   string
 23  token  string
 24  client *http.Client
 25}
 26
 27// NewPublisher creates a GitHub release publisher.
 28// If token is empty, it is resolved from GH_TOKEN, GITHUB_TOKEN, or
 29// SEMANTIC_RELEASE_GITHUB_TOKEN environment variables.
 530func NewPublisher(owner, repo, token string) *Publisher {
 131  if token == "" {
 132    for _, key := range []string{"GH_TOKEN", "GITHUB_TOKEN", "SEMANTIC_RELEASE_GITHUB_TOKEN"} {
 133      if v := os.Getenv(key); v != "" {
 134        token = v
 135        break
 36      }
 37    }
 38  }
 539  return &Publisher{
 540    owner:  owner,
 541    repo:   repo,
 542    token:  token,
 543    client: &http.Client{},
 544  }
 45}
 46
 47type createReleaseRequest struct {
 48  TagName    string `json:"tag_name"`
 49  Name       string `json:"name"`
 50  Body       string `json:"body"`
 51  Prerelease bool   `json:"prerelease"`
 52  Draft      bool   `json:"draft"`
 53}
 54
 55type createReleaseResponse struct {
 56  HTMLURL string `json:"html_url"`
 57  ID      int    `json:"id"`
 58}
 59
 60func (p *Publisher) Publish(ctx context.Context, params ports.PublishParams) (domain.ProjectReleaseResult, error) {
 61  name := params.TagName
 62  if params.Project != "" {
 63    name = fmt.Sprintf("%s %s", params.Project, params.Version.String())
 64  }
 65
 66  reqBody := createReleaseRequest{
 67    TagName:    params.TagName,
 68    Name:       name,
 69    Body:       params.Changelog,
 70    Prerelease: params.Prerelease,
 71  }
 72
 73  jsonData, err := json.Marshal(reqBody)
 74  if err != nil {
 75    return domain.ProjectReleaseResult{}, fmt.Errorf("marshaling release request: %w", err)
 76  }
 77
 78  url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", p.owner, p.repo)
 79  req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData))
 80  if err != nil {
 81    return domain.ProjectReleaseResult{}, fmt.Errorf("creating request: %w", err)
 82  }
 83
 84  req.Header.Set("Authorization", "token "+p.token)
 85  req.Header.Set("Content-Type", "application/json")
 86  req.Header.Set("Accept", "application/vnd.github+json")
 87
 88  resp, err := p.client.Do(req)
 89  if err != nil {
 90    return domain.ProjectReleaseResult{}, fmt.Errorf("publishing release: %w", err)
 91  }
 92  defer func() { _ = resp.Body.Close() }()
 93
 94  if resp.StatusCode != http.StatusCreated {
 95    body, _ := io.ReadAll(resp.Body)
 96    return domain.ProjectReleaseResult{}, fmt.Errorf("github release failed (%d): %s", resp.StatusCode, string(body))
 97  }
 98
 99  var releaseResp createReleaseResponse
 100  if err := json.NewDecoder(resp.Body).Decode(&releaseResp); err != nil {
 101    return domain.ProjectReleaseResult{}, fmt.Errorf("decoding release response: %w", err)
 102  }
 103
 104  return domain.ProjectReleaseResult{
 105    TagName:    params.TagName,
 106    Published:  true,
 107    PublishURL: releaseResp.HTMLURL,
 108    Changelog:  params.Changelog,
 109  }, nil
 110}

Methods/Properties

NewPlugin
resolveToken
NewPublisher