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