package main import ( "errors" "fmt" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "testing" "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli" ) func TestWoodpeckerTagDeliveryTemplate(t *testing.T) { root := cliRepositoryRoot(t) templatePath := filepath.Join(root, "examples", "woodpecker", "tag-delivery.yml") contents, err := os.ReadFile(templatePath) if err != nil { t.Fatal(err) } block, err := extractWoodpeckerCommand(string(contents), "deliver") if err != nil { t.Fatal(err) } command := strings.ReplaceAll(block, "$${", "${") t.Run("source-only", func(t *testing.T) { repository := newCLIRepository(t) selectReleaseKitModule(t, repository, root, false) state := &deliveryServerState{} server := newDeliveryServer(t, state) defer server.Close() writeDeliveryProfile(t, repository, cli.Settings{ Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, }) commit := pushTaggedCLIRepository(t, repository) state.commit = commit goDir, goLog := installGoBootstrapShim(t) env := templateEnvironment(repository, commit, goDir, goLog) moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod")) output := runWoodpeckerBlock(t, command, repository, env) if !strings.Contains(output, "state complete") { t.Fatalf("delivery output=%q", output) } if state.releaseCreates != 1 || state.assetUploads != 0 { t.Fatalf("source-only calls = creates:%d uploads:%d", state.releaseCreates, state.assetUploads) } assertModuleBootstrap(t, repository, goLog, moduleBefore) }) t.Run("binary", func(t *testing.T) { repository := newDeliveryBuildRepository(t) selectReleaseKitModule(t, repository, root, true) state := &deliveryServerState{} server := newDeliveryServer(t, state) defer server.Close() writeDeliveryProfile(t, repository, cli.Settings{ Product: "Example Product", Build: &cli.BuildSettings{ MainPackage: ".", BinaryName: "helper", Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)}, }, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, }) commit := pushTaggedCLIRepository(t, repository) state.commit = commit goDir, goLog := installGoBootstrapShim(t) env := templateEnvironment(repository, commit, goDir, goLog) moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod")) sumBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.sum")) for attempt := 0; attempt < 2; attempt++ { output := runWoodpeckerBlock(t, command, repository, env) if !strings.Contains(output, "state complete") { t.Fatalf("delivery attempt %d output=%q", attempt+1, output) } if attempt == 1 && !strings.Contains(output, "matching SHA256SUMS") { t.Fatalf("second delivery did not reuse matching assets: %q", output) } } if state.releaseCreates != 1 || state.assetUploads != 2 { t.Fatalf("binary calls = creates:%d uploads:%d", state.releaseCreates, state.assetUploads) } if _, ok := state.assets["helper-v1.2.3-"+runtime.GOOS+"-"+runtime.GOARCH]; !ok { t.Fatalf("binary asset was not delivered: %#v", state.assets) } if _, ok := state.assets["SHA256SUMS"]; !ok { t.Fatalf("checksum manifest was not delivered: %#v", state.assets) } assertModuleBootstrap(t, repository, goLog, moduleBefore, sumBefore) }) t.Run("wrong tag stops before Go", func(t *testing.T) { repository := newCLIRepository(t) selectReleaseKitModule(t, repository, root, false) commit := pushTaggedCLIRepository(t, repository) goDir, goLog := installControlledGoBootstrapShim(t) env := templateEnvironment(repository, strings.Repeat("f", len(commit)), goDir, goLog) if output, err := executeWoodpeckerBlock(command, repository, env); err == nil { t.Fatalf("wrong tag succeeded: %s", output) } assertNoCommands(t, goLog) }) t.Run("dirty module stops before Go", func(t *testing.T) { repository := newCLIRepository(t) selectReleaseKitModule(t, repository, root, false) commit := pushTaggedCLIRepository(t, repository) writeRepositoryFile(t, repository, "go.mod", "module example.com/different-tool-selection\n\ngo 1.25.0\n") goDir, goLog := installControlledGoBootstrapShim(t) env := templateEnvironment(repository, commit, goDir, goLog) if output, err := executeWoodpeckerBlock(command, repository, env); err == nil { t.Fatalf("dirty module succeeded: %s", output) } assertNoCommands(t, goLog) }) t.Run("selected executable receives event arguments and its status", func(t *testing.T) { repository := newCLIRepository(t) selectReleaseKitModule(t, repository, root, false) commit := pushTaggedCLIRepository(t, repository) goDir, goLog := installControlledGoBootstrapShim(t) toolArgs := filepath.Join(t.TempDir(), "tool-arguments") env := templateEnvironment(repository, strings.ToUpper(commit), goDir, goLog) env["RELEASEKIT_TOOL_ARGS_LOG"] = toolArgs env["RELEASEKIT_TOOL_EXIT"] = "37" output, err := executeWoodpeckerBlock(command, repository, env) var exitError *exec.ExitError if !errors.As(err, &exitError) || exitError.ExitCode() != 37 { t.Fatalf("template error = %v, want propagated exit 37; output=%q", err, output) } args := readWoodpeckerFixtureFile(t, toolArgs) for _, expected := range []string{"", "<--repo>", "<" + repository + ">", "<--commit>", "<" + strings.ToUpper(commit) + ">", ""} { if !strings.Contains(args, expected) { t.Errorf("selected executable arguments %q omit %q", args, expected) } } assertGoSelection(t, repository, goLog) }) } func cliRepositoryRoot(t *testing.T) string { t.Helper() _, sourceFile, _, ok := runtime.Caller(0) if !ok { t.Fatal("locate integration test source") } return filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "../..")) } func extractWoodpeckerCommand(contents, stepName string) (string, error) { lines := strings.Split(contents, "\n") stepHeader := " - name: " + stepName for i, line := range lines { if strings.TrimSpace(line) != strings.TrimSpace(stepHeader) { continue } for j := i + 1; j < len(lines); j++ { trimmed := strings.TrimSpace(lines[j]) if strings.HasPrefix(lines[j], " - name:") { break } if trimmed != "- |" { continue } var block []string for k := j + 1; k < len(lines); k++ { current := lines[k] if strings.TrimSpace(current) == "" { block = append(block, "") continue } indent := len(current) - len(strings.TrimLeft(current, " ")) if indent < 8 { break } block = append(block, current[8:]) } return strings.TrimSpace(strings.Join(block, "\n")), nil } return "", fmt.Errorf("step %q has no literal command block", stepName) } return "", fmt.Errorf("step %q was not found", stepName) } func installGoBootstrapShim(t *testing.T) (string, string) { t.Helper() shimDir := t.TempDir() goLog := filepath.Join(shimDir, "go-commands") shim := `#!/bin/sh printf 'cwd=%s GOWORK=%s GOTOOLCHAIN=%s %s\n' "$PWD" "$GOWORK" "$GOTOOLCHAIN" "$*" >> "$RELEASEKIT_GO_LOG" exec "$RELEASEKIT_REAL_GO" "$@" ` if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil { t.Fatal(err) } return shimDir, goLog } func installControlledGoBootstrapShim(t *testing.T) (string, string) { t.Helper() shimDir := t.TempDir() goLog := filepath.Join(shimDir, "go-commands") shim := `#!/bin/sh set -eu printf 'cwd=%s GOWORK=%s GOTOOLCHAIN=%s %s\n' "$PWD" "$GOWORK" "$GOTOOLCHAIN" "$*" >> "$RELEASEKIT_GO_LOG" modfile= for argument in "$@"; do case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac done [ -n "$modfile" ] && [ -f "$modfile" ] || { echo 'alternate module file is missing' >&2; exit 89; } [ -f "${modfile%.mod}.sum" ] || { echo 'alternate checksum file is missing' >&2; exit 88; } grep -F "$RELEASEKIT_MODULE v0.2.0" "$modfile" >/dev/null || { echo 'module selection was not copied' >&2; exit 87; } case "$1" in list) printf '%s v0.2.0\n' "$RELEASEKIT_MODULE" ;; build) output= previous= for argument in "$@"; do if [ "$previous" = "-o" ]; then output=$argument; break; fi previous=$argument done [ -n "$output" ] || { echo 'go build omitted output path' >&2; exit 90; } cat > "$output" <<'TOOL' #!/bin/sh printf '<%s>\n' "$@" > "$RELEASEKIT_TOOL_ARGS_LOG" exit "${RELEASEKIT_TOOL_EXIT:-0}" TOOL chmod +x "$output" ;; *) echo "unexpected Go command: $1" >&2 exit 91 ;; esac ` if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil { t.Fatal(err) } return shimDir, goLog } func templateEnvironment(repository, commit, shimDir, goLog string) map[string]string { realGo, _ := exec.LookPath("go") return map[string]string{ "CI_WORKSPACE": repository, "CI_COMMIT_SHA": commit, "CI_COMMIT_TAG": "v1.2.3", "RELEASEKIT_GITEA_TOKEN": "fixture-token", "RELEASEKIT_MODULE": "gitea.maximumdirect.net/eric/ReleaseKit", "RELEASEKIT_REAL_GO": realGo, "RELEASEKIT_GO_LOG": goLog, "GOTOOLCHAIN": "local", "GOPROXY": "off", "GOSUMDB": "off", "GOFLAGS": "", "PATH": shimDir + string(os.PathListSeparator) + os.Getenv("PATH"), } } func runWoodpeckerBlock(t *testing.T, block, repository string, environment map[string]string) string { t.Helper() output, err := executeWoodpeckerBlock(block, repository, environment) if err != nil { t.Fatalf("Woodpecker command failed: %v\ncommand: %s\noutput: %s", err, block, output) } return output } func executeWoodpeckerBlock(block, repository string, environment map[string]string) (string, error) { command := exec.Command("sh", "-e", "-c", block) command.Dir = repository command.Env = os.Environ() for key, value := range environment { command.Env = setEnvironment(command.Env, key, value) } output, err := command.CombinedOutput() return string(output), err } func selectReleaseKitModule(t *testing.T, repository, releaseKitSource string, includeSum bool) { t.Helper() const module = "gitea.maximumdirect.net/eric/ReleaseKit" goMod := fmt.Sprintf("module example.com/tagged-app\n\ngo 1.25.0\n\nrequire %s v0.2.0\n\nreplace %s => %s\n", module, module, strconv.Quote(releaseKitSource)) writeRepositoryFile(t, repository, "go.mod", goMod) cliGitRun(t, repository, "add", "go.mod") if includeSum { writeRepositoryFile(t, repository, "go.sum", "") cliGitRun(t, repository, "add", "go.sum") } cliGitRun(t, repository, "commit", "-m", "select ReleaseKit from module configuration") } func assertModuleBootstrap(t *testing.T, repository, goLog, moduleBefore string, sumBefore ...string) { t.Helper() assertGoSelection(t, repository, goLog) if got := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod")); got != moduleBefore { t.Fatalf("source go.mod changed during bootstrap: got %q, want %q", got, moduleBefore) } sumPath := filepath.Join(repository, "go.sum") if len(sumBefore) == 0 { if _, err := os.Stat(sumPath); !os.IsNotExist(err) { t.Fatalf("source go.sum state changed: stat error = %v", err) } } else if got := readWoodpeckerFixtureFile(t, sumPath); got != sumBefore[0] { t.Fatalf("source go.sum changed during bootstrap: got %q, want %q", got, sumBefore[0]) } if status := cliGitOutput(t, repository, "status", "--porcelain", "--untracked-files=all"); status != "" { t.Fatalf("bootstrap changed tagged source: %q", status) } if branch := cliGitOutput(t, repository, "rev-parse", "--abbrev-ref", "HEAD"); branch != "HEAD" { t.Fatalf("checkout branch = %q, want detached HEAD", branch) } entries, err := os.ReadDir(filepath.Dir(repository)) if err != nil { t.Fatal(err) } for _, entry := range entries { if strings.HasPrefix(entry.Name(), ".releasekit-bootstrap.") { t.Fatalf("temporary bootstrap directory remains: %s", entry.Name()) } } } func assertGoSelection(t *testing.T, repository, goLog string) { t.Helper() log := readWoodpeckerFixtureFile(t, goLog) list := "list -mod=mod -modfile=" build := "build -mod=mod -modfile=" module := "gitea.maximumdirect.net/eric/ReleaseKit" if !strings.Contains(log, "GOWORK=off GOTOOLCHAIN=local") || !strings.Contains(log, list) || !strings.Contains(log, "-m "+module) { t.Fatalf("Go module selection command is missing required settings: %q", log) } if !strings.Contains(log, build) || !strings.Contains(log, module+"/cmd/releasekit") || strings.Contains(log, "@v0.2.0") { t.Fatalf("Go build did not use the selected module without a version suffix: %q", log) } if strings.Index(log, list) > strings.Index(log, build) { t.Fatalf("Go build ran before module selection: %q", log) } var modfile string for _, field := range strings.Fields(log) { if strings.HasPrefix(field, "-modfile=") { modfile = strings.TrimPrefix(field, "-modfile=") break } } if modfile == "" { t.Fatalf("Go commands did not receive an alternate module file: %q", log) } if relative, err := filepath.Rel(repository, modfile); err != nil || relative == "." || (!strings.HasPrefix(relative, ".."+string(filepath.Separator)) && relative != "..") { t.Fatalf("alternate module file %q is not outside source %q: relative=%q error=%v", modfile, repository, relative, err) } } func assertNoCommands(t *testing.T, goLog string) { t.Helper() contents, err := os.ReadFile(goLog) if err != nil && !os.IsNotExist(err) { t.Fatal(err) } if len(contents) != 0 { t.Fatalf("Go ran before tag/source verification: %q", contents) } } func readWoodpeckerFixtureFile(t *testing.T, path string) string { t.Helper() contents, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return string(contents) } func setEnvironment(environment []string, key, value string) []string { prefix := key + "=" for i, entry := range environment { if strings.HasPrefix(entry, prefix) { environment[i] = prefix + value return environment } } return append(environment, prefix+value) }