From f9ec14313055b87410301538aa630e3ac11ac6b6 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 8 Oct 2026 16:41:23 +0000 Subject: [PATCH] Test the release launcher behavior --- docs/roadmap/implementation.md | 9 +- scripts/release_test.go | 405 +++++++++++++++++++++++++++++++++ 2 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 scripts/release_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index c54a7db..a359463 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -240,7 +240,7 @@ publication to the module-selected native CLI. Commit the stage. ## Stage 3: cover launcher behavior with fast offline tests -**Status:** pending. **Prerequisite:** Stage 2. +**Status:** complete. **Prerequisite:** Stage 2. **Files:** `scripts/release_test.go`, plus launcher corrections found by the tests and stage evidence in this plan. Use a tests-only external Go package such @@ -278,6 +278,13 @@ as `release_test`; add no production Go file or exported helper API. the default offline suite with the Stage 1 environment. None of these tests compiles a real publisher or contacts a service. +Observed 2026-10-08: `gofmt -d scripts/release_test.go` reported no changes. +`go test -mod=readonly -count=1 ./scripts` passed. The offline default +`go test -mod=readonly -count=1 ./...` passed for the library, internal adapter, +examples, and scripts package. The tests use only temporary fixtures and a +controlled Go executable; they do not build the real publisher or contact a +service. + **Completion:** the small launcher has regression coverage for its own parsing, path, build, and delegation responsibilities. Commit the stage. diff --git a/scripts/release_test.go b/scripts/release_test.go new file mode 100644 index 0000000..ad55682 --- /dev/null +++ b/scripts/release_test.go @@ -0,0 +1,405 @@ +package release_test + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" +) + +const ( + releasePackage = "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit" + releaseVersion = "v1.2.3-rc.1+local" +) + +func TestReleaseLauncherArgumentHandling(t *testing.T) { + shell := requirePOSIXShell(t) + for _, test := range []struct { + name string + args []string + }{ + {name: "no arguments"}, + {name: "multiple arguments", args: []string{"v1.2.3", "extra"}}, + } { + t.Run(test.name, func(t *testing.T) { + fixture := newFixture(t) + result := runLauncher(t, shell, fixture, test.args, nil) + + if result.status != 2 { + t.Fatalf("status = %d, want 2; stderr: %q", result.status, result.stderr) + } + if result.stdout != "" { + t.Fatalf("stdout = %q, want empty", result.stdout) + } + if result.stderr != "usage: ./scripts/release VERSION\n" { + t.Fatalf("stderr = %q, want usage", result.stderr) + } + if _, err := os.Stat(fixture.goLog); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Go invocation log exists or could not be checked: %v", err) + } + }) + } +} + +func TestReleaseLauncherDelegatesPublication(t *testing.T) { + shell := requirePOSIXShell(t) + for _, status := range []int{0, 1, 2, 3} { + t.Run(fmt.Sprintf("exit status %d", status), func(t *testing.T) { + fixture := newFixture(t) + result := runLauncher(t, shell, fixture, []string{releaseVersion}, map[string]string{ + "CDPATH": "/tmp", + "GOOS": "windows", + "GOARCH": "arm64", + "PUBLISH_EXIT": strconv.Itoa(status), + "PUBLISH_STDOUT": "publisher stdout", + "PUBLISH_STDERR": "publisher stderr", + }) + + if result.status != status { + t.Fatalf("status = %d, want %d; stderr: %q", result.status, status, result.stderr) + } + if result.stdout != "publisher stdout\n" { + t.Fatalf("stdout = %q, want publisher output", result.stdout) + } + if result.stderr != "publisher stderr\n" { + t.Fatalf("stderr = %q, want publisher error output", result.stderr) + } + + wantArgs := []string{ + "publish", + "--repo", fixture.repoRoot, + "--config", filepath.Join(fixture.repoRoot, "releasekit.json"), + releaseVersion, + } + if got := readLines(t, fixture.publishLog); !equalStrings(got, wantArgs) { + t.Fatalf("publisher args = %#v, want %#v", got, wantArgs) + } + assertNativeBootstrap(t, fixture) + }) + } +} + +func TestReleaseLauncherStopsAfterBootstrapFailure(t *testing.T) { + shell := requirePOSIXShell(t) + fixture := newFixture(t) + result := runLauncher(t, shell, fixture, []string{releaseVersion}, map[string]string{ + "FAKE_GO_BUILD_EXIT": "23", + }) + + if result.status != 23 { + t.Fatalf("status = %d, want build status 23; stderr: %q", result.status, result.stderr) + } + if _, err := os.Stat(fixture.publishLog); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("publisher was invoked or its log could not be checked: %v", err) + } + if !hasGoBuildCall(t, fixture) { + t.Fatal("Go build invocation was not recorded") + } +} + +type launcherResult struct { + status int + stdout string + stderr string +} + +type fixture struct { + repoRoot string + launcher string + outside string + fakeBin string + goLog string + publishLog string + publisher string +} + +func newFixture(t *testing.T) fixture { + t.Helper() + if runtime.GOOS == "windows" { + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("POSIX shell is unavailable") + } + } + + _, sourceFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("could not locate test source") + } + repo := filepath.Dir(filepath.Dir(sourceFile)) + root := t.TempDir() + repoRoot := filepath.Join(root, "CommandKit fixture with spaces") + outside := filepath.Join(root, "outside working directory") + fakeBin := filepath.Join(root, "controlled tools") + for _, dir := range []string{filepath.Join(repoRoot, "scripts"), outside, fakeBin} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + copyFile(t, filepath.Join(repo, "scripts", "release"), filepath.Join(repoRoot, "scripts", "release"), 0o755) + copyFile(t, filepath.Join(repo, "releasekit.json"), filepath.Join(repoRoot, "releasekit.json"), 0o644) + + goLog := filepath.Join(root, "go calls.log") + publishLog := filepath.Join(root, "publisher arguments.log") + publisher := filepath.Join(root, "fake publisher") + writeFile(t, publisher, fakePublisher, 0o644) + writeFile(t, filepath.Join(fakeBin, "go"), fakeGo, 0o755) + + return fixture{ + repoRoot: repoRoot, + launcher: filepath.Join(repoRoot, "scripts", "release"), + outside: outside, + fakeBin: fakeBin, + goLog: goLog, + publishLog: publishLog, + publisher: publisher, + } +} + +func runLauncher(t *testing.T, shell string, fixture fixture, args []string, overrides map[string]string) launcherResult { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, shell, append([]string{fixture.launcher}, args...)...) + cmd.Dir = fixture.outside + cmd.Env = fixtureEnvironment(fixture, overrides) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + + result := launcherResult{stdout: stdout.String(), stderr: stderr.String()} + if err == nil { + return result + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + result.status = exitErr.ExitCode() + return result + } + t.Fatalf("run launcher: %v", err) + return launcherResult{} +} + +func fixtureEnvironment(fixture fixture, overrides map[string]string) []string { + values := map[string]string{ + "PATH": fixture.fakeBin + string(os.PathListSeparator) + os.Getenv("PATH"), + "GO_LOG": fixture.goLog, + "PUBLISH_ARGS_LOG": fixture.publishLog, + "FAKE_GOHOSTOS": "linux", + "FAKE_GOHOSTARCH": "amd64", + "FAKE_PUBLISHER_SOURCE": fixture.publisher, + "FAKE_GO_BUILD_EXIT": "0", + "PUBLISH_EXIT": "0", + "PUBLISH_STDOUT": "publisher stdout", + "PUBLISH_STDERR": "publisher stderr", + } + for key, value := range overrides { + values[key] = value + } + var env []string + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + if _, overridden := values[key]; !overridden { + env = append(env, entry) + } + } + for key, value := range values { + env = append(env, key+"="+value) + } + return env +} + +func assertNativeBootstrap(t *testing.T, fixture fixture) { + t.Helper() + calls := readGoCalls(t, fixture.goLog) + var foundOS, foundArch, foundBuild bool + for _, call := range calls { + if call.cwd != fixture.repoRoot { + t.Fatalf("Go ran in %q, want repository root %q", call.cwd, fixture.repoRoot) + } + if call.args[0] == "env" { + if call.gowork != "off" || call.toolchain != "local" { + t.Fatalf("Go env settings = GOWORK=%q GOTOOLCHAIN=%q", call.gowork, call.toolchain) + } + switch strings.Join(call.args, " ") { + case "env GOHOSTOS": + foundOS = true + case "env GOHOSTARCH": + foundArch = true + } + } + if call.args[0] == "build" { + foundBuild = true + want := []string{ + "build", "-mod=readonly", "-o", + filepath.Join(fixture.repoRoot, ".releasekit", "bin", "releasekit"), + releasePackage, + } + if !equalStrings(call.args, want) { + t.Fatalf("build args = %#v, want %#v", call.args, want) + } + if call.goos != "linux" || call.goarch != "amd64" { + t.Fatalf("build target = %s/%s, want linux/amd64", call.goos, call.goarch) + } + if call.gowork != "off" || call.toolchain != "local" { + t.Fatalf("build settings = GOWORK=%q GOTOOLCHAIN=%q", call.gowork, call.toolchain) + } + } + } + if !foundOS || !foundArch || !foundBuild { + t.Fatalf("host queries and build not all observed: os=%t arch=%t build=%t", foundOS, foundArch, foundBuild) + } +} + +type goCall struct { + cwd string + goos string + goarch string + gowork string + toolchain string + args []string +} + +func readGoCalls(t *testing.T, path string) []goCall { + t.Helper() + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + + var calls []goCall + scanner := bufio.NewScanner(file) + for scanner.Scan() { + fields := strings.Split(scanner.Text(), "\t") + if len(fields) < 6 { + t.Fatalf("malformed Go call record: %q", scanner.Text()) + } + argc, err := strconv.Atoi(fields[5]) + if err != nil || len(fields) != 6+argc { + t.Fatalf("malformed Go arguments in record: %q", scanner.Text()) + } + calls = append(calls, goCall{ + cwd: fields[0], + goos: fields[1], + goarch: fields[2], + gowork: fields[3], + toolchain: fields[4], + args: fields[6:], + }) + } + if err := scanner.Err(); err != nil { + t.Fatal(err) + } + return calls +} + +func hasGoBuildCall(t *testing.T, fixture fixture) bool { + t.Helper() + for _, call := range readGoCalls(t, fixture.goLog) { + if len(call.args) > 0 && call.args[0] == "build" { + return true + } + } + return false +} + +func readLines(t *testing.T, path string) []string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") +} + +func copyFile(t *testing.T, source, destination string, mode os.FileMode) { + t.Helper() + data, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + writeFile(t, destination, string(data), mode) +} + +func writeFile(t *testing.T, path, data string, mode os.FileMode) { + t.Helper() + if err := os.WriteFile(path, []byte(data), mode); err != nil { + t.Fatal(err) + } +} + +func requirePOSIXShell(t *testing.T) string { + t.Helper() + shell, err := exec.LookPath("sh") + if err != nil { + t.Skip("POSIX shell is unavailable") + } + return shell +} + +func equalStrings(got, want []string) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} + +const fakeGo = `#!/bin/sh +set -eu +printf '%s\t%s\t%s\t%s\t%s\t%s' "$PWD" "${GOOS-}" "${GOARCH-}" "${GOWORK-}" "${GOTOOLCHAIN-}" "$#" >> "$GO_LOG" +for arg do printf '\t%s' "$arg" >> "$GO_LOG"; done +printf '\n' >> "$GO_LOG" + +if [ "$1" = env ]; then + case "$2" in + GOHOSTOS) printf '%s\n' "$FAKE_GOHOSTOS" ;; + GOHOSTARCH) printf '%s\n' "$FAKE_GOHOSTARCH" ;; + *) exit 90 ;; + esac + exit 0 +fi + +if [ "$1" = build ]; then + if [ "$FAKE_GO_BUILD_EXIT" -ne 0 ]; then exit "$FAKE_GO_BUILD_EXIT"; fi + output= + expect_output=0 + for arg do + if [ "$expect_output" -eq 1 ]; then + output=$arg + expect_output=0 + continue + fi + if [ "$arg" = -o ]; then expect_output=1; fi + done + [ -n "$output" ] || exit 91 + cat "$FAKE_PUBLISHER_SOURCE" > "$output" + chmod +x "$output" + exit 0 +fi + +exit 92 +` + +const fakePublisher = `#!/bin/sh +set -eu +printf '%s\n' "$@" > "$PUBLISH_ARGS_LOG" +printf '%s\n' "$PUBLISH_STDOUT" +printf '%s\n' "$PUBLISH_STDERR" >&2 +exit "$PUBLISH_EXIT" +`