package candidate import ( "bytes" "context" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool" "gitea.maximumdirect.net/eric/ReleaseKit/internal/validation" ) const testVersion = "v1.2.3" func TestValidStableVersion(t *testing.T) { for _, version := range []string{ "v0.0.0", "v1.2.3", "v123456789012345678901234567890.0.9", } { if !validStableVersion(version) { t.Errorf("validStableVersion(%q) = false", version) } } for _, version := range []string{ "", "1.2.3", "V1.2.3", "v01.2.3", "v1.02.3", "v1.2.03", "v1.2", "v1.2.3-alpha", "v1..3", "v+1.2.3", } { if validStableVersion(version) { t.Errorf("validStableVersion(%q) = true", version) } } } func TestCheckCapturesCandidateAndCallsAdditionalChecksOnce(t *testing.T) { repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") writeTestFile(t, repository, "guide.md", "# Guide\n[section](#guide)\n") gitRun(t, repository, "add", "guide.md") gitRun(t, repository, "commit", "-m", "add Markdown guide") calls := 0 var seen Candidate candidate, err := Check(context.Background(), Request{ RepositoryDir: repository, Product: "Example Product", Version: testVersion, Validation: validation.Settings{Markdown: &validation.MarkdownSettings{}}, AdditionalChecks: func(_ context.Context, captured Candidate) error { calls++ seen = captured return nil }, }) if err != nil { t.Fatal(err) } commit := gitOutputForTest(t, repository, "rev-parse", "HEAD") if calls != 1 { t.Fatalf("validation callback called %d times, want 1", calls) } if seen != candidate { t.Fatalf("callback candidate = %#v, returned candidate = %#v", seen, candidate) } if candidate.RepositoryDir != repository || candidate.ModuleDir != repository || candidate.ModulePath != "example.com/candidate" || candidate.Tag != testVersion || candidate.Commit != commit || candidate.Version != testVersion { t.Fatalf("Check() candidate = %#v", candidate) } if tags := gitOutputForTest(t, repository, "tag", "--list"); tags != "" { t.Fatalf("Check created local tags: %q", tags) } if refs := gitOutputForTest(t, repository, "for-each-ref", "--format=%(refname)", "refs/remotes"); refs != "" { t.Fatalf("Check created remote-tracking refs: %q", refs) } } func TestCaptureUsesNestedMajorModuleNoteAndTag(t *testing.T) { repository := newRepository(t, "# Example Product v1.2.3\n\nRoot release.\n") moduleDir := "integrations/ckconfig/v2" writeTestFile(t, repository, moduleDir+"/go.mod", "module example.com/ckconfig/v2\n\ngo 1.25.0\n") writeTestFile(t, repository, moduleDir+"/docs/releases/v2.3.4.md", "# Example Product v2.3.4\n\nNested release.\n") gitRun(t, repository, "add", moduleDir) gitRun(t, repository, "commit", "-m", "add nested major module") captured, err := Capture(context.Background(), Request{ RepositoryDir: repository, ModuleDir: moduleDir, Product: "Example Product", Version: "v2.3.4", }) if err != nil { t.Fatal(err) } if captured.RepositoryDir != repository || captured.ModuleDir != filepath.Join(repository, moduleDir) || captured.ModulePath != "example.com/ckconfig/v2" || captured.Tag != "integrations/ckconfig/v2.3.4" { t.Fatalf("nested candidate identity = %#v", captured) } if !strings.Contains(captured.Note, "Nested release.") || strings.Contains(captured.Note, "Root release.") { t.Fatalf("captured note = %q, want nested module note", captured.Note) } gitRun(t, repository, "tag", captured.Tag) conflicted, err := Capture(context.Background(), Request{ RepositoryDir: repository, ModuleDir: moduleDir, Product: "Example Product", Version: "v2.3.4", }) if err == nil || !strings.Contains(err.Error(), captured.Tag) { t.Fatalf("Capture() with existing nested tag returned %#v, %v", conflicted, err) } if conflicted.ModulePath != captured.ModulePath || conflicted.Tag != captured.Tag || conflicted.Commit != captured.Commit { t.Fatalf("capture failure lost identity evidence: %#v", conflicted) } } func TestCaptureKeepsRepositoryWideCleanlinessGuardForNestedSelection(t *testing.T) { repository := newRepository(t, "# Example Product v1.2.3\n") moduleDir := "integrations/ckconfig" writeTestFile(t, repository, moduleDir+"/go.mod", "module example.com/ckconfig\n") writeTestFile(t, repository, moduleDir+"/docs/releases/"+testVersion+".md", "# Example Product v1.2.3\n") gitRun(t, repository, "add", moduleDir) gitRun(t, repository, "commit", "-m", "add nested module") writeTestFile(t, repository, "sibling/untracked.txt", "dirty\n") captured, err := Capture(context.Background(), Request{ RepositoryDir: repository, ModuleDir: moduleDir, Product: "Example Product", Version: testVersion, }) if err == nil || !strings.Contains(err.Error(), "must be clean") { t.Fatalf("Capture() = %#v, %v; want repository-wide dirty-state rejection", captured, err) } if captured.Commit == "" { t.Fatalf("dirty-state error lost captured commit: %#v", captured) } } func TestCheckStopsBeforeProductAndBuildCallbacksWhenGoFormatFails(t *testing.T) { repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") writeTestFile(t, repository, "unformatted.go", "package candidate\nfunc Value( ) int{return 1}\n") gitRun(t, repository, "add", "unformatted.go") gitRun(t, repository, "commit", "-m", "add unformatted Go source") productCalls := 0 buildCalls := 0 _, err := Check(context.Background(), Request{ RepositoryDir: repository, Product: "Example Product", Version: testVersion, Validation: validation.Settings{GoFormat: true}, AdditionalChecks: func(context.Context, Candidate) error { productCalls++ return nil }, AfterValidate: func(context.Context, Candidate, *gotool.Owner) error { buildCalls++ return nil }, }) var failure *validation.Failure if !errors.As(err, &failure) || failure.Check != validation.GoFormat { t.Fatalf("Check error = %v, want go-format failure", err) } if productCalls != 0 || buildCalls != 0 { t.Fatalf("product callback calls = %d, build callback calls = %d; want neither after formatting failure", productCalls, buildCalls) } } func TestCheckStopsBeforeProductAndBuildCallbacksWhenMarkdownBoundaryFails(t *testing.T) { repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") writeTestFile(t, repository, "guide.md", "[outside](../../outside.md)\n") gitRun(t, repository, "add", "guide.md") gitRun(t, repository, "commit", "-m", "add outside Markdown destination") productCalls := 0 buildCalls := 0 _, err := Check(context.Background(), Request{ RepositoryDir: repository, Product: "Example Product", Version: testVersion, Validation: validation.Settings{ Markdown: &validation.MarkdownSettings{EnforceCheckoutBoundary: true}, }, AdditionalChecks: func(context.Context, Candidate) error { productCalls++ return nil }, AfterValidate: func(context.Context, Candidate, *gotool.Owner) error { buildCalls++ return nil }, }) var failure *validation.Failure if !errors.As(err, &failure) || failure.Check != validation.MarkdownLinks { t.Fatalf("Check() error = %v, want markdown-links failure", err) } if productCalls != 0 || buildCalls != 0 { t.Fatalf("product callback calls = %d, build callback calls = %d; want neither after Markdown failure", productCalls, buildCalls) } } func TestCheckReportsCommittedWorkspaceFilesWhenGoWorkspaceIsDisabled(t *testing.T) { t.Setenv("GOWORK", "off") repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") writeTestFile(t, repository, "go.work", "go 1.25.0\n") writeTestFile(t, repository, "nested/go.work.sum", "workspace-sum\n") if err := os.MkdirAll(filepath.Join(repository, "linked"), 0o700); err != nil { t.Fatal(err) } if err := os.Symlink("../go.work", filepath.Join(repository, "linked", "go.work")); err != nil { t.Fatal(err) } gitRun(t, repository, "add", "go.work", "nested/go.work.sum", "linked/go.work") gitRun(t, repository, "commit", "-m", "add workspace files") _, err := Check(context.Background(), Request{ RepositoryDir: repository, Product: "Example Product", Version: testVersion, Validation: validation.Settings{ModuleDistribution: validation.ModuleDistributionSettings{ WorkspaceFiles: true, }}, }) var failure *validation.Failure if !errors.As(err, &failure) || failure.Check != validation.WorkspaceFiles { t.Fatalf("Check error = %v, want workspace-files failure", err) } want := map[string]bool{ "go.work": false, "linked/go.work": false, "nested/go.work.sum": false, } if len(failure.Findings) != len(want) { t.Fatalf("workspace findings = %#v, want %d paths", failure.Findings, len(want)) } for _, finding := range failure.Findings { if _, ok := want[finding.Path]; !ok { t.Errorf("unexpected workspace path %q", finding.Path) } want[finding.Path] = true } for path, found := range want { if !found { t.Errorf("workspace path %q was not reported", path) } } } func TestCheckRunsDefaultOfflineGoValidation(t *testing.T) { repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") var output bytes.Buffer _, err := Check(context.Background(), Request{ RepositoryDir: repository, Product: "Example Product", Version: testVersion, Output: &output, }) if err != nil { t.Fatalf("Check() with default validation: %v\n%s", err, output.String()) } } func TestCheckRefreshesGoSettingsAfterAdditionalChecks(t *testing.T) { repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") t.Setenv("GOENV", "off") t.Setenv("GOMODCACHE", filepath.Join(t.TempDir(), "initial-module-cache")) selectedCache := filepath.Join(t.TempDir(), "callback-module-cache") var observed string _, err := Check(context.Background(), Request{ RepositoryDir: repository, Product: "Example Product", Version: testVersion, AdditionalChecks: func(context.Context, Candidate) error { t.Setenv("GOMODCACHE", selectedCache) return nil }, AfterValidate: func(ctx context.Context, captured Candidate, owner *gotool.Owner) error { tool, err := owner.Acquire(ctx) if err != nil { return err } output, err := tool.Output(ctx, captured.ModuleDir, gotool.Native, gotool.Target{}, nil, "env", "GOMODCACHE") if err != nil { return err } observed = strings.TrimSpace(string(output)) return nil }, }) if err != nil { t.Fatal(err) } if observed != selectedCache { t.Fatalf("Go settings after additional checks = %q, want %q", observed, selectedCache) } } func TestCheckNeedsNoGoDiscoveryAfterFinalCallback(t *testing.T) { repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") gitPath, err := exec.LookPath("git") if err != nil { t.Fatal(err) } gitOnly := t.TempDir() if err := os.Symlink(gitPath, filepath.Join(gitOnly, "git")); err != nil { t.Fatal(err) } _, err = Check(context.Background(), Request{ RepositoryDir: repository, Product: "Example Product", Version: testVersion, AfterValidate: func(context.Context, Candidate, *gotool.Owner) error { t.Setenv("PATH", gitOnly) return nil }, }) if err != nil { t.Fatalf("completed Go work was rediscovered after final callback: %v", err) } } func TestCheckSharesGoDiscoveryWithAfterValidateCallback(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("the controlled Go command fixture uses a POSIX shell") } repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") logPath := installCandidateGoShim(t) t.Setenv("GOENV", "off") _, err := Check(context.Background(), Request{ RepositoryDir: repository, Product: "Example Product", Version: testVersion, AfterValidate: func(ctx context.Context, captured Candidate, owner *gotool.Owner) error { tool, err := owner.Acquire(ctx) if err != nil { return err } _, err = tool.Output(ctx, captured.ModuleDir, gotool.Native, gotool.Target{}, nil, "version") return err }, }) if err != nil { t.Fatal(err) } contents, err := os.ReadFile(logPath) if err != nil { t.Fatal(err) } commands := strings.Split(strings.TrimSpace(string(contents)), "\n") var discoveries, suites, callbacks int for _, command := range commands { switch { case strings.HasPrefix(command, "env "): discoveries++ case strings.HasPrefix(command, "test "): suites++ case command == "version": callbacks++ } } if discoveries != 2 || suites != 1 || callbacks != 1 { t.Fatalf("Go invocations = %q; discovery=%d suite=%d callback=%d, want 2, 1, 1", commands, discoveries, suites, callbacks) } } func installCandidateGoShim(t *testing.T) string { t.Helper() directory := t.TempDir() logPath := filepath.Join(directory, "go-commands") settings := map[string]string{ "GOHOSTOS": runtime.GOOS, "GOHOSTARCH": runtime.GOARCH, "GOENV": "off", "CC": "", "CXX": "", "CGO_CFLAGS": "", "CGO_CPPFLAGS": "", "CGO_CXXFLAGS": "", "CGO_ENABLED": "1", "CGO_LDFLAGS": "", "GOCACHE": "", "GOCACHEPROG": "", "GOMODCACHE": "", "GOPATH": "", "GOROOT": "", "GOTMPDIR": "", "PKG_CONFIG": "", } encoded, err := json.Marshal(settings) if err != nil { t.Fatal(err) } script := fmt.Sprintf(`#!/bin/sh set -eu printf '%%s\n' "$*" >> "$RELEASEKIT_GO_LOG" if [ "${1-}" = "env" ]; then printf '%%s\n' '%s' exit 0 fi if [ "${1-}" = "version" ]; then echo 'go version fixture' exit 0 fi exit 0 `, encoded) shimPath := filepath.Join(directory, "go") if err := os.WriteFile(shimPath, []byte(script), 0o700); err != nil { t.Fatal(err) } path := directory if current := os.Getenv("PATH"); current != "" { path += string(os.PathListSeparator) + current } t.Setenv("PATH", path) t.Setenv("RELEASEKIT_GO_LOG", logPath) return logPath } func TestDefaultValidationDoesNotInheritGOFLAGSRunFilter(t *testing.T) { repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") writeTestFile(t, repository, "failure_test.go", "package candidate\nimport \"testing\"\nfunc TestExpectedFailure(t *testing.T) { t.Fatal(\"expected fixture failure\") }\n") gitRun(t, repository, "add", "failure_test.go") gitRun(t, repository, "commit", "-m", "add failing validation fixture") for _, source := range []string{"process environment", "Go environment file"} { t.Run(source, func(t *testing.T) { unsetCandidateEnv(t, "GOFLAGS", "GOOS", "GOARCH") goEnv := filepath.Join(t.TempDir(), "go-env") if source == "Go environment file" { if err := os.WriteFile(goEnv, []byte("GOFLAGS=-run=^$\nGOOS=windows\nGOARCH=arm64\n"), 0o600); err != nil { t.Fatal(err) } } else { if err := os.WriteFile(goEnv, nil, 0o600); err != nil { t.Fatal(err) } t.Setenv("GOFLAGS", "-run=^$") t.Setenv("GOOS", "windows") t.Setenv("GOARCH", "arm64") } t.Setenv("GOENV", goEnv) var output bytes.Buffer _, err := Check(context.Background(), Request{ RepositoryDir: repository, Product: "Example Product", Version: testVersion, Output: &output, }) if err == nil || !strings.Contains(output.String(), "TestExpectedFailure") { t.Fatalf("Check() error = %v, output = %q; wanted the failing standard test to run", err, output.String()) } var failure *validation.Failure if !errors.As(err, &failure) || failure.Check != validation.GoTest { t.Fatalf("Check() error = %v; want a go-test validation failure", err) } var exitErr *exec.ExitError if !errors.As(err, &exitErr) { t.Fatalf("Check() error = %v; want the Go process exit error preserved", err) } }) } } func unsetCandidateEnv(t *testing.T, keys ...string) { t.Helper() for _, key := range keys { value, exists := os.LookupEnv(key) if err := os.Unsetenv(key); err != nil { t.Fatal(err) } t.Cleanup(func() { if exists { _ = os.Setenv(key, value) } else { _ = os.Unsetenv(key) } }) } } func TestCheckRejectsInvalidVersionsBeforeGitAccess(t *testing.T) { for _, version := range []string{"v01.2.3", "v1.2.3-alpha", "1.2.3"} { t.Run(version, func(t *testing.T) { _, err := Check(context.Background(), Request{Version: version}) if !errors.Is(err, ErrInvalidVersion) { t.Fatalf("Check() error = %v, want ErrInvalidVersion", err) } }) } } func TestCheckRequiresMatchingCommittedNote(t *testing.T) { tests := []struct { name string note string }{ {name: "missing note"}, {name: "wrong product", note: "# Other Product v1.2.3\n"}, {name: "wrong version", note: "# Example Product v1.2.4\n"}, {name: "embedded product text", note: "# Example Products v1.2.3\n"}, {name: "not a level one heading", note: "## Example Product v1.2.3\n"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { repository := newRepository(t, test.note) called := false _, err := Check(context.Background(), Request{ RepositoryDir: repository, Product: "Example Product", Version: testVersion, AdditionalChecks: func(context.Context, Candidate) error { called = true return nil }, }) if err == nil { t.Fatal("Check() succeeded with an invalid committed note") } if called { t.Fatal("validation ran before the committed note passed") } }) } } func TestCheckRejectsDirtyTrackedAndUntrackedState(t *testing.T) { for _, test := range []struct { name string path string value string }{ {name: "tracked", path: "tracked.txt", value: "changed\n"}, {name: "untracked", path: "new file.txt", value: "untracked\n"}, } { t.Run(test.name, func(t *testing.T) { repository := newRepository(t, "# Example Product v1.2.3\n") if err := os.WriteFile(filepath.Join(repository, test.path), []byte(test.value), 0o600); err != nil { t.Fatal(err) } _, err := Check(context.Background(), validRequest(repository)) if err == nil || !strings.Contains(err.Error(), "must be clean") { t.Fatalf("Check() error = %v, want dirty state error", err) } }) } } func TestCheckRequiresMainBranchAndUnusedTag(t *testing.T) { t.Run("detached HEAD", func(t *testing.T) { repository := newRepository(t, "# Example Product v1.2.3\n") gitRun(t, repository, "checkout", "--detach", "HEAD") _, err := Check(context.Background(), validRequest(repository)) if err == nil || !strings.Contains(err.Error(), "must be on main") { t.Fatalf("Check() error = %v, want branch error", err) } }) t.Run("existing local tag", func(t *testing.T) { repository := newRepository(t, "# Example Product v1.2.3\n") gitRun(t, repository, "tag", testVersion) _, err := Check(context.Background(), validRequest(repository)) if err == nil || !strings.Contains(err.Error(), "already exists") { t.Fatalf("Check() error = %v, want existing-tag error", err) } }) } func TestCheckStopsOnAdditionalCheckFailure(t *testing.T) { repository := newRepository(t, "# Example Product v1.2.3\n") want := errors.New("selected check failed") calls := 0 _, err := Check(context.Background(), Request{ RepositoryDir: repository, Product: "Example Product", Version: testVersion, AdditionalChecks: func(context.Context, Candidate) error { calls++ return want }, }) if !errors.Is(err, want) { t.Fatalf("Check() error = %v, want wrapped callback error", err) } if calls != 1 { t.Fatalf("validation callback called %d times, want 1", calls) } } func TestCheckRechecksHeadAndWorktreeAfterCallback(t *testing.T) { tests := []struct { name string validate func(*testing.T, string) want string }{ { name: "HEAD changes", validate: func(t *testing.T, repository string) { t.Helper() if err := os.WriteFile(filepath.Join(repository, "later.txt"), []byte("later\n"), 0o600); err != nil { t.Fatal(err) } gitRun(t, repository, "add", "later.txt") gitRun(t, repository, "-c", "commit.gpgsign=false", "commit", "-m", "advance HEAD") }, want: "HEAD changed from", }, { name: "worktree changes", validate: func(t *testing.T, repository string) { t.Helper() if err := os.WriteFile(filepath.Join(repository, "later.txt"), []byte("later\n"), 0o600); err != nil { t.Fatal(err) } }, want: "worktree or index changed", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { repository := newRepository(t, "# Example Product v1.2.3\n") _, err := Check(context.Background(), Request{ RepositoryDir: repository, Product: "Example Product", Version: testVersion, AdditionalChecks: func(context.Context, Candidate) error { test.validate(t, repository) return nil }, }) if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("Check() error = %v, want text %q", err, test.want) } }) } } func TestCheckRechecksLocalTagAfterCallback(t *testing.T) { repository := newRepository(t, "# Example Product v1.2.3\n") captured, err := Check(context.Background(), Request{ RepositoryDir: repository, Product: "Example Product", Version: testVersion, AdditionalChecks: func(context.Context, Candidate) error { gitRun(t, repository, "tag", testVersion) return nil }, }) if err == nil || !strings.Contains(err.Error(), "appeared during validation") { t.Fatalf("Check() error = %v, want tag recheck error", err) } if captured.RepositoryDir != repository || captured.ModuleDir != repository || captured.ModulePath != "example.com/candidate" || captured.Tag != testVersion || captured.Commit == "" { t.Fatalf("tag recheck failure lost candidate evidence: %#v", captured) } } func validRequest(repository string) Request { return Request{RepositoryDir: repository, Product: "Example Product", Version: testVersion, AdditionalChecks: func(context.Context, Candidate) error { return nil }} } func newRepository(t *testing.T, note string) string { t.Helper() repository := filepath.Join(t.TempDir(), "repository with spaces") if err := os.Mkdir(repository, 0o700); err != nil { t.Fatal(err) } gitRun(t, repository, "init", "--initial-branch=main") gitRun(t, repository, "config", "user.name", "ReleaseKit Test") gitRun(t, repository, "config", "user.email", "releasekit@example.test") gitRun(t, repository, "config", "commit.gpgsign", "false") writeTestFile(t, repository, "go.mod", "module example.com/candidate\n\ngo 1.25.0\n") writeTestFile(t, repository, "candidate_test.go", "package candidate\nimport \"testing\"\nfunc TestFixture(t *testing.T) {}\n") writeTestFile(t, repository, "tracked.txt", "tracked\n") if note != "" { writeTestFile(t, repository, "docs/releases/"+testVersion+".md", note) } gitRun(t, repository, "add", "--all") gitRun(t, repository, "commit", "-m", "candidate") return repository } func writeTestFile(t *testing.T, repository, name, contents string) { t.Helper() path := filepath.Join(repository, filepath.FromSlash(name)) if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { t.Fatal(err) } } func gitRun(t *testing.T, repository string, args ...string) { t.Helper() commandArgs := append([]string{"git"}, args...) command := exec.Command(commandArgs[0], commandArgs[1:]...) command.Dir = repository output, err := command.CombinedOutput() if err != nil { t.Fatalf("%v: %v\n%s", commandArgs, err, output) } } func gitOutputForTest(t *testing.T, repository string, args ...string) string { t.Helper() commandArgs := append([]string{"git"}, args...) command := exec.Command(commandArgs[0], commandArgs[1:]...) command.Dir = repository output, err := command.Output() if err != nil { t.Fatalf("%v: %v", commandArgs, err) } return strings.TrimSpace(string(output)) }