Build the CI tool from tagged module settings

This commit is contained in:
2026-10-07 23:33:00 +00:00
parent 40068af1bf
commit b80d4ee071
3 changed files with 261 additions and 72 deletions

View File

@@ -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{"<ci-deliver>", "<--repo>", "<" + repository + ">", "<--commit>", "<" + strings.ToUpper(commit) + ">", "<v1.2.3>"} {
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 {