chore(go): inject release service dependencies and mirror local validation

This commit is contained in:
Micheal Wilkinson
2026-03-21 14:12:15 +00:00
parent f31141702d
commit 383aad48be
4 changed files with 361 additions and 40 deletions

View File

@@ -33,6 +33,102 @@ var (
refLinkLineRe = regexp.MustCompile(`^\[[^\]]+\]: \S`)
)
type fileSystem interface {
ReadFile(path string) ([]byte, error)
WriteFile(path string, data []byte, perm os.FileMode) error
}
type environment interface {
Getenv(key string) string
}
type gitRunner interface {
FirstCommitShortHash(rootDir string) (string, bool)
}
type osFileSystem struct{}
func (osFileSystem) ReadFile(path string) ([]byte, error) {
// #nosec G304 -- This adapter intentionally accepts caller-provided paths so the service can work against repository-relative files.
return os.ReadFile(path)
}
func (osFileSystem) WriteFile(path string, data []byte, perm os.FileMode) error {
return os.WriteFile(path, data, perm)
}
type osEnvironment struct{}
func (osEnvironment) Getenv(key string) string {
return os.Getenv(key)
}
type commandGitRunner struct{}
func (commandGitRunner) FirstCommitShortHash(rootDir string) (string, bool) {
command := exec.Command("git", "-C", rootDir, "rev-list", "--max-parents=0", "--abbrev-commit", "HEAD")
output, err := command.Output()
if err != nil {
return "", false
}
commit := strings.TrimSpace(string(output))
if commit == "" {
return "", false
}
if strings.Contains(commit, "\n") {
commit = strings.SplitN(commit, "\n", 2)[0]
}
return commit, true
}
// Dependencies defines the injected collaborators required by Service.
type Dependencies struct {
FileSystem fileSystem
Environment environment
Git gitRunner
}
// Service coordinates changelog and version file operations using injected dependencies.
type Service struct {
fileSystem fileSystem
environment environment
git gitRunner
}
// NewService validates and wires the dependencies required by the release service.
func NewService(deps Dependencies) (*Service, error) {
if deps.FileSystem == nil {
return nil, fmt.Errorf("file system dependency must not be nil")
}
if deps.Environment == nil {
return nil, fmt.Errorf("environment dependency must not be nil")
}
if deps.Git == nil {
return nil, fmt.Errorf("git runner dependency must not be nil")
}
return &Service{
fileSystem: deps.FileSystem,
environment: deps.Environment,
git: deps.Git,
}, nil
}
func defaultService() *Service {
service, err := NewService(Dependencies{
FileSystem: osFileSystem{},
Environment: osEnvironment{},
Git: commandGitRunner{},
})
if err != nil {
panic(err)
}
return service
}
type Options struct {
// VersionFile is the path to the file that stores the current version,
// relative to the repository root. When empty, release-version is used.
@@ -67,6 +163,12 @@ type resolvedOptions struct {
// when repository metadata can be derived from CI environment variables or the
// origin git remote.
func Prepare(rootDir, version, releaseDate string, options Options) error {
return defaultService().Prepare(rootDir, version, releaseDate, options)
}
// Prepare updates version state and promotes the Unreleased changelog notes
// into a new release section.
func (s *Service) Prepare(rootDir, version, releaseDate string, options Options) error {
normalizedVersion := strings.TrimPrefix(strings.TrimSpace(version), "v")
if normalizedVersion == "" {
return fmt.Errorf("version must not be empty")
@@ -82,11 +184,11 @@ func Prepare(rootDir, version, releaseDate string, options Options) error {
return err
}
if err := updateVersionFile(rootDir, normalizedVersion, resolved); err != nil {
if err := s.updateVersionFile(rootDir, normalizedVersion, resolved); err != nil {
return err
}
if err := updateChangelog(rootDir, normalizedVersion, releaseDate, resolved.Changelog); err != nil {
if err := s.updateChangelog(rootDir, normalizedVersion, releaseDate, resolved.Changelog); err != nil {
return err
}
@@ -104,6 +206,11 @@ func Prepare(rootDir, version, releaseDate string, options Options) error {
// When no previous release is present in the changelog, the first
// recommendation is always v1.0.0.
func RecommendedTag(rootDir string, options Options) (string, error) {
return defaultService().RecommendedTag(rootDir, options)
}
// RecommendedTag returns the next semantic release tag based on current changelog state.
func (s *Service) RecommendedTag(rootDir string, options Options) (string, error) {
resolved, err := resolveOptions(options)
if err != nil {
return "", err
@@ -112,12 +219,12 @@ func RecommendedTag(rootDir string, options Options) (string, error) {
var currentVersion string
isFirstRelease := false
if options.VersionFile != "" {
currentVersion, err = readCurrentVersion(rootDir, resolved)
currentVersion, err = s.readCurrentVersion(rootDir, resolved)
if err != nil {
return "", err
}
} else {
version, found, err := readLatestChangelogVersion(rootDir, resolved.Changelog)
version, found, err := s.readLatestChangelogVersion(rootDir, resolved.Changelog)
if err != nil {
return "", err
}
@@ -129,7 +236,7 @@ func RecommendedTag(rootDir string, options Options) (string, error) {
}
}
unreleasedBody, err := readUnreleasedBody(rootDir, resolved.Changelog)
unreleasedBody, err := s.readUnreleasedBody(rootDir, resolved.Changelog)
if err != nil {
return "", err
}
@@ -205,11 +312,15 @@ func resolveOptions(options Options) (resolvedOptions, error) {
}
func updateVersionFile(rootDir, version string, options resolvedOptions) error {
return defaultService().updateVersionFile(rootDir, version, options)
}
func (s *Service) updateVersionFile(rootDir, version string, options resolvedOptions) error {
path := filepath.Join(rootDir, options.VersionFile)
contents, err := os.ReadFile(path)
contents, err := s.fileSystem.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return os.WriteFile(path, []byte(version+"\n"), 0o644)
return s.fileSystem.WriteFile(path, []byte(version+"\n"), 0o644)
}
return fmt.Errorf("read version file: %w", err)
}
@@ -225,7 +336,7 @@ func updateVersionFile(rootDir, version string, options resolvedOptions) error {
return nil
}
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
if err := s.fileSystem.WriteFile(path, []byte(updated), 0o644); err != nil {
return fmt.Errorf("write version file: %w", err)
}
@@ -233,7 +344,11 @@ func updateVersionFile(rootDir, version string, options resolvedOptions) error {
}
func updateChangelog(rootDir, version, releaseDate, changelogPath string) error {
unreleasedBody, text, afterHeader, nextSectionStart, path, err := readChangelogState(rootDir, changelogPath)
return defaultService().updateChangelog(rootDir, version, releaseDate, changelogPath)
}
func (s *Service) updateChangelog(rootDir, version, releaseDate, changelogPath string) error {
unreleasedBody, text, afterHeader, nextSectionStart, path, err := s.readChangelogState(rootDir, changelogPath)
if err != nil {
return err
}
@@ -249,11 +364,11 @@ func updateChangelog(rootDir, version, releaseDate, changelogPath string) error
}
updated := text[:afterHeader] + "\n" + defaultUnreleasedTemplate + "\n" + newSection + text[nextSectionStart:]
repoURL, ok := deriveRepositoryURL(rootDir)
repoURL, ok := s.deriveRepositoryURL(rootDir)
if ok {
updated = addChangelogLinks(updated, repoURL, rootDir)
updated = s.addChangelogLinks(updated, repoURL, rootDir)
}
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
if err := s.fileSystem.WriteFile(path, []byte(updated), 0o644); err != nil {
return fmt.Errorf("write changelog: %w", err)
}
@@ -261,8 +376,12 @@ func updateChangelog(rootDir, version, releaseDate, changelogPath string) error
}
func readCurrentVersion(rootDir string, options resolvedOptions) (string, error) {
return defaultService().readCurrentVersion(rootDir, options)
}
func (s *Service) readCurrentVersion(rootDir string, options resolvedOptions) (string, error) {
path := filepath.Join(rootDir, options.VersionFile)
contents, err := os.ReadFile(path)
contents, err := s.fileSystem.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read version file: %w", err)
}
@@ -276,7 +395,11 @@ func readCurrentVersion(rootDir string, options resolvedOptions) (string, error)
}
func readUnreleasedBody(rootDir, changelogPath string) (string, error) {
unreleasedBody, _, _, _, _, err := readChangelogState(rootDir, changelogPath)
return defaultService().readUnreleasedBody(rootDir, changelogPath)
}
func (s *Service) readUnreleasedBody(rootDir, changelogPath string) (string, error) {
unreleasedBody, _, _, _, _, err := s.readChangelogState(rootDir, changelogPath)
if err != nil {
return "", err
}
@@ -301,8 +424,12 @@ func unreleasedHasEntries(unreleasedBody string) bool {
}
func readChangelogState(rootDir, changelogPath string) (string, string, int, int, string, error) {
return defaultService().readChangelogState(rootDir, changelogPath)
}
func (s *Service) readChangelogState(rootDir, changelogPath string) (string, string, int, int, string, error) {
path := filepath.Join(rootDir, changelogPath)
contents, err := os.ReadFile(path)
contents, err := s.fileSystem.ReadFile(path)
if err != nil {
return "", "", 0, 0, "", fmt.Errorf("read changelog: %w", err)
}
@@ -325,8 +452,12 @@ func readChangelogState(rootDir, changelogPath string) (string, string, int, int
}
func readLatestChangelogVersion(rootDir, changelogPath string) (string, bool, error) {
return defaultService().readLatestChangelogVersion(rootDir, changelogPath)
}
func (s *Service) readLatestChangelogVersion(rootDir, changelogPath string) (string, bool, error) {
path := filepath.Join(rootDir, changelogPath)
contents, err := os.ReadFile(path)
contents, err := s.fileSystem.ReadFile(path)
if err != nil {
return "", false, fmt.Errorf("read changelog: %w", err)
}
@@ -339,9 +470,13 @@ func readLatestChangelogVersion(rootDir, changelogPath string) (string, bool, er
}
func deriveRepositoryURL(rootDir string) (string, bool) {
override := strings.TrimSpace(os.Getenv("VOCIFERATE_REPOSITORY_URL"))
return defaultService().deriveRepositoryURL(rootDir)
}
func (s *Service) deriveRepositoryURL(rootDir string) (string, bool) {
override := strings.TrimSpace(s.environment.Getenv("VOCIFERATE_REPOSITORY_URL"))
if override != "" {
repositoryPath, ok := deriveRepositoryPath(rootDir)
repositoryPath, ok := s.deriveRepositoryPath(rootDir)
if !ok {
return "", false
}
@@ -350,14 +485,14 @@ func deriveRepositoryURL(rootDir string) (string, bool) {
return baseURL + "/" + repositoryPath, true
}
serverURL := strings.TrimSpace(os.Getenv("GITHUB_SERVER_URL"))
repository := strings.TrimSpace(os.Getenv("GITHUB_REPOSITORY"))
serverURL := strings.TrimSpace(s.environment.Getenv("GITHUB_SERVER_URL"))
repository := strings.TrimSpace(s.environment.Getenv("GITHUB_REPOSITORY"))
if serverURL != "" && repository != "" {
return strings.TrimSuffix(serverURL, "/") + "/" + strings.TrimPrefix(repository, "/"), true
}
gitConfigPath := filepath.Join(rootDir, ".git", "config")
contents, err := os.ReadFile(gitConfigPath)
contents, err := s.fileSystem.ReadFile(gitConfigPath)
if err != nil {
return "", false
}
@@ -376,13 +511,17 @@ func deriveRepositoryURL(rootDir string) (string, bool) {
}
func deriveRepositoryPath(rootDir string) (string, bool) {
repository := strings.TrimSpace(os.Getenv("GITHUB_REPOSITORY"))
return defaultService().deriveRepositoryPath(rootDir)
}
func (s *Service) deriveRepositoryPath(rootDir string) (string, bool) {
repository := strings.TrimSpace(s.environment.Getenv("GITHUB_REPOSITORY"))
if repository != "" {
return strings.TrimPrefix(repository, "/"), true
}
gitConfigPath := filepath.Join(rootDir, ".git", "config")
contents, err := os.ReadFile(gitConfigPath)
contents, err := s.fileSystem.ReadFile(gitConfigPath)
if err != nil {
return "", false
}
@@ -476,6 +615,10 @@ func normalizeRepoURL(remoteURL string) (string, bool) {
}
func addChangelogLinks(text, repoURL, rootDir string) string {
return defaultService().addChangelogLinks(text, repoURL, rootDir)
}
func (s *Service) addChangelogLinks(text, repoURL, rootDir string) string {
repoURL = strings.TrimSuffix(strings.TrimSpace(repoURL), "/")
if repoURL == "" {
return text
@@ -523,7 +666,7 @@ func addChangelogLinks(text, repoURL, rootDir string) string {
linkDefs = append(linkDefs, fmt.Sprintf("[Unreleased]: %s/src/branch/main", displayRepoURL))
}
firstCommitShort, hasFirstCommit := firstCommitShortHash(rootDir)
firstCommitShort, hasFirstCommit := s.firstCommitShortHash(rootDir)
for i, version := range releasedVersions {
if i+1 < len(releasedVersions) {
previousVersion := releasedVersions[i+1]
@@ -554,22 +697,11 @@ func displayURL(url string) string {
}
func firstCommitShortHash(rootDir string) (string, bool) {
command := exec.Command("git", "-C", rootDir, "rev-list", "--max-parents=0", "--abbrev-commit", "HEAD")
output, err := command.Output()
if err != nil {
return "", false
}
return defaultService().firstCommitShortHash(rootDir)
}
commit := strings.TrimSpace(string(output))
if commit == "" {
return "", false
}
if strings.Contains(commit, "\n") {
commit = strings.SplitN(commit, "\n", 2)[0]
}
return commit, true
func (s *Service) firstCommitShortHash(rootDir string) (string, bool) {
return s.git.FirstCommitShortHash(rootDir)
}
func compareURL(repoURL, baseRef, headRef string) string {