diff --git a/cmd/releasekit/release_wrapper_test.go b/cmd/releasekit/release_wrapper_test.go index 46b697e..e1c98c0 100644 --- a/cmd/releasekit/release_wrapper_test.go +++ b/cmd/releasekit/release_wrapper_test.go @@ -12,9 +12,6 @@ import ( ) func TestReleaseWrapperPreservesNativeExitStatuses(t *testing.T) { - if os.Getenv("RELEASEKIT_SKIP_WRAPPER_TEST") == "1" { - t.Skip("release wrapper is already running candidate validation") - } if runtime.GOOS == "windows" { t.Skip("POSIX release entry point") } @@ -25,56 +22,53 @@ func TestReleaseWrapperPreservesNativeExitStatuses(t *testing.T) { if err != nil { t.Fatal(err) } - controller, err := os.ReadFile(filepath.Join(root, "cmd/releasekit/main.go")) - if err != nil { - t.Fatal(err) - } - updatedSources := map[string][]byte{} - for _, path := range []string{ - "check.go", "publish.go", "delivery.go", "internal/candidate/check.go", "internal/delivery/delivery.go", "internal/publish/publish.go", - "build_test.go", "publish_test.go", "internal/candidate/check_test.go", - "internal/gitea/upload.go", "internal/gitea/upload_test.go", - "delivery_test.go", "cmd/releasekit/main_test.go", "cmd/releasekit/release_wrapper_test.go", - "cmd/releasekit/internal/cli/config_test.go", "examples/releasekit.json", - "cmd/releasekit/internal/cli/config.go", - } { - contents, err := os.ReadFile(filepath.Join(root, path)) - if err != nil { - t.Fatal(err) - } - updatedSources[path] = contents - } - wrapper, err := os.ReadFile(filepath.Join(root, "scripts/release")) - if err != nil { - t.Fatal(err) - } + sources := releaseWrapperSources(t, root) for _, mode := range []struct { name string status int version string }{{"success", 0, "v1.2.3"}, {"invalid", 2, "invalid"}, {"rejected", 1, "v1.2.3"}, {"unknown", 3, "v1.2.3"}} { t.Run(mode.name, func(t *testing.T) { - t.Setenv("RELEASEKIT_SKIP_WRAPPER_TEST", "1") repository := filepath.Join(t.TempDir(), "source") - clone := exec.Command(realGit, "clone", "--local", "--no-hardlinks", root, repository) - if output, err := clone.CombinedOutput(); err != nil { - t.Fatalf("clone: %v %s", err, output) + for path, contents := range sources { + name := filepath.Join(repository, path) + if err := os.MkdirAll(filepath.Dir(name), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(name, contents, 0600); err != nil { + t.Fatal(err) + } } + if err := os.Chmod(filepath.Join(repository, "scripts/release"), 0755); err != nil { + t.Fatal(err) + } + cliGitRun(t, repository, "init", "--initial-branch=main") cliGitRun(t, repository, "config", "user.name", "ReleaseKit Test") cliGitRun(t, repository, "config", "user.email", "fixture@example.test") cliGitRun(t, repository, "config", "commit.gpgsign", "false") - if err := os.WriteFile(filepath.Join(repository, "scripts/release"), wrapper, 0755); err != nil { + validationLog := filepath.Join(t.TempDir(), "validation.log") + t.Setenv("RELEASEKIT_TEST_CANDIDATE_LOG", validationLog) + candidateTest := `package releasekit + +import ( + "os" + "testing" +) + +func TestReleaseCandidate(t *testing.T) { + f, err := os.OpenFile(os.Getenv("RELEASEKIT_TEST_CANDIDATE_LOG"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if _, err := f.WriteString("validated\n"); err != nil { + t.Fatal(err) + } +} +` + if err := os.WriteFile(filepath.Join(repository, "candidate_test.go"), []byte(candidateTest), 0600); err != nil { t.Fatal(err) } - // Exercise the current controller, including changes awaiting this commit. - if err := os.WriteFile(filepath.Join(repository, "cmd/releasekit/main.go"), controller, 0600); err != nil { - t.Fatal(err) - } - for path, contents := range updatedSources { - if err := os.WriteFile(filepath.Join(repository, filepath.FromSlash(path)), contents, 0600); err != nil { - t.Fatalf("write current source %s: %v", path, err) - } - } if err := os.MkdirAll(filepath.Join(repository, "docs/releases"), 0700); err != nil { t.Fatal(err) } @@ -99,7 +93,7 @@ func TestReleaseWrapperPreservesNativeExitStatuses(t *testing.T) { cliGitRun(t, repository, "commit", "-m", "prepare wrapper fixture") remote := filepath.Join(t.TempDir(), "origin.git") cliGitRun(t, repository, "init", "--bare", remote) - cliGitRun(t, repository, "remote", "set-url", "origin", remote) + cliGitRun(t, repository, "remote", "add", "origin", remote) cliGitRun(t, repository, "push", "origin", "HEAD:refs/heads/main") fetch := filepath.Join(t.TempDir(), "fetch.git") cliGitRun(t, repository, "init", "--bare", fetch) @@ -138,7 +132,6 @@ exec "$RELEASEKIT_TEST_REAL_GIT" "$@" t.Setenv("RELEASEKIT_TEST_PUSH_MARKER", marker) t.Setenv("RELEASEKIT_TEST_MOVED_ENDPOINT", fetch) t.Setenv("RELEASEKIT_TEST_ARM_GIT_SHIM", armMarker) - t.Setenv("RELEASEKIT_SKIP_WRAPPER_TEST", "1") } command := exec.Command("./scripts/release", mode.version) command.Dir = repository @@ -154,6 +147,14 @@ exec "$RELEASEKIT_TEST_REAL_GIT" "$@" if status != mode.status { t.Fatalf("status=%d want=%d output=%s", status, mode.status, output) } + validated, readErr := os.ReadFile(validationLog) + if mode.name == "invalid" { + if !os.IsNotExist(readErr) { + t.Fatalf("invalid input ran candidate validation: %q, %v", validated, readErr) + } + } else if readErr != nil || string(validated) != "validated\n" { + t.Fatalf("candidate suite must run once: %q, %v", validated, readErr) + } if mode.name == "unknown" { if !strings.Contains(string(output), fmt.Sprintf("endpoint=%q", remote)) { t.Fatalf("captured endpoint missing: %s", output) @@ -190,3 +191,30 @@ exec "$RELEASEKIT_TEST_REAL_GIT" "$@" }) } } + +// Capture current, nonignored source once, including worktree changes. The +// candidate gets one small test instead of the project's full test suite. +func releaseWrapperSources(t *testing.T, root string) map[string][]byte { + t.Helper() + list := exec.Command("git", "ls-files", "-z", "--cached", "--others", "--exclude-standard") + list.Dir = root + paths, err := list.Output() + if err != nil { + t.Fatal(err) + } + sources := make(map[string][]byte) + for _, path := range strings.Split(string(paths), "\x00") { + if path == "" || strings.HasSuffix(path, "_test.go") { + continue + } + contents, err := os.ReadFile(filepath.Join(root, path)) + if os.IsNotExist(err) { + continue // Tracked files removed from the current worktree. + } + if err != nil { + t.Fatal(err) + } + sources[path] = contents + } + return sources +} diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index db543af..766c096 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -2,7 +2,7 @@ Date: 2026-10-06. Reviewed source: `55333b0`. -Status: R1 resolved; R2 requires remediation. +Status: Complete. R1 and R2 are resolved. ## Scope and assessment @@ -76,7 +76,7 @@ fix. Go-environment, candidate-validation, and build regression checks pass. ## R2 — Medium: Wrapper regression testing repeats the project suite inside itself -Status: Open. +Status: Resolved. Category: Test maintainability and development cost. Introduced by: `3e9b5c1`. @@ -114,10 +114,33 @@ intended object with separate fetch and push repositories. The outer default suite supplies the project's broader behavioral evidence once. Use test scope and command observations for verification rather than a wall-clock gate. +**Resolution (2026-10-07):** Wrapper fixtures capture the current nonignored +source, including tracked and untracked worktree changes, through one inventory. +They exclude the project's test files and add one small candidate test with an +external validation log. The manually maintained overlay and recursion skip +flag are removed. The real source-bootstrap wrapper preserves statuses 0, 1, +2, and 3; the validation log confirms one suite execution for each valid +candidate and none for invalid input. Captured-endpoint recovery and URL-rewrite +guards still pass with separate fetch and push repositories. + +The focused wrapper regression passes in 7.964 seconds on this host. The final +uncached offline suite passes, with the CLI package taking 22.842 seconds. +These measurements record the observed reduction in duplicated work and add +no duration requirement. + +## Completion verification + +Both fixes are implemented with focused regression evidence. The complete +offline suite and compiled API/dependency checker pass with Go 1.26.8 on +Linux/amd64 using the commands below. Documentation link and whitespace checks +pass. Each remediation unit is committed separately with a plain English +message. Production changes are confined to Go configuration discovery; the +wrapper change preserves the existing publication and recovery contracts. + ## Validation and remaining scope -The following checks passed on the reviewed source with Go 1.26.8 on -Linux/amd64: +The following checks passed on the reviewed source and after remediation with +Go 1.26.8 on Linux/amd64: ```sh GOWORK=off GOPROXY=off GONOPROXY=none GOSUMDB=off GOTOOLCHAIN=local \