< Summary - go-semantic-release Coverage

Line coverage
95%
Covered lines: 135
Uncovered lines: 6
Coverable lines: 141
Total lines: 303
Line coverage: 95.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
Name0%00100%
Prepare0%00100%
previewPrepare0%00100%
validatedChangelogPath0%0082.35%
updateVersionFile0%00100%
updateVersionFiles0%00100%
runCommand0%00100%
changelogPath0%00100%
updateChangelog0%0087.5%

File(s)

/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.
 30func WithCommandRunner(fn commandRunnerFunc) PrepareOption {
 31  return func(p *PreparePlugin) {
 32    p.runCmd = fn
 33  }
 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.
 49func NewPreparePlugin(fsys ports.FileSystem, logger ports.Logger, cfg domain.PrepareConfig, opts ...PrepareOption) *Prep
 50  p := &PreparePlugin{
 51    fs:            fsys,
 52    logger:        logger,
 53    changelogFile: cfg.ChangelogFile,
 54    versionFile:   cfg.VersionFile,
 55    command:       cfg.Command,
 56    versionFiles:  cfg.VersionFiles,
 57    runCmd:        defaultCommandRunner,
 58  }
 59  for _, opt := range opts {
 60    opt(p)
 61  }
 62  return p
 63}
 64
 165func (p *PreparePlugin) Name() string { return "prepare-files" }
 66
 2867func (p *PreparePlugin) Prepare(ctx context.Context, rc *domain.ReleaseContext) error {
 168  if rc.CurrentProject == nil {
 169    return nil
 170  }
 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.
 375  if rc.DryRun {
 376    return p.previewPrepare(rc)
 377  }
 78
 2479  version := rc.CurrentProject.NextVersion
 2480
 181  if err := p.updateVersionFile(ctx, version, rc.RepositoryRoot); err != nil {
 182    return err
 183  }
 84
 485  if err := p.updateVersionFiles(ctx, version, rc.RepositoryRoot); err != nil {
 486    return err
 487  }
 88
 189  if err := p.runCommand(ctx, version); err != nil {
 190    return err
 191  }
 92
 1893  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.
 3100func (p *PreparePlugin) previewPrepare(rc *domain.ReleaseContext) error {
 3101  version := rc.CurrentProject.NextVersion
 3102
 1103  if p.versionFile != "" {
 1104    path := filepath.Join(rc.RepositoryRoot, p.versionFile)
 1105    p.logger.Info("dry run: would update version file", "path", path, "version", version)
 1106  }
 107
 2108  for _, entry := range p.versionFiles {
 2109    ve := domain.ParseVersionFileEntry(entry)
 2110    path := filepath.Join(rc.RepositoryRoot, ve.Path)
 1111    if ve.KeyPath == "" {
 1112      p.logger.Info("dry run: would update version file", "path", path, "version", version)
 1113    } else {
 1114      p.logger.Info("dry run: would update TOML version key", "path", path, "key", ve.KeyPath, "version", version)
 1115    }
 116  }
 117
 1118  if p.command != "" {
 1119    p.logger.Info("dry run: would run prepare command", "command", p.command)
 1120  }
 121
 3122  path, err := p.validatedChangelogPath(rc)
 1123  if err != nil || path == "" {
 1124    return err
 1125  }
 1126  if rc.Notes == "" {
 1127    return nil
 1128  }
 1129  p.logger.Info("dry run: would update changelog", "path", path)
 1130  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.
 21136func (p *PreparePlugin) validatedChangelogPath(rc *domain.ReleaseContext) (string, error) {
 1137  if !filepath.IsAbs(rc.RepositoryRoot) {
 1138    return "", fmt.Errorf("RepositoryRoot must be an absolute path, got: %q", rc.RepositoryRoot)
 1139  }
 20140  raw := p.changelogPath(rc)
 6141  if raw == "" {
 6142    return "", nil
 6143  }
 14144  path := filepath.Clean(raw)
 0145  if path == "." {
 0146    return "", nil
 0147  }
 14148  root := filepath.Clean(rc.RepositoryRoot)
 3149  if !strings.HasPrefix(path, root+string(filepath.Separator)) {
 3150    return "", fmt.Errorf("changelog_file path escapes repository root: %s", path)
 3151  }
 11152  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.
 24157func (p *PreparePlugin) updateVersionFile(_ context.Context, version domain.Version, repoRoot string) error {
 22158  if p.versionFile == "" {
 22159    return nil
 22160  }
 161
 2162  path := filepath.Join(repoRoot, p.versionFile)
 2163  content := version.String() + "\n"
 2164
 1165  if err := p.fs.WriteFile(path, []byte(content), fs.FileMode(0o644)); err != nil {
 1166    return fmt.Errorf("writing version file %s: %w", path, err)
 1167  }
 1168  p.logger.Info("updated version file", "path", path, "version", version)
 1169  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.
 23174func (p *PreparePlugin) updateVersionFiles(_ context.Context, version domain.Version, repoRoot string) error {
 7175  for _, entry := range p.versionFiles {
 7176    ve := domain.ParseVersionFileEntry(entry)
 7177    path := filepath.Join(repoRoot, ve.Path)
 7178
 2179    if ve.KeyPath == "" {
 1180      if err := p.fs.WriteFile(path, []byte(version.String()+"\n"), fs.FileMode(0o644)); err != nil {
 1181        return fmt.Errorf("writing version file %s: %w", path, err)
 1182      }
 1183      p.logger.Info("updated version file", "path", path, "version", version)
 1184      continue
 185    }
 186
 5187    content, err := p.fs.ReadFile(path)
 1188    if err != nil {
 1189      return fmt.Errorf("reading %s: %w", path, err)
 1190    }
 4191    updated, err := updateTOMLKey(content, ve.KeyPath, version.String())
 1192    if err != nil {
 1193      return fmt.Errorf("updating TOML key in %s: %w", path, err)
 1194    }
 1195    if err := p.fs.WriteFile(path, updated, fs.FileMode(0o644)); err != nil {
 1196      return fmt.Errorf("writing %s: %w", path, err)
 1197    }
 2198    p.logger.Info("updated TOML version key", "path", path, "key", ve.KeyPath, "version", version)
 199  }
 19200  return nil
 201}
 202
 203// runCommand executes the configured prepare command, exposing NEXT_RELEASE_VERSION as an env var.
 19204func (p *PreparePlugin) runCommand(ctx context.Context, version domain.Version) error {
 17205  if p.command == "" {
 17206    return nil
 17207  }
 2208  p.logger.Info("running prepare command", "command", p.command)
 1209  if err := p.runCmd(ctx, p.command, version); err != nil {
 1210    return fmt.Errorf("prepare command failed: %w", err)
 1211  }
 1212  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.
 219func defaultCommandRunner(ctx context.Context, cmd string, version domain.Version) error {
 220  c := exec.CommandContext(ctx, "sh", "-c", cmd)
 221  c.Env = append(os.Environ(), "NEXT_RELEASE_VERSION="+version.String())
 222  var out bytes.Buffer
 223  c.Stdout = &out
 224  c.Stderr = &out
 225  if err := c.Run(); err != nil {
 226    return fmt.Errorf("%w: %s", err, strings.TrimSpace(out.String()))
 227  }
 228  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.
 24235func (p *PreparePlugin) changelogPath(rc *domain.ReleaseContext) string {
 4236  if rc.CurrentProject != nil && rc.CurrentProject.Project.ChangelogFile != "" {
 4237    return filepath.Join(rc.RepositoryRoot, rc.CurrentProject.Project.Path, rc.CurrentProject.Project.ChangelogFile)
 4238  }
 7239  if p.changelogFile == "" {
 7240    return ""
 7241  }
 13242  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.
 18247func (p *PreparePlugin) updateChangelog(_ context.Context, rc *domain.ReleaseContext) error {
 18248  path, err := p.validatedChangelogPath(rc)
 9249  if err != nil || path == "" {
 9250    return err
 9251  }
 252
 9253  newEntry := rc.Notes
 1254  if newEntry == "" {
 1255    // Nothing to prepend — skip silently rather than writing a blank entry.
 1256    return nil
 1257  }
 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.
 8266  existing := ""
 2267  if p.fs.Exists(path) {
 2268    data, err := p.fs.ReadFile(path)
 0269    if err != nil {
 0270      return fmt.Errorf("reading changelog %s: %w", path, err)
 0271    }
 2272    existing = string(data)
 273  }
 274
 275  // Prepend new entry after the title line if it exists, otherwise at the top.
 8276  updated := prependChangelog(existing, newEntry)
 8277
 1278  if err := p.fs.WriteFile(path, []byte(updated), fs.FileMode(0o644)); err != nil {
 1279    return fmt.Errorf("writing changelog %s: %w", path, err)
 1280  }
 7281  p.logger.Info("updated changelog", "path", path)
 7282  return nil
 283}
 284
 285func prependChangelog(existing, newEntry string) string {
 286  if existing == "" {
 287    return "# Changelog\n\n" + newEntry + "\n"
 288  }
 289
 290  // If there's a title line (# Changelog), insert after it.
 291  lines := strings.SplitN(existing, "\n", 2)
 292  if strings.HasPrefix(lines[0], "# ") {
 293    rest := ""
 294    if len(lines) > 1 {
 295      rest = lines[1]
 296    }
 297    // TrimLeft removes any leading newlines from the remainder so that repeated
 298    // prepend operations do not accumulate blank lines between entries.
 299    return lines[0] + "\n\n" + newEntry + "\n\n" + strings.TrimLeft(rest, "\n")
 300  }
 301
 302  return newEntry + "\n\n" + existing
 303}