Consolidate Woodpecker delivery templates

This commit is contained in:
2026-10-06 19:49:19 +00:00
parent c6a5db8aeb
commit 21294404d7
12 changed files with 295 additions and 184 deletions

View File

@@ -78,22 +78,30 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
cliGitRun(t, repository, "remote", "set-url", "origin", remote)
cliGitRun(t, repository, "push", "origin", "HEAD:refs/heads/main", "refs/tags/"+version)
// Match the maintained workflow: fetch and bind the tag, then build the
// executable from that detached tagged checkout before page-only delivery.
cliGitRun(t, repository, "fetch", "origin", "refs/tags/"+version+":refs/tags/"+version)
if got := cliGitOutput(t, repository, "rev-parse", "refs/tags/"+version+"^{commit}"); got != state.commit {
t.Fatalf("fetched tag commit = %s, want %s", got, state.commit)
}
cliGitRun(t, repository, "checkout", "--detach", state.commit)
tool := filepath.Join(repository, ".releasekit", "bin", "releasekit")
if err := os.MkdirAll(filepath.Dir(tool), 0o700); err != nil {
template, err := os.ReadFile(filepath.Join(root, "examples", "woodpecker", "releasekit-source-delivery.yml"))
if err != nil {
t.Fatal(err)
}
build := exec.Command("go", "build", "-o", tool, "./cmd/releasekit")
build.Dir = repository
build.Env = append(os.Environ(), "GOTOOLCHAIN=local", "GOPROXY=off", "GOSUMDB=off")
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build CLI from tagged checkout: %v\n%s", err, output)
buildBlock, err := extractWoodpeckerCommand(string(template), "build-tool")
if err != nil {
t.Fatal(err)
}
deliveryBlock, err := extractWoodpeckerCommand(string(template), "deliver-page")
if err != nil {
t.Fatal(err)
}
env := map[string]string{
"CI_WORKSPACE": repository,
"CI_COMMIT_SHA": state.commit,
"CI_COMMIT_TAG": version,
"RELEASEKIT_GITEA_TOKEN": "fixture-token",
"GOPROXY": "off",
"GOSUMDB": "off",
}
runWoodpeckerBlock(t, strings.ReplaceAll(buildBlock, "$${", "${"), repository, env)
if got := cliGitOutput(t, repository, "rev-parse", "HEAD"); got != state.commit {
t.Fatalf("template checkout = %s, want %s", got, state.commit)
}
state.remoteCommit = strings.Repeat("f", 40)
@@ -109,7 +117,7 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
state.remoteCommit = ""
for attempt := 0; attempt < 2; attempt++ {
output := runWoodpeckerCommand(t, tool, repository, true, "deliver", "--repo", repository, version)
output := runWoodpeckerBlock(t, strings.ReplaceAll(deliveryBlock, "$${", "${"), repository, env)
if !strings.Contains(output, "state complete") {
t.Fatalf("delivery attempt %d output = %q", attempt+1, output)
}

View File

@@ -1,8 +1,7 @@
package main
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -13,12 +12,24 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
)
func TestWoodpeckerDeliveryCommandSequences(t *testing.T) {
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate integration test source")
func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
root := cliRepositoryRoot(t)
templatePath := filepath.Join(root, "examples", "woodpecker", "tag-delivery.yml")
contents, err := os.ReadFile(templatePath)
if err != nil {
t.Fatal(err)
}
root := filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "../.."))
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, "$${", "${")
executable := filepath.Join(t.TempDir(), "releasekit")
build := exec.Command("go", "build", "-o", executable, "./cmd/releasekit")
build.Dir = root
@@ -26,8 +37,12 @@ func TestWoodpeckerDeliveryCommandSequences(t *testing.T) {
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")
state := &deliveryServerState{}
server := newDeliveryServer(t, state)
defer server.Close()
@@ -35,13 +50,22 @@ func TestWoodpeckerDeliveryCommandSequences(t *testing.T) {
Product: "Example Product",
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
})
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD")
cliGitRun(t, repository, "tag", "v1.2.3")
cliGitRun(t, repository, "checkout", "--detach", "v1.2.3")
commit := pushTaggedCLIRepository(t, repository)
state.commit = commit
output := runWoodpeckerCommand(t, executable, repository, true, "deliver", "--repo", repository, "v1.2.3")
if !strings.Contains(output, "state complete") || state.releaseCreates != 1 || state.assetUploads != 0 {
t.Fatalf("source-only workflow output=%q creates=%d uploads=%d", output, state.releaseCreates, state.assetUploads)
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)
}
}
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)
}
})
@@ -51,56 +75,163 @@ func TestWoodpeckerDeliveryCommandSequences(t *testing.T) {
server := newDeliveryServer(t, state)
defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{
Product: "Example Product",
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
Product: "Example Product",
Build: &cli.BuildSettings{
MainPackage: ".",
BinaryName: "helper",
Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)},
},
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
})
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD")
cliGitRun(t, repository, "tag", "v1.2.3")
cliGitRun(t, repository, "checkout", "--detach", "v1.2.3")
exclude, err := os.OpenFile(filepath.Join(repository, ".git", "info", "exclude"), os.O_APPEND|os.O_WRONLY, 0)
if err != nil {
t.Fatal(err)
}
if _, err := exclude.WriteString("\n/.releasekit/\n"); err != nil {
t.Fatal(err)
}
if err := exclude.Close(); err != nil {
t.Fatal(err)
}
assets := filepath.Join(repository, ".releasekit", "assets")
if err := os.MkdirAll(filepath.Dir(assets), 0o700); err != nil {
t.Fatal(err)
}
commit := pushTaggedCLIRepository(t, repository)
state.commit = commit
// This follows the binary template's build then delivery commands, with
// the executable built above replacing its pinned go install step.
runWoodpeckerCommand(t, executable, repository, false, "build", "--repo", repository, "--output", assets, "v1.2.3")
output := runWoodpeckerCommand(t, executable, repository, true, "deliver", "--repo", repository, "--assets", assets, "v1.2.3")
if !strings.Contains(output, "state complete") || state.releaseCreates != 1 || state.assetUploads != 2 {
t.Fatalf("binary workflow output=%q creates=%d uploads=%d", output, state.releaseCreates, state.assetUploads)
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)
}
if attempt == 1 && !strings.Contains(output, "matching SHA256SUMS") {
t.Fatalf("second delivery did not reuse matching assets: %q", output)
}
}
if _, ok := state.assets["helper-v1.2.3-linux-amd64"]; !ok {
if state.releaseCreates != 1 || state.assetUploads != 2 {
t.Fatalf("binary calls = creates:%d uploads:%d", state.releaseCreates, state.assetUploads)
}
if _, ok := state.assets["helper-v1.2.3-"+runtime.GOOS+"-"+runtime.GOARCH]; !ok {
t.Fatalf("binary asset was not delivered: %#v", state.assets)
}
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)
}
})
}
func runWoodpeckerCommand(t *testing.T, executable, repository string, credential bool, args ...string) string {
func cliRepositoryRoot(t *testing.T) string {
t.Helper()
command := exec.CommandContext(context.Background(), executable, args...)
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate integration test source")
}
return filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "../.."))
}
func extractWoodpeckerCommand(contents, stepName string) (string, error) {
lines := strings.Split(contents, "\n")
stepHeader := " - name: " + stepName
for i, line := range lines {
if strings.TrimSpace(line) != strings.TrimSpace(stepHeader) {
continue
}
for j := i + 1; j < len(lines); j++ {
trimmed := strings.TrimSpace(lines[j])
if strings.HasPrefix(lines[j], " - name:") {
break
}
if trimmed != "- |" {
continue
}
var block []string
for k := j + 1; k < len(lines); k++ {
current := lines[k]
if strings.TrimSpace(current) == "" {
block = append(block, "")
continue
}
indent := len(current) - len(strings.TrimLeft(current, " "))
if indent < 8 {
break
}
block = append(block, current[8:])
}
return strings.TrimSpace(strings.Join(block, "\n")), nil
}
return "", fmt.Errorf("step %q has no literal command block", stepName)
}
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"
exec "$RELEASEKIT_REAL_GO" "$@"
`
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil {
t.Fatal(err)
}
return shimDir, goLog
}
func templateEnvironment(repository, commit, executable, module, 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"),
}
}
func runWoodpeckerBlock(t *testing.T, block, repository string, environment map[string]string) string {
t.Helper()
command := exec.Command("sh", "-e", "-c", block)
command.Dir = repository
command.Env = os.Environ()
if credential {
command.Env = append(command.Env, "RELEASEKIT_GITEA_TOKEN=fixture-token")
for key, value := range environment {
command.Env = setEnvironment(command.Env, key, value)
}
var stdout, stderr bytes.Buffer
command.Stdout, command.Stderr = &stdout, &stderr
if err := command.Run(); err != nil {
t.Fatalf("ReleaseKit %v failed: %v\nstdout: %s\nstderr: %s", args, err, stdout.String(), stderr.String())
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("Woodpecker command failed: %v\ncommand: %s\noutput: %s", err, block, output)
}
return stdout.String()
return string(output)
}
func setEnvironment(environment []string, key, value string) []string {
prefix := key + "="
for i, entry := range environment {
if strings.HasPrefix(entry, prefix) {
environment[i] = prefix + value
return environment
}
}
return append(environment, prefix+value)
}