package main import ( "archive/zip" "errors" "fmt" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "sync/atomic" "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 + ">", "<--prepared>", "<--commit>", "<" + strings.ToUpper(commit) + ">", ""} { if !strings.Contains(args, expected) { t.Errorf("selected executable arguments %q omit %q", args, expected) } } assertGoSelection(t, repository, goLog) }) } func TestWoodpeckerTagDeliveryUsesTaggedModuleVersion(t *testing.T) { // Synthetic downloads must leave the caller's module cache untouched. callerCache := t.TempDir() t.Setenv("GOMODCACHE", callerCache) t.Cleanup(func() { entries, err := os.ReadDir(callerCache) if err != nil { t.Fatal(err) } if len(entries) != 0 { t.Errorf("synthetic module downloads modified the caller's cache: %v", entries) } }) root := cliRepositoryRoot(t) contents, err := os.ReadFile(filepath.Join(root, "examples", "woodpecker", "tag-delivery.yml")) if err != nil { t.Fatal(err) } block, err := extractWoodpeckerCommand(string(contents), "deliver") if err != nil { t.Fatal(err) } command := strings.ReplaceAll(block, "$${", "${") proxy := writeReleaseKitModuleProxy(t) t.Run("older tag retry uses its committed module selection", func(t *testing.T) { goDir, goLog := installGoBootstrapShim(t) repository := newCLIRepository(t) const module = "gitea.maximumdirect.net/eric/ReleaseKit" writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+module+" v0.2.197\n") cliGitRun(t, repository, "add", "go.mod") cliGitRun(t, repository, "commit", "-m", "select the earlier ReleaseKit version") oldCommit := cliGitOutput(t, repository, "rev-parse", "HEAD") cliGitRun(t, repository, "tag", "v1.2.3") writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+module+" v0.2.198\n") cliGitRun(t, repository, "add", "go.mod") cliGitRun(t, repository, "commit", "-m", "upgrade the default branch tool") cliGitRun(t, repository, "init", "--bare", filepath.Join(filepath.Dir(repository), "origin.git")) remote := filepath.Join(filepath.Dir(repository), "origin.git") cliGitRun(t, repository, "remote", "add", "origin", remote) cliGitRun(t, repository, "push", "origin", "refs/heads/main", "refs/tags/v1.2.3") cliGitRun(t, repository, "checkout", "--detach", oldCommit) if mainModule := cliGitOutput(t, repository, "show", "main:go.mod"); !strings.Contains(mainModule, module+" v0.2.198") { t.Fatalf("default branch module = %q, want upgraded tool selection", mainModule) } moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod")) commit := oldCommit env := templateEnvironment(repository, commit, goDir, goLog) useSyntheticModuleProxy(t, env, "file://"+filepath.ToSlash(proxy)) sumLog := filepath.Join(t.TempDir(), "tool-only-sums") env["RELEASEKIT_SUM_LOG"] = sumLog output := runWoodpeckerBlock(t, command, repository, env) if !strings.Contains(output, "tool-version=v0.2.197") || !strings.Contains(output, "helper=tool-only dependency") || strings.Contains(output, "tool-version=v0.2.198") { t.Fatalf("retry selected the wrong tool version: %q", output) } assertModuleBootstrap(t, repository, goLog, moduleBefore) assertToolOnlyChecksums(t, sumLog) }) t.Run("versioned replacement selects its declared source", func(t *testing.T) { goDir, goLog := installGoBootstrapShim(t) repository := newCLIRepository(t) const module = "gitea.maximumdirect.net/eric/ReleaseKit" goMod := "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire " + module + " v0.2.197\n\nreplace " + module + " v0.2.197 => " + module + " v0.2.198\n" writeRepositoryFile(t, repository, "go.mod", goMod) cliGitRun(t, repository, "add", "go.mod") cliGitRun(t, repository, "commit", "-m", "select a versioned tool replacement") commit := pushTaggedCLIRepository(t, repository) moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod")) env := templateEnvironment(repository, commit, goDir, goLog) useSyntheticModuleProxy(t, env, "file://"+filepath.ToSlash(proxy)) sumLog := filepath.Join(t.TempDir(), "tool-only-sums") env["RELEASEKIT_SUM_LOG"] = sumLog output := runWoodpeckerBlock(t, command, repository, env) if !strings.Contains(output, "tool-version=v0.2.198") || !strings.Contains(output, "helper=tool-only dependency") { t.Fatalf("versioned replacement was not selected: %q", output) } assertModuleBootstrap(t, repository, goLog, moduleBefore) assertToolOnlyChecksums(t, sumLog) }) } func TestWoodpeckerSourceDeliveryTemplateSelfBootstraps(t *testing.T) { root := cliRepositoryRoot(t) contents, err := os.ReadFile(filepath.Join(root, "examples", "woodpecker", "releasekit-source-delivery.yml")) if err != nil { t.Fatal(err) } block, err := extractWoodpeckerCommand(string(contents), "deliver") if err != nil { t.Fatal(err) } command := strings.ReplaceAll(block, "$${", "${") repository := newCLIRepository(t) const dependency = "example.com/releasekit-helper" proxyDir := t.TempDir() writeModuleProxyVersion(t, proxyDir, dependency, "v1.0.0", "module "+dependency+"\n\ngo 1.25.0\n", map[string]string{ "helper.go": "package helper\n\nconst Message = \"prepared tool dependency\"\n", }) var proxyRequests atomic.Int64 files := http.FileServer(http.Dir(proxyDir)) proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { proxyRequests.Add(1) files.ServeHTTP(w, r) })) defer proxy.Close() writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+dependency+" v1.0.0\n") writeRepositoryFile(t, repository, "cmd/releasekit/main.go", "package main\n\nimport (\"fmt\"; \"os\"; helper \""+dependency+"\")\n\nfunc main() {\n\tfmt.Println(\"self-bootstrap \" + helper.Message)\n\tfmt.Printf(\"tool-args=%q\\n\", os.Args[1:])\n}\n") preparationEnv := map[string]string{} useSyntheticModuleProxy(t, preparationEnv, proxy.URL) runWoodpeckerBlock(t, "go mod download "+dependency, repository, preparationEnv) cliGitRun(t, repository, "add", "cmd/releasekit/main.go", "go.mod", "go.sum") cliGitRun(t, repository, "commit", "-m", "add the self-bootstrapped command") commit := pushTaggedCLIRepository(t, repository) goDir, goLog := installGoBootstrapShim(t) env := templateEnvironment(repository, commit, goDir, goLog) useSyntheticModuleProxy(t, env, proxy.URL) proxyRequests.Store(0) env["CI_COMMIT_SHA"] = strings.Repeat("0", len(commit)) if output, err := executeWoodpeckerBlock(command, repository, env); err == nil { t.Fatalf("wrong commit unexpectedly bootstrapped the tool: %s", output) } assertNoCommands(t, goLog) if proxyRequests.Load() != 0 { t.Fatal("wrong commit reached tool dependency preparation") } env["CI_COMMIT_SHA"] = commit output := runWoodpeckerBlock(t, command, repository, env) if !strings.Contains(output, "self-bootstrap prepared tool dependency") || !strings.Contains(output, `"`+commit+`"`) { t.Fatalf("self-bootstrap output = %q", output) } if proxyRequests.Load() == 0 { t.Fatal("self-bootstrap did not prepare its dependency from the local proxy") } if !strings.Contains(output, `"ci-deliver"`) || !strings.Contains(output, `"`+repository+`"`) || !strings.Contains(output, `"--prepared"`) || !strings.Contains(output, `"v1.2.3"`) { t.Fatalf("self-bootstrapped command arguments = %q", output) } log := readWoodpeckerFixtureFile(t, goLog) if !strings.Contains(log, "GOWORK=off GOTOOLCHAIN=local") || !strings.Contains(log, "build -mod=readonly -o ") || !strings.Contains(log, "./cmd/releasekit") { t.Fatalf("self-bootstrap Go command = %q", log) } var executable string for index, field := range strings.Fields(log) { if field == "-o" && index+1 < len(strings.Fields(log)) { executable = strings.Fields(log)[index+1] break } } if executable == "" { t.Fatalf("self-bootstrap output path missing from Go command: %q", log) } if relative, err := filepath.Rel(repository, executable); err != nil || relative == "." || (!strings.HasPrefix(relative, ".."+string(filepath.Separator)) && relative != "..") { t.Fatalf("self-bootstrap executable %q is not outside source %q: relative=%q error=%v", executable, repository, relative, err) } if status := cliGitOutput(t, repository, "status", "--porcelain", "--untracked-files=all"); status != "" { t.Fatalf("self-bootstrap changed the tagged source: %q", status) } if branch := cliGitOutput(t, repository, "rev-parse", "--abbrev-ref", "HEAD"); branch != "HEAD" { t.Fatalf("self-bootstrap 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-source.") { t.Fatalf("self-bootstrap temporary directory remains: %s", entry.Name()) } } } 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" "$RELEASEKIT_REAL_GO" "$@" status=$? if [ "$status" -eq 0 ] && [ "$1" = "build" ] && [ -n "${RELEASEKIT_SUM_LOG:-}" ]; then modfile= for argument in "$@"; do case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac done cp "${modfile%.mod}.sum" "$RELEASEKIT_SUM_LOG" fi exit "$status" ` 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 useSyntheticModuleProxy(t *testing.T, environment map[string]string, proxyURL string) { t.Helper() environment["GOMODCACHE"] = t.TempDir() environment["GOPROXY"] = proxyURL environment["GOSUMDB"] = "off" environment["GONOPROXY"] = "none" environment["GOPRIVATE"] = "" environment["GOWORK"] = "off" environment["GOTOOLCHAIN"] = "local" environment["GOENV"] = "off" environment["GOFLAGS"] = "-modcacherw" } 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.26.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 writeReleaseKitModuleProxy(t *testing.T) string { t.Helper() const module = "gitea.maximumdirect.net/eric/ReleaseKit" const toolDependency = "example.com/releasekit-helper" proxyDir := t.TempDir() toolModule := "module " + module + "\n\ngo 1.25.0\n\nrequire " + toolDependency + " v1.0.0\n" for _, version := range []string{"v0.2.197", "v0.2.198"} { files := map[string]string{ "cmd/releasekit/main.go": "package main\n\nimport (\"fmt\"; \"os\"; tooldep \"example.com/releasekit-helper\")\n\nfunc main() {\n\tfmt.Println(\"tool-version=" + version + " helper=\" + tooldep.Version)\n\tfmt.Printf(\"tool-args=%q\\n\", os.Args[1:])\n}\n", } writeModuleProxyVersion(t, proxyDir, module, version, toolModule, files) } writeModuleProxyVersion(t, proxyDir, toolDependency, "v1.0.0", "module "+toolDependency+"\n\ngo 1.25.0\n", map[string]string{"tooldep.go": "package tooldep\n\nconst Version = \"tool-only dependency\"\n"}) return proxyDir } func writeModuleProxyVersion(t *testing.T, proxyDir, module, version, moduleFile string, files map[string]string) { t.Helper() var escaped strings.Builder for _, character := range module { if character >= 'A' && character <= 'Z' { escaped.WriteByte('!') character += 'a' - 'A' } escaped.WriteRune(character) } moduleVersions := filepath.Join(proxyDir, filepath.FromSlash(escaped.String()), "@v") if err := os.MkdirAll(moduleVersions, 0o700); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(moduleVersions, "list")); os.IsNotExist(err) { if err := os.WriteFile(filepath.Join(moduleVersions, "list"), []byte(version+"\n"), 0o600); err != nil { t.Fatal(err) } } else { listPath := filepath.Join(moduleVersions, "list") list := readWoodpeckerFixtureFile(t, listPath) if !strings.Contains(list, version+"\n") { if err := os.WriteFile(listPath, []byte(list+version+"\n"), 0o600); err != nil { t.Fatal(err) } } } if err := os.WriteFile(filepath.Join(moduleVersions, version+".mod"), []byte(moduleFile), 0o600); err != nil { t.Fatal(err) } info := fmt.Sprintf("{\"Version\":%q,\"Time\":\"2026-10-01T00:00:00Z\"}\n", version) if err := os.WriteFile(filepath.Join(moduleVersions, version+".info"), []byte(info), 0o600); err != nil { t.Fatal(err) } archive, err := os.Create(filepath.Join(moduleVersions, version+".zip")) if err != nil { t.Fatal(err) } writer := zip.NewWriter(archive) prefix := module + "@" + version + "/" files["go.mod"] = moduleFile for name, contents := range files { file, err := writer.Create(prefix + name) if err != nil { t.Fatal(err) } if _, err := file.Write([]byte(contents)); err != nil { t.Fatal(err) } } if err := writer.Close(); err != nil { t.Fatal(err) } if err := archive.Close(); err != nil { t.Fatal(err) } } 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 assertToolOnlyChecksums(t *testing.T, path string) { t.Helper() sums := readWoodpeckerFixtureFile(t, path) if !strings.Contains(sums, "example.com/releasekit-helper v1.0.0 h1:") { t.Fatalf("temporary module sum file omitted the tool-only dependency checksum: %q", sums) } } 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) }