package releasekit_test import ( "bytes" "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "time" releasekit "gitea.maximumdirect.net/eric/ReleaseKit" ) func TestBuildCreatesSelectedTargetsAndInjectsVersion(t *testing.T) { repository := newBuildConsumer(t, `package main import ( "fmt" "os" "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" ) func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixture %s\n", buildinfo.Version()) } } `) outputDir := filepath.Join(t.TempDir(), "artifacts") t.Setenv("GOOS", "windows") t.Setenv("GOARCH", "arm64") t.Setenv("GOFLAGS", "-race") var progress bytes.Buffer result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{ RepositoryDir: repository, Version: "v1.2.3", Spec: releasekit.BuildSpec{ MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{ {OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "arm64"}, {OS: "darwin", Arch: "arm64"}, {OS: "linux", Arch: "amd64"}, }, }, OutputDir: outputDir, Output: &progress, }) if err != nil { t.Fatalf("Build() error = %v; partial result = %#v", err, result) } if result.CandidateCommit == "" || result.CandidateCommit != buildGit(t, repository, "rev-parse", "HEAD") { t.Fatalf("candidate commit = %q", result.CandidateCommit) } if result.Compiler == "" || len(result.Targets) != 3 || len(result.Artifacts) != 3 { t.Fatalf("build result = %#v", result) } wantNames := []string{ "fixture-v1.2.3-linux-amd64", "fixture-v1.2.3-linux-arm64", "fixture-v1.2.3-darwin-arm64", } for index, artifact := range result.Artifacts { if artifact.Filename != wantNames[index] || artifact.Target != result.Targets[index] { t.Fatalf("artifact %d = %#v", index, artifact) } contents, err := os.ReadFile(artifact.Path) if err != nil { t.Fatal(err) } digest := sha256.Sum256(contents) if artifact.SHA256 != hex.EncodeToString(digest[:]) { t.Fatalf("artifact digest = %q, want %x", artifact.SHA256, digest) } } if !strings.Contains(progress.String(), "checking fixture version output") { t.Fatalf("build progress = %q", progress.String()) } for _, artifact := range result.Artifacts { if artifact.Target != (releasekit.Target{OS: runtime.GOOS, Arch: runtime.GOARCH}) { continue } command := exec.Command(artifact.Path, "version") output, err := command.CombinedOutput() if err != nil || string(output) != "fixture v1.2.3\n" { t.Fatalf("native version command output = %q, error = %v", output, err) } return } } func TestBuildUsesGoHostDespiteAmbientTargetEnvironment(t *testing.T) { repository := newBuildConsumer(t, `package main import ( "fmt" "os" "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" ) func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixture %s\n", buildinfo.Version()) } } `) t.Setenv("GOOS", "windows") t.Setenv("GOARCH", "arm64") result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{ RepositoryDir: repository, Version: "v2.3.4", Spec: releasekit.BuildSpec{ MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: "windows", Arch: "arm64"}}, }, OutputDir: filepath.Join(t.TempDir(), "artifacts"), }) if err != nil { t.Fatalf("Build() error = %v; partial result = %#v", err, result) } if len(result.Artifacts) != 1 || result.Artifacts[0].Filename != "fixture-v2.3.4-windows-arm64.exe" { t.Fatalf("build artifacts = %#v", result.Artifacts) } } func TestBuildWithNoTargetsDoesNotInvokeCompiler(t *testing.T) { repository := newBuildConsumer(t, `package main func main() {} `) outputDir := filepath.Join(t.TempDir(), "must-not-be-created") result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{ RepositoryDir: repository, Version: "v1.2.3", OutputDir: outputDir, }) if err != nil { t.Fatalf("Build() error = %v", err) } if result.CandidateCommit == "" || result.Compiler != "" || len(result.Targets) != 0 || len(result.Artifacts) != 0 { t.Fatalf("empty build result = %#v", result) } if _, err := os.Stat(outputDir); !errors.Is(err, os.ErrNotExist) { t.Fatalf("empty build created output directory: %v", err) } } func TestBuildRegeneratesOwnedOutputsAndPreservesUnrelatedFiles(t *testing.T) { const source = `package main import("fmt";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo") func main(){fmt.Println("fixture",buildinfo.Version())} ` repository := newBuildConsumer(t, source) outputDir := t.TempDir() unrelated := filepath.Join(outputDir, "keep.txt") if err := os.WriteFile(unrelated, []byte("keep this file"), 0600); err != nil { t.Fatal(err) } request := releasekit.BuildRequest{RepositoryDir: repository, Version: "v1.2.3", Spec: releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}}, OutputDir: outputDir} unsafe := request unsafe.Spec.BinaryName = "../unsafe" if _, err := releasekit.Build(context.Background(), unsafe); err == nil { t.Fatal("unsafe binary name accepted") } first, err := releasekit.Build(context.Background(), request) if err != nil { t.Fatal(err) } repeated, err := releasekit.Build(context.Background(), request) if err != nil { t.Fatal(err) } if repeated.Artifacts[0].SHA256 != first.Artifacts[0].SHA256 { t.Fatal("identical build changed output") } writeBuildFixture(t, repository, "cmd/app/main.go", source+"\nfunc init(){if len([]byte(buildinfo.Version()))==0 {panic(\"changed build\")}}\n") changed, err := releasekit.Build(context.Background(), request) if err != nil { t.Fatal(err) } if changed.Artifacts[0].SHA256 == first.Artifacts[0].SHA256 { t.Fatal("changed source did not regenerate binary") } content, err := os.ReadFile(unrelated) if err != nil || string(content) != "keep this file" { t.Fatalf("unrelated file=%q %v", content, err) } } func TestBuildReturnsPartialArtifactsAfterTargetFailure(t *testing.T) { repository := newBuildConsumer(t, "package main\nfunc main() {}\n") result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{ RepositoryDir: repository, Version: "v1.2.3", Spec: releasekit.BuildSpec{ MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{ {OS: runtime.GOOS, Arch: runtime.GOARCH}, {OS: "invalid-os", Arch: "invalid-arch"}, }, }, OutputDir: filepath.Join(t.TempDir(), "artifacts"), }) if err == nil || len(result.Artifacts) != 1 { t.Fatalf("Build() result = %#v, error = %v; want one retained artifact", result, err) } if _, err := os.Stat(result.Artifacts[0].Path); err != nil { t.Fatalf("partial artifact was not retained: %v", err) } } func TestBuildReportsMissingMainPackage(t *testing.T) { repository := newBuildConsumer(t, "package main\nfunc main() {}\n") result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{ RepositoryDir: repository, Version: "v1.2.3", Spec: releasekit.BuildSpec{ MainPackage: "./cmd/missing", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}, }, OutputDir: filepath.Join(t.TempDir(), "artifacts"), }) if err == nil || !strings.Contains(err.Error(), "cmd/missing") || len(result.Artifacts) != 0 { t.Fatalf("Build() result = %#v, error = %v; want missing-package failure", result, err) } } func TestBuildReportsIncorrectVersionOutputAndRetainsBinary(t *testing.T) { repository := newBuildConsumer(t, `package main import ( "fmt" "os" ) func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("wrong") } } `) outputDir := filepath.Join(t.TempDir(), "artifacts") result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{ RepositoryDir: repository, Version: "v1.2.3", Spec: releasekit.BuildSpec{ MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}, }, OutputDir: outputDir, }) if err == nil || !strings.Contains(err.Error(), "native version output") || len(result.Artifacts) != 1 { t.Fatalf("Build() result = %#v, error = %v; want retained-binary smoke failure", result, err) } if _, err := os.Stat(result.Artifacts[0].Path); err != nil { t.Fatalf("built artifact was not retained after smoke failure: %v", err) } } func TestBuildCancellationReturnsCompletedArtifacts(t *testing.T) { repository := newBuildConsumer(t, "package main\nfunc main() {}\n") realGo, err := exec.LookPath("go") if err != nil { t.Fatal(err) } wrapperDir := t.TempDir() wrapper := filepath.Join(wrapperDir, "go") callCount := filepath.Join(wrapperDir, "build-count") blocked := filepath.Join(wrapperDir, "second-build-started") contents := "#!/bin/sh\n" + "for argument do\n" + " if [ \"$argument\" = build ]; then\n" + " count=0\n" + " if [ -f \"$RELEASEKIT_BUILD_COUNT\" ]; then count=$(cat \"$RELEASEKIT_BUILD_COUNT\"); fi\n" + " count=$((count + 1))\n" + " printf '%s\\n' \"$count\" > \"$RELEASEKIT_BUILD_COUNT\"\n" + " if [ \"$count\" -eq 2 ]; then : > \"$RELEASEKIT_BUILD_BLOCKED\"; exec sleep 30; fi\n" + " fi\n" + "done\n" + "exec \"$RELEASEKIT_BUILD_REAL_GO\" \"$@\"\n" if err := os.WriteFile(wrapper, []byte(contents), 0o755); err != nil { t.Fatal(err) } t.Setenv("PATH", wrapperDir+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("RELEASEKIT_BUILD_COUNT", callCount) t.Setenv("RELEASEKIT_BUILD_BLOCKED", blocked) t.Setenv("RELEASEKIT_BUILD_REAL_GO", realGo) secondTarget := releasekit.Target{OS: "darwin", Arch: "arm64"} if secondTarget == (releasekit.Target{OS: runtime.GOOS, Arch: runtime.GOARCH}) { secondTarget = releasekit.Target{OS: "linux", Arch: "amd64"} } ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct { result releasekit.BuildResult err error }, 1) go func() { result, err := releasekit.Build(ctx, releasekit.BuildRequest{ RepositoryDir: repository, Version: "v1.2.3", Spec: releasekit.BuildSpec{ MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}, secondTarget}, }, OutputDir: filepath.Join(t.TempDir(), "artifacts"), }) done <- struct { result releasekit.BuildResult err error }{result: result, err: err} }() ticker := time.NewTicker(10 * time.Millisecond) defer ticker.Stop() deadline := time.NewTimer(30 * time.Second) defer deadline.Stop() for { if _, err := os.Stat(blocked); err == nil { cancel() break } select { case outcome := <-done: cancel() t.Fatalf("Build() ended before cancellation: result=%#v error=%v", outcome.result, outcome.err) case <-deadline.C: cancel() outcome := <-done t.Fatalf("second build did not start: result=%#v error=%v", outcome.result, outcome.err) case <-ticker.C: } } outcome := <-done if !errors.Is(outcome.err, context.Canceled) || len(outcome.result.Artifacts) != 1 { t.Fatalf("canceled build result=%#v error=%v; want one retained artifact", outcome.result, outcome.err) } } func TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree(t *testing.T) { repository := newPublishFixture(t) prepareCandidateBuild(t, repository.work, `package main import ( "fmt" "os" "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" ) func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixture %s\n", buildinfo.Version()) } } `) var validations int candidate, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{ RepositoryDir: repository.work, Product: "Sample App", Version: "v1.2.3", Validate: func(context.Context, releasekit.Candidate) error { validations++ return nil }, Build: &releasekit.BuildSpec{ MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}, }, }) if err != nil { t.Fatalf("Check() error = %v", err) } if validations != 1 || candidate.Commit != gitText(t, repository.work, "rev-parse", "HEAD") { t.Fatalf("candidate = %#v, validation calls = %d", candidate, validations) } if got := gitText(t, repository.work, "status", "--porcelain"); got != "" { t.Fatalf("candidate build dirtied worktree: %s", got) } if paths, err := filepath.Glob(filepath.Join(filepath.Dir(repository.work), ".releasekit-candidate-build-*")); err != nil || len(paths) != 0 { t.Fatalf("temporary candidate output remains: %v, error=%v", paths, err) } } func TestPublishBuildFailurePreventsTagCreation(t *testing.T) { repository := newPublishFixture(t) prepareCandidateBuild(t, repository.work, "package main\nfunc main() {}\n") var validations int result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ RepositoryDir: repository.work, Product: "Sample App", Version: "v1.2.3", Validate: func(context.Context, releasekit.Candidate) error { validations++ return nil }, Build: &releasekit.BuildSpec{ MainPackage: "./cmd/missing", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}, }, }) if err == nil || !strings.Contains(err.Error(), "build candidate") || result.TagObject != "" { t.Fatalf("Publish() result=%#v error=%v; want build failure before tag creation", result, err) } if validations != 1 { t.Fatalf("validation calls = %d, want 1", validations) } if _, err := exec.Command("git", "--git-dir", repository.bare, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3").CombinedOutput(); err == nil { t.Fatal("build failure published the release tag") } if _, err := exec.Command("git", "-C", repository.work, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3").CombinedOutput(); err == nil { t.Fatal("build failure created a local release tag") } } func newBuildConsumer(t *testing.T, mainSource string) string { t.Helper() repository, err := os.MkdirTemp("", "releasekit-build-consumer-") if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.RemoveAll(repository) }) root, err := filepath.Abs(".") if err != nil { t.Fatal(err) } module := fmt.Sprintf("module example.com/buildfixture\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root)) writeBuildFixture(t, repository, "go.mod", module) writeBuildFixture(t, repository, "cmd/app/main.go", mainSource) buildGit(t, repository, "init", "--initial-branch=main") buildGit(t, repository, "config", "user.name", "ReleaseKit Build Test") buildGit(t, repository, "config", "user.email", "releasekit-build@example.test") buildGit(t, repository, "add", "--all") buildGit(t, repository, "commit", "-m", "build fixture") return repository } func prepareCandidateBuild(t *testing.T, repository, mainSource string) { t.Helper() root, err := filepath.Abs(".") if err != nil { t.Fatal(err) } module := fmt.Sprintf("module example.com/candidatebuild\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root)) writeFile(t, repository, "go.mod", module) writeFile(t, repository, ".gitignore", "dist/\n") writeFile(t, repository, "cmd/app/main.go", mainSource) gitRun(t, repository, "add", "--all") gitRun(t, repository, "commit", "-m", "add buildable candidate") gitRun(t, repository, "config", "remote.origin.mirror", "false") gitRun(t, repository, "push", "origin", "HEAD:refs/heads/main") } func writeBuildFixture(t *testing.T, directory, name, contents string) { t.Helper() path := filepath.Join(directory, filepath.FromSlash(name)) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { t.Fatal(err) } } func buildGit(t *testing.T, directory string, args ...string) string { t.Helper() command := exec.Command("git", append([]string{"-C", directory}, args...)...) output, err := command.CombinedOutput() if err != nil { t.Fatalf("git %v: %v\n%s", args, err, output) } return strings.TrimSpace(string(output)) } func TestBuildSourceEligibilitySurvivesLaterCheckoutCleanup(t *testing.T) { const source = `package main import("fmt";"os";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo") func main(){ if len(os.Args)>1 && os.Args[1]=="version" {fmt.Println("fixture",buildinfo.Version())} } ` for _, mode := range []string{"clean", "dirty before build", "changed during build"} { t.Run(mode, func(t *testing.T) { repository := newBuildConsumer(t, source) writeBuildFixture(t, repository, "docs/releases/v1.2.3.md", "# Fixture v1.2.3\n") buildGit(t, repository, "add", "--all") buildGit(t, repository, "commit", "-m", "release note") buildGit(t, repository, "tag", "v1.2.3") mutate := func() { writeBuildFixture(t, repository, "cmd/app/main.go", source+"\n// local change\n") } if mode == "dirty before build" { mutate() } var writer *sourceChangeWriter request := releasekit.BuildRequest{RepositoryDir: repository, Version: "v1.2.3", Spec: releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}}, OutputDir: t.TempDir()} if mode == "changed during build" { writer = &sourceChangeWriter{change: mutate} request.Output = writer } result, err := releasekit.Build(context.Background(), request) if err != nil { t.Fatal(err) } if result.SourceVerified != (mode == "clean") { t.Fatalf("source eligibility=%t for %s", result.SourceVerified, mode) } if mode == "clean" { return } buildGit(t, repository, "restore", "cmd/app/main.go") delivered, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3", Build: &result}) if err == nil || !strings.Contains(err.Error(), "build source") || delivered.State != releasekit.DeliveryNotAttempted { t.Fatalf("dirty source delivered: %#v %v", delivered, err) } }) } } type sourceChangeWriter struct{ change func() } func (w *sourceChangeWriter) Write(p []byte) (int, error) { if w.change != nil { w.change() w.change = nil } return len(p), nil }