Preserve native exit codes in the release wrapper

This commit is contained in:
2026-10-06 01:03:18 +00:00
parent 6b777dd861
commit 685cd8a64d
5 changed files with 118 additions and 3 deletions

View File

@@ -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)
}
})
}
}

View File

@@ -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:

View File

@@ -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.

View File

@@ -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.

View File

@@ -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"