297 lines
8.6 KiB
Go
297 lines
8.6 KiB
Go
package scripts
|
|
|
|
import (
|
|
"encoding/json"
|
|
"maps"
|
|
"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)
|
|
}
|
|
}
|
|
|
|
func TestReleaseKitTargetMembership(t *testing.T) {
|
|
want := []string{"linux/amd64", "linux/arm64"}
|
|
for _, test := range []struct {
|
|
name string
|
|
targets []string
|
|
match bool
|
|
}{
|
|
{"missing amd64", []string{"linux/arm64", "linux/arm64"}, false},
|
|
{"missing arm64", []string{"linux/amd64", "linux/amd64"}, false},
|
|
{"additional target", []string{"linux/amd64", "linux/arm64", "darwin/arm64"}, false},
|
|
{"reordered", []string{"linux/arm64", "linux/amd64"}, true},
|
|
{"repeated", []string{"linux/amd64", "linux/arm64", "linux/amd64"}, true},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if got := sameMembers(test.targets, want); got != test.match {
|
|
t.Fatalf("target membership for %q = %t, want %t", test.targets, got, test.match)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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 {
|
|
gotSet := make(map[string]struct{}, len(got))
|
|
for _, value := range got {
|
|
gotSet[value] = struct{}{}
|
|
}
|
|
wantSet := make(map[string]struct{}, len(want))
|
|
for _, value := range want {
|
|
wantSet[value] = struct{}{}
|
|
}
|
|
return maps.Equal(gotSet, wantSet)
|
|
}
|
|
|
|
func hasKey(values map[string]string, key string) bool {
|
|
_, exists := values[key]
|
|
return exists
|
|
}
|