< Summary - go-semantic-release Coverage

Line coverage
91%
Covered lines: 217
Uncovered lines: 21
Coverable lines: 238
Total lines: 1014
Line coverage: 91.1%
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/adapters/plugins/commit_analyzer.go

#LineLine coverage
 1package plugins
 2
 3import (
 4  "context"
 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 checks.
 11var (
 12  _ ports.Plugin               = (*CommitAnalyzerPlugin)(nil)
 13  _ ports.AnalyzeCommitsPlugin = (*CommitAnalyzerPlugin)(nil)
 14)
 15
 16// CommitAnalyzerPlugin implements AnalyzeCommitsPlugin using conventional commits.
 17type CommitAnalyzerPlugin struct {
 18  parser      ports.CommitParser
 19  typeMapping map[string]domain.ReleaseType
 20}
 21
 22// NewCommitAnalyzerPlugin creates the default commit analyzer plugin.
 423func NewCommitAnalyzerPlugin(parser ports.CommitParser, typeMapping map[string]domain.ReleaseType) *CommitAnalyzerPlugin
 424  return &CommitAnalyzerPlugin{parser: parser, typeMapping: typeMapping}
 425}
 26
 27func (p *CommitAnalyzerPlugin) Name() string { return "commit-analyzer" }
 28
 29func (p *CommitAnalyzerPlugin) AnalyzeCommits(_ context.Context, rc *domain.ReleaseContext) (domain.ReleaseType, error) 
 30  highest := domain.ReleaseNone
 31  for i := range rc.Commits {
 32    rt := rc.Commits[i].ReleaseType(p.typeMapping)
 33    highest = highest.Higher(rt)
 34  }
 35  return highest, nil
 36}

/home/runner/work/go-semantic-release/go-semantic-release/internal/adapters/plugins/external.go

#LineLine coverage
 1package plugins
 2
 3import (
 4  "bytes"
 5  "context"
 6  "encoding/json"
 7  "fmt"
 8  "os/exec"
 9  "strings"
 10
 11  "github.com/jedi-knights/go-semantic-release/internal/domain"
 12  "github.com/jedi-knights/go-semantic-release/internal/ports"
 13)
 14
 15// Compile-time interface compliance checks.
 16var (
 17  _ ports.Plugin                 = (*ExternalPlugin)(nil)
 18  _ ports.VerifyConditionsPlugin = (*ExternalPlugin)(nil)
 19  _ ports.AnalyzeCommitsPlugin   = (*ExternalPlugin)(nil)
 20  _ ports.VerifyReleasePlugin    = (*ExternalPlugin)(nil)
 21  _ ports.GenerateNotesPlugin    = (*ExternalPlugin)(nil)
 22  _ ports.PreparePlugin          = (*ExternalPlugin)(nil)
 23  _ ports.PublishPlugin          = (*ExternalPlugin)(nil)
 24  _ ports.AddChannelPlugin       = (*ExternalPlugin)(nil)
 25  _ ports.SuccessPlugin          = (*ExternalPlugin)(nil)
 26  _ ports.FailPlugin             = (*ExternalPlugin)(nil)
 27)
 28
 29// ExternalPlugin wraps an external executable as a lifecycle plugin.
 30// Communication uses JSON over stdin/stdout.
 31type ExternalPlugin struct {
 32  name       string
 33  executable string
 34}
 35
 36// NewExternalPlugin creates an external plugin adapter.
 1937func NewExternalPlugin(name, executable string) *ExternalPlugin {
 1938  return &ExternalPlugin{name: name, executable: executable}
 1939}
 40
 41func (p *ExternalPlugin) Name() string { return p.name }
 42
 43// VerifyConditions calls the external plugin's verifyConditions step.
 44func (p *ExternalPlugin) VerifyConditions(ctx context.Context, rc *domain.ReleaseContext) error {
 45  _, err := p.invoke(ctx, string(domain.StepVerifyConditions), rc)
 46  return err
 47}
 48
 49// AnalyzeCommits calls the external plugin's analyzeCommits step.
 50func (p *ExternalPlugin) AnalyzeCommits(ctx context.Context, rc *domain.ReleaseContext) (domain.ReleaseType, error) {
 51  resp, err := p.invoke(ctx, string(domain.StepAnalyzeCommits), rc)
 52  if err != nil {
 53    return domain.ReleaseNone, err
 54  }
 55
 56  switch strings.ToLower(resp.ReleaseType) {
 57  case "major":
 58    return domain.ReleaseMajor, nil
 59  case "minor":
 60    return domain.ReleaseMinor, nil
 61  case "patch":
 62    return domain.ReleasePatch, nil
 63  default:
 64    return domain.ReleaseNone, nil
 65  }
 66}
 67
 68// VerifyRelease calls the external plugin's verifyRelease step.
 69func (p *ExternalPlugin) VerifyRelease(ctx context.Context, rc *domain.ReleaseContext) error {
 70  _, err := p.invoke(ctx, string(domain.StepVerifyRelease), rc)
 71  return err
 72}
 73
 74// GenerateNotes calls the external plugin's generateNotes step.
 75func (p *ExternalPlugin) GenerateNotes(ctx context.Context, rc *domain.ReleaseContext) (string, error) {
 76  resp, err := p.invoke(ctx, string(domain.StepGenerateNotes), rc)
 77  if err != nil {
 78    return "", err
 79  }
 80  return resp.Notes, nil
 81}
 82
 83// Prepare calls the external plugin's prepare step.
 84func (p *ExternalPlugin) Prepare(ctx context.Context, rc *domain.ReleaseContext) error {
 85  _, err := p.invoke(ctx, string(domain.StepPrepare), rc)
 86  return err
 87}
 88
 89// Publish calls the external plugin's publish step.
 90func (p *ExternalPlugin) Publish(ctx context.Context, rc *domain.ReleaseContext) (*domain.ProjectReleaseResult, error) {
 91  _, err := p.invoke(ctx, string(domain.StepPublish), rc)
 92  if err != nil {
 93    return nil, err
 94  }
 95  return nil, nil
 96}
 97
 98// AddChannel calls the external plugin's addChannel step.
 99func (p *ExternalPlugin) AddChannel(ctx context.Context, rc *domain.ReleaseContext) error {
 100  _, err := p.invoke(ctx, string(domain.StepAddChannel), rc)
 101  return err
 102}
 103
 104// Success calls the external plugin's success step.
 105func (p *ExternalPlugin) Success(ctx context.Context, rc *domain.ReleaseContext) error {
 106  _, err := p.invoke(ctx, string(domain.StepSuccess), rc)
 107  return err
 108}
 109
 110// Fail calls the external plugin's fail step.
 111func (p *ExternalPlugin) Fail(ctx context.Context, rc *domain.ReleaseContext) error {
 112  _, err := p.invoke(ctx, string(domain.StepFail), rc)
 113  return err
 114}
 115
 116func (p *ExternalPlugin) invoke(ctx context.Context, step string, rc *domain.ReleaseContext) (*ExternalPluginResponse, e
 117  request := ExternalPluginRequest{
 118    Step:    step,
 119    Context: toExternalContext(rc),
 120  }
 121
 122  inputData, err := json.Marshal(request)
 123  if err != nil {
 124    return nil, fmt.Errorf("marshaling plugin request: %w", err)
 125  }
 126
 127  cmd := exec.CommandContext(ctx, p.executable, "--step", step)
 128  cmd.Stdin = bytes.NewReader(inputData)
 129
 130  var stdout, stderr bytes.Buffer
 131  cmd.Stdout = &stdout
 132  cmd.Stderr = &stderr
 133
 134  if err := cmd.Run(); err != nil {
 135    errMsg := strings.TrimSpace(stderr.String())
 136    if errMsg == "" {
 137      errMsg = err.Error()
 138    }
 139    return nil, fmt.Errorf("external plugin %q step %s failed: %s", p.name, step, errMsg)
 140  }
 141
 142  // If no output, the plugin doesn't implement this step â€” that's OK.
 143  if stdout.Len() == 0 {
 144    return &ExternalPluginResponse{}, nil
 145  }
 146
 147  var resp ExternalPluginResponse
 148  if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
 149    return nil, fmt.Errorf("parsing plugin %q response: %w", p.name, err)
 150  }
 151
 152  if resp.Error != "" {
 153    return nil, fmt.Errorf("external plugin %q: %s", p.name, resp.Error)
 154  }
 155
 156  return &resp, nil
 157}

/home/runner/work/go-semantic-release/go-semantic-release/internal/adapters/plugins/git_plugin.go

#LineLine coverage
 1package plugins
 2
 3import (
 4  "bytes"
 5  "context"
 6  "fmt"
 7  "text/template"
 8
 9  "github.com/jedi-knights/go-semantic-release/internal/domain"
 10  "github.com/jedi-knights/go-semantic-release/internal/ports"
 11)
 12
 13// Compile-time interface compliance checks.
 14var (
 15  _ ports.Plugin                 = (*GitPlugin)(nil)
 16  _ ports.VerifyConditionsPlugin = (*GitPlugin)(nil)
 17  _ ports.PublishPlugin          = (*GitPlugin)(nil)
 18)
 19
 20// GitPlugin handles git operations: verifyConditions (git access), publish (stage â†’ commit â†’ push â†’ tag).
 21type GitPlugin struct {
 22  git        ports.GitRepository
 23  tagService ports.TagService
 24  fs         ports.FileSystem
 25  logger     ports.Logger
 26  identity   domain.GitIdentity
 27  gitConfig  domain.GitConfig
 28}
 29
 30// NewGitPlugin creates the built-in git plugin.
 31func NewGitPlugin(
 32  git ports.GitRepository,
 33  tagService ports.TagService,
 34  fs ports.FileSystem,
 35  logger ports.Logger,
 36  identity domain.GitIdentity,
 37  gitConfig domain.GitConfig,
 1538) *GitPlugin {
 1539  return &GitPlugin{
 1540    git:        git,
 1541    tagService: tagService,
 1542    fs:         fs,
 1543    logger:     logger,
 1544    identity:   identity,
 1545    gitConfig:  gitConfig,
 1546  }
 1547}
 48
 49func (p *GitPlugin) Name() string { return "git" }
 50
 51func (p *GitPlugin) VerifyConditions(ctx context.Context, rc *domain.ReleaseContext) error {
 52  _, err := p.git.CurrentBranch(ctx)
 53  if err != nil {
 54    return fmt.Errorf("unable to access git repository: %w", err)
 55  }
 56  return nil
 57}
 58
 59func (p *GitPlugin) Publish(ctx context.Context, rc *domain.ReleaseContext) (*domain.ProjectReleaseResult, error) {
 60  if rc.CurrentProject == nil {
 61    return nil, nil
 62  }
 63
 64  tagName, err := p.tagService.FormatTag(rc.CurrentProject.Project.Name, rc.CurrentProject.NextVersion)
 65  if err != nil {
 66    return nil, fmt.Errorf("formatting tag: %w", err)
 67  }
 68  rc.TagName = tagName
 69
 70  // Stage and commit release assets before tagging so the tag points to the release commit.
 71  if len(p.gitConfig.Assets) > 0 {
 72    if err = p.git.Stage(ctx, p.gitConfig.Assets); err != nil {
 73      return nil, fmt.Errorf("staging release assets: %w", err)
 74    }
 75    commitMsg := renderCommitMessage(p.gitConfig.Message, tagName, rc.CurrentProject.NextVersion, rc.Notes)
 76    if err = p.git.Commit(ctx, commitMsg); err != nil {
 77      return nil, fmt.Errorf("committing release assets: %w", err)
 78    }
 79    if err = p.git.Push(ctx); err != nil {
 80      return nil, fmt.Errorf("pushing release branch: %w", err)
 81    }
 82  }
 83
 84  headHash, err := p.git.HeadHash(ctx)
 85  if err != nil {
 86    return nil, fmt.Errorf("getting HEAD hash: %w", err)
 87  }
 88
 89  tagMessage := fmt.Sprintf("chore(release): %s", tagName)
 90  if rc.Notes != "" {
 91    tagMessage = rc.Notes
 92  }
 93
 94  if err := p.git.CreateTag(ctx, tagName, headHash, tagMessage); err != nil {
 95    return nil, fmt.Errorf("creating tag %s: %w", tagName, err)
 96  }
 97
 98  if err := p.git.PushTag(ctx, tagName); err != nil {
 99    return nil, fmt.Errorf("pushing tag %s: %w", tagName, err)
 100  }
 101
 102  p.logger.Info("created and pushed tag", "tag", tagName)
 103
 104  return &domain.ProjectReleaseResult{
 105    Project:    rc.CurrentProject.Project,
 106    Version:    rc.CurrentProject.NextVersion,
 107    TagName:    tagName,
 108    TagCreated: true,
 109    Changelog:  rc.Notes,
 110  }, nil
 111}
 112
 113// renderCommitMessage renders the commit message template with release data.
 114// Supports {{.Version}}, {{.Tag}}, and {{.Notes}} placeholders.
 115// Falls back to "chore(release): {tagName}" on empty template or render error.
 12116func renderCommitMessage(tmpl, tagName string, version domain.Version, notes string) string {
 3117  if tmpl == "" {
 3118    return fmt.Sprintf("chore(release): %s", tagName)
 3119  }
 9120  data := struct {
 9121    Version string
 9122    Tag     string
 9123    Notes   string
 9124  }{
 9125    Version: version.String(),
 9126    Tag:     tagName,
 9127    Notes:   notes,
 9128  }
 9129  t, err := template.New("").Parse(tmpl)
 1130  if err != nil {
 1131    return fmt.Sprintf("chore(release): %s", tagName)
 1132  }
 8133  var buf bytes.Buffer
 1134  if err := t.Execute(&buf, data); err != nil {
 1135    return fmt.Sprintf("chore(release): %s", tagName)
 1136  }
 7137  return buf.String()
 138}

/home/runner/work/go-semantic-release/go-semantic-release/internal/adapters/plugins/lint_plugin.go

#LineLine coverage
 1package plugins
 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// Compile-time interface compliance checks.
 13var (
 14  _ ports.Plugin              = (*LintPlugin)(nil)
 15  _ ports.VerifyReleasePlugin = (*LintPlugin)(nil)
 16)
 17
 18// LintPlugin implements VerifyReleasePlugin by linting commit messages.
 19type LintPlugin struct {
 20  linter ports.CommitLinter
 21  logger ports.Logger
 22}
 23
 24// NewLintPlugin creates a commit linting plugin.
 625func NewLintPlugin(linter ports.CommitLinter, logger ports.Logger) *LintPlugin {
 626  return &LintPlugin{linter: linter, logger: logger}
 627}
 28
 29func (p *LintPlugin) Name() string { return "commit-lint" }
 30
 31// VerifyRelease lints all commits and returns an error if any have error-severity violations.
 32func (p *LintPlugin) VerifyRelease(_ context.Context, rc *domain.ReleaseContext) error {
 33  var allErrors []string
 34
 35  for i := range rc.Commits {
 36    violations := p.linter.Lint(rc.Commits[i])
 37    for _, v := range violations {
 38      msg := fmt.Sprintf("%s (%s): %s [%s]", rc.Commits[i].Hash[:minInt(7, len(rc.Commits[i].Hash))], v.Severity, v.Mess
 39      if v.Severity == domain.LintError {
 40        allErrors = append(allErrors, msg)
 41      } else {
 42        p.logger.Warn("lint warning", "commit", rc.Commits[i].Hash, "rule", v.Rule, "message", v.Message)
 43      }
 44    }
 45  }
 46
 47  if len(allErrors) > 0 {
 48    return fmt.Errorf("commit lint errors:\n  %s", strings.Join(allErrors, "\n  "))
 49  }
 50  return nil
 51}
 52
 353func minInt(a, b int) int {
 154  if a < b {
 155    return a
 156  }
 257  return b
 58}

/home/runner/work/go-semantic-release/go-semantic-release/internal/adapters/plugins/loader.go

#LineLine coverage
 1package plugins
 2
 3import (
 4  "fmt"
 5  "os/exec"
 6  "path/filepath"
 7  "strings"
 8
 9  "github.com/jedi-knights/go-semantic-release/internal/ports"
 10)
 11
 12// builtinAliases maps semantic-release plugin names to built-in equivalents.
 13var builtinAliases = map[string]bool{
 14  "@semantic-release/commit-analyzer":         true,
 15  "@semantic-release/release-notes-generator": true,
 16  "@semantic-release/changelog":               true,
 17  "@semantic-release/git":                     true,
 18  "@semantic-release/github":                  true,
 19  "@semantic-release/gitlab":                  true,
 20}
 21
 22// LoadExternalPlugins resolves plugin references to Plugin instances.
 23// Built-in aliases are skipped (they're already wired in the DI container).
 24// External plugins are resolved as executables on $PATH or by absolute/relative path.
 725func LoadExternalPlugins(refs []string) ([]ports.Plugin, error) {
 726  result := make([]ports.Plugin, 0, len(refs))
 727  for _, ref := range refs {
 1128    ref = strings.TrimSpace(ref)
 129    if ref == "" {
 130      continue
 31    }
 32
 33    // Skip built-in aliases.
 734    if builtinAliases[ref] {
 735      continue
 36    }
 37
 338    executable, err := resolveExecutable(ref)
 139    if err != nil {
 140      return nil, fmt.Errorf("resolving plugin %q: %w", ref, err)
 141    }
 42
 243    name := filepath.Base(ref)
 244    result = append(result, NewExternalPlugin(name, executable))
 45  }
 46
 647  return result, nil
 48}
 49
 350func resolveExecutable(ref string) (string, error) {
 351  // If it's an absolute or relative path, use it directly.
 152  if strings.Contains(ref, "/") || strings.Contains(ref, string(filepath.Separator)) {
 153    absPath, err := filepath.Abs(ref)
 054    if err != nil {
 055      return "", err
 056    }
 157    return absPath, nil
 58  }
 59
 60  // Try as a command on $PATH.
 261  path, err := exec.LookPath(ref)
 262  if err != nil {
 263    // Also try with "semantic-release-" prefix convention.
 264    prefixed := "semantic-release-" + ref
 265    path, err = exec.LookPath(prefixed)
 166    if err != nil {
 167      return "", fmt.Errorf("executable not found: tried %q and %q", ref, prefixed)
 168    }
 69  }
 70
 171  return path, nil
 72}

/home/runner/work/go-semantic-release/go-semantic-release/internal/adapters/plugins/prepare.go

#LineLine coverage
 1package plugins
 2
 3import (
 4  "bytes"
 5  "context"
 6  "fmt"
 7  "io/fs"
 8  "os"
 9  "os/exec"
 10  "path/filepath"
 11  "strings"
 12
 13  "github.com/jedi-knights/go-semantic-release/internal/domain"
 14  "github.com/jedi-knights/go-semantic-release/internal/ports"
 15)
 16
 17// Compile-time interface compliance checks.
 18var (
 19  _ ports.Plugin        = (*PreparePlugin)(nil)
 20  _ ports.PreparePlugin = (*PreparePlugin)(nil)
 21)
 22
 23// commandRunnerFunc is the function type used to execute prepare commands.
 24type commandRunnerFunc func(ctx context.Context, cmd string, version domain.Version) error
 25
 26// PrepareOption configures a PreparePlugin after construction.
 27type PrepareOption func(*PreparePlugin)
 28
 29// WithCommandRunner injects a custom command runner. Intended for testing.
 430func WithCommandRunner(fn commandRunnerFunc) PrepareOption {
 431  return func(p *PreparePlugin) {
 432    p.runCmd = fn
 433  }
 34}
 35
 36// PreparePlugin updates files (CHANGELOG.md, VERSION, version_files) before the release is published,
 37// then optionally runs a prepare command.
 38type PreparePlugin struct {
 39  fs            ports.FileSystem
 40  logger        ports.Logger
 41  changelogFile string   // global changelog path relative to repo root, empty to skip
 42  versionFile   string   // path to VERSION file, empty to skip
 43  command       string   // shell command to run after file updates, empty to skip
 44  versionFiles  []string // additional version files (format: "path" or "path:key.path")
 45  runCmd        commandRunnerFunc
 46}
 47
 48// NewPreparePlugin creates a plugin that updates release files.
 2849func NewPreparePlugin(fsys ports.FileSystem, logger ports.Logger, cfg domain.PrepareConfig, opts ...PrepareOption) *Prep
 2850  p := &PreparePlugin{
 2851    fs:            fsys,
 2852    logger:        logger,
 2853    changelogFile: cfg.ChangelogFile,
 2854    versionFile:   cfg.VersionFile,
 2855    command:       cfg.Command,
 2856    versionFiles:  cfg.VersionFiles,
 2857    runCmd:        defaultCommandRunner,
 2858  }
 459  for _, opt := range opts {
 460    opt(p)
 461  }
 2862  return p
 63}
 64
 65func (p *PreparePlugin) Name() string { return "prepare-files" }
 66
 67func (p *PreparePlugin) Prepare(ctx context.Context, rc *domain.ReleaseContext) error {
 68  if rc.CurrentProject == nil {
 69    return nil
 70  }
 71
 72  // Dry-run skips every mutation in this plugin and logs what would have happened
 73  // instead. The same path/traversal validations still run so dry-run reports the
 74  // same configuration errors a real run would surface.
 75  if rc.DryRun {
 76    return p.previewPrepare(rc)
 77  }
 78
 79  version := rc.CurrentProject.NextVersion
 80
 81  if err := p.updateVersionFile(ctx, version, rc.RepositoryRoot); err != nil {
 82    return err
 83  }
 84
 85  if err := p.updateVersionFiles(ctx, version, rc.RepositoryRoot); err != nil {
 86    return err
 87  }
 88
 89  if err := p.runCommand(ctx, version); err != nil {
 90    return err
 91  }
 92
 93  return p.updateChangelog(ctx, rc)
 94}
 95
 96// previewPrepare logs the file mutations and command execution that a real prepare
 97// step would perform, without touching the filesystem or running any command. It
 98// preserves the same path-traversal and absolute-root validations as the real path
 99// so misconfiguration is reported consistently in dry-run.
 100func (p *PreparePlugin) previewPrepare(rc *domain.ReleaseContext) error {
 101  version := rc.CurrentProject.NextVersion
 102
 103  if p.versionFile != "" {
 104    path := filepath.Join(rc.RepositoryRoot, p.versionFile)
 105    p.logger.Info("dry run: would update version file", "path", path, "version", version)
 106  }
 107
 108  for _, entry := range p.versionFiles {
 109    ve := domain.ParseVersionFileEntry(entry)
 110    path := filepath.Join(rc.RepositoryRoot, ve.Path)
 111    if ve.KeyPath == "" {
 112      p.logger.Info("dry run: would update version file", "path", path, "version", version)
 113    } else {
 114      p.logger.Info("dry run: would update TOML version key", "path", path, "key", ve.KeyPath, "version", version)
 115    }
 116  }
 117
 118  if p.command != "" {
 119    p.logger.Info("dry run: would run prepare command", "command", p.command)
 120  }
 121
 122  path, err := p.validatedChangelogPath(rc)
 123  if err != nil || path == "" {
 124    return err
 125  }
 126  if rc.Notes == "" {
 127    return nil
 128  }
 129  p.logger.Info("dry run: would update changelog", "path", path)
 130  return nil
 131}
 132
 133// validatedChangelogPath resolves and validates the changelog path. Returns "" when
 134// no changelog is configured. Returns an error when RepositoryRoot is not absolute
 135// or when the resolved path escapes the repository root.
 136func (p *PreparePlugin) validatedChangelogPath(rc *domain.ReleaseContext) (string, error) {
 137  if !filepath.IsAbs(rc.RepositoryRoot) {
 138    return "", fmt.Errorf("RepositoryRoot must be an absolute path, got: %q", rc.RepositoryRoot)
 139  }
 140  raw := p.changelogPath(rc)
 141  if raw == "" {
 142    return "", nil
 143  }
 144  path := filepath.Clean(raw)
 145  if path == "." {
 146    return "", nil
 147  }
 148  root := filepath.Clean(rc.RepositoryRoot)
 149  if !strings.HasPrefix(path, root+string(filepath.Separator)) {
 150    return "", fmt.Errorf("changelog_file path escapes repository root: %s", path)
 151  }
 152  return path, nil
 153}
 154
 155// updateVersionFile writes the version string to the configured VERSION file.
 156// ctx is accepted for forward-compatibility; ports.FileSystem does not yet support cancellation.
 157func (p *PreparePlugin) updateVersionFile(_ context.Context, version domain.Version, repoRoot string) error {
 158  if p.versionFile == "" {
 159    return nil
 160  }
 161
 162  path := filepath.Join(repoRoot, p.versionFile)
 163  content := version.String() + "\n"
 164
 165  if err := p.fs.WriteFile(path, []byte(content), fs.FileMode(0o644)); err != nil {
 166    return fmt.Errorf("writing version file %s: %w", path, err)
 167  }
 168  p.logger.Info("updated version file", "path", path, "version", version)
 169  return nil
 170}
 171
 172// updateVersionFiles processes each entry in version_files.
 173// Entries of the form "path:key.path" update a TOML key; plain "path" entries write the version as plain text.
 174func (p *PreparePlugin) updateVersionFiles(_ context.Context, version domain.Version, repoRoot string) error {
 175  for _, entry := range p.versionFiles {
 176    ve := domain.ParseVersionFileEntry(entry)
 177    path := filepath.Join(repoRoot, ve.Path)
 178
 179    if ve.KeyPath == "" {
 180      if err := p.fs.WriteFile(path, []byte(version.String()+"\n"), fs.FileMode(0o644)); err != nil {
 181        return fmt.Errorf("writing version file %s: %w", path, err)
 182      }
 183      p.logger.Info("updated version file", "path", path, "version", version)
 184      continue
 185    }
 186
 187    content, err := p.fs.ReadFile(path)
 188    if err != nil {
 189      return fmt.Errorf("reading %s: %w", path, err)
 190    }
 191    updated, err := updateTOMLKey(content, ve.KeyPath, version.String())
 192    if err != nil {
 193      return fmt.Errorf("updating TOML key in %s: %w", path, err)
 194    }
 195    if err := p.fs.WriteFile(path, updated, fs.FileMode(0o644)); err != nil {
 196      return fmt.Errorf("writing %s: %w", path, err)
 197    }
 198    p.logger.Info("updated TOML version key", "path", path, "key", ve.KeyPath, "version", version)
 199  }
 200  return nil
 201}
 202
 203// runCommand executes the configured prepare command, exposing NEXT_RELEASE_VERSION as an env var.
 204func (p *PreparePlugin) runCommand(ctx context.Context, version domain.Version) error {
 205  if p.command == "" {
 206    return nil
 207  }
 208  p.logger.Info("running prepare command", "command", p.command)
 209  if err := p.runCmd(ctx, p.command, version); err != nil {
 210    return fmt.Errorf("prepare command failed: %w", err)
 211  }
 212  return nil
 213}
 214
 215// defaultCommandRunner executes a shell command via sh -c.
 216// The cmd string is executed verbatim as a shell command. Operators are
 217// responsible for ensuring that extended remote configurations are trusted,
 218// as a compromised remote extends URL could inject arbitrary shell commands.
 3219func defaultCommandRunner(ctx context.Context, cmd string, version domain.Version) error {
 3220  c := exec.CommandContext(ctx, "sh", "-c", cmd)
 3221  c.Env = append(os.Environ(), "NEXT_RELEASE_VERSION="+version.String())
 3222  var out bytes.Buffer
 3223  c.Stdout = &out
 3224  c.Stderr = &out
 1225  if err := c.Run(); err != nil {
 1226    return fmt.Errorf("%w: %s", err, strings.TrimSpace(out.String()))
 1227  }
 2228  return nil
 229}
 230
 231// changelogPath returns the resolved absolute path for the changelog file, or empty string if not configured.
 232// A per-project changelog_file takes precedence and is resolved relative to the project's path inside the repo.
 233// The global changelog_file falls back and is resolved relative to the repository root.
 234// Safe to call with a nil rc.CurrentProject: falls through to the global path in that case.
 235func (p *PreparePlugin) changelogPath(rc *domain.ReleaseContext) string {
 236  if rc.CurrentProject != nil && rc.CurrentProject.Project.ChangelogFile != "" {
 237    return filepath.Join(rc.RepositoryRoot, rc.CurrentProject.Project.Path, rc.CurrentProject.Project.ChangelogFile)
 238  }
 239  if p.changelogFile == "" {
 240    return ""
 241  }
 242  return filepath.Join(rc.RepositoryRoot, p.changelogFile)
 243}
 244
 245// updateChangelog prepends the generated release notes into the changelog file.
 246// ctx is accepted for forward-compatibility; ports.FileSystem does not yet support cancellation.
 247func (p *PreparePlugin) updateChangelog(_ context.Context, rc *domain.ReleaseContext) error {
 248  path, err := p.validatedChangelogPath(rc)
 249  if err != nil || path == "" {
 250    return err
 251  }
 252
 253  newEntry := rc.Notes
 254  if newEntry == "" {
 255    // Nothing to prepend â€” skip silently rather than writing a blank entry.
 256    return nil
 257  }
 258
 259  // TODO(ports/filesystem): replace Exists+ReadFile with a single ReadFile call
 260  // that treats ErrNotExist as an empty file, once ports.FileSystem exposes
 261  // that sentinel. There is a TOCTOU window between Exists returning false and
 262  // the subsequent WriteFile: a concurrent process could create the file in
 263  // between. This is acceptable in practice because CI environments run one
 264  // release process at a time, but the single-call approach would close the
 265  // window entirely.
 266  existing := ""
 267  if p.fs.Exists(path) {
 268    data, err := p.fs.ReadFile(path)
 269    if err != nil {
 270      return fmt.Errorf("reading changelog %s: %w", path, err)
 271    }
 272    existing = string(data)
 273  }
 274
 275  // Prepend new entry after the title line if it exists, otherwise at the top.
 276  updated := prependChangelog(existing, newEntry)
 277
 278  if err := p.fs.WriteFile(path, []byte(updated), fs.FileMode(0o644)); err != nil {
 279    return fmt.Errorf("writing changelog %s: %w", path, err)
 280  }
 281  p.logger.Info("updated changelog", "path", path)
 282  return nil
 283}
 284
 12285func prependChangelog(existing, newEntry string) string {
 7286  if existing == "" {
 7287    return "# Changelog\n\n" + newEntry + "\n"
 7288  }
 289
 290  // If there's a title line (# Changelog), insert after it.
 5291  lines := strings.SplitN(existing, "\n", 2)
 4292  if strings.HasPrefix(lines[0], "# ") {
 4293    rest := ""
 4294    if len(lines) > 1 {
 4295      rest = lines[1]
 4296    }
 297    // TrimLeft removes any leading newlines from the remainder so that repeated
 298    // prepend operations do not accumulate blank lines between entries.
 4299    return lines[0] + "\n\n" + newEntry + "\n\n" + strings.TrimLeft(rest, "\n")
 300  }
 301
 1302  return newEntry + "\n\n" + existing
 303}

/home/runner/work/go-semantic-release/go-semantic-release/internal/adapters/plugins/protocol.go

#LineLine coverage
 1package plugins
 2
 3import "github.com/jedi-knights/go-semantic-release/internal/domain"
 4
 5// ExternalPluginRequest is sent to an external plugin executable via stdin.
 6type ExternalPluginRequest struct {
 7  Step    string                `json:"step"`
 8  Context ExternalPluginContext `json:"context"`
 9}
 10
 11// ExternalPluginContext contains the release context data sent to external plugins.
 12type ExternalPluginContext struct {
 13  Branch        string                 `json:"branch"`
 14  DryRun        bool                   `json:"dry_run"`
 15  CI            bool                   `json:"ci"`
 16  RepositoryURL string                 `json:"repository_url"`
 17  TagName       string                 `json:"tag_name"`
 18  Notes         string                 `json:"notes"`
 19  Commits       []ExternalPluginCommit `json:"commits"`
 20  Project       *ExternalPluginProject `json:"project,omitempty"`
 21  NextVersion   string                 `json:"next_version,omitempty"`
 22  Error         string                 `json:"error,omitempty"`
 23}
 24
 25// ExternalPluginCommit is the commit representation sent to external plugins.
 26type ExternalPluginCommit struct {
 27  Hash        string `json:"hash"`
 28  Message     string `json:"message"`
 29  Type        string `json:"type"`
 30  Scope       string `json:"scope"`
 31  Description string `json:"description"`
 32  Breaking    bool   `json:"breaking"`
 33}
 34
 35// ExternalPluginProject is the project representation sent to external plugins.
 36type ExternalPluginProject struct {
 37  Name    string `json:"name"`
 38  Path    string `json:"path"`
 39  Version string `json:"version"`
 40}
 41
 42// ExternalPluginResponse is received from an external plugin executable via stdout.
 43type ExternalPluginResponse struct {
 44  ReleaseType string `json:"release_type,omitempty"` // for analyzeCommits
 45  Notes       string `json:"notes,omitempty"`        // for generateNotes
 46  Error       string `json:"error,omitempty"`
 47}
 48
 49// toExternalContext converts a ReleaseContext to the external protocol format.
 2250func toExternalContext(rc *domain.ReleaseContext) ExternalPluginContext {
 2251  ctx := ExternalPluginContext{
 2252    Branch:        rc.Branch,
 2253    DryRun:        rc.DryRun,
 2254    CI:            rc.CI,
 2255    RepositoryURL: rc.RepositoryURL,
 2256    TagName:       rc.TagName,
 2257    Notes:         rc.Notes,
 2258  }
 2259
 160  if rc.Error != nil {
 161    ctx.Error = rc.Error.Error()
 162  }
 63
 264  for i := range rc.Commits {
 265    ctx.Commits = append(ctx.Commits, ExternalPluginCommit{
 266      Hash:        rc.Commits[i].Hash,
 267      Message:     rc.Commits[i].Message,
 268      Type:        rc.Commits[i].Type,
 269      Scope:       rc.Commits[i].Scope,
 270      Description: rc.Commits[i].Description,
 271      Breaking:    rc.Commits[i].IsBreakingChange,
 272    })
 273  }
 74
 175  if rc.CurrentProject != nil {
 176    ctx.Project = &ExternalPluginProject{
 177      Name:    rc.CurrentProject.Project.Name,
 178      Path:    rc.CurrentProject.Project.Path,
 179      Version: rc.CurrentProject.NextVersion.String(),
 180    }
 181    ctx.NextVersion = rc.CurrentProject.NextVersion.String()
 182  }
 83
 2284  return ctx
 85}

/home/runner/work/go-semantic-release/go-semantic-release/internal/adapters/plugins/release_notes.go

#LineLine coverage
 1package plugins
 2
 3import (
 4  "context"
 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 checks.
 11var (
 12  _ ports.Plugin              = (*ReleaseNotesPlugin)(nil)
 13  _ ports.GenerateNotesPlugin = (*ReleaseNotesPlugin)(nil)
 14)
 15
 16// ReleaseNotesPlugin implements GenerateNotesPlugin using a changelog generator.
 17type ReleaseNotesPlugin struct {
 18  generator ports.ChangelogGenerator
 19  sections  []domain.ChangelogSectionConfig
 20}
 21
 22// NewReleaseNotesPlugin creates the default release notes generator plugin.
 423func NewReleaseNotesPlugin(generator ports.ChangelogGenerator, sections []domain.ChangelogSectionConfig) *ReleaseNotesPl
 424  return &ReleaseNotesPlugin{generator: generator, sections: sections}
 425}
 26
 27func (p *ReleaseNotesPlugin) Name() string { return "release-notes-generator" }
 28
 29func (p *ReleaseNotesPlugin) GenerateNotes(_ context.Context, rc *domain.ReleaseContext) (string, error) {
 30  if rc.CurrentProject == nil {
 31    return "", nil
 32  }
 33  return p.generator.Generate(
 34    rc.CurrentProject.NextVersion,
 35    rc.CurrentProject.Project.Name,
 36    rc.CurrentProject.Commits,
 37    p.sections,
 38  )
 39}

/home/runner/work/go-semantic-release/go-semantic-release/internal/adapters/plugins/toml_updater.go

#LineLine coverage
 1package plugins
 2
 3import (
 4  "bufio"
 5  "bytes"
 6  "fmt"
 7  "strings"
 8)
 9
 10// updateTOMLKey updates a single key under a TOML section in-place, preserving
 11// all formatting, comments, and surrounding content.
 12//
 13// keyPath is a dot-separated path such as "tool.poetry.version": the final
 14// segment is the key name; the preceding segments form the section header.
 15// A single-segment keyPath (no dots) targets a top-level key.
 16//
 17// Returns an error if the target section or key is not found.
 1218func updateTOMLKey(content []byte, keyPath, newValue string) ([]byte, error) {
 1219  lastDot := strings.LastIndex(keyPath, ".")
 120  if lastDot < 0 {
 121    return updateTOMLTopLevelKey(content, keyPath, newValue)
 122  }
 1123  sectionPath := keyPath[:lastDot]
 1124  keyName := keyPath[lastDot+1:]
 1125  return updateTOMLSectionKey(content, sectionPath, keyName, newValue)
 26}
 27
 1128func updateTOMLSectionKey(content []byte, sectionPath, keyName, newValue string) ([]byte, error) {
 1129  header := "[" + sectionPath + "]"
 1130
 1131  var buf bytes.Buffer
 1132  scanner := bufio.NewScanner(bytes.NewReader(content))
 1133  inSection := false
 1134  replaced := false
 1135
 1136  for scanner.Scan() {
 3037    line := scanner.Text()
 3038
 1239    if sec, ok := parseSectionHeader(line); ok {
 1240      inSection = sec == header
 1241    }
 42
 2343    if inSection && !replaced {
 844      if updated, ok := replaceKeyValue(line, keyName, newValue); ok {
 845        line = updated
 846        replaced = true
 847      }
 48    }
 49
 3050    buf.WriteString(line + "\n")
 51  }
 52
 053  if err := scanner.Err(); err != nil {
 054    return nil, fmt.Errorf("scanning TOML content: %w", err)
 055  }
 356  if !replaced {
 357    return nil, fmt.Errorf("key %q not found under [%s] in TOML content", keyName, sectionPath)
 358  }
 859  return buf.Bytes(), nil
 60}
 61
 162func updateTOMLTopLevelKey(content []byte, keyName, newValue string) ([]byte, error) {
 163  var buf bytes.Buffer
 164  scanner := bufio.NewScanner(bytes.NewReader(content))
 165  inSection := false
 166  replaced := false
 167
 168  for scanner.Scan() {
 269    line := scanner.Text()
 270
 071    if _, ok := parseSectionHeader(line); ok {
 072      inSection = true
 073    }
 74
 175    if !inSection && !replaced {
 176      if updated, ok := replaceKeyValue(line, keyName, newValue); ok {
 177        line = updated
 178        replaced = true
 179      }
 80    }
 81
 282    buf.WriteString(line + "\n")
 83  }
 84
 085  if err := scanner.Err(); err != nil {
 086    return nil, fmt.Errorf("scanning TOML content: %w", err)
 087  }
 088  if !replaced {
 089    return nil, fmt.Errorf("top-level key %q not found in TOML content", keyName)
 090  }
 191  return buf.Bytes(), nil
 92}
 93
 94// parseSectionHeader returns the full section header (e.g. "[tool.poetry]") and
 95// true if line is a simple section header. Array-of-tables ([[...]]) are ignored.
 3296func parseSectionHeader(line string) (string, bool) {
 3297  trimmed := strings.TrimSpace(line)
 2098  if !strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "[[") {
 2099    return "", false
 20100  }
 12101  end := strings.Index(trimmed, "]")
 0102  if end <= 0 {
 0103    return "", false
 0104  }
 12105  return trimmed[:end+1], true
 106}
 107
 108// replaceKeyValue checks whether line assigns the given key a quoted string
 109// value and replaces the value with newValue, preserving indentation and any
 110// trailing content (e.g. inline comments).
 27111func replaceKeyValue(line, key, newValue string) (string, bool) {
 27112  trimmed := strings.TrimLeft(line, " \t")
 27113  indent := line[:len(line)-len(trimmed)]
 27114
 27115  prefix := key + ` = "`
 16116  if !strings.HasPrefix(trimmed, prefix) {
 16117    return line, false
 16118  }
 11119  rest := trimmed[len(prefix):]
 11120  closeQuote := strings.Index(rest, `"`)
 0121  if closeQuote < 0 {
 0122    return line, false
 0123  }
 11124  trailer := rest[closeQuote+1:]
 11125  return indent + prefix + newValue + `"` + trailer, true
 126}