package release_test import ( "bufio" "bytes" "context" "errors" "flag" "fmt" "io" "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) } if err := checkPublication(readLines(t, fixture.publishLog), fixture.repoRoot); err != nil { t.Fatal(err) } assertNativeBootstrap(t, fixture) }) } } func TestReleaseLauncherAcceptsEquivalentCommands(t *testing.T) { shell := requirePOSIXShell(t) fixture := newFixture(t) // Change only the fixture script: the production launcher need not be refactored. writeFile(t, fixture.launcher, `#!/bin/sh set -eu version=$1 root=$(CDPATH= cd "$(dirname "$0")/.." && pwd) CDPATH= cd "$root" mkdir -p .releasekit/bin set -- $(GOWORK=off GOTOOLCHAIN=local go env GOHOSTOS GOHOSTARCH) GOOS="$1" GOARCH="$2" GOWORK=off GOTOOLCHAIN=local go build \ -o=.releasekit/bin/releasekit -mod readonly \ gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit exec .releasekit/bin/releasekit publish \ --config="$root/releasekit.json" --repo "$root" "$version" `, 0o755) result := runLauncher(t, shell, fixture, []string{releaseVersion}, nil) if result.status != 0 { t.Fatalf("equivalent launcher failed: status=%d stderr=%q", result.status, result.stderr) } if err := checkPublication(readLines(t, fixture.publishLog), fixture.repoRoot); err != nil { t.Fatal(err) } assertNativeBootstrap(t, fixture) } func TestLauncherChecksRejectContractViolations(t *testing.T) { root := filepath.Join(t.TempDir(), "repository with spaces") output := filepath.Join(root, ".releasekit", "bin", "releasekit") for _, test := range []struct { name string change func(*goCall) }{ {"wrong target", func(c *goCall) { c.goarch = "wrong" }}, {"workspace enabled", func(c *goCall) { c.gowork = "auto" }}, {"toolchain download", func(c *goCall) { c.toolchain = "auto" }}, {"wrong directory", func(c *goCall) { c.cwd = filepath.Dir(root) }}, {"missing readonly", func(c *goCall) { c.args = []string{"build", "-o", output, releasePackage} }}, {"wrong output", func(c *goCall) { c.args[3] = filepath.Join(root, "wrong") }}, {"independent package pin", func(c *goCall) { c.args[4] += "@v0.2.0" }}, } { t.Run(test.name, func(t *testing.T) { call := goCall{cwd: root, goos: "linux", goarch: "amd64", gowork: "off", toolchain: "local", args: []string{"build", "-mod=readonly", "-o", output, releasePackage}} test.change(&call) if err := checkNativeBootstrap([]goCall{call}, root, "linux", "amd64"); err == nil { t.Fatal("invalid bootstrap accepted") } }) } for _, args := range [][]string{ {"check", "--repo", root, "--config", filepath.Join(root, "releasekit.json"), releaseVersion}, {"publish", "--repo", "wrong", "--config", filepath.Join(root, "releasekit.json"), releaseVersion}, {"publish", "--repo", root, "--config", "wrong", releaseVersion}, {"publish", "--repo", root, "--config", filepath.Join(root, "releasekit.json"), "wrong-version"}, {"publish", releaseVersion, "--repo", root, "--config", filepath.Join(root, "releasekit.json")}, } { if err := checkPublication(args, root); err == nil { t.Errorf("invalid publication accepted: %q", args) } } } 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() if err := checkNativeBootstrap(readGoCalls(t, fixture.goLog), fixture.repoRoot, "linux", "amd64"); err != nil { t.Fatal(err) } } func checkNativeBootstrap(calls []goCall, root, hostOS, hostArch string) error { foundBuild := false for _, call := range calls { if call.cwd != root { return fmt.Errorf("Go ran in %q, want repository root %q", call.cwd, root) } if call.gowork != "off" || call.toolchain != "local" { return fmt.Errorf("Go settings = GOWORK=%q GOTOOLCHAIN=%q", call.gowork, call.toolchain) } if len(call.args) > 0 && call.args[0] == "build" { foundBuild = true if call.args[len(call.args)-1] != releasePackage { return fmt.Errorf("build does not select module-owned package %q: %q", releasePackage, call.args) } if argumentValue(call.args[1:], "-mod") != "readonly" { return fmt.Errorf("build does not use readonly module metadata: %q", call.args) } output := argumentValue(call.args[1:], "-o") if !filepath.IsAbs(output) { output = filepath.Join(call.cwd, output) } if filepath.Clean(output) != filepath.Join(root, ".releasekit", "bin", "releasekit") { return fmt.Errorf("incorrect build output %q", output) } if call.goos != hostOS || call.goarch != hostArch { return fmt.Errorf("build target = %s/%s, want %s/%s", call.goos, call.goarch, hostOS, hostArch) } } } if !foundBuild { return errors.New("no native build observed") } return nil } // Inspect only the selected build options, leaving other Go flags to Go itself. func argumentValue(args []string, name string) string { value := "" for i := 0; i < len(args); i++ { if args[i] == name && i+1 < len(args) { i++ value = args[i] } else if supplied, ok := strings.CutPrefix(args[i], name+"="); ok { value = supplied } } return value } func checkPublication(args []string, root string) error { if len(args) == 0 || args[0] != "publish" { return fmt.Errorf("incorrect publication command: %q", args) } flags := flag.NewFlagSet("publish", flag.ContinueOnError) flags.SetOutput(io.Discard) repo := flags.String("repo", "", "") config := flags.String("config", "", "") if err := flags.Parse(args[1:]); err != nil { return err } if *repo != root || *config != filepath.Join(root, "releasekit.json") { return fmt.Errorf("incorrect publication paths: repo=%q config=%q", *repo, *config) } if !equalStrings(flags.Args(), []string{releaseVersion}) { return fmt.Errorf("incorrect publication operands: %q", flags.Args()) } return nil } 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 shift for name do case "$name" in GOHOSTOS) printf '%s\n' "$FAKE_GOHOSTOS" ;; GOHOSTARCH) printf '%s\n' "$FAKE_GOHOSTARCH" ;; *) exit 90 ;; esac done 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 case "$arg" in -o=*) output=${arg#-o=} ;; esac 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" `