package releasekit_test import ( "context" "errors" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strconv" "strings" "sync/atomic" "testing" "time" releasekit "go.maximumdirect.net/releasekit" ) func TestPrepareTaggedCheckoutRedactsRemoteCredentials(t *testing.T) { t.Setenv("NO_PROXY", "*") t.Setenv("no_proxy", "*") t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) t.Setenv("GIT_CONFIG_NOSYSTEM", "1") t.Setenv("GIT_TERMINAL_PROMPT", "0") repo := newPublishFixture(t) var authenticated atomic.Bool server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, password, ok := r.BasicAuth() if !ok { w.Header().Set("WWW-Authenticate", `Basic realm="fixture"`) w.WriteHeader(http.StatusUnauthorized) return } if user != "review-user" || password != "review-password" { t.Error("fetch did not preserve the configured credentials") } authenticated.Store(true) w.WriteHeader(http.StatusServiceUnavailable) })) defer server.Close() remote := strings.Replace(server.URL, "http://", "http://review-user:review-password@", 1) + "/repo.git" err := releasekit.PrepareTaggedCheckout(t.Context(), releasekit.TaggedCheckoutRequest{ RepositoryDir: repo.work, Tag: "v1.2.3", Remote: remote, ExpectedCommit: repo.commit, }) var exit *exec.ExitError if !errors.As(err, &exit) || !authenticated.Load() || !strings.Contains(err.Error(), "v1.2.3") || !strings.Contains(err.Error(), "repo.git") { t.Fatalf("preparation failure = %v, authenticated = %t", err, authenticated.Load()) } if strings.Contains(err.Error(), "review-user") || strings.Contains(err.Error(), "review-password") { t.Fatalf("preparation diagnostic exposes credentials: %v", err) } } func TestPrepareBuildDependenciesCancellationReleasesInheritedOutput(t *testing.T) { directory := t.TempDir() ready := filepath.Join(t.TempDir(), "descendant") gitPath := filepath.Join(t.TempDir(), "git") if err := os.WriteFile(gitPath, []byte("#!/bin/sh\nsleep 60 &\nprintf '%s' \"$!\" > \"$RELEASEKIT_PREPARE_DESCENDANT\"\nwait\n"), 0o700); err != nil { t.Fatal(err) } t.Setenv("PATH", filepath.Dir(gitPath)+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("RELEASEKIT_PREPARE_DESCENDANT", ready) ctx, cancel := context.WithCancel(context.Background()) defer cancel() result := make(chan error, 1) go func() { result <- releasekit.PrepareBuildDependencies(ctx, releasekit.DependencyPreparationRequest{RepositoryDir: directory}) }() deadline := time.After(10 * time.Second) var pid int for pid == 0 { if data, err := os.ReadFile(ready); err == nil { pid, _ = strconv.Atoi(string(data)) } select { case <-deadline: t.Fatal("preparation descendant did not start") default: time.Sleep(10 * time.Millisecond) } } t.Cleanup(func() { if descendant, err := os.FindProcess(pid); err == nil { _ = descendant.Kill() } }) cancel() select { case err := <-result: if !errors.Is(err, context.Canceled) { t.Fatalf("preparation error = %v, want cancellation", err) } case <-time.After(10 * time.Second): t.Fatal("preparation did not return after cancellation") } } func TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches(t *testing.T) { repository := newTaggedDeliveryConsumer(t) tagCommit := publishPreparationTag(t, repository) advancePreparationCheckout(t, repository, "later local commit") if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ RepositoryDir: repository, Tag: "v1.2.3", ExpectedCommit: tagCommit, }); err != nil { t.Fatal(err) } if head := buildGit(t, repository, "rev-parse", "HEAD"); head != tagCommit { t.Fatalf("prepared HEAD = %s, want %s", head, tagCommit) } if branch := buildGit(t, repository, "rev-parse", "--abbrev-ref", "HEAD"); branch != "HEAD" { t.Fatalf("prepared checkout branch = %q, want detached HEAD", branch) } } func TestPrepareTaggedCheckoutAcceptsNestedFullTag(t *testing.T) { repository := newTaggedDeliveryConsumer(t) tag := "integrations/ckconfig/v1.2.3" tagCommit := publishPreparationTagNamed(t, repository, tag) advancePreparationCheckout(t, repository, "later local commit") if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ RepositoryDir: repository, Tag: tag, ExpectedCommit: tagCommit, }); err != nil { t.Fatal(err) } if head := buildGit(t, repository, "rev-parse", "HEAD"); head != tagCommit { t.Fatalf("prepared HEAD = %s, want %s", head, tagCommit) } } func TestPrepareTaggedCheckoutRejectsWrongCommitBeforeCheckout(t *testing.T) { repository := newTaggedDeliveryConsumer(t) tagCommit := publishPreparationTag(t, repository) currentCommit := advancePreparationCheckout(t, repository, "later local commit") remote := buildGit(t, repository, "remote", "get-url", "origin") buildGit(t, repository, "remote", "add", "candidate", remote) err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ RepositoryDir: repository, Tag: "v1.2.3", Remote: "candidate", ExpectedCommit: currentCommit, }) if err == nil { t.Fatal("PrepareTaggedCheckout() succeeded for a tag that resolves to another commit") } if head := buildGit(t, repository, "rev-parse", "HEAD"); head != currentCommit { t.Fatalf("wrong-tag rejection changed HEAD to %s, want %s (tag commit %s)", head, currentCommit, tagCommit) } if branch := buildGit(t, repository, "rev-parse", "--abbrev-ref", "HEAD"); branch == "HEAD" { t.Fatal("wrong-tag rejection detached the checkout") } } func TestPrepareTaggedCheckoutReportsLocalCheckoutConflict(t *testing.T) { repository := newTaggedDeliveryConsumer(t) tagCommit := publishPreparationTag(t, repository) advancePreparationCheckout(t, repository, "later committed content") path := filepath.Join(repository, "cmd/app/main.go") if err := os.WriteFile(path, []byte("uncommitted conflicting content"), 0o600); err != nil { t.Fatal(err) } currentCommit := buildGit(t, repository, "rev-parse", "HEAD") err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ RepositoryDir: repository, Tag: "v1.2.3", ExpectedCommit: tagCommit, }) if err == nil { t.Fatal("PrepareTaggedCheckout() succeeded despite a conflicting local edit") } if head := buildGit(t, repository, "rev-parse", "HEAD"); head != currentCommit { t.Fatalf("conflicting checkout changed HEAD to %s, want %s", head, currentCommit) } contents, err := os.ReadFile(path) if err != nil || string(contents) != "uncommitted conflicting content" { t.Fatalf("conflicting local file = %q, %v", contents, err) } } func TestPrepareTaggedCheckoutReportsConflictingLocalTag(t *testing.T) { repository := newTaggedDeliveryConsumer(t) publishPreparationTag(t, repository) currentCommit := advancePreparationCheckout(t, repository, "later local commit") buildGit(t, repository, "tag", "v1.2.3") err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ RepositoryDir: repository, Tag: "v1.2.3", ExpectedCommit: buildGit(t, repository, "rev-parse", "HEAD~1"), }) if err == nil { t.Fatal("PrepareTaggedCheckout() replaced a conflicting local tag") } if head := buildGit(t, repository, "rev-parse", "HEAD"); head != currentCommit { t.Fatalf("conflicting tag fetch changed HEAD to %s, want %s", head, currentCommit) } if tagCommit := buildGit(t, repository, "rev-parse", "refs/tags/v1.2.3^{commit}"); tagCommit != currentCommit { t.Fatalf("conflicting local tag changed to %s, want %s", tagCommit, currentCommit) } } func TestPrepareTaggedCheckoutValidatesInputsBeforeFetching(t *testing.T) { repository := newTaggedDeliveryConsumer(t) publishPreparationTag(t, repository) for _, request := range []releasekit.TaggedCheckoutRequest{ {RepositoryDir: repository, Tag: "not-a-version", ExpectedCommit: buildGit(t, repository, "rev-parse", "HEAD")}, {RepositoryDir: repository, Tag: "v1.2.3"}, {RepositoryDir: repository, Tag: "v1.2.3", ExpectedCommit: "not-an-object-id"}, } { err := releasekit.PrepareTaggedCheckout(context.Background(), request) if err == nil { t.Fatalf("PrepareTaggedCheckout(%#v) succeeded", request) } if _, err := buildGitOutput(repository, "show-ref", "--verify", "refs/tags/v1.2.3"); err == nil { t.Fatal("input validation fetched the tag before rejecting the request") } } if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ RepositoryDir: repository, Tag: "invalid", ExpectedCommit: "bad", }); !errors.Is(err, releasekit.ErrInvalidVersion) { t.Fatalf("invalid-version error = %v, want ErrInvalidVersion", err) } } func TestPrepareTaggedCheckoutVerifyOnlyIsReadOnly(t *testing.T) { for _, test := range []struct { name string wrong bool mutate func(t *testing.T, repository string) }{ {name: "wrong expected commit", wrong: true}, {name: "dirty checkout", mutate: func(t *testing.T, repository string) { if err := os.WriteFile(filepath.Join(repository, "dirty.txt"), []byte("dirty"), 0o600); err != nil { t.Fatal(err) } }}, {name: "missing tag", mutate: func(t *testing.T, repository string) { buildGit(t, repository, "tag", "-d", "v1.2.3") }}, {name: "moved tag", mutate: func(t *testing.T, repository string) { advancePreparationCheckout(t, repository, "moved tagged commit") buildGit(t, repository, "tag", "--force", "v1.2.3") }}, } { t.Run(test.name, func(t *testing.T) { repository := newTaggedDeliveryConsumer(t) tagCommit := publishPreparationTag(t, repository) if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ RepositoryDir: repository, Tag: "v1.2.3", ExpectedCommit: tagCommit, }); err != nil { t.Fatal(err) } if test.mutate != nil { test.mutate(t, repository) } commit := tagCommit if test.wrong { commit = strings.Repeat("0", len(tagCommit)) } if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ RepositoryDir: repository, Tag: "v1.2.3", Remote: "missing-remote", ExpectedCommit: commit, VerifyOnly: true, }); err == nil { t.Fatal("VerifyOnly accepted an inconsistent prepared checkout") } }) } repository := newTaggedDeliveryConsumer(t) tagCommit := publishPreparationTag(t, repository) if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ RepositoryDir: repository, Tag: "v1.2.3", ExpectedCommit: tagCommit, }); err != nil { t.Fatal(err) } if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{ RepositoryDir: repository, Tag: "v1.2.3", Remote: "missing-remote", ExpectedCommit: tagCommit, VerifyOnly: true, }); err != nil { t.Fatalf("VerifyOnly rejected a prepared checkout: %v", err) } if head := buildGit(t, repository, "rev-parse", "HEAD"); head != tagCommit { t.Fatalf("VerifyOnly changed HEAD to %s, want %s", head, tagCommit) } if branch := buildGit(t, repository, "rev-parse", "--abbrev-ref", "HEAD"); branch != "HEAD" { t.Fatalf("VerifyOnly changed checkout branch to %q", branch) } } func TestPrepareBuildDependenciesRequiresRepositoryDirectory(t *testing.T) { if err := releasekit.PrepareBuildDependencies(context.Background(), releasekit.DependencyPreparationRequest{RepositoryDir: " "}); err == nil { t.Fatal("PrepareBuildDependencies() accepted an empty repository directory") } } func TestPrepareBuildDependenciesUsesSelectedModuleDirectory(t *testing.T) { repository := t.TempDir() moduleDir := filepath.Join(repository, "integrations", "ckconfig") if err := os.MkdirAll(moduleDir, 0o700); err != nil { t.Fatal(err) } if err := os.Mkdir(filepath.Join(moduleDir, "deep"), 0o700); err != nil { t.Fatal(err) } if err := os.Symlink("integrations/ckconfig/deep", filepath.Join(repository, "linked")); err != nil { t.Fatal(err) } toolDir := t.TempDir() marker := filepath.Join(t.TempDir(), "go-directory") tool := filepath.Join(toolDir, "go") if err := os.WriteFile(tool, []byte("#!/bin/sh\nprintf '%s' \"$PWD\" > \"$PREPARE_DIR_MARKER\"\n"), 0o700); err != nil { t.Fatal(err) } t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("PREPARE_DIR_MARKER", marker) for _, selected := range []string{"integrations/ckconfig", "linked/.."} { if err := releasekit.PrepareBuildDependencies(context.Background(), releasekit.DependencyPreparationRequest{ RepositoryDir: repository, ModuleDir: selected, }); err != nil { t.Fatal(err) } got, err := os.ReadFile(marker) if err != nil || string(got) != moduleDir { t.Fatalf("go mod download directory for %q = %q, error=%v, want %q", selected, got, err, moduleDir) } } } func TestPrepareBuildDependenciesSelectsFromCheckoutRoot(t *testing.T) { repo := t.TempDir() buildGit(t, repo, "init") nested := filepath.Join(repo, "nested") child := filepath.Join(nested, "child") if err := os.MkdirAll(child, 0o755); err != nil { t.Fatal(err) } if err := os.Symlink("nested/child", filepath.Join(repo, "link")); err != nil { t.Fatal(err) } bin := t.TempDir() marker := filepath.Join(t.TempDir(), "directory") if err := os.WriteFile(filepath.Join(bin, "go"), []byte("#!/bin/sh\nprintf '%s' \"$PWD\" > \"$PREPARE_DIR_MARKER\"\n"), 0o755); err != nil { t.Fatal(err) } t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("PREPARE_DIR_MARKER", marker) for _, tc := range []struct{ dir, module, want string }{ {repo, "", repo}, {child, "", repo}, {repo, "nested", nested}, {child, "nested", nested}, {child, "link/..", nested}, } { err := releasekit.PrepareBuildDependencies(context.Background(), releasekit.DependencyPreparationRequest{RepositoryDir: tc.dir, ModuleDir: tc.module}) got, readErr := os.ReadFile(marker) if err != nil || readErr != nil || string(got) != tc.want { t.Fatalf("selection %q, %q = %q, error %v, read %v; want %q", tc.dir, tc.module, got, err, readErr, tc.want) } } } func TestPrepareBuildDependenciesWithoutGit(t *testing.T) { repo := t.TempDir() buildGit(t, repo, "init") standalone := t.TempDir() bin := t.TempDir() marker := filepath.Join(t.TempDir(), "go-ran") if err := os.WriteFile(filepath.Join(bin, "go"), []byte("#!/bin/sh\nprintf ran > \"$PREPARE_DIR_MARKER\"\n"), 0o755); err != nil { t.Fatal(err) } t.Setenv("PATH", bin) t.Setenv("PREPARE_DIR_MARKER", marker) if err := releasekit.PrepareBuildDependencies(context.Background(), releasekit.DependencyPreparationRequest{RepositoryDir: standalone}); err != nil { t.Fatal(err) } if _, err := os.Stat(marker); err != nil { t.Fatalf("Go did not run: %v", err) } if err := os.Remove(marker); err != nil { t.Fatal(err) } err := releasekit.PrepareBuildDependencies(context.Background(), releasekit.DependencyPreparationRequest{RepositoryDir: repo}) if !errors.Is(err, exec.ErrNotFound) { t.Fatalf("checkout error = %v, want missing Git", err) } if _, err := os.Stat(marker); !os.IsNotExist(err) { t.Fatalf("Go ran after discovery failure: %v", err) } } func TestPrepareBuildDependenciesRejectsDamagedCheckout(t *testing.T) { repo := t.TempDir() child := filepath.Join(repo, "child") writeBuildFixture(t, repo, "go.mod", "module example.com/root\n\ngo 1.26.0\n") writeBuildFixture(t, repo, "child/go.mod", "module example.com/child\n\ngo 1.26.0\n") buildGit(t, repo, "init", "-b", "main") buildGit(t, repo, "config", "user.name", "Test") buildGit(t, repo, "config", "user.email", "test@example.invalid") buildGit(t, repo, "add", ".") buildGit(t, repo, "commit", "-m", "create module fixture") bin := t.TempDir() marker := filepath.Join(t.TempDir(), "go-directory") if err := os.WriteFile(filepath.Join(bin, "go"), []byte("#!/bin/sh\nprintf '%s' \"$PWD\" > \"$PREPARE_DIR_MARKER\"\n"), 0o755); err != nil { t.Fatal(err) } t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("PREPARE_DIR_MARKER", marker) request := releasekit.DependencyPreparationRequest{RepositoryDir: child} if err := releasekit.PrepareBuildDependencies(t.Context(), request); err != nil { t.Fatal(err) } if got, err := os.ReadFile(marker); err != nil || string(got) != repo { t.Fatalf("healthy checkout selected %q, error %v; want %q", got, err, repo) } if err := os.Remove(marker); err != nil { t.Fatal(err) } if err := os.Rename(filepath.Join(repo, ".git", "HEAD"), filepath.Join(t.TempDir(), "saved-head")); err != nil { t.Fatal(err) } err := releasekit.PrepareBuildDependencies(t.Context(), request) var exitErr *exec.ExitError if !errors.As(err, &exitErr) || exitErr.ExitCode() != 128 { t.Fatalf("damaged checkout error = %v; want original Git failure", err) } if _, err := os.Stat(marker); !os.IsNotExist(err) { t.Fatalf("Go ran after damaged checkout discovery: %v", err) } request.RepositoryDir = t.TempDir() if err := releasekit.PrepareBuildDependencies(t.Context(), request); err != nil { t.Fatalf("standalone preparation: %v", err) } if got, err := os.ReadFile(marker); err != nil || string(got) != request.RepositoryDir { t.Fatalf("standalone selected %q, error %v; want %q", got, err, request.RepositoryDir) } } func publishPreparationTag(t *testing.T, repository string) string { t.Helper() return publishPreparationTagNamed(t, repository, "v1.2.3") } func publishPreparationTagNamed(t *testing.T, repository, tag string) string { t.Helper() ref := "refs/tags/" + tag if _, err := buildGitOutput(repository, "show-ref", "--verify", "--quiet", ref); err != nil { buildGit(t, repository, "tag", tag) } remoteDir := t.TempDir() remote := filepath.Join(remoteDir, "origin.git") buildGit(t, remoteDir, "init", "--bare", remote) buildGit(t, repository, "remote", "add", "origin", remote) commit := buildGit(t, repository, "rev-parse", ref+"^{commit}") buildGit(t, repository, "push", "origin", ref) buildGit(t, repository, "tag", "-d", tag) return commit } func advancePreparationCheckout(t *testing.T, repository, source string) string { t.Helper() path := filepath.Join(repository, "cmd/app/main.go") if err := os.WriteFile(path, []byte("package main\n// "+source+"\nfunc main() {}\n"), 0o600); err != nil { t.Fatal(err) } buildGit(t, repository, "add", "cmd/app/main.go") buildGit(t, repository, "commit", "-m", source) return buildGit(t, repository, "rev-parse", "HEAD") } func buildGitOutput(repository string, args ...string) ([]byte, error) { command := exec.Command("git", args...) command.Dir = repository return command.Output() }