package scripts import ( "encoding/json" "os" "os/exec" "path/filepath" "reflect" "strings" "testing" ) const goStub = `#!/usr/bin/env bash set -euo pipefail printf '%s\0' "$PWD" "GOWORK=$GOWORK" "GOTOOLCHAIN=$GOTOOLCHAIN" "$@" > "$RELEASEKIT_BUILD_LOG" if [[ ${RELEASEKIT_BOOTSTRAP_STATUS:-0} != 0 ]]; then exit "$RELEASEKIT_BOOTSTRAP_STATUS" fi output= while (($#)); do if [[ $1 == -o ]]; then shift output=${1:?missing output path} fi shift done [[ -n $output ]] cat > "$output" <<'RELEASEKIT_TOOL' #!/usr/bin/env bash set -euo pipefail printf '%s\0' "$PWD" "$@" > "$RELEASEKIT_ARGS_LOG" exit "${RELEASEKIT_TOOL_STATUS:-0}" RELEASEKIT_TOOL chmod +x "$output" ` func TestReleaseWrapperForwardsNativeArgumentsAndStatus(t *testing.T) { for _, toolStatus := range []string{"0", "3"} { t.Run("status "+toolStatus, func(t *testing.T) { layout := newReleaseTestLayout(t) args := []string{"publish", "v4.2.0", "argument with spaces"} cmd := exec.Command("bash", append([]string{layout.wrapper}, args...)...) cmd.Dir = layout.caller cmd.Env = layout.environment(map[string]string{ "RELEASEKIT_ARGS_LOG": layout.argsLog, "RELEASEKIT_BUILD_LOG": layout.buildLog, "RELEASEKIT_TOOL_STATUS": toolStatus, }) output, err := cmd.CombinedOutput() if got := commandStatus(err); got != toolStatusInt(toolStatus) { t.Fatalf("exit=%d, want %s; output=%q, error=%v", got, toolStatus, output, err) } if len(output) != 0 { t.Fatalf("unexpected output: %q", output) } buildInvocation := readNULArguments(t, layout.buildLog) if len(buildInvocation) < 4 || buildInvocation[0] != layout.repo { t.Fatalf("build invocation=%q, want repository %q", buildInvocation, layout.repo) } if buildInvocation[1] != "GOWORK=off" || buildInvocation[2] != "GOTOOLCHAIN=local" { t.Fatalf("build environment=%q", buildInvocation[1:3]) } buildArgs := buildInvocation[3:] if buildArgs[0] != "build" || !containsArgument(buildArgs, "-mod=readonly") || !containsArgument(buildArgs, "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit") { t.Fatalf("unexpected build arguments: %q", buildArgs) } if !hasAdjacentArguments(buildArgs, "-o", "./bin/releasekit") { t.Fatalf("build output was not selected: %q", buildArgs) } toolInvocation := readNULArguments(t, layout.argsLog) wantToolInvocation := append([]string{layout.repo}, args...) if !reflect.DeepEqual(toolInvocation, wantToolInvocation) { t.Fatalf("tool invocation=%q, want %q", toolInvocation, wantToolInvocation) } }) } } func TestReleaseWrapperStopsWhenBootstrapFails(t *testing.T) { layout := newReleaseTestLayout(t) cmd := exec.Command("bash", layout.wrapper, "publish", "v4.2.0") cmd.Dir = layout.caller cmd.Env = layout.environment(map[string]string{ "RELEASEKIT_ARGS_LOG": layout.argsLog, "RELEASEKIT_BUILD_LOG": layout.buildLog, "RELEASEKIT_BOOTSTRAP_STATUS": "42", }) output, err := cmd.CombinedOutput() if got := commandStatus(err); got != 42 { t.Fatalf("exit=%d, want 42; output=%q, error=%v", got, output, err) } if _, err := os.Stat(layout.argsLog); !os.IsNotExist(err) { t.Fatalf("ReleaseKit executed after failed bootstrap; stat error=%v", err) } } func TestReleaseKitProfileDeclaresProductAndDeliverySettings(t *testing.T) { data, err := os.ReadFile("../releasekit.json") if err != nil { t.Fatal(err) } var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { t.Fatal(err) } if _, exists := raw["checks"]; exists { t.Fatal("profile must not define additional checks") } var deliveryFields map[string]json.RawMessage if err := json.Unmarshal(raw["delivery"], &deliveryFields); err != nil { t.Fatal(err) } if _, exists := deliveryFields["token"]; exists { t.Fatal("profile must not contain a credential value") } var profile struct { Product string `json:"product"` Remote string `json:"remote"` Build struct { MainPackage string `json:"main_package"` BinaryName string `json:"binary_name"` Targets []string `json:"targets"` } `json:"build"` Delivery struct { BaseURL string `json:"base_url"` Owner string `json:"owner"` Repository string `json:"repository"` TokenEnv string `json:"token_env"` } `json:"delivery"` } if err := json.Unmarshal(data, &profile); err != nil { t.Fatal(err) } if profile.Product != "Audita" || profile.Remote != "origin" { t.Fatalf("product=%q remote=%q", profile.Product, profile.Remote) } if profile.Build.MainPackage != "./cmd/audita" || profile.Build.BinaryName != "audita" { t.Fatalf("build=%+v", profile.Build) } if !sameMembers(profile.Build.Targets, []string{"linux/amd64", "linux/arm64"}) { t.Fatalf("build targets=%q", profile.Build.Targets) } if profile.Delivery.BaseURL != "https://gitea.maximumdirect.net" || profile.Delivery.Owner != "eric" || profile.Delivery.Repository != "audita" || profile.Delivery.TokenEnv != "RELEASEKIT_GITEA_TOKEN" { t.Fatalf("delivery=%+v", profile.Delivery) } } type releaseTestLayout struct { repo string caller string wrapper string argsLog string buildLog string path string } func newReleaseTestLayout(t *testing.T) releaseTestLayout { t.Helper() root := t.TempDir() layout := releaseTestLayout{ repo: filepath.Join(root, "repository"), caller: filepath.Join(root, "caller"), argsLog: filepath.Join(root, "tool-arguments"), buildLog: filepath.Join(root, "build-arguments"), path: filepath.Join(root, "bin"), } if err := os.MkdirAll(filepath.Join(layout.repo, "scripts"), 0755); err != nil { t.Fatal(err) } if err := os.MkdirAll(layout.caller, 0755); err != nil { t.Fatal(err) } if err := os.MkdirAll(layout.path, 0755); err != nil { t.Fatal(err) } wrapper, err := os.ReadFile("release.sh") if err != nil { t.Fatal(err) } layout.wrapper = filepath.Join(layout.repo, "scripts", "release.sh") writeReleaseTestFile(t, layout.wrapper, wrapper, 0755) writeReleaseTestFile(t, filepath.Join(layout.path, "go"), []byte(goStub), 0755) return layout } func (layout releaseTestLayout) environment(values map[string]string) []string { filtered := make([]string, 0, len(os.Environ())+len(values)+1) for _, value := range os.Environ() { key, _, _ := strings.Cut(value, "=") if key == "PATH" || hasKey(values, key) { continue } filtered = append(filtered, value) } filtered = append(filtered, "PATH="+layout.path+string(os.PathListSeparator)+os.Getenv("PATH")) for key, value := range values { filtered = append(filtered, key+"="+value) } return filtered } func writeReleaseTestFile(t *testing.T, path string, data []byte, mode os.FileMode) { t.Helper() if err := os.WriteFile(path, data, mode); err != nil { t.Fatal(err) } } func readNULArguments(t *testing.T, path string) []string { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } parts := strings.Split(string(data), "\x00") if len(parts) > 0 && parts[len(parts)-1] == "" { parts = parts[:len(parts)-1] } return parts } func commandStatus(err error) int { if err == nil { return 0 } if exitErr, ok := err.(*exec.ExitError); ok { return exitErr.ExitCode() } return -1 } func toolStatusInt(value string) int { if value == "3" { return 3 } return 0 } func containsArgument(args []string, want string) bool { for _, arg := range args { if arg == want { return true } } return false } func hasAdjacentArguments(args []string, first, second string) bool { for i := 0; i+1 < len(args); i++ { if args[i] == first && args[i+1] == second { return true } } return false } func sameMembers(got, want []string) bool { if len(got) != len(want) { return false } for _, value := range got { if !containsArgument(want, value) { return false } } return true } func hasKey(values map[string]string, key string) bool { _, exists := values[key] return exists }