diff --git a/cmd/releasekit/multi_module_example_test.go b/cmd/releasekit/multi_module_example_test.go index f425854..69b79cc 100644 --- a/cmd/releasekit/multi_module_example_test.go +++ b/cmd/releasekit/multi_module_example_test.go @@ -2,11 +2,13 @@ package main import ( "bytes" + "errors" "fmt" "io/fs" "os" "os/exec" "path/filepath" + "strconv" "strings" "testing" ) @@ -80,20 +82,13 @@ func TestMultiModuleLauncherUsesSelectedModuleAndProfile(t *testing.T) { 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" +if [ "$1" = tool ]; then + shift 3 + printf '<%s>\n' "$@" > "$RELEASEKIT_TEST_TOOL_ARGS" fi ` if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil { @@ -134,7 +129,7 @@ fi } } -func TestMultiModuleLauncherBuildsToolFromSelectedDependency(t *testing.T) { +func TestMultiModuleLauncherRunsToolFromSelectedDependency(t *testing.T) { example := filepath.Join(cliRepositoryRoot(t), "examples", "multi-module") repository := newCLIRepository(t) copyExampleTree(t, example, repository) @@ -161,27 +156,50 @@ func TestMultiModuleLauncherBuildsToolFromSelectedDependency(t *testing.T) { 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) + for _, status := range []int{0, 1, 2, 3} { + t.Run(strconv.Itoa(status), func(t *testing.T) { + command := exec.Command(filepath.Join(repository, "release.sh"), "ckconfig", "v1.2.3") + command.Env = setEnvironment(os.Environ(), "RELEASEKIT_TOOL_EXIT", strconv.Itoa(status)) + output, err := command.CombinedOutput() + if status == 0 && err != nil { + t.Fatalf("launcher error: %v output=%q", err, output) + } + if status != 0 { + var exitError *exec.ExitError + if !errors.As(err, &exitError) || exitError.ExitCode() != status { + t.Fatalf("launcher error = %v, want exit %d; output=%q", err, status, 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 ran 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) + } + if status := cliGitOutput(t, repository, "status", "--porcelain", "--untracked-files=all"); status != "" { + t.Fatalf("launcher changed source: %q", status) + } + 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()) + } + } + }) } } diff --git a/cmd/releasekit/woodpecker_test.go b/cmd/releasekit/woodpecker_test.go index 02ebe6c..4897dc4 100644 --- a/cmd/releasekit/woodpecker_test.go +++ b/cmd/releasekit/woodpecker_test.go @@ -236,6 +236,26 @@ func TestWoodpeckerTagDeliveryUsesTaggedModuleVersion(t *testing.T) { assertToolOnlyChecksums(t, sumLog) }) + t.Run("vendored tool dependency", func(t *testing.T) { + goDir, goLog := installGoBootstrapShim(t) + repository := newCLIRepository(t) + const module = "go.maximumdirect.net/releasekit" + writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+module+" v0.2.197\n\ntool "+module+"/cmd/releasekit\n") + env := templateEnvironment(repository, "", goDir, goLog) + useSyntheticModuleProxy(t, env, "file://"+filepath.ToSlash(proxy)) + runWoodpeckerBlock(t, "go mod tidy\ngo mod vendor", repository, env) + cliGitRun(t, repository, "add", "go.mod", "go.sum", "vendor") + cliGitRun(t, repository, "commit", "-m", "vendor the selected release tool") + env["CI_COMMIT_SHA"] = pushTaggedCLIRepository(t, repository) + moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod")) + sumBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.sum")) + output := runWoodpeckerBlock(t, command, repository, env) + if !strings.Contains(output, "tool-version=v0.2.197 helper=tool-only dependency") { + t.Fatalf("vendored tool was not selected: %q", output) + } + assertModuleBootstrap(t, repository, goLog, moduleBefore, sumBefore) + }) + t.Run("versioned replacement selects its declared source", func(t *testing.T) { goDir, goLog := installGoBootstrapShim(t) repository := newCLIRepository(t) @@ -400,7 +420,7 @@ func installGoBootstrapShim(t *testing.T) (string, string) { 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 +if [ "$status" -eq 0 ] && [ "$1" = "tool" ] && [ -n "${RELEASEKIT_SUM_LOG:-}" ]; then modfile= for argument in "$@"; do case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac @@ -433,20 +453,12 @@ 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" + mod) + ;; + tool) + shift 3 + printf '<%s>\n' "$@" > "$RELEASEKIT_TOOL_ARGS_LOG" + exit "${RELEASEKIT_TOOL_EXIT:-0}" ;; *) echo "unexpected Go command: $1" >&2 @@ -532,7 +544,7 @@ func writeReleaseKitModuleProxy(t *testing.T) string { 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", + "cmd/releasekit/main.go": "package main\n\nimport (\"fmt\"; \"os\"; \"strconv\"; 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\tstatus, _ := strconv.Atoi(os.Getenv(\"RELEASEKIT_TOOL_EXIT\"))\n\tos.Exit(status)\n}\n", } writeModuleProxyVersion(t, proxyDir, module, version, toolModule, files) } @@ -633,16 +645,16 @@ func assertGoSelection(t *testing.T, repository, goLog string) { t.Helper() log := readWoodpeckerFixtureFile(t, goLog) list := "list -mod=mod -modfile=" - build := "build -mod=mod -modfile=" + tool := "tool -modfile=" module := "go.maximumdirect.net/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.Contains(log, tool) || !strings.Contains(log, " releasekit ci-deliver ") || strings.Contains(log, "@v0.2.0") { + t.Fatalf("Go tool did not run the module-selected ReleaseKit: %q", log) } - if strings.Index(log, list) > strings.Index(log, build) { - t.Fatalf("Go build ran before module selection: %q", log) + if strings.Index(log, list) > strings.Index(log, tool) { + t.Fatalf("Go tool ran before module selection: %q", log) } var modfile string for _, field := range strings.Fields(log) { diff --git a/docs/cli.md b/docs/cli.md index d6fbd5b..50a9943 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -275,9 +275,9 @@ status 2. Final delivery uses the same statuses as `deliver`: 0 for complete, state, including when cancellation prevents reconciliation. The maintained module-based tag workflow verifies and checks out the event tag -before resolving the consumer module. It builds the ReleaseKit version already -selected by that tagged `go.mod`, using temporary alternate module files outside -the source checkout. This tool bootstrap is separate from `ci-deliver`'s +before resolving the consumer module. It runs `go tool releasekit` for the version +already selected by that tagged `go.mod`, using temporary alternate module files +outside the source checkout. This tool bootstrap is separate from `ci-deliver`'s application dependency preparation for binary profiles. ReleaseKit's own source workflow self-bootstraps `./cmd/releasekit`; consumers without a declared ReleaseKit module dependency can use the separately installed-tool command. diff --git a/docs/consumers/adoption.md b/docs/consumers/adoption.md index c6828e2..3f33b50 100644 --- a/docs/consumers/adoption.md +++ b/docs/consumers/adoption.md @@ -34,8 +34,21 @@ Go minimum. See the [Go tool dependency reference](https://go.dev/ref/mod#go-mod-file-tool). Run `go tool releasekit` from within the consumer module to use its selected -version. Follow the [CLI contract](../cli.md) for commands and exit statuses, -and the [configuration reference](../config.md) for the release profile. +version. Go builds and caches the executable and preserves its exit status. +A launcher invoked from that module can forward commands directly: + +```sh +#!/bin/sh +exec go tool releasekit "$@" +``` + +For a launcher callable from other directories, first change to its consumer +module directory. Follow the [CLI contract](../cli.md) for commands and exit +statuses, and the [configuration reference](../config.md) for the release profile. + +For consumers using vendoring, register the CLI before running `go mod vendor` +so the vendor tree includes its dependencies. `go tool` follows Go's normal +vendor selection. ## Go API consumers @@ -89,9 +102,10 @@ Go module identity. The Go consumer minimum remains 1.26.0. ## CI adoption The maintained [consumer tag-delivery workflow](delivery.md#woodpecker-tag-workflows) -supports both tool and ordinary package dependencies. It builds ReleaseKit from -the tagged consumer's module selection, so the committed module files also -govern CI delivery and retries. Registering the CLI as a tool is sufficient for +supports both tool and ordinary package dependencies. It runs `go tool releasekit` +with temporary module metadata derived from the tagged consumer's module +selection, so the committed module files also govern CI delivery and retries. +Registering the CLI as a tool is sufficient for consumers that use ReleaseKit only for maintainer commands. Upgrade the module-selected tool and workflow together. For a tag created before diff --git a/docs/consumers/delivery.md b/docs/consumers/delivery.md index 28c036d..6275758 100644 --- a/docs/consumers/delivery.md +++ b/docs/consumers/delivery.md @@ -293,11 +293,13 @@ identify an annotated tag object or a commit. The template requires the resolved commits to match, checks out that commit detached, and confirms the source tree is clean. It then copies the tagged `go.mod` and any `go.sum` to a temporary directory outside the checkout. `go list -m` confirms that the module selects -ReleaseKit; Go uses -an alternate `-modfile` to prepare dependencies and build the selected -`cmd/releasekit` package without changing the tagged module files. The template -invokes that executable with `CI_WORKSPACE`, the resolved event commit, and -`CI_COMMIT_TAG`. A cleanup trap removes the temporary metadata and executable. +ReleaseKit. The template adds its CLI tool declaration to that temporary copy +and prepares tool dependencies with `go list -deps`. It then uses +`go tool -modfile=... releasekit` to build, cache, and run the selected tool with +`CI_WORKSPACE`, the resolved event commit, and `CI_COMMIT_TAG`. The tagged module +files stay unchanged, including for consumers with an ordinary package +dependency. A cleanup trap removes the temporary metadata; Go manages the +cached executable. The command preserves the tool's exit status. Tool bootstrap and application artifact preparation are separate. The bootstrap resolves and builds ReleaseKit from the tagged consumer's module configuration. diff --git a/examples/multi-module/README.md b/examples/multi-module/README.md index df4980e..f161ca4 100644 --- a/examples/multi-module/README.md +++ b/examples/multi-module/README.md @@ -72,11 +72,14 @@ Then select a module and semantic version with the shared launcher: 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. +module's local dependency selection, adds the CLI tool declaration to that +temporary copy, and prepares its dependencies. It uses `go tool releasekit` +with the alternate module file to invoke `publish` with the repository profile +and `--expected-module` so the library can verify that the profile agrees with +the selected table row. Go builds and caches the executable and preserves its +exit status; the launcher removes the temporary metadata 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, @@ -86,8 +89,9 @@ This declaration does not infer dependency order or rerun the standard suite. The maintained [Woodpecker example](woodpecker.yml) uses the same map and selector for root, `ckconfig`, and major-version tags. It verifies and detaches the literal event tag before selecting the module, copies that module's metadata -outside the checkout, and builds the ReleaseKit version selected there. It then -uses `ci-deliver --prepared` with the mapped profile and expected physical module. +outside the checkout, and runs the ReleaseKit version selected there through +`go tool releasekit`. It then uses `ci-deliver --prepared` with the mapped profile +and expected physical module. The `core-v2` profile demonstrates binary delivery; the other profiles are page-only. Before enabling the workflow, configure each selected profile's `delivery` object with `"forge": "gitea"`, its `base_url`, owner, and repository, diff --git a/examples/multi-module/release.sh b/examples/multi-module/release.sh index 629f3df..895f99b 100755 --- a/examples/multi-module/release.sh +++ b/examples/multi-module/release.sh @@ -37,12 +37,11 @@ releasekit_module=go.maximumdirect.net/releasekit 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" + go mod edit -modfile="$bootstrap_dir/releasekit.mod" -tool="$releasekit_module/cmd/releasekit" + go list -mod=mod -modfile="$bootstrap_dir/releasekit.mod" -deps "$releasekit_module/cmd/releasekit" > /dev/null + go tool -modfile="$bootstrap_dir/releasekit.mod" releasekit publish \ + --repo "$repository_dir" \ + --config "$profile_path" \ + --expected-module "$module_dir" \ + "$version" ) - -"$bootstrap_dir/releasekit" publish \ - --repo "$repository_dir" \ - --config "$profile_path" \ - --expected-module "$module_dir" \ - "$version" diff --git a/examples/multi-module/woodpecker.yml b/examples/multi-module/woodpecker.yml index ce5304d..5a64db0 100644 --- a/examples/multi-module/woodpecker.yml +++ b/examples/multi-module/woodpecker.yml @@ -40,14 +40,12 @@ steps: if [ "$${module_dir}" != . ]; then module_path="$${CI_WORKSPACE}/$${module_dir}" fi - cp "$${module_path}/go.mod" "$${bootstrap_dir}/go.mod" + cp "$${module_path}/go.mod" "$${bootstrap_dir}/releasekit.mod" if [ -f "$${module_path}/go.sum" ]; then - cp "$${module_path}/go.sum" "$${bootstrap_dir}/go.sum" + cp "$${module_path}/go.sum" "$${bootstrap_dir}/releasekit.sum" else - : > "$${bootstrap_dir}/go.sum" + : > "$${bootstrap_dir}/releasekit.sum" fi - cp "$${bootstrap_dir}/go.mod" "$${bootstrap_dir}/releasekit.mod" - cp "$${bootstrap_dir}/go.sum" "$${bootstrap_dir}/releasekit.sum" export GOWORK=off export GOTOOLCHAIN=local @@ -55,13 +53,13 @@ steps: ( cd "$${module_path}" 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" + go mod edit -modfile="$${bootstrap_dir}/releasekit.mod" -tool="$${releasekit_module}/cmd/releasekit" + go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -deps "$${releasekit_module}/cmd/releasekit" > /dev/null + go tool -modfile="$${bootstrap_dir}/releasekit.mod" releasekit ci-deliver \ + --repo "$${CI_WORKSPACE}" \ + --config "$${profile_path}" \ + --expected-module "$${module_dir}" \ + --prepared \ + --commit "$${expected_commit}" \ + "$${CI_COMMIT_TAG}" ) - "$${bootstrap_dir}/releasekit" ci-deliver \ - --repo "$${CI_WORKSPACE}" \ - --config "$${profile_path}" \ - --expected-module "$${module_dir}" \ - --prepared \ - --commit "$${expected_commit}" \ - "$${CI_COMMIT_TAG}" diff --git a/examples/woodpecker/tag-delivery.yml b/examples/woodpecker/tag-delivery.yml index dc21877..f23ad6e 100644 --- a/examples/woodpecker/tag-delivery.yml +++ b/examples/woodpecker/tag-delivery.yml @@ -32,22 +32,20 @@ steps: bootstrap_dir=$(mktemp -d "$${CI_WORKSPACE%/*}/.releasekit-bootstrap.XXXXXX") trap 'rm -rf "$${bootstrap_dir}"' EXIT - cp go.mod "$${bootstrap_dir}/go.mod" + cp go.mod "$${bootstrap_dir}/releasekit.mod" if [ -f go.sum ]; then - cp go.sum "$${bootstrap_dir}/go.sum" + cp go.sum "$${bootstrap_dir}/releasekit.sum" else - : > "$${bootstrap_dir}/go.sum" + : > "$${bootstrap_dir}/releasekit.sum" fi - cp "$${bootstrap_dir}/go.mod" "$${bootstrap_dir}/releasekit.mod" - cp "$${bootstrap_dir}/go.sum" "$${bootstrap_dir}/releasekit.sum" export GOWORK=off export GOTOOLCHAIN=local releasekit_module=go.maximumdirect.net/releasekit 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" ci-deliver \ + go mod edit -modfile="$${bootstrap_dir}/releasekit.mod" -tool="$${releasekit_module}/cmd/releasekit" + go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -deps "$${releasekit_module}/cmd/releasekit" > /dev/null + go tool -modfile="$${bootstrap_dir}/releasekit.mod" releasekit ci-deliver \ --repo "$${CI_WORKSPACE}" \ --prepared \ --commit "$${expected_commit}" \