| | | 1 | | package plugins |
| | | 2 | | |
| | | 3 | | import ( |
| | | 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. |
| | | 18 | | var ( |
| | | 19 | | _ ports.Plugin = (*PreparePlugin)(nil) |
| | | 20 | | _ ports.PreparePlugin = (*PreparePlugin)(nil) |
| | | 21 | | ) |
| | | 22 | | |
| | | 23 | | // commandRunnerFunc is the function type used to execute prepare commands. |
| | | 24 | | type commandRunnerFunc func(ctx context.Context, cmd string, version domain.Version) error |
| | | 25 | | |
| | | 26 | | // PrepareOption configures a PreparePlugin after construction. |
| | | 27 | | type PrepareOption func(*PreparePlugin) |
| | | 28 | | |
| | | 29 | | // WithCommandRunner injects a custom command runner. Intended for testing. |
| | 4 | 30 | | func WithCommandRunner(fn commandRunnerFunc) PrepareOption { |
| | 4 | 31 | | return func(p *PreparePlugin) { |
| | 4 | 32 | | p.runCmd = fn |
| | 4 | 33 | | } |
| | | 34 | | } |
| | | 35 | | |
| | | 36 | | // PreparePlugin updates files (CHANGELOG.md, VERSION, version_files) before the release is published, |
| | | 37 | | // then optionally runs a prepare command. |
| | | 38 | | type 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. |
| | 28 | 49 | | func NewPreparePlugin(fsys ports.FileSystem, logger ports.Logger, cfg domain.PrepareConfig, opts ...PrepareOption) *Prep |
| | 28 | 50 | | p := &PreparePlugin{ |
| | 28 | 51 | | fs: fsys, |
| | 28 | 52 | | logger: logger, |
| | 28 | 53 | | changelogFile: cfg.ChangelogFile, |
| | 28 | 54 | | versionFile: cfg.VersionFile, |
| | 28 | 55 | | command: cfg.Command, |
| | 28 | 56 | | versionFiles: cfg.VersionFiles, |
| | 28 | 57 | | runCmd: defaultCommandRunner, |
| | 28 | 58 | | } |
| | 4 | 59 | | for _, opt := range opts { |
| | 4 | 60 | | opt(p) |
| | 4 | 61 | | } |
| | 28 | 62 | | return p |
| | | 63 | | } |
| | | 64 | | |
| | | 65 | | func (p *PreparePlugin) Name() string { return "prepare-files" } |
| | | 66 | | |
| | | 67 | | func (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. |
| | | 100 | | func (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. |
| | | 136 | | func (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. |
| | | 157 | | func (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. |
| | | 174 | | func (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. |
| | | 204 | | func (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. |
| | 3 | 219 | | func defaultCommandRunner(ctx context.Context, cmd string, version domain.Version) error { |
| | 3 | 220 | | c := exec.CommandContext(ctx, "sh", "-c", cmd) |
| | 3 | 221 | | c.Env = append(os.Environ(), "NEXT_RELEASE_VERSION="+version.String()) |
| | 3 | 222 | | var out bytes.Buffer |
| | 3 | 223 | | c.Stdout = &out |
| | 3 | 224 | | c.Stderr = &out |
| | 1 | 225 | | if err := c.Run(); err != nil { |
| | 1 | 226 | | return fmt.Errorf("%w: %s", err, strings.TrimSpace(out.String())) |
| | 1 | 227 | | } |
| | 2 | 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. |
| | | 235 | | func (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. |
| | | 247 | | func (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 | | |
| | 12 | 285 | | func prependChangelog(existing, newEntry string) string { |
| | 7 | 286 | | if existing == "" { |
| | 7 | 287 | | return "# Changelog\n\n" + newEntry + "\n" |
| | 7 | 288 | | } |
| | | 289 | | |
| | | 290 | | // If there's a title line (# Changelog), insert after it. |
| | 5 | 291 | | lines := strings.SplitN(existing, "\n", 2) |
| | 4 | 292 | | if strings.HasPrefix(lines[0], "# ") { |
| | 4 | 293 | | rest := "" |
| | 4 | 294 | | if len(lines) > 1 { |
| | 4 | 295 | | rest = lines[1] |
| | 4 | 296 | | } |
| | | 297 | | // TrimLeft removes any leading newlines from the remainder so that repeated |
| | | 298 | | // prepend operations do not accumulate blank lines between entries. |
| | 4 | 299 | | return lines[0] + "\n\n" + newEntry + "\n\n" + strings.TrimLeft(rest, "\n") |
| | | 300 | | } |
| | | 301 | | |
| | 1 | 302 | | return newEntry + "\n\n" + existing |
| | | 303 | | } |