From b80d4ee0717b4b314fc9670ce76fdd7b898aa55c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 7 Oct 2026 23:33:00 +0000 Subject: [PATCH] Build the CI tool from tagged module settings --- cmd/releasekit/woodpecker_test.go | 295 ++++++++++++++++++++------- docs/roadmap/implementation.md | 6 + examples/woodpecker/tag-delivery.yml | 32 ++- 3 files changed, 261 insertions(+), 72 deletions(-) diff --git a/cmd/releasekit/woodpecker_test.go b/cmd/releasekit/woodpecker_test.go index d2f3269..8af7d85 100644 --- a/cmd/releasekit/woodpecker_test.go +++ b/cmd/releasekit/woodpecker_test.go @@ -1,11 +1,13 @@ package main import ( + "errors" "fmt" "os" "os/exec" "path/filepath" "runtime" + "strconv" "strings" "testing" @@ -19,30 +21,15 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) { if err != nil { t.Fatal(err) } - pin, err := woodpeckerMatrixValue(string(contents), "RELEASEKIT_VERSION") - if err != nil { - t.Fatal(err) - } block, err := extractWoodpeckerCommand(string(contents), "deliver") if err != nil { t.Fatal(err) } - command := strings.ReplaceAll(block, "${RELEASEKIT_VERSION}", pin) - command = strings.ReplaceAll(command, "$${", "${") + command := strings.ReplaceAll(block, "$${", "${") - executable := filepath.Join(t.TempDir(), "releasekit") - build := exec.Command("go", "build", "-o", executable, "./cmd/releasekit") - build.Dir = root - if output, err := build.CombinedOutput(); err != nil { - t.Fatalf("build local ReleaseKit executable: %v\n%s", err, output) - } - - module := "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@" + pin t.Run("source-only", func(t *testing.T) { repository := newCLIRepository(t) - writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire example.com/unavailable/module v1.2.3\n") - cliGitRun(t, repository, "add", "go.mod") - cliGitRun(t, repository, "commit", "-m", "declare unavailable dependency") + selectReleaseKitModule(t, repository, root, false) state := &deliveryServerState{} server := newDeliveryServer(t, state) defer server.Close() @@ -53,24 +40,22 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) { commit := pushTaggedCLIRepository(t, repository) state.commit = commit - shimDir, goLog := installGoBootstrapShim(t) - env := templateEnvironment(repository, commit, executable, module, shimDir, goLog) - for attempt := 0; attempt < 2; attempt++ { - output := runWoodpeckerBlock(t, command, repository, env) - if !strings.Contains(output, "state complete") { - t.Fatalf("delivery attempt %d output=%q", attempt+1, output) - } + goDir, goLog := installGoBootstrapShim(t) + env := templateEnvironment(repository, commit, goDir, goLog) + moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod")) + output := runWoodpeckerBlock(t, command, repository, env) + if !strings.Contains(output, "state complete") { + t.Fatalf("delivery output=%q", output) } if state.releaseCreates != 1 || state.assetUploads != 0 { t.Fatalf("source-only calls = creates:%d uploads:%d", state.releaseCreates, state.assetUploads) } - if log, err := os.ReadFile(goLog); err != nil || strings.Contains(string(log), "mod download") { - t.Fatalf("source-only command downloaded application dependencies: log=%q error=%v", log, err) - } + assertModuleBootstrap(t, repository, goLog, moduleBefore) }) t.Run("binary", func(t *testing.T) { repository := newDeliveryBuildRepository(t) + selectReleaseKitModule(t, repository, root, true) state := &deliveryServerState{} server := newDeliveryServer(t, state) defer server.Close() @@ -86,8 +71,10 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) { commit := pushTaggedCLIRepository(t, repository) state.commit = commit - shimDir, goLog := installGoBootstrapShim(t) - env := templateEnvironment(repository, commit, executable, module, shimDir, goLog) + goDir, goLog := installGoBootstrapShim(t) + env := templateEnvironment(repository, commit, goDir, goLog) + moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod")) + sumBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.sum")) for attempt := 0; attempt < 2; attempt++ { output := runWoodpeckerBlock(t, command, repository, env) if !strings.Contains(output, "state complete") { @@ -106,9 +93,55 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) { if _, ok := state.assets["SHA256SUMS"]; !ok { t.Fatalf("checksum manifest was not delivered: %#v", state.assets) } - if log, err := os.ReadFile(goLog); err != nil || !strings.Contains(string(log), "mod download") { - t.Fatalf("binary command did not prepare application dependencies: log=%q error=%v", log, err) + assertModuleBootstrap(t, repository, goLog, moduleBefore, sumBefore) + }) + + t.Run("wrong tag stops before Go", func(t *testing.T) { + repository := newCLIRepository(t) + selectReleaseKitModule(t, repository, root, false) + commit := pushTaggedCLIRepository(t, repository) + goDir, goLog := installControlledGoBootstrapShim(t) + env := templateEnvironment(repository, strings.Repeat("f", len(commit)), goDir, goLog) + if output, err := executeWoodpeckerBlock(command, repository, env); err == nil { + t.Fatalf("wrong tag succeeded: %s", output) } + assertNoCommands(t, goLog) + }) + + t.Run("dirty module stops before Go", func(t *testing.T) { + repository := newCLIRepository(t) + selectReleaseKitModule(t, repository, root, false) + commit := pushTaggedCLIRepository(t, repository) + writeRepositoryFile(t, repository, "go.mod", "module example.com/different-tool-selection\n\ngo 1.25.0\n") + goDir, goLog := installControlledGoBootstrapShim(t) + env := templateEnvironment(repository, commit, goDir, goLog) + if output, err := executeWoodpeckerBlock(command, repository, env); err == nil { + t.Fatalf("dirty module succeeded: %s", output) + } + assertNoCommands(t, goLog) + }) + + t.Run("selected executable receives event arguments and its status", func(t *testing.T) { + repository := newCLIRepository(t) + selectReleaseKitModule(t, repository, root, false) + commit := pushTaggedCLIRepository(t, repository) + goDir, goLog := installControlledGoBootstrapShim(t) + toolArgs := filepath.Join(t.TempDir(), "tool-arguments") + env := templateEnvironment(repository, strings.ToUpper(commit), goDir, goLog) + env["RELEASEKIT_TOOL_ARGS_LOG"] = toolArgs + env["RELEASEKIT_TOOL_EXIT"] = "37" + output, err := executeWoodpeckerBlock(command, repository, env) + var exitError *exec.ExitError + if !errors.As(err, &exitError) || exitError.ExitCode() != 37 { + t.Fatalf("template error = %v, want propagated exit 37; output=%q", err, output) + } + args := readWoodpeckerFixtureFile(t, toolArgs) + for _, expected := range []string{"", "<--repo>", "<" + repository + ">", "<--commit>", "<" + strings.ToUpper(commit) + ">", ""} { + if !strings.Contains(args, expected) { + t.Errorf("selected executable arguments %q omit %q", args, expected) + } + } + assertGoSelection(t, repository, goLog) }) } @@ -156,33 +189,12 @@ func extractWoodpeckerCommand(contents, stepName string) (string, error) { return "", fmt.Errorf("step %q was not found", stepName) } -func woodpeckerMatrixValue(contents, key string) (string, error) { - for _, line := range strings.Split(contents, "\n") { - field, value, ok := strings.Cut(strings.TrimSpace(line), ":") - if ok && field == key { - return strings.TrimSpace(value), nil - } - } - return "", fmt.Errorf("matrix value %q was not found", key) -} - func installGoBootstrapShim(t *testing.T) (string, string) { t.Helper() shimDir := t.TempDir() goLog := filepath.Join(shimDir, "go-commands") shim := `#!/bin/sh -if [ "$1" = "run" ]; then - shift - [ "$1" = "$RELEASEKIT_EXPECTED_MODULE" ] || { echo "unexpected module: $1" >&2; exit 90; } - shift - [ "$1" = "ci-deliver" ] || { echo "unexpected command: $1" >&2; exit 91; } - [ "$2" = "--repo" ] && [ "$3" = "$CI_WORKSPACE" ] || { echo "unexpected repository arguments" >&2; exit 92; } - [ "$4" = "--commit" ] && [ "$5" = "$CI_COMMIT_SHA" ] || { echo "unexpected commit arguments" >&2; exit 93; } - [ "$6" = "$CI_COMMIT_TAG" ] && [ "$#" = 6 ] || { echo "unexpected version argument" >&2; exit 94; } - printf '%s\n' "run $RELEASEKIT_EXPECTED_MODULE" >> "$RELEASEKIT_GO_LOG" - exec "$RELEASEKIT_LOCAL_EXECUTABLE" "$@" -fi -printf '%s\n' "$*" >> "$RELEASEKIT_GO_LOG" +printf 'cwd=%s GOWORK=%s GOTOOLCHAIN=%s %s\n' "$PWD" "$GOWORK" "$GOTOOLCHAIN" "$*" >> "$RELEASEKIT_GO_LOG" exec "$RELEASEKIT_REAL_GO" "$@" ` if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil { @@ -191,27 +203,79 @@ exec "$RELEASEKIT_REAL_GO" "$@" return shimDir, goLog } -func templateEnvironment(repository, commit, executable, module, shimDir, goLog string) map[string]string { +func installControlledGoBootstrapShim(t *testing.T) (string, string) { + t.Helper() + shimDir := t.TempDir() + goLog := filepath.Join(shimDir, "go-commands") + shim := `#!/bin/sh +set -eu +printf 'cwd=%s GOWORK=%s GOTOOLCHAIN=%s %s\n' "$PWD" "$GOWORK" "$GOTOOLCHAIN" "$*" >> "$RELEASEKIT_GO_LOG" +modfile= +for argument in "$@"; do + case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac +done +[ -n "$modfile" ] && [ -f "$modfile" ] || { echo 'alternate module file is missing' >&2; exit 89; } +[ -f "${modfile%.mod}.sum" ] || { echo 'alternate checksum file is missing' >&2; exit 88; } +grep -F "$RELEASEKIT_MODULE v0.2.0" "$modfile" >/dev/null || { echo 'module selection was not copied' >&2; exit 87; } +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" + ;; + *) + echo "unexpected Go command: $1" >&2 + exit 91 + ;; +esac +` + if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil { + t.Fatal(err) + } + return shimDir, goLog +} + +func templateEnvironment(repository, commit, shimDir, goLog string) map[string]string { realGo, _ := exec.LookPath("go") return map[string]string{ - "CI_WORKSPACE": repository, - "CI_COMMIT_SHA": commit, - "CI_COMMIT_TAG": "v1.2.3", - "RELEASEKIT_GITEA_TOKEN": "fixture-token", - "RELEASEKIT_EXPECTED_MODULE": module, - "RELEASEKIT_LOCAL_EXECUTABLE": executable, - "RELEASEKIT_REAL_GO": realGo, - "RELEASEKIT_GO_LOG": goLog, - "GOTOOLCHAIN": "local", - "GOPROXY": "off", - "GOSUMDB": "off", - "GOFLAGS": "", - "PATH": shimDir + string(os.PathListSeparator) + os.Getenv("PATH"), + "CI_WORKSPACE": repository, + "CI_COMMIT_SHA": commit, + "CI_COMMIT_TAG": "v1.2.3", + "RELEASEKIT_GITEA_TOKEN": "fixture-token", + "RELEASEKIT_MODULE": "gitea.maximumdirect.net/eric/ReleaseKit", + "RELEASEKIT_REAL_GO": realGo, + "RELEASEKIT_GO_LOG": goLog, + "GOTOOLCHAIN": "local", + "GOPROXY": "off", + "GOSUMDB": "off", + "GOFLAGS": "", + "PATH": shimDir + string(os.PathListSeparator) + os.Getenv("PATH"), } } func runWoodpeckerBlock(t *testing.T, block, repository string, environment map[string]string) string { t.Helper() + output, err := executeWoodpeckerBlock(block, repository, environment) + if err != nil { + t.Fatalf("Woodpecker command failed: %v\ncommand: %s\noutput: %s", err, block, output) + } + return output +} + +func executeWoodpeckerBlock(block, repository string, environment map[string]string) (string, error) { command := exec.Command("sh", "-e", "-c", block) command.Dir = repository command.Env = os.Environ() @@ -219,10 +283,101 @@ func runWoodpeckerBlock(t *testing.T, block, repository string, environment map[ command.Env = setEnvironment(command.Env, key, value) } output, err := command.CombinedOutput() - if err != nil { - t.Fatalf("Woodpecker command failed: %v\ncommand: %s\noutput: %s", err, block, output) + return string(output), err +} + +func selectReleaseKitModule(t *testing.T, repository, releaseKitSource string, includeSum bool) { + t.Helper() + const module = "gitea.maximumdirect.net/eric/ReleaseKit" + goMod := fmt.Sprintf("module example.com/tagged-app\n\ngo 1.25.0\n\nrequire %s v0.2.0\n\nreplace %s => %s\n", module, module, strconv.Quote(releaseKitSource)) + writeRepositoryFile(t, repository, "go.mod", goMod) + cliGitRun(t, repository, "add", "go.mod") + if includeSum { + writeRepositoryFile(t, repository, "go.sum", "") + cliGitRun(t, repository, "add", "go.sum") } - return string(output) + cliGitRun(t, repository, "commit", "-m", "select ReleaseKit from module configuration") +} + +func assertModuleBootstrap(t *testing.T, repository, goLog, moduleBefore string, sumBefore ...string) { + t.Helper() + assertGoSelection(t, repository, goLog) + if got := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod")); got != moduleBefore { + t.Fatalf("source go.mod changed during bootstrap: got %q, want %q", got, moduleBefore) + } + sumPath := filepath.Join(repository, "go.sum") + if len(sumBefore) == 0 { + if _, err := os.Stat(sumPath); !os.IsNotExist(err) { + t.Fatalf("source go.sum state changed: stat error = %v", err) + } + } else if got := readWoodpeckerFixtureFile(t, sumPath); got != sumBefore[0] { + t.Fatalf("source go.sum changed during bootstrap: got %q, want %q", got, sumBefore[0]) + } + if status := cliGitOutput(t, repository, "status", "--porcelain", "--untracked-files=all"); status != "" { + t.Fatalf("bootstrap changed tagged source: %q", status) + } + if branch := cliGitOutput(t, repository, "rev-parse", "--abbrev-ref", "HEAD"); branch != "HEAD" { + t.Fatalf("checkout branch = %q, want detached HEAD", branch) + } + 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()) + } + } +} + +func assertGoSelection(t *testing.T, repository, goLog string) { + t.Helper() + log := readWoodpeckerFixtureFile(t, goLog) + list := "list -mod=mod -modfile=" + build := "build -mod=mod -modfile=" + module := "gitea.maximumdirect.net/eric/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.Index(log, list) > strings.Index(log, build) { + t.Fatalf("Go build ran before module selection: %q", log) + } + var modfile string + for _, field := range strings.Fields(log) { + if strings.HasPrefix(field, "-modfile=") { + modfile = strings.TrimPrefix(field, "-modfile=") + break + } + } + if modfile == "" { + t.Fatalf("Go commands did not receive an alternate module file: %q", log) + } + if relative, err := filepath.Rel(repository, modfile); err != nil || relative == "." || (!strings.HasPrefix(relative, ".."+string(filepath.Separator)) && relative != "..") { + t.Fatalf("alternate module file %q is not outside source %q: relative=%q error=%v", modfile, repository, relative, err) + } +} + +func assertNoCommands(t *testing.T, goLog string) { + t.Helper() + contents, err := os.ReadFile(goLog) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + if len(contents) != 0 { + t.Fatalf("Go ran before tag/source verification: %q", contents) + } +} + +func readWoodpeckerFixtureFile(t *testing.T, path string) string { + t.Helper() + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(contents) } func setEnvironment(environment []string, key, value string) []string { diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 5cbfb24..62d68b9 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -506,6 +506,12 @@ one additional-check callback. Run the compiled API/dependency check, including ## Stage 12: Build the CI tool from verified released module configuration +**Completion:** Complete. The maintained tag workflow verifies and checks out +the event tag before reading module files, then confirms the ReleaseKit module +selection and builds its CLI with temporary alternate module metadata outside +the source tree. The delivery command retains the event arguments and exit +status, and the cleanup trap removes its module copies and executable. + **Outcome:** The standard template has one tool-version declaration. 1. Revise `examples/woodpecker/tag-delivery.yml` into a compact bootstrap/delivery diff --git a/examples/woodpecker/tag-delivery.yml b/examples/woodpecker/tag-delivery.yml index e0aa067..4a624aa 100644 --- a/examples/woodpecker/tag-delivery.yml +++ b/examples/woodpecker/tag-delivery.yml @@ -1,7 +1,6 @@ matrix: include: - GO_IMAGE: golang:1.25.0-bookworm - RELEASEKIT_VERSION: v0.1.0 when: - event: tag @@ -15,7 +14,36 @@ steps: from_secret: releasekit_gitea_token commands: - | - go run gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@${RELEASEKIT_VERSION} ci-deliver \ + set -eu + test -n "$${CI_COMMIT_TAG}" + test -n "$${CI_COMMIT_SHA}" + git check-ref-format "refs/tags/$${CI_COMMIT_TAG}" + git fetch origin "refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}" + tag_commit=$(git rev-parse "refs/tags/$${CI_COMMIT_TAG}^{commit}") + expected_commit=$(printf '%s' "$${CI_COMMIT_SHA}" | tr '[:upper:]' '[:lower:]') + test "$(printf '%s' "$${tag_commit}" | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}" + git checkout --detach "$${tag_commit}" + test "$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}" + test -z "$(git status --porcelain --untracked-files=all)" + + bootstrap_dir=$(mktemp -d "$${CI_WORKSPACE%/*}/.releasekit-bootstrap.XXXXXX") + trap 'rm -rf "$${bootstrap_dir}"' EXIT + cp go.mod "$${bootstrap_dir}/go.mod" + if [ -f go.sum ]; then + cp go.sum "$${bootstrap_dir}/go.sum" + else + : > "$${bootstrap_dir}/go.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=gitea.maximumdirect.net/eric/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 \ --repo "$${CI_WORKSPACE}" \ --commit "$${CI_COMMIT_SHA}" \ "$${CI_COMMIT_TAG}"