Add mapped multi-module delivery workflow
This commit is contained in:
401
cmd/releasekit/multi_module_woodpecker_test.go
Normal file
401
cmd/releasekit/multi_module_woodpecker_test.go
Normal file
@@ -0,0 +1,401 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user