From 28a4144b49a24bcbd859c2606ed4d3c8259ec55a Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 1 Nov 2026 21:14:34 +0000 Subject: [PATCH] Preserve cancellation during build source inspection --- build_source_cancellation_test.go | 137 ++++++++++++++++++++++++++++++ docs/roadmap/implementation.md | 4 +- internal/build/build.go | 3 + 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 build_source_cancellation_test.go diff --git a/build_source_cancellation_test.go b/build_source_cancellation_test.go new file mode 100644 index 0000000..6418887 --- /dev/null +++ b/build_source_cancellation_test.go @@ -0,0 +1,137 @@ +package releasekit_test + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" + + releasekit "go.maximumdirect.net/releasekit" +) + +func TestBuildSourceInspectionCancellation(t *testing.T) { + const source = `package main +import("fmt";"os";"go.maximumdirect.net/releasekit/buildinfo") +func main(){ if len(os.Args)>1 && os.Args[1]=="version" {fmt.Println("fixture",buildinfo.Version())} } +` + for _, tc := range []struct { + name string + status int + deadline bool + binary bool + }{ + {"initial", 1, false, false}, {"deadline", 1, true, false}, {"final", 2, false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + repo := newBuildConsumer(t, source) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + bin := t.TempDir() + count := filepath.Join(t.TempDir(), "count") + ready := filepath.Join(t.TempDir(), "ready") + script := `#!/bin/sh +for arg in "$@"; do + if [ "$arg" = status ]; then + count=0 + if [ -f "$INSPECT_COUNT" ]; then count=$(/bin/cat "$INSPECT_COUNT"); fi + count=$((count+1)); printf '%s' "$count" > "$INSPECT_COUNT" + if [ "$count" = "$INSPECT_BLOCK" ]; then : > "$INSPECT_READY"; exec /bin/sleep 60; fi + fi +done +exec "$INSPECT_GIT" "$@" +` + if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("INSPECT_GIT", realGit) + t.Setenv("INSPECT_COUNT", count) + t.Setenv("INSPECT_READY", ready) + t.Setenv("INSPECT_BLOCK", fmt.Sprint(tc.status)) + var ctx context.Context + var cancel context.CancelFunc + if tc.deadline { + ctx, cancel = context.WithTimeout(context.Background(), 3*time.Second) + } else { + ctx, cancel = context.WithCancel(context.Background()) + } + defer cancel() + request := releasekit.BuildRequest{RepositoryDir: repo, Version: "v1.2.3", OutputDir: t.TempDir()} + if tc.binary { + request.Spec = releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}} + } + type outcome struct { + result releasekit.BuildResult + err error + } + done := make(chan outcome, 1) + go func() { result, err := releasekit.Build(ctx, request); done <- outcome{result, err} }() + timer := time.NewTimer(45 * time.Second) + defer timer.Stop() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + waiting := true + for waiting { + if _, err := os.Stat(ready); err == nil { + waiting = false + break + } + select { + case result := <-done: + t.Fatalf("build ended before inspection: %#v, %v", result.result, result.err) + case <-timer.C: + cancel() + result := <-done + t.Fatalf("inspection did not start: %#v, %v", result.result, result.err) + case <-ticker.C: + } + } + if !tc.deadline { + cancel() + } + result := <-done + want := context.Canceled + if tc.deadline { + want = context.DeadlineExceeded + } + if !errors.Is(result.err, want) || result.result.SourceVerified { + t.Fatalf("result = %#v, error = %v", result.result, result.err) + } + if tc.binary { + if len(result.result.Artifacts) != 1 || result.result.Artifacts[0].SHA256 == "" { + t.Fatalf("completed artifact missing: %#v", result.result.Artifacts) + } + if _, err := os.Stat(result.result.Artifacts[0].Path); err != nil { + t.Fatal(err) + } + } + }) + } +} + +func TestBuildSourceInspectionPreservesGitFailure(t *testing.T) { + repo := newBuildConsumer(t, "package main\nfunc main() {}\n") + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + bin := t.TempDir() + script := "#!/bin/sh\nfor arg do if [ \"$arg\" = status ]; then exit 37; fi; done\nexec \"$INSPECT_GIT\" \"$@\"\n" + if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("INSPECT_GIT", realGit) + result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{RepositoryDir: repo, Version: "v1.2.3"}) + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 37 || result.SourceVerified { + t.Fatalf("result = %#v, error = %v", result, err) + } +} diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 2fa26fd..8518ff9 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -313,7 +313,9 @@ candidate regression tests. ## Stage 7: Preserve build source-inspection cancellation causes -**Status:** pending. +**Status:** complete. Synchronized initial, final, and deadline interruption +checks, ordinary Git failure coverage, and existing build and managed-delivery +cancellation tests pass; the cancellation paths also pass with `-race`. **Files:** `internal/build/build.go` and focused public tests in `build_test.go` or a companion root test file. diff --git a/internal/build/build.go b/internal/build/build.go index 2ecedaa..af6f328 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -257,6 +257,9 @@ func cleanSource(ctx context.Context, repositoryDir string) (bool, error) { command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "status", "--porcelain", "--untracked-files=all") output, err := command.Output() if err != nil { + if ctx.Err() != nil { + return false, fmt.Errorf("inspect build source: %w", ctx.Err()) + } return false, fmt.Errorf("inspect build source: %w", err) } return len(output) == 0, nil