From 685cd8a64d98461ac03ab1e6f1d3fbae62c100fe Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 6 Oct 2026 01:03:18 +0000 Subject: [PATCH] Preserve native exit codes in the release wrapper --- cmd/releasekit/release_wrapper_test.go | 100 +++++++++++++++++++++++++ docs/cli.md | 5 +- docs/release.md | 4 +- docs/roadmap/remediation.md | 8 ++ scripts/release | 4 +- 5 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 cmd/releasekit/release_wrapper_test.go diff --git a/cmd/releasekit/release_wrapper_test.go b/cmd/releasekit/release_wrapper_test.go new file mode 100644 index 0000000..f4276c7 --- /dev/null +++ b/cmd/releasekit/release_wrapper_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" +) + +func TestReleaseWrapperPreservesNativeExitStatuses(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX release entry point") + } + _, source, _, _ := runtime.Caller(0) + root := filepath.Clean(filepath.Join(filepath.Dir(source), "../..")) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + wrapper, err := os.ReadFile(filepath.Join(root, "scripts/release")) + if err != nil { + t.Fatal(err) + } + 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) { + 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) + } + 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 { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(repository, "docs/releases"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, "docs/releases/v1.2.3.md"), []byte("# ReleaseKit v1.2.3\n"), 0600); err != nil { + t.Fatal(err) + } + profile := map[string]any{"product": "ReleaseKit", "checks": []map[string]any{{"args": []string{"sh", "-c", "true"}}}} + content, _ := json.Marshal(profile) + if err := os.WriteFile(filepath.Join(repository, "releasekit.json"), content, 0600); err != nil { + t.Fatal(err) + } + cliGitRun(t, repository, "add", "--all") + 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, "push", "origin", "HEAD:refs/heads/main") + if mode.name == "rejected" { + if err := os.WriteFile(filepath.Join(remote, "hooks/pre-receive"), []byte("#!/bin/sh\nexit 1\n"), 0755); err != nil { + t.Fatal(err) + } + } + if mode.name == "unknown" { + bin := t.TempDir() + marker := filepath.Join(bin, "push-started") + shim := `#!/bin/sh +for arg do + if [ "$arg" = push ]; then : > "$RELEASEKIT_TEST_PUSH_MARKER"; exit 1; fi +done +if [ -f "$RELEASEKIT_TEST_PUSH_MARKER" ]; then + for arg do if [ "$arg" = ls-remote ]; then exit 128; fi; done +fi +exec "$RELEASEKIT_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(filepath.Join(bin, "git"), []byte(shim), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("RELEASEKIT_TEST_REAL_GIT", realGit) + t.Setenv("RELEASEKIT_TEST_PUSH_MARKER", marker) + } + command := exec.Command("./scripts/release", mode.version) + command.Dir = repository + output, err := command.CombinedOutput() + status := 0 + if err != nil { + exit, ok := err.(*exec.ExitError) + if !ok { + t.Fatal(err) + } + status = exit.ExitCode() + } + if status != mode.status { + t.Fatalf("status=%d want=%d output=%s", status, mode.status, output) + } + }) + } +} diff --git a/docs/cli.md b/docs/cli.md index 4cc393d..1263ec1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,9 @@ # Command line -ReleaseKit currently provides five commands. +ReleaseKit currently provides five commands. The exit statuses below belong +to the native executable. `go run` examples are convenient for source execution, +but the Go driver maps a nonzero child exit status to 1. Use the built executable +or `./scripts/release` for publication when distinct statuses are needed. Print the executable version: diff --git a/docs/release.md b/docs/release.md index f765532..28138d1 100644 --- a/docs/release.md +++ b/docs/release.md @@ -64,7 +64,9 @@ on success. Exit status 0 means publication is confirmed; status 1 means a known failure or conflict; status 2 means invalid input or profile; status 3 means the push outcome remains unknown and requires resolution. -The CLI runs from the source tree being published, so this command also works +The wrapper builds the native CLI into ignored `.releasekit/bin`, then invokes +it directly to preserve its exit status. The CLI comes from the source tree +being published, so this command also works for ReleaseKit's first release before any released ReleaseKit executable exists. It uses the local Go toolchain. Preparation must finish before the offline validation begins. diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index f86485b..c6df3ec 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -291,6 +291,8 @@ authentication. The Gitea suite passes. ### R8 — Medium: The release wrapper collapses documented exit codes +Status: Resolved. + Category: Executable integration and documentation correctness. **Evidence:** [scripts/release](../../scripts/release), line 9, executes @@ -313,6 +315,12 @@ distribution, or external preparation gate. unknown-publication status 3, as well as ordinary success and known failure. Use local fixtures rather than a real publication endpoint. +**Resolution:** The source wrapper builds its native executable into ignored +`.releasekit/bin` and invokes it directly. Documentation distinguishes native +exit codes from `go run` driver behavior. Local-repository tests execute the +actual wrapper and verify success 0, rejected publication 1, invalid input 2, +and unresolved publication 3. The focused wrapper test passes. + ### R9 — High: Manual recovery inspects the fetch endpoint Category: Release recovery correctness and documentation gap. diff --git a/scripts/release b/scripts/release index 6d469b2..7286be1 100755 --- a/scripts/release +++ b/scripts/release @@ -6,4 +6,6 @@ if [ "$#" -ne 1 ]; then exit 2 fi -GOTOOLCHAIN=local exec go run ./cmd/releasekit publish "$1" +mkdir -p .releasekit/bin +GOTOOLCHAIN=local go build -o .releasekit/bin/releasekit ./cmd/releasekit +exec .releasekit/bin/releasekit publish "$1"