< Summary - go-semantic-release Coverage

Information
Line coverage
100%
Covered lines: 8
Uncovered lines: 0
Coverable lines: 8
Total lines: 433
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
Publish0%00100%

File(s)

/home/runner/work/go-semantic-release/go-semantic-release/internal/di/container.go

#LineLine coverage
 1package di
 2
 3import (
 4  "context"
 5  "fmt"
 6  "os"
 7  "sync"
 8  "sync/atomic"
 9
 10  "github.com/jedi-knights/go-semantic-release/internal/adapters/bitbucket"
 11  "github.com/jedi-knights/go-semantic-release/internal/adapters/changelog"
 12  adapterfs "github.com/jedi-knights/go-semantic-release/internal/adapters/fs"
 13  adaptergit "github.com/jedi-knights/go-semantic-release/internal/adapters/git"
 14  adaptergithub "github.com/jedi-knights/go-semantic-release/internal/adapters/github"
 15  "github.com/jedi-knights/go-semantic-release/internal/adapters/gitlab"
 16  adaptergogit "github.com/jedi-knights/go-semantic-release/internal/adapters/gogit"
 17  adapterlint "github.com/jedi-knights/go-semantic-release/internal/adapters/lint"
 18  "github.com/jedi-knights/go-semantic-release/internal/adapters/plugins"
 19  "github.com/jedi-knights/go-semantic-release/internal/app"
 20  "github.com/jedi-knights/go-semantic-release/internal/domain"
 21  "github.com/jedi-knights/go-semantic-release/internal/platform"
 22  "github.com/jedi-knights/go-semantic-release/internal/ports"
 23)
 24
 25// Container is the dependency injection container that wires all components.
 26// It is safe for concurrent use: lazy singletons are protected by mu; the logger
 27// is stored atomically so WithLogger has no ordering constraints.
 28// fileSystem is the exception: it is initialized via fileSystemOnce (sync.Once)
 29// rather than mu so that ProjectDiscoverer can call FileSystem() while already
 30// holding mu without deadlocking.
 31type Container struct {
 32  mu        sync.Mutex
 33  config    domain.Config // immutable after construction; read without mu is safe
 34  loggerPtr atomic.Pointer[ports.Logger]
 35  workDir   string
 36
 37  // fileSystem is initialized exactly once via fileSystemOnce; all other
 38  // singletons below are lazily initialized under mu.
 39  fileSystemOnce sync.Once
 40  fileSystem     ports.FileSystem
 41
 42  // Singletons (lazily initialized; access only while holding mu).
 43  gitRepo        ports.GitRepository
 44  commitParser   ports.CommitParser
 45  tagService     ports.TagService
 46  versionCalc    ports.VersionCalculator
 47  changelogGen   ports.ChangelogGenerator
 48  publisher      ports.ReleasePublisher
 49  impactAnalyzer ports.ProjectImpactAnalyzer
 50  discoverer     ports.ProjectDiscoverer
 51
 52  // Plugin list is built exactly once via pluginsOnce.
 53  pluginsOnce sync.Once
 54  pluginList  []ports.Plugin
 55  pluginsErr  error
 56}
 57
 58// NewContainer creates a DI container with the given config.
 59// workDir must be an absolute path to the repository root; construction fails if the path does not exist.
 60func NewContainer(config domain.Config, workDir string) (*Container, error) {
 61  if _, err := os.Stat(workDir); err != nil {
 62    return nil, fmt.Errorf("invalid workDir %q: %w", workDir, err)
 63  }
 64  c := &Container{config: config, workDir: workDir}
 65  var l ports.Logger = platform.DefaultLogger()
 66  c.loggerPtr.Store(&l)
 67  return c, nil
 68}
 69
 70// WithLogger overrides the logger. Safe to call concurrently with any other method.
 71func (c *Container) WithLogger(logger ports.Logger) *Container {
 72  c.loggerPtr.Store(&logger)
 73  return c
 74}
 75
 76// Logger returns the current logger. Safe to call concurrently.
 77func (c *Container) Logger() ports.Logger {
 78  return *c.loggerPtr.Load()
 79}
 80
 81// Config returns the container's configuration. config is immutable after
 82// construction so this is safe to call without holding mu.
 83func (c *Container) Config() domain.Config {
 84  return c.config
 85}
 86
 87// GitRepository returns the configured git repository implementation (CLI or go-git).
 88func (c *Container) GitRepository() ports.GitRepository {
 89  c.mu.Lock()
 90  defer c.mu.Unlock()
 91  if c.gitRepo == nil {
 92    if c.config.GitBackend == "go-git" {
 93      repo, err := adaptergogit.NewRepository(c.workDir)
 94      if err != nil {
 95        c.Logger().Warn("failed to open go-git repository, falling back to CLI", "error", err)
 96        c.gitRepo = adaptergit.NewRepository(c.workDir)
 97      } else {
 98        c.gitRepo = repo
 99      }
 100    } else {
 101      c.gitRepo = adaptergit.NewRepository(c.workDir)
 102    }
 103  }
 104  return c.gitRepo
 105}
 106
 107// CommitParser returns the conventional commit parser.
 108func (c *Container) CommitParser() ports.CommitParser {
 109  c.mu.Lock()
 110  defer c.mu.Unlock()
 111  if c.commitParser == nil {
 112    c.commitParser = adaptergit.NewConventionalCommitParser()
 113  }
 114  return c.commitParser
 115}
 116
 117// FileSystem returns the OS filesystem adapter.
 118// Initialization is guarded by fileSystemOnce rather than mu so that
 119// ProjectDiscoverer (and buildPlugins) can call FileSystem() while already
 120// holding mu without deadlocking.
 121func (c *Container) FileSystem() ports.FileSystem {
 122  c.fileSystemOnce.Do(func() {
 123    c.fileSystem = adapterfs.NewOSFileSystem()
 124  })
 125  return c.fileSystem
 126}
 127
 128// TagService returns the tag formatter configured for this repository's tag format.
 129func (c *Container) TagService() ports.TagService {
 130  c.mu.Lock()
 131  defer c.mu.Unlock()
 132  if c.tagService == nil {
 133    c.tagService = adaptergit.NewTemplateTagService(c.config.TagFormat, c.config.ProjectTagFormat)
 134  }
 135  return c.tagService
 136}
 137
 138// VersionCalculator returns the semver bump calculator.
 139func (c *Container) VersionCalculator() ports.VersionCalculator {
 140  c.mu.Lock()
 141  defer c.mu.Unlock()
 142  if c.versionCalc == nil {
 143    c.versionCalc = app.NewVersionCalculatorService()
 144  }
 145  return c.versionCalc
 146}
 147
 148// ChangelogGenerator returns the template-based changelog generator.
 149func (c *Container) ChangelogGenerator() ports.ChangelogGenerator {
 150  c.mu.Lock()
 151  defer c.mu.Unlock()
 152  if c.changelogGen == nil {
 153    c.changelogGen = changelog.NewTemplateGenerator(c.config.ChangelogTemplate)
 154  }
 155  return c.changelogGen
 156}
 157
 158// ReleasePublisher returns the configured release publisher.
 159// When github.create_release is false, a no-op publisher is returned so callers
 160// that receive the publisher from the container never need to nil-check the result.
 161// MustNewReleaseExecutor panics on nil publisher; callers that bypass the container
 162// must pass noopPublisher{} explicitly when publishing is disabled.
 163func (c *Container) ReleasePublisher() ports.ReleasePublisher {
 164  c.mu.Lock()
 165  defer c.mu.Unlock()
 166  if c.publisher == nil {
 167    if c.config.GitHub.CreateRelease {
 168      c.publisher = adaptergithub.NewPublisher(
 169        c.config.GitHub.Owner,
 170        c.config.GitHub.Repo,
 171        c.config.GitHub.Token,
 172      )
 173    } else {
 174      c.publisher = noopPublisher{}
 175    }
 176  }
 177  return c.publisher
 178}
 179
 180// ProjectImpactAnalyzer returns the path-based project impact analyzer.
 181func (c *Container) ProjectImpactAnalyzer() ports.ProjectImpactAnalyzer {
 182  c.mu.Lock()
 183  defer c.mu.Unlock()
 184  if c.impactAnalyzer == nil {
 185    c.impactAnalyzer = adaptergit.NewPathBasedImpactAnalyzer(c.config.DependencyPropagation, c.config.IncludePaths, c.co
 186  }
 187  return c.impactAnalyzer
 188}
 189
 190// ProjectDiscoverer returns the composite project discoverer for this repository.
 191func (c *Container) ProjectDiscoverer() ports.ProjectDiscoverer {
 192  c.mu.Lock()
 193  defer c.mu.Unlock()
 194  if c.discoverer == nil {
 195    c.discoverer = c.buildDiscoverer()
 196  }
 197  return c.discoverer
 198}
 199
 200// buildDiscoverer must be called with mu held. It calls c.FileSystem() safely
 201// because FileSystem uses its own fileSystemOnce and does not acquire mu.
 202func (c *Container) buildDiscoverer() ports.ProjectDiscoverer {
 203  fs := c.FileSystem()
 204  var discoverers []ports.ProjectDiscoverer
 205
 206  if len(c.config.Projects) > 0 {
 207    discoverers = append(discoverers, adaptergit.NewConfiguredDiscoverer(c.config.Projects))
 208  }
 209  // WorkspaceDiscoverer is always appended so that repos without explicit project config
 210  // are still discovered via go.work. Note: CompositeDiscoverer uses first-wins semantics,
 211  // so if ConfiguredDiscoverer returns results, WorkspaceDiscoverer is NOT called and
 212  // additional go.work modules are silently skipped. A future MergingDiscoverer would be
 213  // needed to combine both sources.
 214  discoverers = append(discoverers, adaptergit.NewWorkspaceDiscoverer(fs))
 215  if c.config.DiscoverModules {
 216    discoverers = append(discoverers, adaptergit.NewModuleDiscoverer(fs))
 217  }
 218  if c.config.DiscoverCmd {
 219    discoverers = append(discoverers, adaptergit.NewCmdDiscoverer(fs))
 220  }
 221
 222  return adaptergit.NewCompositeDiscoverer(discoverers...)
 223}
 224
 225// Plugins builds and caches the ordered list of lifecycle plugins based on config.
 226// The list is constructed exactly once (via sync.Once) regardless of concurrent callers.
 227// Returns an error if any explicitly configured external plugin fails to load.
 228//
 229// Once a load error occurs the sync.Once body is complete and will never re-run,
 230// so subsequent calls will always return (nil, err) for the same permanent error.
 231// Callers that need to react to transient failures must create a new Container.
 232//
 233// Concurrency note: pluginsOnce.Do intentionally does NOT hold c.mu. Each
 234// accessor called inside Do (GitRepository, CommitParser, etc.) acquires c.mu
 235// independently for its own singleton initialisation. Holding c.mu across the
 236// entire Do body would cause a deadlock because those accessors also try to
 237// acquire c.mu. sync.Once provides its own mutual-exclusion guarantee for the
 238// Do body itself, so no additional locking is required here.
 239func (c *Container) Plugins() ([]ports.Plugin, error) {
 240  c.pluginsOnce.Do(func() {
 241    c.pluginList, c.pluginsErr = c.buildPlugins()
 242  })
 243  return c.pluginList, c.pluginsErr
 244}
 245
 246// buildPlugins constructs the ordered plugin list. It must only be called from
 247// within pluginsOnce.Do; sync.Once provides the mutual-exclusion guarantee.
 248//
 249// Side effect: calling buildPlugins initializes most container singletons
 250// (GitRepository, TagService, FileSystem, CommitParser, ChangelogGenerator, …)
 251// as a side effect of constructing their respective plugins. A first call to
 252// Plugins() is therefore equivalent to eagerly initialising the majority of
 253// the container's infrastructure.
 254func (c *Container) buildPlugins() ([]ports.Plugin, error) {
 255  logger := c.Logger()
 256
 257  ps := []ports.Plugin{
 258    // Git plugin: verifyConditions + publish (stage → commit → push → tag).
 259    plugins.NewGitPlugin(
 260      c.GitRepository(),
 261      c.TagService(),
 262      c.FileSystem(),
 263      logger,
 264      c.config.GitAuthor,
 265      c.config.Git,
 266    ),
 267    // Commit analyzer plugin: analyzeCommits.
 268    plugins.NewCommitAnalyzerPlugin(
 269      c.CommitParser(),
 270      c.config.CommitTypes,
 271    ),
 272    // Release notes plugin: generateNotes.
 273    plugins.NewReleaseNotesPlugin(
 274      c.ChangelogGenerator(),
 275      c.config.ChangelogSections,
 276    ),
 277  }
 278
 279  // Prepare plugin: update CHANGELOG.md, VERSION, version_files, run command.
 280  // Register if any prepare option is configured OR any project defines a per-project changelog_file.
 281  if c.config.Prepare.ChangelogFile != "" || c.config.Prepare.VersionFile != "" ||
 282    c.config.Prepare.Command != "" || len(c.config.Prepare.VersionFiles) > 0 ||
 283    c.config.AnyProjectDefinesChangelog() {
 284    ps = append(ps, plugins.NewPreparePlugin(
 285      c.FileSystem(),
 286      logger,
 287      c.config.Prepare,
 288    ))
 289  }
 290
 291  // Lint plugin: verifyRelease (commit message linting).
 292  if c.config.Lint.Enabled {
 293    lintCfg := c.config.Lint
 294    if len(lintCfg.AllowedTypes) == 0 {
 295      // No allowed types configured — fall back to the full default set.
 296      lintCfg = domain.DefaultEnabledLintConfig()
 297    }
 298    ps = append(ps, plugins.NewLintPlugin(
 299      adapterlint.NewConventionalLinter(lintCfg),
 300      logger,
 301    ))
 302  }
 303
 304  // GitHub plugin: verifyConditions + publish + addChannel + success + fail.
 305  if c.config.GitHub.CreateRelease {
 306    ps = append(ps, adaptergithub.NewPlugin(
 307      adaptergithub.PluginConfig{
 308        Owner:                  c.config.GitHub.Owner,
 309        Repo:                   c.config.GitHub.Repo,
 310        Token:                  c.config.GitHub.Token,
 311        APIURL:                 c.config.GitHub.APIURL,
 312        Assets:                 c.config.GitHub.Assets,
 313        DraftRelease:           c.config.GitHub.DraftRelease,
 314        DiscussionCategoryName: c.config.GitHub.DiscussionCategoryName,
 315        SuccessComment:         c.config.GitHub.SuccessComment,
 316        FailComment:            c.config.GitHub.FailComment,
 317        ReleasedLabels:         c.config.GitHub.ReleasedLabels,
 318        FailLabels:             c.config.GitHub.FailLabels,
 319      },
 320      logger,
 321    ))
 322  }
 323
 324  // GitLab plugin: verifyConditions + publish + addChannel + success + fail.
 325  if c.config.GitLab.CreateRelease {
 326    ps = append(ps, gitlab.NewPlugin(
 327      gitlab.PluginConfig{
 328        ProjectID:  c.config.GitLab.ProjectID,
 329        Token:      c.config.GitLab.Token,
 330        APIURL:     c.config.GitLab.APIURL,
 331        Assets:     c.config.GitLab.Assets,
 332        Milestones: c.config.GitLab.Milestones,
 333      },
 334      logger,
 335    ))
 336  }
 337
 338  // Bitbucket plugin: verifyConditions + publish + addChannel + success + fail.
 339  if c.config.Bitbucket.CreateRelease {
 340    ps = append(ps, bitbucket.NewPlugin(
 341      bitbucket.PluginConfig{
 342        Workspace: c.config.Bitbucket.Workspace,
 343        RepoSlug:  c.config.Bitbucket.RepoSlug,
 344        Token:     c.config.Bitbucket.Token,
 345        APIURL:    c.config.Bitbucket.APIURL,
 346      },
 347      logger,
 348    ))
 349  }
 350
 351  // External plugins from config/flags.
 352  if len(c.config.Plugins) > 0 {
 353    external, err := plugins.LoadExternalPlugins(c.config.Plugins)
 354    if err != nil {
 355      // Return (nil, err) so callers cannot accidentally use a partial list.
 356      return nil, err
 357    }
 358    ps = append(ps, external...)
 359  }
 360
 361  return ps, nil
 362}
 363
 364// noopPublisher is a null-object implementation of ports.ReleasePublisher.
 365// It is used when github.create_release is false so that ReleasePublisher()
 366// always returns a non-nil value and callers never need to nil-check.
 367type noopPublisher struct{}
 368
 2369func (noopPublisher) Publish(ctx context.Context, _ ports.PublishParams) (domain.ProjectReleaseResult, error) {
 2370  // Respect cancellation so that a cancelled pipeline does not silently
 2371  // succeed at the publish step, keeping pipeline error propagation consistent
 2372  // regardless of whether a real publisher is wired.
 1373  if ctx.Err() != nil {
 1374    return domain.ProjectReleaseResult{}, ctx.Err()
 1375  }
 1376  return domain.ProjectReleaseResult{}, nil
 377}
 378
 379// Pipeline creates a lifecycle pipeline with all configured plugins.
 380// Returns an error if any explicitly configured external plugin failed to load.
 381func (c *Container) Pipeline() (*app.Pipeline, error) {
 382  ps, err := c.Plugins()
 383  if err != nil {
 384    return nil, err
 385  }
 386  return app.NewPipeline(ps, c.Logger()), nil
 387}
 388
 389// CommitAnalyzer creates a CommitAnalyzer use case.
 390// A new instance is returned on each call — this is intentional. Use cases are
 391// lightweight value objects; the underlying infrastructure (GitRepository, CommitParser)
 392// is shared via their own singletons.
 393func (c *Container) CommitAnalyzer() *app.CommitAnalyzer {
 394  return app.NewCommitAnalyzer(c.GitRepository(), c.CommitParser(), c.Logger())
 395}
 396
 397// ProjectDetector creates a ProjectDetector use case.
 398// A new instance is returned on each call (intentional — see CommitAnalyzer).
 399func (c *Container) ProjectDetector() *app.ProjectDetector {
 400  return app.NewProjectDetector(c.ProjectDiscoverer(), c.Logger())
 401}
 402
 403// ReleasePlanner creates a ReleasePlanner use case.
 404// A new instance is returned on each call (intentional — see CommitAnalyzer).
 405func (c *Container) ReleasePlanner() *app.ReleasePlanner {
 406  return app.NewReleasePlanner(
 407    c.GitRepository(),
 408    c.TagService(),
 409    c.VersionCalculator(),
 410    c.ProjectImpactAnalyzer(),
 411    c.Logger(),
 412    c.config.CommitTypes,
 413  )
 414}
 415
 416// ReleaseExecutor creates a ReleaseExecutor use case.
 417// A new instance is returned on each call (intentional — see CommitAnalyzer).
 418func (c *Container) ReleaseExecutor() *app.ReleaseExecutor {
 419  return app.MustNewReleaseExecutor(
 420    c.GitRepository(),
 421    c.TagService(),
 422    c.ChangelogGenerator(),
 423    c.ReleasePublisher(),
 424    c.Logger(),
 425    c.config.ChangelogSections,
 426  )
 427}
 428
 429// ConditionVerifier creates a ConditionVerifier use case.
 430// A new instance is returned on each call (intentional — see CommitAnalyzer).
 431func (c *Container) ConditionVerifier() *app.ConditionVerifier {
 432  return app.NewConditionVerifier(c.GitRepository(), c.config, c.Logger())
 433}

Methods/Properties

Publish