package main import ( "encoding/json" "fmt" "io" "io/fs" "net/http" "net/http/httptest" "net/url" "os" "os/exec" "path/filepath" "strings" "testing" "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli" ) func TestMultiModuleWoodpeckerDelivery(t *testing.T) { root := cliRepositoryRoot(t) example := filepath.Join(root, "examples", "multi-module") contents, err := os.ReadFile(filepath.Join(example, "woodpecker.yml")) if err != nil { t.Fatal(err) } block, err := extractWoodpeckerCommand(string(contents), "deliver") if err != nil { t.Fatal(err) } command := strings.ReplaceAll(block, "$${", "${") for _, eventTag := range []string{"refs/tags/v[01].*", "refs/tags/integrations/ckconfig/v*", "refs/tags/integrations/core/v2.*"} { if !strings.Contains(string(contents), eventTag) { t.Errorf("workflow tag filters omit %q", eventTag) } } toolBinary := buildWorkflowCLI(t, root) proxy := writeWorkflowToolProxy(t) t.Run("nested page delivery retries from tagged mapping", func(t *testing.T) { const tag = "integrations/ckconfig/v1.2.3" delivery := newMappedDeliveryFixture(t, tag) repository := newWorkflowRepository(t, example) writeMappedProfile(t, repository, "releasekit-ckconfig.json", cli.Settings{ Product: "Example Consumer Integration", ModuleDir: "integrations/ckconfig", Delivery: workflowDeliverySettings(delivery.URL), }) writeRepositoryFile(t, repository, "integrations/ckconfig/docs/releases/v1.2.3.md", "# Example Consumer Integration v1.2.3\n\nTagged integration note.\n") writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", workflowModuleFile("example.com/multi-module-consumer/integrations/ckconfig", "v0.2.197")) commit := tagAndPushWorkflowRepository(t, repository, tag) delivery.commit = commit env := multiModuleWorkflowEnvironment(t, repository, commit, tag, toolBinary, proxy) if output := runWoodpeckerBlock(t, command, repository, env); !strings.Contains(output, "state complete") || !strings.Contains(output, "tool-version=v0.2.197") { t.Fatalf("tagged page delivery output = %q", output) } if delivery.creates != 1 || delivery.requests == 0 || delivery.tag != tag { t.Fatalf("page delivery fixture = %#v", delivery) } cliGitRun(t, repository, "checkout", "main") writeRepositoryFile(t, repository, "release-modules.tsv", "# changed after release\nroot\tv[01].*\t.\treleasekit-root.json\n") writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", workflowModuleFile("example.com/multi-module-consumer/integrations/ckconfig", "v0.2.198")) cliGitRun(t, repository, "add", ".") cliGitRun(t, repository, "commit", "-m", "change default branch release selection") cliGitRun(t, repository, "push", "origin", "main") output := runWoodpeckerBlock(t, command, repository, env) if !strings.Contains(output, "tool-version=v0.2.197") || strings.Contains(output, "tool-version=v0.2.198") { t.Fatalf("tag retry did not use its committed tool version: %q", output) } if delivery.creates != 1 || delivery.tag != tag { t.Fatalf("tag retry did not reuse the tagged mapping: %#v", delivery) } }) t.Run("binary delivery from physical major module", func(t *testing.T) { const tag = "integrations/core/v2.1.0" delivery := newMappedDeliveryFixture(t, tag) repository := newWorkflowRepository(t, example) writeMappedProfile(t, repository, "releasekit-core-v2.json", cli.Settings{ Product: "Example Consumer Core v2", ModuleDir: "integrations/core/v2", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "core", Targets: []string{"linux/amd64"}}, Delivery: workflowDeliverySettings(delivery.URL), }) writeRepositoryFile(t, repository, "integrations/core/v2/docs/releases/v2.1.0.md", "# Example Consumer Core v2 v2.1.0\n\nTagged core note.\n") prepareWorkflowModuleSum(t, repository, "integrations/core/v2", proxy) commit := tagAndPushWorkflowRepository(t, repository, tag) delivery.commit = commit env := multiModuleWorkflowEnvironment(t, repository, commit, tag, toolBinary, proxy) output := runWoodpeckerBlock(t, command, repository, env) if !strings.Contains(output, "tool-version=v0.2.197") || !strings.Contains(output, "state complete") { t.Fatalf("binary delivery output = %q", output) } if delivery.creates != 1 || delivery.uploads != 2 || delivery.tag != tag { t.Fatalf("binary delivery fixture = %#v", delivery) } if _, ok := delivery.assets["core-v2.1.0-linux-amd64"]; !ok { t.Fatalf("binary asset missing: %#v", delivery.assets) } if _, ok := delivery.assets["SHA256SUMS"]; !ok { t.Fatalf("checksum asset missing: %#v", delivery.assets) } }) t.Run("binary delivery from virtual major module", func(t *testing.T) { const tag = "integrations/core/v2.1.0" delivery := newMappedDeliveryFixture(t, tag) repository := newWorkflowRepository(t, example) writeRepositoryFile(t, repository, "release-modules.tsv", strings.ReplaceAll(readWorkflowFile(t, repository, "release-modules.tsv"), "integrations/core/v2\treleasekit-core-v2.json", "integrations/core\treleasekit-core-v2.json")) writeMappedProfile(t, repository, "releasekit-core-v2.json", cli.Settings{ Product: "Example Consumer Core v2", ModuleDir: "integrations/core", Delivery: workflowDeliverySettings(delivery.URL), }) writeRepositoryFile(t, repository, "integrations/core/go.mod", workflowModuleFile("example.com/multi-module-consumer/integrations/core/v2", "v0.2.197")) writeRepositoryFile(t, repository, "integrations/core/main.go", readWorkflowFile(t, example, "integrations/core/v2/main.go")) writeRepositoryFile(t, repository, "integrations/core/docs/releases/v2.1.0.md", "# Example Consumer Core v2 v2.1.0\n\nTagged core note.\n") prepareWorkflowModuleSum(t, repository, "integrations/core", proxy) commit := tagAndPushWorkflowRepository(t, repository, tag) delivery.commit = commit env := multiModuleWorkflowEnvironment(t, repository, commit, tag, toolBinary, proxy) output := runWoodpeckerBlock(t, command, repository, env) if !strings.Contains(output, "tool-version=v0.2.197") || !strings.Contains(output, "state complete") { t.Fatalf("virtual-major delivery output = %q", output) } if delivery.creates != 1 || delivery.tag != tag { t.Fatalf("virtual-major delivery fixture = %#v", delivery) } }) t.Run("inconsistent mapping fails exact event tag check", func(t *testing.T) { const tag = "integrations/ckconfig/v2.1.0" delivery := newMappedDeliveryFixture(t, tag) repository := newWorkflowRepository(t, example) mapping := readWorkflowFile(t, repository, "release-modules.tsv") mapping = strings.Replace(mapping, "ckconfig\tintegrations/ckconfig/v*\tintegrations/ckconfig\treleasekit-ckconfig.json", "ckconfig\tintegrations/ckconfig/v*\tintegrations/core/v2\treleasekit-core-v2.json", 1) writeRepositoryFile(t, repository, "release-modules.tsv", mapping) writeMappedProfile(t, repository, "releasekit-core-v2.json", cli.Settings{ Product: "Example Consumer Core v2", ModuleDir: "integrations/core/v2", Delivery: workflowDeliverySettings(delivery.URL), }) writeRepositoryFile(t, repository, "integrations/core/v2/docs/releases/v2.1.0.md", "# Example Consumer Core v2 v2.1.0\n\nMismatched mapped note.\n") commit := tagAndPushWorkflowRepository(t, repository, tag) delivery.commit = commit env := multiModuleWorkflowEnvironment(t, repository, commit, tag, toolBinary, proxy) if output, err := executeWoodpeckerBlock(command, repository, env); err == nil { t.Fatalf("inconsistent mapping succeeded: %s", output) } else if !strings.Contains(output, tag) || !strings.Contains(output, "integrations/core/v2.1.0") { t.Fatalf("inconsistent mapping did not fail the exact tag comparison: %s", output) } if delivery.requests != 0 { t.Fatalf("exact event tag mismatch reached hosted delivery: %#v", delivery) } }) } type mappedDeliveryFixture struct { URL string tag string commit string page bool name string body string creates int uploads int requests int assets map[string][]byte } func newMappedDeliveryFixture(t *testing.T, tag string) *mappedDeliveryFixture { t.Helper() state := &mappedDeliveryFixture{tag: tag, assets: make(map[string][]byte)} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { state.requests++ if r.Header.Get("Authorization") != "token fixture-token" { t.Errorf("delivery authorization = %q", r.Header.Get("Authorization")) } apiRoot := "/api/v1/repos/owner/example/" escapedTag := url.PathEscape(tag) switch r.URL.EscapedPath() { case apiRoot + "tags/" + escapedTag: _ = json.NewEncoder(w).Encode(map[string]any{"name": tag, "commit": map[string]string{"sha": state.commit}}) case apiRoot + "releases/tags/" + escapedTag: if !state.page { http.NotFound(w, r) return } _ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": serverURL(r) + "/releases/10", "tag_name": tag, "target_commitish": state.commit, "name": state.name, "body": state.body, "draft": false, "prerelease": false}) case apiRoot + "releases": var payload struct { Tag string `json:"tag_name"` Commit string `json:"target_commitish"` Name string `json:"name"` Body string `json:"body"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Errorf("decode release create: %v", err) } if payload.Tag != tag || payload.Commit != state.commit { t.Errorf("release payload = %#v", payload) } state.page = true state.name = payload.Name state.body = payload.Body state.creates++ _ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": serverURL(r) + "/releases/10", "tag_name": tag, "target_commitish": state.commit, "name": payload.Name, "body": payload.Body, "draft": false, "prerelease": false}) case apiRoot + "releases/10/assets": if r.Method == http.MethodGet { listing := make([]map[string]any, 0, len(state.assets)) for name, content := range state.assets { listing = append(listing, map[string]any{"id": len(listing) + 1, "name": name, "size": len(content), "browser_download_url": serverURL(r) + "/files/" + name}) } _ = json.NewEncoder(w).Encode(listing) return } if err := r.ParseMultipartForm(1 << 20); err != nil { t.Errorf("parse asset upload: %v", err) http.Error(w, "multipart", http.StatusBadRequest) return } name := r.URL.Query().Get("name") file, _, err := r.FormFile("attachment") if err != nil { t.Errorf("read asset %q: %v", name, err) http.Error(w, "attachment", http.StatusBadRequest) return } content, err := io.ReadAll(file) _ = file.Close() if err != nil { t.Errorf("read asset bytes: %v", err) } state.assets[name] = content state.uploads++ _ = json.NewEncoder(w).Encode(map[string]any{"id": state.uploads, "name": name, "size": len(content)}) default: if strings.HasPrefix(r.URL.Path, "/files/") { name := strings.TrimPrefix(r.URL.Path, "/files/") if content, ok := state.assets[name]; ok { _, _ = w.Write(content) return } http.NotFound(w, r) return } t.Errorf("unexpected delivery request %s %s", r.Method, r.URL.EscapedPath()) http.NotFound(w, r) } })) t.Cleanup(server.Close) state.URL = server.URL return state } func serverURL(r *http.Request) string { return "http://" + r.Host } func buildWorkflowCLI(t *testing.T, root string) string { t.Helper() path := filepath.Join(t.TempDir(), "releasekit") command := exec.Command("go", "build", "-o", path, "./cmd/releasekit") command.Dir = root command.Env = append(os.Environ(), "GOTOOLCHAIN=local", "GOPROXY=off", "GOSUMDB=off") if output, err := command.CombinedOutput(); err != nil { t.Fatalf("build workflow CLI: %v\n%s", err, output) } return path } func writeWorkflowToolProxy(t *testing.T) string { t.Helper() const module = "gitea.maximumdirect.net/eric/ReleaseKit" const helper = "example.com/releasekit-helper" proxyDir := t.TempDir() moduleFile := "module " + module + "\n\ngo 1.25.0\n\nrequire " + helper + " v1.0.0\n" for _, version := range []string{"v0.2.197", "v0.2.198"} { main := fmt.Sprintf(`package main import ( "fmt" "os" "os/exec" helper "%s" ) func main() { fmt.Println("tool-version=%s helper=" + helper.Version) command := exec.Command(os.Getenv("RELEASEKIT_TEST_BINARY"), os.Args[1:]...) command.Env = os.Environ() command.Stdout = os.Stdout command.Stderr = os.Stderr if err := command.Run(); err != nil { if exit, ok := err.(*exec.ExitError); ok { os.Exit(exit.ExitCode()) } fmt.Fprintln(os.Stderr, err) os.Exit(1) } } `, helper, version) files := map[string]string{ "cmd/releasekit/main.go": main, "buildinfo/version.go": "package buildinfo\nimport \"gitea.maximumdirect.net/eric/ReleaseKit/internal/runtimeinfo\"\nfunc Version() string { return runtimeinfo.Version() }\n", "internal/runtimeinfo/version.go": "package runtimeinfo\nvar injectedVersion string\nfunc Version() string { if injectedVersion != \"\" { return injectedVersion }; return \"dev\" }\n", } writeModuleProxyVersion(t, proxyDir, module, version, moduleFile, files) } writeModuleProxyVersion(t, proxyDir, helper, "v1.0.0", "module "+helper+"\n\ngo 1.25.0\n", map[string]string{"helper.go": "package helper\nconst Version = \"tagged dependency\"\n"}) return proxyDir } func newWorkflowRepository(t *testing.T, example string) string { t.Helper() repository := filepath.Join(t.TempDir(), "multi module checkout") if err := os.Mkdir(repository, 0o700); err != nil { t.Fatal(err) } copyExampleTree(t, example, repository) cliGitRun(t, repository, "init", "--initial-branch=main") cliGitRun(t, repository, "config", "user.name", "ReleaseKit Test") cliGitRun(t, repository, "config", "user.email", "releasekit@example.test") cliGitRun(t, repository, "config", "commit.gpgsign", "false") return repository } func workflowModuleFile(module, version string) string { return fmt.Sprintf("module %s\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit %s\n", module, version) } func workflowDeliverySettings(baseURL string) *cli.DeliverySettings { return &cli.DeliverySettings{BaseURL: baseURL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_GITEA_TOKEN"} } func writeMappedProfile(t *testing.T, repository, name string, settings cli.Settings) { t.Helper() contents, err := json.MarshalIndent(settings, "", " ") if err != nil { t.Fatal(err) } writeRepositoryFile(t, repository, name, string(contents)+"\n") } func tagAndPushWorkflowRepository(t *testing.T, repository, tag string) string { t.Helper() cliGitRun(t, repository, "add", "--all") cliGitRun(t, repository, "commit", "-m", "prepare mapped delivery") commit := cliGitOutput(t, repository, "rev-parse", "HEAD") cliGitRun(t, repository, "tag", tag) remote := filepath.Join(filepath.Dir(repository), "origin.git") cliGitRun(t, repository, "init", "--bare", remote) cliGitRun(t, repository, "remote", "add", "origin", remote) cliGitRun(t, repository, "push", "origin", "HEAD:refs/heads/main", "refs/tags/"+tag) return commit } func multiModuleWorkflowEnvironment(t *testing.T, repository, commit, tag, binary, proxy string) map[string]string { t.Helper() goDir, goLog := installGoBootstrapShim(t) environment := templateEnvironment(repository, commit, goDir, goLog) environment["CI_COMMIT_TAG"] = tag environment["RELEASEKIT_TEST_BINARY"] = binary useSyntheticModuleProxy(t, environment, "file://"+filepath.ToSlash(proxy)) cache := environment["GOMODCACHE"] t.Cleanup(func() { _ = filepath.WalkDir(cache, func(path string, entry fs.DirEntry, err error) error { if err == nil && entry.IsDir() { _ = os.Chmod(path, 0o700) } return nil }) }) return environment } func prepareWorkflowModuleSum(t *testing.T, repository, moduleDir, proxy string) { t.Helper() cache := t.TempDir() t.Cleanup(func() { _ = filepath.WalkDir(cache, func(path string, entry fs.DirEntry, err error) error { if err == nil && entry.IsDir() { _ = os.Chmod(path, 0o700) } return nil }) }) command := exec.Command("go", "mod", "download", "gitea.maximumdirect.net/eric/ReleaseKit@v0.2.197") command.Dir = filepath.Join(repository, filepath.FromSlash(moduleDir)) command.Env = append(os.Environ(), "GOMODCACHE="+cache, "GOPROXY=file://"+filepath.ToSlash(proxy), "GOSUMDB=off", "GOWORK=off", "GOTOOLCHAIN=local", "GOFLAGS=-modcacherw") if output, err := command.CombinedOutput(); err != nil { t.Fatalf("prepare tagged module sums: %v\n%s", err, output) } } func readWorkflowFile(t *testing.T, repository, name string) string { t.Helper() contents, err := os.ReadFile(filepath.Join(repository, filepath.FromSlash(name))) if err != nil { t.Fatal(err) } return string(contents) }