Run consumer release commands through Go tools

This commit is contained in:
2026-11-02 14:46:42 +00:00
parent 6fab30337a
commit 691926d839
9 changed files with 148 additions and 103 deletions

View File

@@ -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())
}
}
})
}
}

View File

@@ -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) {

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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,

View File

@@ -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"

View File

@@ -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}"

View File

@@ -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}" \