Files
commandkit/scripts/release_test.go

504 lines
14 KiB
Go

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