< Summary - go-semantic-release Coverage

Information
Line coverage
73%
Covered lines: 73
Uncovered lines: 26
Coverable lines: 99
Total lines: 201
Line coverage: 73.7%
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
Execute0%0065.22%
executeProject0%0081.25%
createAndPushTag0%0035.71%
publish0%00100%

File(s)

/home/runner/work/go-semantic-release/go-semantic-release/internal/app/release_executor.go

#LineLine coverage
 1package app
 2
 3import (
 4  "context"
 5  "errors"
 6  "fmt"
 7
 8  "github.com/jedi-knights/go-semantic-release/internal/domain"
 9  "github.com/jedi-knights/go-semantic-release/internal/ports"
 10)
 11
 12// ReleaseExecutor executes a release plan by creating tags and publishing releases.
 13type ReleaseExecutor struct {
 14  git        ports.GitRepository
 15  tagService ports.TagService
 16  changelog  ports.ChangelogGenerator
 17  publisher  ports.ReleasePublisher
 18  logger     ports.Logger
 19  sections   []domain.ChangelogSectionConfig
 20}
 21
 22// MustNewReleaseExecutor creates a release executor.
 23// All parameters are required and must be non-nil. For publisher, pass a
 24// noopPublisher (available from the DI container via di.Container.ReleasePublisher)
 25// when publishing is disabled rather than passing nil.
 26// Panics on any nil argument — these are programming errors, not runtime errors.
 27func MustNewReleaseExecutor(
 28  git ports.GitRepository,
 29  tagService ports.TagService,
 30  changelog ports.ChangelogGenerator,
 31  publisher ports.ReleasePublisher,
 32  logger ports.Logger,
 33  sections []domain.ChangelogSectionConfig,
 34) *ReleaseExecutor {
 35  if git == nil {
 36    panic("MustNewReleaseExecutor: git must not be nil")
 37  }
 38  if tagService == nil {
 39    panic("MustNewReleaseExecutor: tagService must not be nil")
 40  }
 41  if changelog == nil {
 42    panic("MustNewReleaseExecutor: changelog must not be nil")
 43  }
 44  if publisher == nil {
 45    panic("MustNewReleaseExecutor: publisher must not be nil; use noopPublisher for no-op behavior")
 46  }
 47  if logger == nil {
 48    panic("MustNewReleaseExecutor: logger must not be nil")
 49  }
 50  return &ReleaseExecutor{
 51    git:        git,
 52    tagService: tagService,
 53    changelog:  changelog,
 54    publisher:  publisher,
 55    logger:     logger,
 56    sections:   sections,
 57  }
 58}
 59
 60// Execute runs the release for all releasable projects in the plan.
 61//
 62// Error model:
 63//   - Context cancellation and tag/push failures are returned directly and abort
 64//     the loop immediately. These are hard failures: git state may be partially
 65//     mutated (e.g. a local tag exists without a corresponding push), so continuing
 66//     to the next project would compound the inconsistency.
 67//   - Publish failures (e.g. GitHub release creation) are soft: the tag is already
 68//     pushed, so the release is technically done. These are collected into
 69//     result.Projects[i].Error so the caller can report all failures before exiting.
 70//
 71// Use result.HasErrors() to check whether any per-project publish error occurred.
 572func (e *ReleaseExecutor) Execute(ctx context.Context, plan *domain.ReleasePlan) (*domain.ReleaseResult, error) {
 573  result := &domain.ReleaseResult{DryRun: plan.DryRun}
 574
 575  releasable := plan.ReleasableProjects()
 576  for i := range releasable {
 577    // Cancellation is checked between projects, not during an in-progress
 578    // executeProject call. If createAndPushTag is blocked on a slow network
 579    // operation the context is not respected until the current project finishes.
 580    // This is intentional: aborting mid-tag would leave git state inconsistent.
 081    if err := ctx.Err(); err != nil {
 082      return nil, fmt.Errorf("release cancelled: %w", err)
 083    }
 584    pr, err := e.executeProject(ctx, releasable[i], plan)
 085    if err != nil {
 086      // Hard failure (tag/push): abort immediately rather than continuing to
 087      // create more tags in an inconsistent state.
 088      return nil, fmt.Errorf("tagging %s: %w", releasable[i].Project.Name, err)
 089    }
 190    if pr.Error != nil {
 191      e.logger.Error("publish failed", "project", pr.Project.Name, "error", pr.Error)
 192    }
 593    result.Projects = append(result.Projects, pr)
 94  }
 95
 596  return result, nil
 97}
 98
 99func (e *ReleaseExecutor) executeProject(
 100  ctx context.Context,
 101  pp domain.ProjectReleasePlan,
 102  plan *domain.ReleasePlan,
 5103) (domain.ProjectReleaseResult, error) {
 5104  result := domain.ProjectReleaseResult{
 5105    Project:        pp.Project,
 5106    CurrentVersion: pp.CurrentVersion,
 5107    Version:        pp.NextVersion,
 5108  }
 5109
 5110  // Generate changelog.
 5111  notes, err := e.changelog.Generate(pp.NextVersion, pp.Project.Name, pp.Commits, e.sections)
 0112  if err != nil {
 0113    return result, domain.NewReleaseError("generate-notes", err)
 0114  }
 5115  result.Changelog = notes
 5116
 5117  // Format tag name.
 5118  tagName, err := e.tagService.FormatTag(pp.Project.Name, pp.NextVersion)
 0119  if err != nil {
 0120    return result, domain.NewReleaseError("format-tag", err)
 0121  }
 5122  result.TagName = tagName
 5123
 1124  if plan.DryRun {
 1125    result.Skipped = true
 1126    result.SkipReason = "dry run"
 1127    e.logger.Info("dry run: would create tag", "tag", tagName, "version", pp.NextVersion)
 1128    return result, nil
 1129  }
 130
 131  // Create and push tag.
 0132  if err := e.createAndPushTag(ctx, tagName, notes); err != nil {
 0133    return result, err
 0134  }
 4135  result.TagCreated = true
 4136
 4137  // Publish release. Publish failures are soft: the tag is already pushed so
 4138  // the release is technically done. Store the error in result rather than
 4139  // returning it so the caller can continue with remaining projects.
 4140  published, publishURL, publishErr := e.publish(ctx, pp, tagName, notes, plan.Policy)
 1141  if publishErr != nil {
 1142    result.SetError(publishErr)
 1143  } else {
 3144    result.Published = published
 3145    result.PublishURL = publishURL
 3146  }
 147
 148  // Log at different levels so operators can distinguish a full success from
 149  // a partial one (tag pushed, publish failed) without parsing the error.
 3150  if result.Error == nil {
 3151    e.logger.Info("release completed", "project", pp.Project.Name, "version", pp.NextVersion, "tag", tagName)
 1152  } else {
 1153    e.logger.Warn("release partially completed (publish failed)", "project", pp.Project.Name, "version", pp.NextVersion,
 1154  }
 4155  return result, nil
 156}
 157
 4158func (e *ReleaseExecutor) createAndPushTag(ctx context.Context, tagName, message string) error {
 4159  headHash, err := e.git.HeadHash(ctx)
 0160  if err != nil {
 0161    return domain.NewReleaseError("get-head", err)
 0162  }
 163
 2164  if err := e.git.CreateTag(ctx, tagName, headHash, message); err != nil {
 0165    if !errors.Is(err, domain.ErrTagAlreadyExists) {
 0166      return domain.NewReleaseError("create-tag", err)
 0167    }
 168    // Tag already exists at this commit — idempotent re-run.
 169    // Still push in case the tag was created locally but not yet pushed.
 2170    e.logger.Info("tag already exists at current commit, skipping create", "tag", tagName)
 171  }
 172
 0173  if err := e.git.PushTag(ctx, tagName); err != nil {
 0174    return domain.NewReleaseError("push-tag", err)
 0175  }
 4176  return nil
 177}
 178
 179// publish calls the publisher and returns (published, publishURL, err).
 180// Returning only the two fields callers actually use avoids implying that the
 181// other ProjectReleaseResult zero-value fields (TagCreated, Project, …) are meaningful.
 182func (e *ReleaseExecutor) publish(
 183  ctx context.Context,
 184  pp domain.ProjectReleasePlan,
 185  tagName, notes string,
 186  policy *domain.BranchPolicy,
 4187) (published bool, publishURL string, err error) {
 4188  isPrerelease := policy != nil && policy.Prerelease
 4189
 4190  result, err := e.publisher.Publish(ctx, ports.PublishParams{
 4191    TagName:    tagName,
 4192    Version:    pp.NextVersion,
 4193    Project:    pp.Project.Name,
 4194    Changelog:  notes,
 4195    Prerelease: isPrerelease,
 4196  })
 1197  if err != nil {
 1198    return false, "", domain.NewReleaseError("publish", err)
 1199  }
 3200  return result.Published, result.PublishURL, nil
 201}