< Summary - go-semantic-release Coverage

Line coverage
91%
Covered lines: 96
Uncovered lines: 9
Coverable lines: 105
Total lines: 951
Line coverage: 91.4%
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

File(s)

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

#LineLine coverage
 1package app
 2
 3import (
 4  "context"
 5  "fmt"
 6
 7  "github.com/jedi-knights/go-semantic-release/internal/domain"
 8  "github.com/jedi-knights/go-semantic-release/internal/ports"
 9)
 10
 11// CommitAnalyzer analyzes commits since the last release.
 12type CommitAnalyzer struct {
 13  git    ports.GitRepository
 14  parser ports.CommitParser
 15  logger ports.Logger
 16}
 17
 18// NewCommitAnalyzer creates a commit analyzer.
 519func NewCommitAnalyzer(git ports.GitRepository, parser ports.CommitParser, logger ports.Logger) *CommitAnalyzer {
 520  return &CommitAnalyzer{git: git, parser: parser, logger: logger}
 521}
 22
 23// Analyze retrieves and parses commits since the given tag hash.
 24func (a *CommitAnalyzer) Analyze(ctx context.Context, sinceHash string) ([]domain.Commit, error) {
 25  rawCommits, err := a.git.CommitsSince(ctx, sinceHash)
 26  if err != nil {
 27    return nil, fmt.Errorf("fetching commits: %w", err)
 28  }
 29
 30  a.logger.Debug("found raw commits", "count", len(rawCommits))
 31
 32  parsed := make([]domain.Commit, 0, len(rawCommits))
 33  for i := range rawCommits {
 34    fullMessage := rawCommits[i].Message
 35    if rawCommits[i].Body != "" {
 36      fullMessage = rawCommits[i].Message + "\n\n" + rawCommits[i].Body
 37    }
 38
 39    commit, err := a.parser.Parse(fullMessage)
 40    if err != nil {
 41      a.logger.Warn("skipping unparseable commit", "hash", rawCommits[i].Hash, "error", err)
 42      continue
 43    }
 44
 45    // Preserve git metadata from raw commit.
 46    commit.Hash = rawCommits[i].Hash
 47    commit.Author = rawCommits[i].Author
 48    commit.AuthorEmail = rawCommits[i].AuthorEmail
 49    commit.Date = rawCommits[i].Date
 50
 51    // Populate changed files.
 52    files, err := a.git.FilesChangedInCommit(ctx, rawCommits[i].Hash)
 53    if err != nil {
 54      a.logger.Warn("failed to get changed files", "hash", rawCommits[i].Hash, "error", err)
 55    }
 56    commit.FilesChanged = files
 57
 58    parsed = append(parsed, commit)
 59  }
 60
 61  a.logger.Info("analyzed commits", "total", len(rawCommits), "parsed", len(parsed))
 62  return parsed, nil
 63}

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

#LineLine coverage
 1package app
 2
 3import (
 4  "context"
 5  "fmt"
 6
 7  "github.com/jedi-knights/go-semantic-release/internal/domain"
 8  "github.com/jedi-knights/go-semantic-release/internal/ports"
 9)
 10
 11// ProjectDetector orchestrates project discovery.
 12type ProjectDetector struct {
 13  discoverer ports.ProjectDiscoverer
 14  logger     ports.Logger
 15}
 16
 17// NewProjectDetector creates a project detector.
 318func NewProjectDetector(discoverer ports.ProjectDiscoverer, logger ports.Logger) *ProjectDetector {
 319  return &ProjectDetector{discoverer: discoverer, logger: logger}
 320}
 21
 22// Detect discovers projects in the repository.
 23func (d *ProjectDetector) Detect(ctx context.Context, rootPath string) ([]domain.Project, error) {
 24  projects, err := d.discoverer.Discover(ctx, rootPath)
 25  if err != nil {
 26    return nil, fmt.Errorf("discovering projects: %w", err)
 27  }
 28
 29  if len(projects) == 0 {
 30    d.logger.Info("no projects discovered, using root project")
 31    return []domain.Project{{
 32      Name: "",
 33      Path: ".",
 34      Type: domain.ProjectTypeRoot,
 35    }}, nil
 36  }
 37
 38  d.logger.Info("discovered projects", "count", len(projects))
 39  for _, p := range projects {
 40    d.logger.Debug("project", "name", p.Name, "path", p.Path, "type", p.Type)
 41  }
 42  return projects, nil
 43}

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

#LineLine coverage
 1package app
 2
 3import (
 4  "context"
 5  "fmt"
 6  "strings"
 7
 8  "github.com/jedi-knights/go-semantic-release/internal/domain"
 9  "github.com/jedi-knights/go-semantic-release/internal/ports"
 10)
 11
 12// Pipeline orchestrates the semantic release lifecycle.
 13type Pipeline struct {
 14  plugins []ports.Plugin
 15  logger  ports.Logger
 16}
 17
 18// NewPipeline creates a new lifecycle pipeline with the given plugins.
 1019func NewPipeline(plugins []ports.Plugin, logger ports.Logger) *Pipeline {
 1020  return &Pipeline{plugins: plugins, logger: logger}
 1021}
 22
 23// Execute runs all lifecycle steps in order against the release context.
 24func (p *Pipeline) Execute(ctx context.Context, rc *domain.ReleaseContext) error {
 25  if err := p.runVerifyConditions(ctx, rc); err != nil {
 26    return p.handleFailure(ctx, rc, err)
 27  }
 28
 29  releaseType, err := p.runAnalyzeCommits(ctx, rc)
 30  if err != nil {
 31    return p.handleFailure(ctx, rc, err)
 32  }
 33
 34  if !releaseType.IsReleasable() {
 35    p.logger.Info("no releasable changes found")
 36    return nil
 37  }
 38
 39  if verifyErr := p.runVerifyRelease(ctx, rc); verifyErr != nil {
 40    return p.handleFailure(ctx, rc, verifyErr)
 41  }
 42
 43  notes, err := p.runGenerateNotes(ctx, rc)
 44  if err != nil {
 45    return p.handleFailure(ctx, rc, err)
 46  }
 47  rc.Notes = notes
 48
 49  if rc.DryRun {
 50    p.logger.Info("dry run complete, skipping prepare/publish/addChannel/success steps")
 51    return nil
 52  }
 53
 54  if err := p.runPrepare(ctx, rc); err != nil {
 55    return p.handleFailure(ctx, rc, err)
 56  }
 57
 58  if err := p.runPublish(ctx, rc); err != nil {
 59    return p.handleFailure(ctx, rc, err)
 60  }
 61
 62  if err := p.runAddChannel(ctx, rc); err != nil {
 63    p.logger.Warn("addChannel failed", "error", err)
 64    // Non-fatal — continue to success notification.
 65  }
 66
 67  return p.runSuccess(ctx, rc)
 68}
 69
 70func (p *Pipeline) runVerifyConditions(ctx context.Context, rc *domain.ReleaseContext) error {
 71  for _, plugin := range p.plugins {
 72    if vp, ok := plugin.(ports.VerifyConditionsPlugin); ok {
 73      p.logger.Debug("running verifyConditions", "plugin", plugin.Name())
 74      if err := vp.VerifyConditions(ctx, rc); err != nil {
 75        return domain.NewReleaseError("verifyConditions:"+plugin.Name(), err)
 76      }
 77    }
 78  }
 79  return nil
 80}
 81
 82func (p *Pipeline) runAnalyzeCommits(ctx context.Context, rc *domain.ReleaseContext) (domain.ReleaseType, error) {
 83  highest := domain.ReleaseNone
 84  for _, plugin := range p.plugins {
 85    if ap, ok := plugin.(ports.AnalyzeCommitsPlugin); ok {
 86      p.logger.Debug("running analyzeCommits", "plugin", plugin.Name())
 87      rt, err := ap.AnalyzeCommits(ctx, rc)
 88      if err != nil {
 89        return domain.ReleaseNone, domain.NewReleaseError("analyzeCommits:"+plugin.Name(), err)
 90      }
 91      highest = highest.Higher(rt)
 92    }
 93  }
 94  return highest, nil
 95}
 96
 97func (p *Pipeline) runVerifyRelease(ctx context.Context, rc *domain.ReleaseContext) error {
 98  for _, plugin := range p.plugins {
 99    if vp, ok := plugin.(ports.VerifyReleasePlugin); ok {
 100      p.logger.Debug("running verifyRelease", "plugin", plugin.Name())
 101      if err := vp.VerifyRelease(ctx, rc); err != nil {
 102        return domain.NewReleaseError("verifyRelease:"+plugin.Name(), err)
 103      }
 104    }
 105  }
 106  return nil
 107}
 108
 109func (p *Pipeline) runGenerateNotes(ctx context.Context, rc *domain.ReleaseContext) (string, error) {
 110  var parts []string
 111  for _, plugin := range p.plugins {
 112    if gp, ok := plugin.(ports.GenerateNotesPlugin); ok {
 113      p.logger.Debug("running generateNotes", "plugin", plugin.Name())
 114      notes, err := gp.GenerateNotes(ctx, rc)
 115      if err != nil {
 116        return "", domain.NewReleaseError("generateNotes:"+plugin.Name(), err)
 117      }
 118      if notes != "" {
 119        parts = append(parts, notes)
 120      }
 121    }
 122  }
 123  return strings.Join(parts, "\n\n"), nil
 124}
 125
 126func (p *Pipeline) runPrepare(ctx context.Context, rc *domain.ReleaseContext) error {
 127  for _, plugin := range p.plugins {
 128    if pp, ok := plugin.(ports.PreparePlugin); ok {
 129      p.logger.Debug("running prepare", "plugin", plugin.Name())
 130      if err := pp.Prepare(ctx, rc); err != nil {
 131        return domain.NewReleaseError("prepare:"+plugin.Name(), err)
 132      }
 133    }
 134  }
 135  return nil
 136}
 137
 138func (p *Pipeline) runPublish(ctx context.Context, rc *domain.ReleaseContext) error {
 139  for _, plugin := range p.plugins {
 140    if pp, ok := plugin.(ports.PublishPlugin); ok {
 141      p.logger.Debug("running publish", "plugin", plugin.Name())
 142      result, err := pp.Publish(ctx, rc)
 143      if err != nil {
 144        return domain.NewReleaseError("publish:"+plugin.Name(), err)
 145      }
 146      if result != nil && rc.Result != nil {
 147        rc.Result.Projects = append(rc.Result.Projects, *result)
 148      }
 149    }
 150  }
 151  return nil
 152}
 153
 154func (p *Pipeline) runAddChannel(ctx context.Context, rc *domain.ReleaseContext) error {
 155  for _, plugin := range p.plugins {
 156    if ap, ok := plugin.(ports.AddChannelPlugin); ok {
 157      p.logger.Debug("running addChannel", "plugin", plugin.Name())
 158      if err := ap.AddChannel(ctx, rc); err != nil {
 159        return fmt.Errorf("addChannel:%s: %w", plugin.Name(), err)
 160      }
 161    }
 162  }
 163  return nil
 164}
 165
 166func (p *Pipeline) runSuccess(ctx context.Context, rc *domain.ReleaseContext) error {
 167  for _, plugin := range p.plugins {
 168    if sp, ok := plugin.(ports.SuccessPlugin); ok {
 169      p.logger.Debug("running success", "plugin", plugin.Name())
 170      if err := sp.Success(ctx, rc); err != nil {
 171        p.logger.Warn("success notification failed", "plugin", plugin.Name(), "error", err)
 172        // Non-fatal — don't fail the release for notification failures.
 173      }
 174    }
 175  }
 176  return nil
 177}
 178
 179func (p *Pipeline) handleFailure(ctx context.Context, rc *domain.ReleaseContext, releaseErr error) error {
 180  rc.Error = releaseErr
 181  for _, plugin := range p.plugins {
 182    if fp, ok := plugin.(ports.FailPlugin); ok {
 183      p.logger.Debug("running fail", "plugin", plugin.Name())
 184      if err := fp.Fail(ctx, rc); err != nil {
 185        p.logger.Warn("fail notification failed", "plugin", plugin.Name(), "error", err)
 186      }
 187    }
 188  }
 189  return releaseErr
 190}

/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,
 1034) *ReleaseExecutor {
 135  if git == nil {
 136    panic("MustNewReleaseExecutor: git must not be nil")
 37  }
 138  if tagService == nil {
 139    panic("MustNewReleaseExecutor: tagService must not be nil")
 40  }
 141  if changelog == nil {
 142    panic("MustNewReleaseExecutor: changelog must not be nil")
 43  }
 144  if publisher == nil {
 145    panic("MustNewReleaseExecutor: publisher must not be nil; use noopPublisher for no-op behavior")
 46  }
 147  if logger == nil {
 148    panic("MustNewReleaseExecutor: logger must not be nil")
 49  }
 550  return &ReleaseExecutor{
 551    git:        git,
 552    tagService: tagService,
 553    changelog:  changelog,
 554    publisher:  publisher,
 555    logger:     logger,
 556    sections:   sections,
 557  }
 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.
 72func (e *ReleaseExecutor) Execute(ctx context.Context, plan *domain.ReleasePlan) (*domain.ReleaseResult, error) {
 73  result := &domain.ReleaseResult{DryRun: plan.DryRun}
 74
 75  releasable := plan.ReleasableProjects()
 76  for i := range releasable {
 77    // Cancellation is checked between projects, not during an in-progress
 78    // executeProject call. If createAndPushTag is blocked on a slow network
 79    // operation the context is not respected until the current project finishes.
 80    // This is intentional: aborting mid-tag would leave git state inconsistent.
 81    if err := ctx.Err(); err != nil {
 82      return nil, fmt.Errorf("release cancelled: %w", err)
 83    }
 84    pr, err := e.executeProject(ctx, releasable[i], plan)
 85    if err != nil {
 86      // Hard failure (tag/push): abort immediately rather than continuing to
 87      // create more tags in an inconsistent state.
 88      return nil, fmt.Errorf("tagging %s: %w", releasable[i].Project.Name, err)
 89    }
 90    if pr.Error != nil {
 91      e.logger.Error("publish failed", "project", pr.Project.Name, "error", pr.Error)
 92    }
 93    result.Projects = append(result.Projects, pr)
 94  }
 95
 96  return result, nil
 97}
 98
 99func (e *ReleaseExecutor) executeProject(
 100  ctx context.Context,
 101  pp domain.ProjectReleasePlan,
 102  plan *domain.ReleasePlan,
 103) (domain.ProjectReleaseResult, error) {
 104  result := domain.ProjectReleaseResult{
 105    Project:        pp.Project,
 106    CurrentVersion: pp.CurrentVersion,
 107    Version:        pp.NextVersion,
 108  }
 109
 110  // Generate changelog.
 111  notes, err := e.changelog.Generate(pp.NextVersion, pp.Project.Name, pp.Commits, e.sections)
 112  if err != nil {
 113    return result, domain.NewReleaseError("generate-notes", err)
 114  }
 115  result.Changelog = notes
 116
 117  // Format tag name.
 118  tagName, err := e.tagService.FormatTag(pp.Project.Name, pp.NextVersion)
 119  if err != nil {
 120    return result, domain.NewReleaseError("format-tag", err)
 121  }
 122  result.TagName = tagName
 123
 124  if plan.DryRun {
 125    result.Skipped = true
 126    result.SkipReason = "dry run"
 127    e.logger.Info("dry run: would create tag", "tag", tagName, "version", pp.NextVersion)
 128    return result, nil
 129  }
 130
 131  // Create and push tag.
 132  if err := e.createAndPushTag(ctx, tagName, notes); err != nil {
 133    return result, err
 134  }
 135  result.TagCreated = true
 136
 137  // Publish release. Publish failures are soft: the tag is already pushed so
 138  // the release is technically done. Store the error in result rather than
 139  // returning it so the caller can continue with remaining projects.
 140  published, publishURL, publishErr := e.publish(ctx, pp, tagName, notes, plan.Policy)
 141  if publishErr != nil {
 142    result.SetError(publishErr)
 143  } else {
 144    result.Published = published
 145    result.PublishURL = publishURL
 146  }
 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.
 150  if result.Error == nil {
 151    e.logger.Info("release completed", "project", pp.Project.Name, "version", pp.NextVersion, "tag", tagName)
 152  } else {
 153    e.logger.Warn("release partially completed (publish failed)", "project", pp.Project.Name, "version", pp.NextVersion,
 154  }
 155  return result, nil
 156}
 157
 158func (e *ReleaseExecutor) createAndPushTag(ctx context.Context, tagName, message string) error {
 159  headHash, err := e.git.HeadHash(ctx)
 160  if err != nil {
 161    return domain.NewReleaseError("get-head", err)
 162  }
 163
 164  if err := e.git.CreateTag(ctx, tagName, headHash, message); err != nil {
 165    if !errors.Is(err, domain.ErrTagAlreadyExists) {
 166      return domain.NewReleaseError("create-tag", err)
 167    }
 168    // Tag already exists at this commit — idempotent re-run.
 169    // Still push in case the tag was created locally but not yet pushed.
 170    e.logger.Info("tag already exists at current commit, skipping create", "tag", tagName)
 171  }
 172
 173  if err := e.git.PushTag(ctx, tagName); err != nil {
 174    return domain.NewReleaseError("push-tag", err)
 175  }
 176  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,
 187) (published bool, publishURL string, err error) {
 188  isPrerelease := policy != nil && policy.Prerelease
 189
 190  result, err := e.publisher.Publish(ctx, ports.PublishParams{
 191    TagName:    tagName,
 192    Version:    pp.NextVersion,
 193    Project:    pp.Project.Name,
 194    Changelog:  notes,
 195    Prerelease: isPrerelease,
 196  })
 197  if err != nil {
 198    return false, "", domain.NewReleaseError("publish", err)
 199  }
 200  return result.Published, result.PublishURL, nil
 201}

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

#LineLine coverage
 1package app
 2
 3import (
 4  "context"
 5  "fmt"
 6  "strings"
 7
 8  "github.com/jedi-knights/go-semantic-release/internal/domain"
 9  "github.com/jedi-knights/go-semantic-release/internal/ports"
 10)
 11
 12// nextBaseVersion computes the bumped Major.Minor.Patch for the given commits
 13// without applying any prerelease suffix. Used by the planner to determine
 14// which base version to search for when counting existing prerelease tags.
 315func nextBaseVersion(current domain.Version, commits []domain.Commit, typeMapping map[string]domain.ReleaseType) domain.
 316  bump := aggregateBump(commits, typeMapping)
 017  if !bump.IsReleasable() {
 018    return current
 019  }
 320  return current.Bump(bump)
 21}
 22
 23// ReleasePlanner builds a release plan for the repository.
 24type ReleasePlanner struct {
 25  git            ports.GitRepository
 26  tagService     ports.TagService
 27  versionCalc    ports.VersionCalculator
 28  impactAnalyzer ports.ProjectImpactAnalyzer
 29  logger         ports.Logger
 30  typeMapping    map[string]domain.ReleaseType
 31}
 32
 33// NewReleasePlanner creates a release planner.
 34func NewReleasePlanner(
 35  git ports.GitRepository,
 36  tagService ports.TagService,
 37  versionCalc ports.VersionCalculator,
 38  impactAnalyzer ports.ProjectImpactAnalyzer,
 39  logger ports.Logger,
 40  typeMapping map[string]domain.ReleaseType,
 1141) *ReleasePlanner {
 1142  return &ReleasePlanner{
 1143    git:            git,
 1144    tagService:     tagService,
 1145    versionCalc:    versionCalc,
 1146    impactAnalyzer: impactAnalyzer,
 1147    logger:         logger,
 1148    typeMapping:    typeMapping,
 1149  }
 1150}
 51
 52// Plan builds a release plan for the given projects and commits.
 53func (p *ReleasePlanner) Plan(
 54  ctx context.Context,
 55  projects []domain.Project,
 56  commits []domain.Commit,
 57  releaseMode domain.ReleaseMode,
 58  policy *domain.BranchPolicy,
 59  dryRun bool,
 60) (*domain.ReleasePlan, error) {
 61  tags, err := p.git.ListTags(ctx)
 62  if err != nil {
 63    return nil, fmt.Errorf("listing tags: %w", err)
 64  }
 65
 66  plan := &domain.ReleasePlan{
 67    DryRun: dryRun,
 68    Policy: policy,
 69  }
 70
 71  if branch, err := p.git.CurrentBranch(ctx); err == nil {
 72    plan.Branch = branch
 73  } else {
 74    p.logger.Warn("could not determine current branch", "error", err)
 75  }
 76
 77  if releaseMode == domain.ReleaseModeIndependent {
 78    return p.planIndependent(projects, commits, tags, policy, plan)
 79  }
 80  return p.planRepo(projects, commits, tags, policy, plan)
 81}
 82
 83func (p *ReleasePlanner) planRepo(
 84  projects []domain.Project,
 85  commits []domain.Commit,
 86  tags []domain.Tag,
 87  policy *domain.BranchPolicy,
 88  plan *domain.ReleasePlan,
 89) (*domain.ReleasePlan, error) {
 90  // Derive the tag-lookup prefix from the project's TagPrefix. Root projects
 91  // (TagPrefix == "") use unprefixed tags like "v1.0.0" and must look up with
 92  // "". Named projects with an explicit prefix (e.g. "sun-neovim/") have tags
 93  // like "sun-neovim/v0.1.1" and must look up with "sun-neovim".
 94  tagLookupPrefix := ""
 95  if len(projects) > 0 && projects[0].TagPrefix != "" {
 96    tagLookupPrefix = strings.TrimSuffix(projects[0].TagPrefix, "/")
 97  }
 98  latestTag, err := p.tagService.FindLatestTag(tags, tagLookupPrefix)
 99  if err != nil {
 100    return nil, fmt.Errorf("finding latest tag: %w", err)
 101  }
 102  currentVersion := domain.ZeroVersion()
 103
 104  if latestTag != nil {
 105    currentVersion = latestTag.Version
 106    // Trim commits to only those newer than the last release tag so that
 107    // commits already counted in a prior release are not re-analyzed.
 108    commits = commitsAfterHash(commits, buildCommitIndex(commits), latestTag.Hash)
 109  }
 110
 111  counter := 0
 112  if policy != nil && policy.IsPrerelease() && !policy.IsMaintenance() {
 113    base := nextBaseVersion(currentVersion, commits, p.typeMapping)
 114    counter = p.countPrereleaseTags(tags, tagLookupPrefix, base, policy.Channel)
 115  }
 116
 117  nextVersion, releaseType, err := p.versionCalc.Calculate(currentVersion, commits, policy, p.typeMapping, counter)
 118  if err != nil {
 119    return nil, fmt.Errorf("calculating version: %w", err)
 120  }
 121
 122  project := domain.Project{Name: "", Path: ".", Type: domain.ProjectTypeRoot}
 123  if len(projects) > 0 {
 124    project = projects[0]
 125  }
 126
 127  plan.Projects = []domain.ProjectReleasePlan{{
 128    Project:        project,
 129    CurrentVersion: currentVersion,
 130    NextVersion:    nextVersion,
 131    ReleaseType:    releaseType,
 132    Commits:        commits,
 133    ShouldRelease:  releaseType.IsReleasable(),
 134    Reason:         buildReason(releaseType, len(commits)),
 135  }}
 136
 137  return plan, nil
 138}
 139
 140func (p *ReleasePlanner) planIndependent(
 141  projects []domain.Project,
 142  commits []domain.Commit,
 143  tags []domain.Tag,
 144  policy *domain.BranchPolicy,
 145  plan *domain.ReleasePlan,
 146) (*domain.ReleasePlan, error) {
 147  // Build a position index once for all per-project filtering below.
 148  // commits is ordered newest-first (git log default), so lower index = newer.
 149  commitIndex := buildCommitIndex(commits)
 150
 151  impactMap := p.impactAnalyzer.Analyze(projects, commits)
 152
 153  plan.Projects = make([]domain.ProjectReleasePlan, 0, len(projects))
 154
 155  for _, proj := range projects {
 156    projectCommits := impactMap[proj.Name]
 157
 158    latestTag, err := p.tagService.FindLatestTag(tags, proj.Name)
 159    if err != nil {
 160      return nil, domain.NewProjectError(proj.Name, "find latest tag", err)
 161    }
 162    currentVersion := domain.ZeroVersion()
 163    if latestTag != nil {
 164      currentVersion = latestTag.Version
 165      // Trim to only commits newer than the last release tag so that
 166      // commits already counted in a prior release are not re-analyzed.
 167      projectCommits = commitsAfterHash(projectCommits, commitIndex, latestTag.Hash)
 168    }
 169
 170    counter := 0
 171    if policy != nil && policy.IsPrerelease() && !policy.IsMaintenance() {
 172      base := nextBaseVersion(currentVersion, projectCommits, p.typeMapping)
 173      counter = p.countPrereleaseTags(tags, proj.Name, base, policy.Channel)
 174    }
 175
 176    nextVersion, releaseType, err := p.versionCalc.Calculate(currentVersion, projectCommits, policy, p.typeMapping, coun
 177    if err != nil {
 178      return nil, domain.NewProjectError(proj.Name, "calculate version", err)
 179    }
 180
 181    plan.Projects = append(plan.Projects, domain.ProjectReleasePlan{
 182      Project:        proj,
 183      CurrentVersion: currentVersion,
 184      NextVersion:    nextVersion,
 185      ReleaseType:    releaseType,
 186      Commits:        projectCommits,
 187      ShouldRelease:  releaseType.IsReleasable(),
 188      Reason:         buildReason(releaseType, len(projectCommits)),
 189    })
 190  }
 191
 192  return plan, nil
 193}
 194
 195// countPrereleaseTags counts existing prerelease tags for a specific project,
 196// base version (Major.Minor.Patch), and channel. The result is used as the
 197// counter N in the {channel}.{N} prerelease suffix so each RC tag in a cycle
 198// is unique and increments automatically.
 199//
 200// Matching rule: the prerelease field must begin with "{channel}." — the dot
 201// boundary prevents a channel named "rc" from matching a hand-crafted tag
 202// whose prerelease starts with "rca" or similar. Tags whose prerelease contains
 203// additional dot-separated segments (e.g. "rc.1.2") are accepted as valid
 204// counter tags; this is intentional to remain compatible with any tooling that
 205// writes counters in the legacy format.
 206func (p *ReleasePlanner) countPrereleaseTags(tags []domain.Tag, project string, base domain.Version, channel string) int
 207  prefix := channel + "."
 208  count := 0
 209  for _, tag := range tags {
 210    proj, ver, err := p.tagService.ParseTag(tag.Name)
 211    if err != nil {
 212      continue
 213    }
 214    if proj != project {
 215      continue
 216    }
 217    if ver.Major != base.Major || ver.Minor != base.Minor || ver.Patch != base.Patch {
 218      continue
 219    }
 220    if strings.HasPrefix(ver.Prerelease, prefix) {
 221      count++
 222    }
 223  }
 224  return count
 225}
 226
 10227func buildReason(rt domain.ReleaseType, commitCount int) string {
 2228  if !rt.IsReleasable() {
 2229    return "no releasable changes"
 2230  }
 8231  return fmt.Sprintf("%d commit(s) require %s bump", commitCount, rt)
 232}
 233
 234// buildCommitIndex constructs a position map for a newest-first commit slice.
 235// Lower index means more recent; this is the natural order from git log.
 9236func buildCommitIndex(commits []domain.Commit) map[string]int {
 9237  idx := make(map[string]int, len(commits))
 9238  for i := range commits {
 12239    idx[commits[i].Hash] = i
 12240  }
 9241  return idx
 242}
 243
 244// commitsAfterHash returns the subset of commits that are newer than the commit
 245// identified by sinceHash, as determined by position in the globally-ordered
 246// newest-first index. Commits at a lower index than sinceHash's position are
 247// newer; commits at the same or higher index were already included in the
 248// release that created sinceHash.
 249//
 250// If sinceHash is empty or not present in the index (first release or the tag
 251// commit is outside the fetched window), all commits are returned unchanged so
 252// the first-release path is handled correctly.
 9253func commitsAfterHash(commits []domain.Commit, index map[string]int, sinceHash string) []domain.Commit {
 2254  if sinceHash == "" {
 2255    return commits
 2256  }
 7257  cutoff, ok := index[sinceHash]
 6258  if !ok {
 6259    // Tag commit not in the fetched window — treat every commit as new.
 6260    return commits
 6261  }
 1262  result := make([]domain.Commit, 0, cutoff)
 1263  for i := range commits {
 0264    if pos, exists := index[commits[i].Hash]; exists && pos < cutoff {
 0265      result = append(result, commits[i])
 0266    }
 267  }
 1268  return result
 269}

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

#LineLine coverage
 1package app
 2
 3import (
 4  "context"
 5  "fmt"
 6  "strings"
 7
 8  "github.com/jedi-knights/go-semantic-release/internal/domain"
 9  "github.com/jedi-knights/go-semantic-release/internal/ports"
 10)
 11
 12// ConditionVerifier checks that all prerequisites for a release are met.
 13type ConditionVerifier struct {
 14  git    ports.GitRepository
 15  config domain.Config
 16  logger ports.Logger
 17}
 18
 19// NewConditionVerifier creates a condition verifier.
 720func NewConditionVerifier(git ports.GitRepository, config domain.Config, logger ports.Logger) *ConditionVerifier {
 721  return &ConditionVerifier{git: git, config: config, logger: logger}
 722}
 23
 24// VerificationResult captures the outcome of condition checks.
 25type VerificationResult struct {
 26  Passed   bool
 27  Failures []string
 28}
 29
 30// Verify checks all release conditions.
 31func (v *ConditionVerifier) Verify(ctx context.Context) (*VerificationResult, error) {
 32  result := &VerificationResult{Passed: true}
 33
 34  branch, err := v.git.CurrentBranch(ctx)
 35  if err != nil {
 36    return nil, fmt.Errorf("getting current branch: %w", err)
 37  }
 38
 39  v.checkBranch(branch, result)
 40  v.checkGitHub(result)
 41
 42  return result, nil
 43}
 44
 45func (v *ConditionVerifier) checkBranch(branch string, result *VerificationResult) {
 46  policy := domain.FindBranchPolicy(v.config.Branches, branch)
 47  if policy == nil {
 48    result.Passed = false
 49    result.Failures = append(result.Failures,
 50      fmt.Sprintf("branch %q is not configured for releases", branch))
 51  }
 52}
 53
 54func (v *ConditionVerifier) checkGitHub(result *VerificationResult) {
 55  if !v.config.GitHub.CreateRelease {
 56    return
 57  }
 58
 59  var missing []string
 60  if v.config.GitHub.Owner == "" {
 61    missing = append(missing, "github.owner")
 62  }
 63  if v.config.GitHub.Repo == "" {
 64    missing = append(missing, "github.repo")
 65  }
 66  if v.config.GitHub.Token == "" {
 67    missing = append(missing, "github.token (or SEMANTIC_RELEASE_GITHUB_TOKEN)")
 68  }
 69
 70  if len(missing) > 0 {
 71    result.Passed = false
 72    result.Failures = append(result.Failures,
 73      fmt.Sprintf("missing GitHub config: %s", strings.Join(missing, ", ")))
 74  }
 75}

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

#LineLine coverage
 1package app
 2
 3import (
 4  "fmt"
 5
 6  "github.com/jedi-knights/go-semantic-release/internal/domain"
 7  "github.com/jedi-knights/go-semantic-release/internal/ports"
 8)
 9
 10// Compile-time interface compliance check.
 11var _ ports.VersionCalculator = (*VersionCalculatorService)(nil)
 12
 13// VersionCalculatorService implements ports.VersionCalculator.
 14type VersionCalculatorService struct{}
 15
 16// NewVersionCalculatorService creates a new version calculator.
 117func NewVersionCalculatorService() *VersionCalculatorService {
 118  return &VersionCalculatorService{}
 119}
 20
 21func (s *VersionCalculatorService) Calculate(
 22  current domain.Version,
 23  commits []domain.Commit,
 24  policy *domain.BranchPolicy,
 25  typeMapping map[string]domain.ReleaseType,
 26  prereleaseCounter int,
 27) (domain.Version, domain.ReleaseType, error) {
 28  bump := aggregateBump(commits, typeMapping)
 29  if !bump.IsReleasable() {
 30    return current, domain.ReleaseNone, nil
 31  }
 32
 33  // For maintenance branches, constrain the allowed bump type.
 34  if policy != nil && policy.IsMaintenance() {
 35    original := bump
 36    bump = constrainMaintenanceBump(bump, policy)
 37    if !bump.IsReleasable() {
 38      return current, domain.ReleaseNone,
 39        fmt.Errorf("commit requires %s bump but maintenance branch %q does not allow it",
 40          original, policy.Name)
 41    }
 42  }
 43
 44  next := current.Bump(bump)
 45
 46  // Validate maintenance range.
 47  if policy != nil && policy.IsMaintenance() {
 48    if err := domain.ValidateMaintenanceVersion(next, *policy); err != nil {
 49      return current, domain.ReleaseNone, err
 50    }
 51  }
 52
 53  // Apply prerelease identifier.
 54  if policy != nil && policy.IsPrerelease() {
 55    pre := buildPrereleaseID(policy.Channel, prereleaseCounter)
 56    next = next.WithPrerelease(pre)
 57  }
 58
 59  return next, bump, nil
 60}
 61
 62// constrainMaintenanceBump limits the bump type based on the maintenance range.
 63// A "N.N.x" range only allows patch bumps; "N.x" allows patch and minor.
 64//
 65// NOTE: for "N.x" ranges this function permits minor bumps but does not verify
 66// that the resulting version stays within the major boundary. That upper-bound
 67// check is performed by ValidateMaintenanceVersion (called by Calculate after
 68// Bump). Callers must invoke both functions in sequence; calling this function
 69// alone is not sufficient to enforce the full maintenance constraint.
 570func constrainMaintenanceBump(bump domain.ReleaseType, policy *domain.BranchPolicy) domain.ReleaseType {
 571  _, maxVer, err := policy.MaintenanceRange()
 172  if err != nil {
 173    return domain.ReleaseNone
 174  }
 75
 76  // Major bumps are never allowed on any maintenance branch.
 277  if bump == domain.ReleaseMajor {
 278    return domain.ReleaseNone
 279  }
 80
 81  // "N.N.x" range (max differs only in minor): only patch is allowed.
 82  // "N.x" range (max differs in major): patch and minor are both allowed.
 183  if maxVer.Minor > 0 && maxVer.Patch == 0 {
 184    // Range like "1.2.x" → max is "1.3.0" → only patch allowed.
 185    if bump > domain.ReleasePatch {
 186      return domain.ReleaseNone
 187    }
 88  }
 89
 190  return bump
 91}
 92
 1993func aggregateBump(commits []domain.Commit, typeMapping map[string]domain.ReleaseType) domain.ReleaseType {
 1994  highest := domain.ReleaseNone
 1995  for i := range commits {
 2496    rt := commits[i].ReleaseType(typeMapping)
 2497    highest = highest.Higher(rt)
 2498  }
 1999  return highest
 100}
 101
 4102func buildPrereleaseID(channel string, counter int) string {
 1103  if channel == "" {
 1104    channel = "pre"
 1105  }
 0106  if counter < 0 {
 0107    counter = 0
 0108  }
 4109  return fmt.Sprintf("%s.%d", channel, counter)
 110}