diff --git a/.gitea/workflows/prepare-release.yml b/.gitea/workflows/prepare-release.yml index d699f24..ab4fe48 100644 --- a/.gitea/workflows/prepare-release.yml +++ b/.gitea/workflows/prepare-release.yml @@ -39,8 +39,31 @@ jobs: cache: true cache-dependency-path: go.sum + - name: Validate formatting + run: test -z "$(gofmt -l .)" + + - name: Module hygiene + run: | + set -euo pipefail + go mod tidy + go mod verify + + - name: Run gosec security analysis + uses: securego/gosec@v2 + with: + args: ./... + + - name: Run govulncheck + uses: golang/govulncheck-action@v1 + with: + go-package: ./... + cache: true + cache-dependency-path: go.sum + - name: Run tests - run: go test ./... + run: | + set -euo pipefail + go test ./... - name: Resolve cache token id: cache-token diff --git a/internal/vociferate/vociferate.go b/internal/vociferate/vociferate.go index 29ffc8e..7d5a566 100644 --- a/internal/vociferate/vociferate.go +++ b/internal/vociferate/vociferate.go @@ -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 { diff --git a/internal/vociferate/vociferate_internal_test.go b/internal/vociferate/vociferate_internal_test.go index 42467ab..9a0bfc9 100644 --- a/internal/vociferate/vociferate_internal_test.go +++ b/internal/vociferate/vociferate_internal_test.go @@ -3,9 +3,54 @@ package vociferate import ( "os" "path/filepath" + "strings" "testing" ) +type stubFileSystem struct { + files map[string][]byte +} + +func newStubFileSystem(files map[string]string) *stubFileSystem { + backing := make(map[string][]byte, len(files)) + for path, contents := range files { + backing[path] = []byte(contents) + } + return &stubFileSystem{files: backing} +} + +func (fs *stubFileSystem) ReadFile(path string) ([]byte, error) { + contents, ok := fs.files[path] + if !ok { + return nil, os.ErrNotExist + } + clone := make([]byte, len(contents)) + copy(clone, contents) + return clone, nil +} + +func (fs *stubFileSystem) WriteFile(path string, data []byte, _ os.FileMode) error { + clone := make([]byte, len(data)) + copy(clone, data) + fs.files[path] = clone + return nil +} + +type stubEnvironment map[string]string + +func (env stubEnvironment) Getenv(key string) string { + return env[key] +} + +type stubGitRunner struct { + commit string + ok bool +} + +func (runner stubGitRunner) FirstCommitShortHash(_ string) (string, bool) { + return runner.commit, runner.ok +} + func TestNormalizeRepoURL(t *testing.T) { t.Parallel() @@ -159,3 +204,110 @@ func TestResolveOptions_UsesUppercaseChangelogDefault(t *testing.T) { t.Fatalf("resolved changelog = %q, want %q", resolved.Changelog, "CHANGELOG.md") } } + +func TestNewService_ValidatesDependencies(t *testing.T) { + t.Parallel() + + validFS := newStubFileSystem(nil) + validEnv := stubEnvironment{} + validGit := stubGitRunner{} + + tests := []struct { + name string + deps Dependencies + wantErr string + }{ + { + name: "missing file system", + deps: Dependencies{Environment: validEnv, Git: validGit}, + wantErr: "file system dependency must not be nil", + }, + { + name: "missing environment", + deps: Dependencies{FileSystem: validFS, Git: validGit}, + wantErr: "environment dependency must not be nil", + }, + { + name: "missing git runner", + deps: Dependencies{FileSystem: validFS, Environment: validEnv}, + wantErr: "git runner dependency must not be nil", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := NewService(tt.deps) + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("NewService() error = %v, want %q", err, tt.wantErr) + } + }) + } +} + +func TestServicePrepare_UsesInjectedEnvironmentForRepositoryLinks(t *testing.T) { + t.Parallel() + + rootDir := "/repo" + fs := newStubFileSystem(map[string]string{ + filepath.Join(rootDir, "release-version"): "1.1.6\n", + filepath.Join(rootDir, "CHANGELOG.md"): "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- New thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n", + }) + + svc, err := NewService(Dependencies{ + FileSystem: fs, + Environment: stubEnvironment{"GITHUB_SERVER_URL": "https://git.hrafn.xyz", "GITHUB_REPOSITORY": "aether/vociferate"}, + Git: stubGitRunner{commit: "deadbee", ok: true}, + }) + if err != nil { + t.Fatalf("NewService() unexpected error: %v", err) + } + + err = svc.Prepare(rootDir, "1.1.7", "2026-03-21", Options{}) + if err != nil { + t.Fatalf("Prepare() unexpected error: %v", err) + } + + updated := string(fs.files[filepath.Join(rootDir, "CHANGELOG.md")]) + if !contains(updated, "[Unreleased]: https://git.hrafn.xyz/aether/vociferate/compare/v1.1.7...main") { + t.Fatalf("Prepare() changelog missing injected environment link:\n%s", updated) + } + if !contains(updated, "[1.1.7]: https://git.hrafn.xyz/aether/vociferate/compare/v1.1.6...v1.1.7") { + t.Fatalf("Prepare() changelog missing injected release link:\n%s", updated) + } +} + +func TestServicePrepare_UsesInjectedGitRunnerForFirstCommitLink(t *testing.T) { + t.Parallel() + + rootDir := "/repo" + fs := newStubFileSystem(map[string]string{ + filepath.Join(rootDir, "release-version"): "1.1.6\n", + filepath.Join(rootDir, "CHANGELOG.md"): "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- New thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n", + filepath.Join(rootDir, ".git", "config"): "[remote \"origin\"]\n\turl = git@git.hrafn.xyz:aether/vociferate.git\n", + }) + + svc, err := NewService(Dependencies{ + FileSystem: fs, + Environment: stubEnvironment{}, + Git: stubGitRunner{commit: "abc1234", ok: true}, + }) + if err != nil { + t.Fatalf("NewService() unexpected error: %v", err) + } + + err = svc.Prepare(rootDir, "1.1.7", "2026-03-21", Options{}) + if err != nil { + t.Fatalf("Prepare() unexpected error: %v", err) + } + + updated := string(fs.files[filepath.Join(rootDir, "CHANGELOG.md")]) + if !contains(updated, "[1.1.6]: https://git.hrafn.xyz/aether/vociferate/compare/abc1234...v1.1.6") { + t.Fatalf("Prepare() changelog missing injected git link:\n%s", updated) + } +} + +func contains(text, fragment string) bool { + return len(fragment) > 0 && strings.Contains(text, fragment) +} diff --git a/justfile b/justfile index 554e3ae..835eb9f 100644 --- a/justfile +++ b/justfile @@ -9,3 +9,17 @@ go-build: go-test: go test ./... + +validate-fmt: + go fmt ./... + test -z "$(gofmt -l .)" + +validate-mod: + go mod tidy + go mod verify + +security: + gosec ./... + govulncheck ./... + +validate: validate-fmt validate-mod go-test security