From cd828dc06dfe5dc0be22b15a9c4ea526ba49659c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 10 Oct 2026 03:46:12 +0000 Subject: [PATCH] Add multi-module release launcher example --- cmd/releasekit/multi_module_example_test.go | 247 ++++++++++++++++++ docs/roadmap/implementation.md | 13 + examples/multi-module/README.md | 55 ++++ examples/multi-module/go.mod | 5 + .../multi-module/integrations/ckconfig/go.mod | 5 + .../multi-module/integrations/core/v2/go.mod | 5 + examples/multi-module/release-modules.tsv | 4 + examples/multi-module/release.sh | 48 ++++ .../multi-module/releasekit-ckconfig.json | 4 + examples/multi-module/releasekit-core-v2.json | 4 + examples/multi-module/releasekit-root.json | 4 + examples/multi-module/select-module.sh | 70 +++++ 12 files changed, 464 insertions(+) create mode 100644 cmd/releasekit/multi_module_example_test.go create mode 100644 examples/multi-module/README.md create mode 100644 examples/multi-module/go.mod create mode 100644 examples/multi-module/integrations/ckconfig/go.mod create mode 100644 examples/multi-module/integrations/core/v2/go.mod create mode 100644 examples/multi-module/release-modules.tsv create mode 100755 examples/multi-module/release.sh create mode 100644 examples/multi-module/releasekit-ckconfig.json create mode 100644 examples/multi-module/releasekit-core-v2.json create mode 100644 examples/multi-module/releasekit-root.json create mode 100755 examples/multi-module/select-module.sh diff --git a/cmd/releasekit/multi_module_example_test.go b/cmd/releasekit/multi_module_example_test.go new file mode 100644 index 0000000..87978b5 --- /dev/null +++ b/cmd/releasekit/multi_module_example_test.go @@ -0,0 +1,247 @@ +package main + +import ( + "bytes" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestMultiModuleSelectorMapsSelectorsAndTags(t *testing.T) { + example := filepath.Join(cliRepositoryRoot(t), "examples", "multi-module") + helper := filepath.Join(example, "select-module.sh") + for _, test := range []struct { + mode string + value string + want string + }{ + {mode: "selector", value: "root", want: "root\tv[01].*\t.\treleasekit-root.json"}, + {mode: "selector", value: "ckconfig", want: "ckconfig\tintegrations/ckconfig/v*\tintegrations/ckconfig\treleasekit-ckconfig.json"}, + {mode: "tag", value: "integrations/ckconfig/v1.2.3", want: "ckconfig\tintegrations/ckconfig/v*\tintegrations/ckconfig\treleasekit-ckconfig.json"}, + {mode: "tag", value: "integrations/core/v2.3.4", want: "core-v2\tintegrations/core/v2.*\tintegrations/core/v2\treleasekit-core-v2.json"}, + {mode: "tag", value: "v1.2.3", want: "root\tv[01].*\t.\treleasekit-root.json"}, + } { + t.Run(test.mode+" "+test.value, func(t *testing.T) { + got := runModuleSelector(t, helper, test.mode, test.value, "") + if strings.TrimSpace(got) != test.want { + t.Fatalf("selector output = %q, want %q", got, test.want) + } + }) + } + + mapPath := filepath.Join(t.TempDir(), "release modules.tsv") + if err := os.WriteFile(mapPath, []byte("one\tfeature/*\tmodules/one\tprofiles/one.json\ntwo\tfeature/v1.*\tmodules/two\tprofiles/two.json\n"), 0o600); err != nil { + t.Fatal(err) + } + if output := runModuleSelectorError(t, helper, "selector", "absent", mapPath); !strings.Contains(output, "no module map row") { + t.Fatalf("unknown selector error = %q", output) + } + if output := runModuleSelectorError(t, helper, "tag", "feature/v1.2.3", mapPath); !strings.Contains(output, "ambiguous") { + t.Fatalf("ambiguous event error = %q", output) + } + + if err := os.WriteFile(mapPath, []byte("space\tfeature/*\tintegrations/ck config\tprofiles/core release.json\n"), 0o600); err != nil { + t.Fatal(err) + } + got := runModuleSelector(t, helper, "tag", "feature/v1.2.3", mapPath) + want := "space\tfeature/*\tintegrations/ck config\tprofiles/core release.json" + if strings.TrimSpace(got) != want { + t.Fatalf("paths with spaces = %q, want %q", got, want) + } +} + +func TestMultiModuleLauncherUsesSelectedModuleAndProfile(t *testing.T) { + example := filepath.Join(cliRepositoryRoot(t), "examples", "multi-module") + repository := newCLIRepository(t) + copyExampleTree(t, example, repository) + + rootModule := "module example.com/multi-module-consumer\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.2.197\n" + selectedModule := "module example.com/multi-module-consumer/integrations/ckconfig\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.2.198\n" + writeRepositoryFile(t, repository, "go.mod", rootModule) + writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", selectedModule) + spaceModule := "module example.com/multi-module-consumer/integrations/ckconfig\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.2.299\n" + writeRepositoryFile(t, repository, "integrations/ck config/go.mod", spaceModule) + writeRepositoryFile(t, repository, "profiles/core release.json", `{"product":"Example Consumer Integration","module_dir":"integrations/ck config"}`) + cliGitRun(t, repository, "add", ".") + cliGitRun(t, repository, "commit", "-m", "prepare multi-module launcher fixture") + + mapPath := filepath.Join(t.TempDir(), "release modules.tsv") + if err := os.WriteFile(mapPath, []byte("with space\tfeature/*\tintegrations/ck config\tprofiles/core release.json\n"), 0o600); err != nil { + t.Fatal(err) + } + shimDir := t.TempDir() + goLog := filepath.Join(t.TempDir(), "go commands") + argsLog := filepath.Join(t.TempDir(), "tool arguments") + shim := `#!/bin/sh +set -eu +printf 'cwd=%s args=%s\n' "$PWD" "$*" >> "$RELEASEKIT_TEST_GO_LOG" +modfile= +output= +previous= +for argument in "$@"; do + case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac + if [ "$previous" = -o ]; then output=$argument; fi + previous=$argument +done +if [ -n "$modfile" ]; then cat "$modfile" >> "$RELEASEKIT_TEST_GO_LOG"; fi +if [ "$1" = build ]; then + cat > "$output" <<'TOOL' +#!/bin/sh +printf '<%s>\n' "$@" > "$RELEASEKIT_TEST_TOOL_ARGS" +TOOL + chmod +x "$output" +fi +` + if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("RELEASEKIT_MODULE_MAP", mapPath) + t.Setenv("RELEASEKIT_TEST_GO_LOG", goLog) + t.Setenv("RELEASEKIT_TEST_TOOL_ARGS", argsLog) + launcher := filepath.Join(repository, "release.sh") + command := exec.Command(launcher, "with space", "v1.2.4") + var stdout, stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + if err := command.Run(); err != nil { + t.Fatalf("launcher error: %v stdout=%q stderr=%q", err, stdout.String(), stderr.String()) + } + + goCommands, err := os.ReadFile(goLog) + if err != nil { + t.Fatal(err) + } + wantModule := filepath.Join(repository, "integrations", "ck config") + if !strings.Contains(string(goCommands), "cwd="+wantModule) || !strings.Contains(string(goCommands), "v0.2.299") || strings.Contains(string(goCommands), "v0.2.197") { + t.Fatalf("tool bootstrap did not use selected module metadata: %s", goCommands) + } + toolArgs, err := os.ReadFile(argsLog) + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{"", "<--repo>", "<" + repository + ">", "<--config>", "", "<--expected-module>", "", ""} { + if !strings.Contains(string(toolArgs), expected) { + t.Errorf("publication arguments %q omit %q", toolArgs, expected) + } + } + if strings.Contains(string(goCommands), repository+"/.releasekit-bootstrap.") { + t.Fatalf("temporary tool metadata was placed inside the repository: %s", goCommands) + } +} + +func TestMultiModuleLauncherBuildsToolFromSelectedDependency(t *testing.T) { + example := filepath.Join(cliRepositoryRoot(t), "examples", "multi-module") + repository := newCLIRepository(t) + copyExampleTree(t, example, repository) + writeRepositoryFile(t, repository, "go.mod", "module example.com/multi-module-consumer\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.2.197\n") + writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", "module example.com/multi-module-consumer/integrations/ckconfig\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.2.198\n") + cliGitRun(t, repository, "add", ".") + cliGitRun(t, repository, "commit", "-m", "prepare selected tool module") + + proxy := writeReleaseKitModuleProxy(t) + moduleCache := t.TempDir() + t.Setenv("GOMODCACHE", moduleCache) + t.Cleanup(func() { + _ = filepath.WalkDir(moduleCache, func(path string, entry fs.DirEntry, err error) error { + if err == nil && entry.IsDir() { + _ = os.Chmod(path, 0o700) + } + return nil + }) + }) + t.Setenv("GOPROXY", "file://"+filepath.ToSlash(proxy)) + t.Setenv("GOSUMDB", "off") + t.Setenv("GOTOOLCHAIN", "local") + moduleBefore, err := os.ReadFile(filepath.Join(repository, "integrations", "ckconfig", "go.mod")) + if err != nil { + t.Fatal(err) + } + command := exec.Command(filepath.Join(repository, "release.sh"), "ckconfig", "v1.2.3") + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("launcher error: %v output=%q", err, output) + } + for _, expected := range []string{ + "tool-version=v0.2.198 helper=tool-only dependency", + `"publish"`, `"--repo"`, `"` + repository + `"`, + `"--config"`, `"releasekit-ckconfig.json"`, + `"--expected-module"`, `"integrations/ckconfig"`, `"v1.2.3"`, + } { + if !strings.Contains(string(output), expected) { + t.Errorf("launcher output %q omits %q", output, expected) + } + } + if strings.Contains(string(output), "tool-version=v0.2.197") { + t.Fatalf("launcher built the root-selected ReleaseKit version: %s", output) + } + moduleAfter, err := os.ReadFile(filepath.Join(repository, "integrations", "ckconfig", "go.mod")) + if err != nil || !bytes.Equal(moduleAfter, moduleBefore) { + t.Fatalf("selected module go.mod changed: got %q want %q error=%v", moduleAfter, moduleBefore, err) + } +} + +func runModuleSelector(t *testing.T, helper, mode, value, mapPath string) string { + t.Helper() + command := exec.Command("sh", helper, mode, value) + command.Env = os.Environ() + if mapPath != "" { + command.Env = append(command.Env, "RELEASEKIT_MODULE_MAP="+mapPath) + } + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("selector %s %q: %v: %s", mode, value, err, output) + } + return string(output) +} + +func runModuleSelectorError(t *testing.T, helper, mode, value, mapPath string) string { + t.Helper() + command := exec.Command("sh", helper, mode, value) + command.Env = append(os.Environ(), "RELEASEKIT_MODULE_MAP="+mapPath) + output, err := command.CombinedOutput() + if err == nil { + t.Fatalf("selector %s %q unexpectedly succeeded: %s", mode, value, output) + } + return string(output) +} + +func copyExampleTree(t *testing.T, source, destination string) { + t.Helper() + err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + relative, err := filepath.Rel(source, path) + if err != nil { + return err + } + if relative == "." { + return nil + } + target := filepath.Join(destination, relative) + if entry.IsDir() { + return os.MkdirAll(target, 0o700) + } + contents, err := os.ReadFile(path) + if err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + mode := info.Mode().Perm() + if err := os.WriteFile(target, contents, mode); err != nil { + return err + } + return os.Chmod(target, mode) + }) + if err != nil { + t.Fatal(fmt.Errorf("copy multi-module example: %w", err)) + } +} diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index b642538..524abb2 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -661,6 +661,19 @@ agreement. Verify the selected module supplies the tool dependency and publicati receives its semantic version/profile. Shell patterns never become arbitrary library tag templates. Update example guidance with the implemented format. +**Completed:** Added the multi-module profiles, four-column tab-separated +mapping, shared POSIX selector, and launcher. The mapping covers root and +nested integration tags plus a physical `/v2` module layout. The launcher +copies selected module metadata outside the checkout, builds the ReleaseKit +version selected by that module, and passes the mapped profile and +`--expected-module` to publication. Guidance covers per-module dependency +preparation and sequential releases where a sibling dependency is required. +Verified selector and ambiguity behavior, unknown selections, paths with +spaces, the physical-major row, and an isolated local-proxy launcher run that +proved the selected module's tool version and publication arguments. Also +passed `sh -n`, `go test ./...`, `go run ./internal/apicheck`, and +`git diff --check`. + ## Stage 14: Bootstrap and deliver mapped module tags in Woodpecker **Outcome:** A maintained multi-module workflow uses the same declaration and diff --git a/examples/multi-module/README.md b/examples/multi-module/README.md new file mode 100644 index 0000000..cafa773 --- /dev/null +++ b/examples/multi-module/README.md @@ -0,0 +1,55 @@ +# Multi-module release example + +This example maps selectors and complete event tags to a physical Go module +directory and a repository-root profile. The library derives the release tag +from the selected module declaration and physical location; the table only +selects the corresponding profile and checkout directory. + +Copy the contents of this directory into a consumer repository root, keeping +the listed module directories and profile paths at those locations, then run +the launcher from that checkout. + +`release-modules.tsv` is literal tab-separated text with four columns: + +1. Local selector. +2. Shell pattern matched against a complete event tag. +3. Repository-relative physical module directory (`.` means the root module). +4. Repository-relative profile path. + +The checked-in patterns are `v[01].*`, `integrations/ckconfig/v*`, and +`integrations/core/v2.*`. The helper passes pattern text to the shell's `case` +matcher without `eval`. A tag must match exactly one row; missing and ambiguous +matches are configuration errors. Module and profile paths may contain spaces. +The `core-v2` row places the Go module physically under `integrations/core/v2` +and declares a `/v2` module path. ReleaseKit derives the versioned tag under +`integrations/core`, while the table retains the full physical path. + +Before running candidate checks offline, prepare each module's dependencies in +its own directory: + +```sh +go -C . mod download +go -C integrations/ckconfig mod download +go -C integrations/core/v2 mod download +``` + +Then select a module and semantic version with the shared launcher: + +```sh +./release.sh root v1.2.3 +./release.sh ckconfig v1.2.3 +./release.sh core-v2 v2.1.0 +``` + +The launcher copies the selected module's `go.mod` and `go.sum` to temporary +alternate module files outside the checkout, resolves ReleaseKit from that +module's local dependency selection, and builds the tool there. It invokes +`publish` with the repository profile and `--expected-module` so the library +can verify that the profile agrees with the selected table row. The temporary +files and tool are removed on exit. As with any `publish` invocation, commit the +selected module's release note and prepare its repository before publishing. + +If an integration depends on a sibling release, publish the dependency first, +then publish the integration with the version that consumes it. For example, +publish the core release before an integration that requires that release. +This declaration does not infer dependency order or rerun the standard suite. diff --git a/examples/multi-module/go.mod b/examples/multi-module/go.mod new file mode 100644 index 0000000..f813a10 --- /dev/null +++ b/examples/multi-module/go.mod @@ -0,0 +1,5 @@ +module example.com/multi-module-consumer + +go 1.25.0 + +require gitea.maximumdirect.net/eric/ReleaseKit v0.2.197 diff --git a/examples/multi-module/integrations/ckconfig/go.mod b/examples/multi-module/integrations/ckconfig/go.mod new file mode 100644 index 0000000..893740e --- /dev/null +++ b/examples/multi-module/integrations/ckconfig/go.mod @@ -0,0 +1,5 @@ +module example.com/multi-module-consumer/integrations/ckconfig + +go 1.25.0 + +require gitea.maximumdirect.net/eric/ReleaseKit v0.2.197 diff --git a/examples/multi-module/integrations/core/v2/go.mod b/examples/multi-module/integrations/core/v2/go.mod new file mode 100644 index 0000000..8518abb --- /dev/null +++ b/examples/multi-module/integrations/core/v2/go.mod @@ -0,0 +1,5 @@ +module example.com/multi-module-consumer/integrations/core/v2 + +go 1.25.0 + +require gitea.maximumdirect.net/eric/ReleaseKit v0.2.197 diff --git a/examples/multi-module/release-modules.tsv b/examples/multi-module/release-modules.tsv new file mode 100644 index 0000000..1e497f1 --- /dev/null +++ b/examples/multi-module/release-modules.tsv @@ -0,0 +1,4 @@ +# selectortag patternmodule directoryprofile path +root v[01].* . releasekit-root.json +ckconfig integrations/ckconfig/v* integrations/ckconfig releasekit-ckconfig.json +core-v2 integrations/core/v2.* integrations/core/v2 releasekit-core-v2.json diff --git a/examples/multi-module/release.sh b/examples/multi-module/release.sh new file mode 100755 index 0000000..cfd1f52 --- /dev/null +++ b/examples/multi-module/release.sh @@ -0,0 +1,48 @@ +#!/bin/sh +set -eu + +usage() { + echo "usage: release.sh SELECTOR VERSION" >&2 + exit 2 +} + +[ "$#" -eq 2 ] || usage +selector=$1 +version=$2 +script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) +repository_dir=$(git -C "$script_dir" rev-parse --show-toplevel) + +bootstrap_dir=$(mktemp -d "$(dirname "$repository_dir")/.releasekit-bootstrap.XXXXXX") +selection_file=$bootstrap_dir/module-selection.tsv +trap 'rm -rf "$bootstrap_dir"' EXIT +"$script_dir/select-module.sh" selector "$selector" > "$selection_file" + +tab=$(printf '\t') +IFS="$tab" read -r selected_selector tag_pattern module_dir profile_path < "$selection_file" +module_path=$repository_dir +if [ "$module_dir" != . ]; then + module_path=$repository_dir/$module_dir +fi + +cp "$module_path/go.mod" "$bootstrap_dir/releasekit.mod" +if [ -f "$module_path/go.sum" ]; then + cp "$module_path/go.sum" "$bootstrap_dir/releasekit.sum" +else + : > "$bootstrap_dir/releasekit.sum" +fi + +releasekit_module=gitea.maximumdirect.net/eric/ReleaseKit +( + cd "$module_path" + export GOWORK=off + export GOTOOLCHAIN=local + go list -mod=mod -modfile="$bootstrap_dir/releasekit.mod" -m "$releasekit_module" + go build -mod=mod -modfile="$bootstrap_dir/releasekit.mod" \ + -o "$bootstrap_dir/releasekit" "$releasekit_module/cmd/releasekit" +) + +"$bootstrap_dir/releasekit" publish \ + --repo "$repository_dir" \ + --config "$profile_path" \ + --expected-module "$module_dir" \ + "$version" diff --git a/examples/multi-module/releasekit-ckconfig.json b/examples/multi-module/releasekit-ckconfig.json new file mode 100644 index 0000000..ca04d3d --- /dev/null +++ b/examples/multi-module/releasekit-ckconfig.json @@ -0,0 +1,4 @@ +{ + "product": "Example Consumer Integration", + "module_dir": "integrations/ckconfig" +} diff --git a/examples/multi-module/releasekit-core-v2.json b/examples/multi-module/releasekit-core-v2.json new file mode 100644 index 0000000..5b3bf32 --- /dev/null +++ b/examples/multi-module/releasekit-core-v2.json @@ -0,0 +1,4 @@ +{ + "product": "Example Consumer Core v2", + "module_dir": "integrations/core/v2" +} diff --git a/examples/multi-module/releasekit-root.json b/examples/multi-module/releasekit-root.json new file mode 100644 index 0000000..625ecb6 --- /dev/null +++ b/examples/multi-module/releasekit-root.json @@ -0,0 +1,4 @@ +{ + "product": "Example Consumer", + "module_dir": "." +} diff --git a/examples/multi-module/select-module.sh b/examples/multi-module/select-module.sh new file mode 100755 index 0000000..d753614 --- /dev/null +++ b/examples/multi-module/select-module.sh @@ -0,0 +1,70 @@ +#!/bin/sh +set -eu + +usage() { + echo "usage: select-module.sh selector|tag VALUE" >&2 + exit 2 +} + +[ "$#" -eq 2 ] || usage +mode=$1 +value=$2 +case "$mode" in + selector|tag) ;; + *) usage ;; +esac + +script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) +mapping=${RELEASEKIT_MODULE_MAP:-$script_dir/release-modules.tsv} +[ -f "$mapping" ] || { echo "module map not found: $mapping" >&2; exit 2; } + +tab=$(printf '\t') +matches=0 +selected_selector= +selected_pattern= +selected_module= +selected_profile= + +while IFS="$tab" read -r selector pattern module profile || [ -n "${selector:-}${pattern:-}${module:-}${profile:-}" ]; do + case "${selector:-}" in + ''|\#*) continue ;; + esac + fields=$(awk -F '\t' '{ print NF }' <&2; exit 2; } + [ -n "$pattern" ] && [ -n "$module" ] && [ -n "$profile" ] || { + echo "module map row has an empty field for selector $selector" >&2 + exit 2 + } + + matched=false + if [ "$mode" = selector ]; then + [ "$selector" = "$value" ] && matched=true + else + # The value remains a shell pattern in this case statement; it is never eval'd. + case "$value" in + $pattern) matched=true ;; + esac + fi + if [ "$matched" = true ]; then + matches=$((matches + 1)) + selected_selector=$selector + selected_pattern=$pattern + selected_module=$module + selected_profile=$profile + fi +done < "$mapping" + +if [ "$matches" -ne 1 ]; then + if [ "$matches" -eq 0 ]; then + echo "no module map row matches $mode $value" >&2 + else + echo "module map has ambiguous rows for $mode $value" >&2 + fi + exit 2 +fi + +printf '%s\t%s\t%s\t%s\n' \ + "$selected_selector" "$selected_pattern" "$selected_module" "$selected_profile"