package scripts import ( "encoding/json" "maps" "os" "os/exec" "path/filepath" "reflect" "slices" "strconv" "strings" "testing" "gopkg.in/yaml.v3" ) const goStub = `#!/usr/bin/env bash set -euo pipefail case ${1:?missing Go command} in list) log=$RELEASEKIT_LIST_LOG printf '%s\0' "$PWD" "GOWORK=$GOWORK" "GOTOOLCHAIN=$GOTOOLCHAIN" "$@" > "$log" modfile= for argument in "$@"; do case $argument in -modfile=*) modfile=${argument#-modfile=} ;; esac done [[ -n $modfile && -f $modfile && -f ${modfile%.mod}.sum ]] cp "$modfile" "$RELEASEKIT_MODULE_LOG" cp "${modfile%.mod}.sum" "$RELEASEKIT_SUM_LOG" if [[ ${RELEASEKIT_BOOTSTRAP_STATUS:-0} != 0 ]]; then exit "$RELEASEKIT_BOOTSTRAP_STATUS" fi printf '%s\n' v0.1.0 ;; build) 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 case $1 in -o) shift output=${1:?missing output path} ;; -o=*) output=${1#-o=} ;; esac 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" ;; *) exit 64 ;; esac ` func TestReleaseWrapperForwardsNativeArgumentsAndStatus(t *testing.T) { for _, toolStatus := range []int{0, 3} { t.Run("status "+strconv.Itoa(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_LIST_LOG": layout.listLog, "RELEASEKIT_MODULE_LOG": layout.moduleLog, "RELEASEKIT_SUM_LOG": layout.sumLog, "RELEASEKIT_TOOL_STATUS": strconv.Itoa(toolStatus), }) output, err := cmd.CombinedOutput() if got := commandStatus(err); got != toolStatus { t.Fatalf("exit=%d, want %d; output=%q, error=%v", got, toolStatus, output, err) } if len(output) != 0 { t.Fatalf("unexpected output: %q", output) } listInvocation := readNULArguments(t, layout.listLog) buildInvocation := readNULArguments(t, layout.buildLog) for name, invocation := range map[string][]string{"list": listInvocation, "build": buildInvocation} { if len(invocation) < 4 || invocation[0] != layout.repo { t.Fatalf("%s invocation=%q, want repository %q", name, invocation, layout.repo) } if invocation[1] != "GOWORK=off" || invocation[2] != "GOTOOLCHAIN=local" { t.Fatalf("%s environment=%q", name, invocation[1:3]) } } listArgs := listInvocation[3:] buildArgs := buildInvocation[3:] modfile := argumentValue(listArgs, "-modfile=") if listArgs[0] != "list" || !slices.Contains(listArgs, "-mod=mod") || !slices.Contains(listArgs, "-m") || !slices.Contains(listArgs, "gitea.maximumdirect.net/eric/ReleaseKit") { t.Fatalf("unexpected module-selection arguments: %q", listArgs) } if modfile == "" || argumentValue(buildArgs, "-modfile=") != modfile || !slices.Contains(buildArgs, "-mod=mod") { t.Fatalf("list/build did not share alternate module metadata: list=%q build=%q", listArgs, buildArgs) } if buildArgs[0] != "build" || !slices.Contains(buildArgs, "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit") { t.Fatalf("unexpected build arguments: %q", buildArgs) } if relative, err := filepath.Rel(layout.repo, modfile); err != nil || relative == "." || (!strings.HasPrefix(relative, ".."+string(filepath.Separator)) && relative != "..") { t.Fatalf("alternate module file %q is not outside repository %q: relative=%q error=%v", modfile, layout.repo, relative, err) } if module := readText(t, layout.moduleLog); !strings.Contains(module, "gitea.maximumdirect.net/eric/ReleaseKit v0.1.0") { t.Fatalf("temporary module file does not retain selected ReleaseKit version: %q", module) } if sum := readText(t, layout.sumLog); !strings.Contains(sum, "fixture checksum") { t.Fatalf("temporary sum file did not copy source checksums: %q", sum) } if sourceModule := readText(t, filepath.Join(layout.repo, "go.mod")); !strings.Contains(sourceModule, "gitea.maximumdirect.net/eric/ReleaseKit v0.1.0") { t.Fatalf("source go.mod changed: %q", sourceModule) } if sourceSum := readText(t, filepath.Join(layout.repo, "go.sum")); sourceSum != "fixture checksum\n" { t.Fatalf("source go.sum changed: %q", sourceSum) } if _, err := os.Stat(modfile); !os.IsNotExist(err) { t.Fatalf("temporary module file remains after wrapper: %v", err) } if _, err := os.Stat(filepath.Join(filepath.Dir(modfile), "releasekit.sum")); !os.IsNotExist(err) { t.Fatalf("temporary sum file remains after wrapper: %v", err) } if info, err := os.Stat(filepath.Join(layout.repo, "bin", "releasekit")); err != nil || !info.Mode().IsRegular() { t.Fatalf("ReleaseKit executable was not built in bin: %v", err) } 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 TestDeliveryWorkflowUsesModuleSelectedReleaseKit(t *testing.T) { data, err := os.ReadFile("../.woodpecker/release.yml") if err != nil { t.Fatal(err) } var workflow struct { Matrix struct { Include []map[string]string `yaml:"include"` } `yaml:"matrix"` When []struct { Event string `yaml:"event"` Ref string `yaml:"ref"` } `yaml:"when"` Steps []struct { Name string `yaml:"name"` Image string `yaml:"image"` Environment map[string]struct { FromSecret string `yaml:"from_secret"` } `yaml:"environment"` Commands []string `yaml:"commands"` } `yaml:"steps"` } if err := yaml.Unmarshal(data, &workflow); err != nil { t.Fatal(err) } if len(workflow.Matrix.Include) != 1 || workflow.Matrix.Include[0]["GO_IMAGE"] != "golang:1.25.5" { t.Fatalf("workflow Go image = %v, want existing Go 1.25.5 image", workflow.Matrix.Include) } if len(workflow.When) != 1 || workflow.When[0].Event != "tag" || workflow.When[0].Ref != "refs/tags/v*" { t.Fatalf("workflow trigger = %+v", workflow.When) } if len(workflow.Steps) != 1 || workflow.Steps[0].Name != "deliver" || workflow.Steps[0].Image != "${GO_IMAGE}" { t.Fatalf("workflow delivery step = %+v", workflow.Steps) } if secret := workflow.Steps[0].Environment["RELEASEKIT_GITEA_TOKEN"].FromSecret; secret != "GITEA_RELEASE_TOKEN" { t.Fatalf("delivery secret = %q, want existing GITEA_RELEASE_TOKEN", secret) } if len(workflow.Steps[0].Commands) != 1 { t.Fatalf("workflow commands = %q, want one bootstrap/delivery command", workflow.Steps[0].Commands) } command := workflow.Steps[0].Commands[0] for _, fragment := range []string{ "git fetch origin \"refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}\"", "git checkout --detach \"$${tag_commit}\"", "git status --porcelain --untracked-files=all", "mktemp -d \"$${CI_WORKSPACE%/*}/.releasekit-bootstrap.XXXXXX\"", "trap 'rm -rf \"$${bootstrap_dir}\"' EXIT", "cp go.mod \"$${bootstrap_dir}/go.mod\"", "cp go.sum \"$${bootstrap_dir}/go.sum\"", "export GOWORK=off", "export GOTOOLCHAIN=local", "releasekit_module=gitea.maximumdirect.net/eric/ReleaseKit", "go list -mod=mod -modfile=", "-m \"$${releasekit_module}\"", "go build -mod=mod -modfile=", "-o \"$${bootstrap_dir}/releasekit\" \"$${releasekit_module}/cmd/releasekit\"", "\"$${bootstrap_dir}/releasekit\" ci-deliver", "--repo \"$${CI_WORKSPACE}\"", "--commit \"$${CI_COMMIT_SHA}\"", "\"$${CI_COMMIT_TAG}\"", } { if !strings.Contains(command, fragment) { t.Fatalf("workflow command is missing %q: %s", fragment, command) } } if strings.Index(command, "git checkout --detach") > strings.Index(command, "go list -mod=mod") || strings.Index(command, "git status --porcelain") > strings.Index(command, "go list -mod=mod") { t.Fatalf("workflow resolves the tool before verifying the tagged source: %s", command) } if strings.Contains(command, "RELEASEKIT_VERSION") || strings.Contains(command, "cmd/releasekit@") { t.Fatalf("workflow retains a separate tool version: %s", command) } goMod, err := os.ReadFile("../go.mod") if err != nil { t.Fatal(err) } if !strings.Contains(string(goMod), "gitea.maximumdirect.net/eric/ReleaseKit v0.1.0") { t.Fatalf("go.mod does not retain selected supported ReleaseKit version: %s", goMod) } } 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_LIST_LOG": layout.listLog, "RELEASEKIT_MODULE_LOG": layout.moduleLog, "RELEASEKIT_SUM_LOG": layout.sumLog, "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) } if _, err := os.Stat(layout.buildLog); !os.IsNotExist(err) { t.Fatalf("ReleaseKit build ran after module selection failed; stat error=%v", err) } listInvocation := readNULArguments(t, layout.listLog) modfile := argumentValue(listInvocation[3:], "-modfile=") if modfile == "" { t.Fatalf("module selection did not use temporary metadata: %q", listInvocation) } if _, err := os.Stat(filepath.Dir(modfile)); !os.IsNotExist(err) { t.Fatalf("temporary bootstrap directory remains after failed selection: %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) } 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"` Checks []json.RawMessage `json:"checks"` 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 len(profile.Checks) != 0 { t.Fatal("profile must not define additional checks") } 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 listLog string moduleLog string sumLog 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"), listLog: filepath.Join(root, "list-arguments"), moduleLog: filepath.Join(root, "temporary-go.mod"), sumLog: filepath.Join(root, "temporary-go.sum"), 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) writeReleaseTestFile(t, filepath.Join(layout.repo, "go.mod"), []byte("module example.com/audita-fixture\n\ngo 1.25.5\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.1.0\n"), 0644) writeReleaseTestFile(t, filepath.Join(layout.repo, "go.sum"), []byte("fixture checksum\n"), 0644) return layout } func readText(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return string(data) } func argumentValue(arguments []string, prefix string) string { for _, argument := range arguments { if strings.HasPrefix(argument, prefix) { return strings.TrimPrefix(argument, prefix) } } return "" } 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 _, overridden := values[key]; key == "PATH" || overridden { 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 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) }