439 lines
18 KiB
Go
439 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"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.snapshot().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.update(func(delivery *mappedDeliveryData) { 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.snapshot().creates != 1 || delivery.snapshot().requests == 0 || delivery.snapshot().tag != tag {
|
|
t.Fatalf("page delivery fixture = %#v", delivery.snapshot())
|
|
}
|
|
|
|
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.snapshot().creates != 1 || delivery.snapshot().tag != tag {
|
|
t.Fatalf("tag retry did not reuse the tagged mapping: %#v", delivery.snapshot())
|
|
}
|
|
})
|
|
|
|
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.snapshot().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.update(func(delivery *mappedDeliveryData) { 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)
|
|
}
|
|
observed := delivery.snapshot()
|
|
if observed.creates != 1 || observed.uploads != 2 || observed.tag != tag {
|
|
t.Fatalf("binary delivery fixture = %#v", observed)
|
|
}
|
|
if _, ok := observed.assets["core-v2.1.0-linux-amd64"]; !ok {
|
|
t.Fatalf("binary asset missing: %#v", observed.assets)
|
|
}
|
|
if _, ok := observed.assets["SHA256SUMS"]; !ok {
|
|
t.Fatalf("checksum asset missing: %#v", observed.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.snapshot().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.update(func(delivery *mappedDeliveryData) { 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.snapshot().creates != 1 || delivery.snapshot().tag != tag {
|
|
t.Fatalf("virtual-major delivery fixture = %#v", delivery.snapshot())
|
|
}
|
|
})
|
|
|
|
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.snapshot().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.update(func(delivery *mappedDeliveryData) { 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.snapshot().requests != 0 {
|
|
t.Fatalf("exact event tag mismatch reached hosted delivery: %#v", delivery.snapshot())
|
|
}
|
|
})
|
|
}
|
|
|
|
type mappedDeliveryFixture struct {
|
|
mu sync.Mutex
|
|
data mappedDeliveryData
|
|
}
|
|
|
|
func (state *mappedDeliveryFixture) update(change func(*mappedDeliveryData)) {
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
change(&state.data)
|
|
}
|
|
|
|
func (state *mappedDeliveryFixture) snapshot() mappedDeliveryData {
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
result := state.data
|
|
|
|
result.assets = make(map[string][]byte, len(state.data.assets))
|
|
for key, value := range state.data.assets {
|
|
result.assets[key] = append([]byte(nil), value...)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
type mappedDeliveryData 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{data: mappedDeliveryData{tag: tag, assets: make(map[string][]byte)}}
|
|
data := &state.data
|
|
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
data.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": data.commit}})
|
|
case apiRoot + "releases/tags/" + escapedTag:
|
|
if !data.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": data.commit, "name": data.name, "body": data.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 != data.commit {
|
|
t.Errorf("release payload = %#v", payload)
|
|
}
|
|
data.page = true
|
|
data.name = payload.Name
|
|
data.body = payload.Body
|
|
data.creates++
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": serverURL(r) + "/releases/10", "tag_name": tag, "target_commitish": data.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(data.assets))
|
|
for name, content := range data.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)
|
|
}
|
|
data.assets[name] = content
|
|
data.uploads++
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"id": data.uploads, "name": name, "size": len(content)})
|
|
default:
|
|
if strings.HasPrefix(r.URL.Path, "/files/") {
|
|
name := strings.TrimPrefix(r.URL.Path, "/files/")
|
|
if content, ok := data.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)
|
|
}
|
|
}))
|
|
data.URL = "http://" + server.Listener.Addr().String()
|
|
server.Start()
|
|
t.Cleanup(server.Close)
|
|
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)
|
|
for _, selection := range []struct{ directory, module string }{
|
|
{".", "example.com/multi-module-consumer"},
|
|
{"integrations/ckconfig", "example.com/multi-module-consumer/integrations/ckconfig"},
|
|
{"integrations/core/v2", "example.com/multi-module-consumer/integrations/core/v2"},
|
|
} {
|
|
writeRepositoryFile(t, repository, selection.directory+"/go.mod", workflowModuleFile(selection.module, "v0.2.197"))
|
|
}
|
|
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)
|
|
}
|