From 17421bc5f352f3fdeb67561b2b60b49979822228 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 7 Oct 2026 23:45:27 +0000 Subject: [PATCH] Use the tagged module selection for CI delivery --- .woodpecker/release.yml | 32 +++++- docs/release.md | 36 +++--- scripts/release.sh | 20 +++- scripts/release_test.go | 245 ++++++++++++++++++++++++++++++++++------ 4 files changed, 281 insertions(+), 52 deletions(-) diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml index ec210e8..f15a50f 100644 --- a/.woodpecker/release.yml +++ b/.woodpecker/release.yml @@ -1,7 +1,6 @@ matrix: include: - GO_IMAGE: golang:1.25.5 - RELEASEKIT_VERSION: v0.1.0 when: - event: tag @@ -15,7 +14,36 @@ steps: from_secret: GITEA_RELEASE_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}" diff --git a/docs/release.md b/docs/release.md index e0130bd..bcdce80 100644 --- a/docs/release.md +++ b/docs/release.md @@ -19,9 +19,10 @@ GOWORK=off GOTOOLCHAIN=local go mod download ``` If dependency preparation changes `go.mod` or `go.sum`, include those changes in -the committed candidate. The release wrapper separately bootstraps the pinned -ReleaseKit command into ignored `bin/releasekit`; that build is tool preparation, -not candidate validation. +the committed candidate. The release wrapper separately builds the ReleaseKit +version selected by `go.mod` into ignored `bin/releasekit`. It uses temporary +copies of `go.mod` and `go.sum` for tool dependency preparation; that build is +tool preparation, not candidate validation. ## Prepare and publish the candidate @@ -71,29 +72,36 @@ profile, or version input error; and `3` means the push outcome remains unknown. ## Binary delivery -Woodpecker runs the pinned ReleaseKit `ci-deliver` command for tags under -`refs/tags/v*`. It verifies the triggering commit against the tag, prepares the -tagged source, and uses `releasekit.json` to build and deliver: +Woodpecker runs ReleaseKit's `ci-deliver` command for tags under +`refs/tags/v*`. The tagged `go.mod` selects the ReleaseKit version, and +`releasekit.json` supplies Audita's build and delivery settings: - `audita--linux-amd64` - `audita--linux-arm64` - `SHA256SUMS` -The workflow invokes: +Before running Go, the workflow fetches and verifies the event tag, checks it +out detached, and confirms the source tree is clean. It copies the tagged +`go.mod` and `go.sum` into temporary module metadata outside the checkout. Go +confirms that this configuration selects ReleaseKit, then builds its CLI using +an alternate `-modfile`. The bootstrap executable and temporary files remain +outside the tagged source. The workflow runs with the existing Go 1.25.5 image, +`GOWORK=off`, and `GOTOOLCHAIN=local`. + +The resulting executable receives: ```sh -go run gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@${RELEASEKIT_VERSION} ci-deliver \ +"$${bootstrap_dir}/releasekit" ci-deliver \ --repo "$${CI_WORKSPACE}" \ --commit "$${CI_COMMIT_SHA}" \ "$${CI_COMMIT_TAG}" ``` -Woodpecker sets `RELEASEKIT_VERSION` to `v0.1.0` for this workflow. - -The complete checked-in release note, including its licensing footer, supplies -the hosted release body. The workflow maps the existing `GITEA_RELEASE_TOKEN` -secret to `RELEASEKIT_GITEA_TOKEN`. Delivery runs independently after source -publication; its status and logs report delivery progress or failure. +The workflow maps the existing `GITEA_RELEASE_TOKEN` secret to +`RELEASEKIT_GITEA_TOKEN`. The complete checked-in release note, including its +licensing footer, supplies the hosted release body. Delivery runs independently +after source publication; its status and logs report delivery progress or +failure. ReleaseKit handles content-aware retries and retained-artifact recovery. See the pinned [publication recovery guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.1.0/docs/consumers/publish.md) diff --git a/scripts/release.sh b/scripts/release.sh index 0e48983..511fb8e 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -3,7 +3,23 @@ set -euo pipefail script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) cd "$script_dir/.." + +releasekit_module=gitea.maximumdirect.net/eric/ReleaseKit +bootstrap_dir=$(mktemp -d "${TMPDIR:-/tmp}/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" + mkdir -p bin -GOWORK=off GOTOOLCHAIN=local go build -mod=readonly \ - -o ./bin/releasekit gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit +GOWORK=off GOTOOLCHAIN=local go list -mod=mod -modfile="$bootstrap_dir/releasekit.mod" -m "$releasekit_module" >/dev/null +GOWORK=off GOTOOLCHAIN=local go build -mod=mod -modfile="$bootstrap_dir/releasekit.mod" \ + -o ./bin/releasekit "$releasekit_module/cmd/releasekit" +rm -rf "$bootstrap_dir" +trap - EXIT exec ./bin/releasekit "$@" diff --git a/scripts/release_test.go b/scripts/release_test.go index 11aa36c..d48d1b6 100644 --- a/scripts/release_test.go +++ b/scripts/release_test.go @@ -11,33 +11,55 @@ import ( "strconv" "strings" "testing" + + "gopkg.in/yaml.v3" ) const goStub = `#!/usr/bin/env bash set -euo pipefail -printf '%s\0' "$PWD" "GOWORK=$GOWORK" "GOTOOLCHAIN=$GOTOOLCHAIN" "$@" > "$RELEASEKIT_BUILD_LOG" -if [[ ${RELEASEKIT_BOOTSTRAP_STATUS:-0} != 0 ]]; then - exit "$RELEASEKIT_BOOTSTRAP_STATUS" -fi -output= -while (($#)); do - case $1 in - -o) +case ${1:?missing Go command} in +list) + log=$RELEASEKIT_LIST_LOG + printf '%s\0' "$PWD" "GOWORK=$GOWORK" "GOTOOLCHAIN=$GOTOOLCHAIN" "$@" > "$log" + modfile= + for argument in "$@"; do + case $argument in -modfile=*) modfile=${argument#-modfile=} ;; esac + done + [[ -n $modfile && -f $modfile && -f ${modfile%.mod}.sum ]] + cp "$modfile" "$RELEASEKIT_MODULE_LOG" + cp "${modfile%.mod}.sum" "$RELEASEKIT_SUM_LOG" + if [[ ${RELEASEKIT_BOOTSTRAP_STATUS:-0} != 0 ]]; then + exit "$RELEASEKIT_BOOTSTRAP_STATUS" + fi + printf '%s\n' v0.1.0 + ;; +build) + printf '%s\0' "$PWD" "GOWORK=$GOWORK" "GOTOOLCHAIN=$GOTOOLCHAIN" "$@" > "$RELEASEKIT_BUILD_LOG" + if [[ ${RELEASEKIT_BOOTSTRAP_STATUS:-0} != 0 ]]; then + exit "$RELEASEKIT_BOOTSTRAP_STATUS" + fi + output= + while (($#)); do + case $1 in + -o) + shift + output=${1:?missing output path} + ;; + -o=*) output=${1#-o=} ;; + esac shift - output=${1:?missing output path} - ;; - -o=*) output=${1#-o=} ;; - esac - shift -done -[[ -n $output ]] -cat > "$output" <<'RELEASEKIT_TOOL' + done + [[ -n $output ]] + cat > "$output" <<'RELEASEKIT_TOOL' #!/usr/bin/env bash set -euo pipefail printf '%s\0' "$PWD" "$@" > "$RELEASEKIT_ARGS_LOG" exit "${RELEASEKIT_TOOL_STATUS:-0}" RELEASEKIT_TOOL -chmod +x "$output" + chmod +x "$output" + ;; +*) exit 64 ;; +esac ` func TestReleaseWrapperForwardsNativeArgumentsAndStatus(t *testing.T) { @@ -50,6 +72,9 @@ func TestReleaseWrapperForwardsNativeArgumentsAndStatus(t *testing.T) { cmd.Env = layout.environment(map[string]string{ "RELEASEKIT_ARGS_LOG": layout.argsLog, "RELEASEKIT_BUILD_LOG": layout.buildLog, + "RELEASEKIT_LIST_LOG": layout.listLog, + "RELEASEKIT_MODULE_LOG": layout.moduleLog, + "RELEASEKIT_SUM_LOG": layout.sumLog, "RELEASEKIT_TOOL_STATUS": strconv.Itoa(toolStatus), }) output, err := cmd.CombinedOutput() @@ -60,17 +85,49 @@ func TestReleaseWrapperForwardsNativeArgumentsAndStatus(t *testing.T) { t.Fatalf("unexpected output: %q", output) } + listInvocation := readNULArguments(t, layout.listLog) buildInvocation := readNULArguments(t, layout.buildLog) - if len(buildInvocation) < 4 || buildInvocation[0] != layout.repo { - t.Fatalf("build invocation=%q, want repository %q", buildInvocation, layout.repo) - } - if buildInvocation[1] != "GOWORK=off" || buildInvocation[2] != "GOTOOLCHAIN=local" { - t.Fatalf("build environment=%q", buildInvocation[1:3]) + for name, invocation := range map[string][]string{"list": listInvocation, "build": buildInvocation} { + if len(invocation) < 4 || invocation[0] != layout.repo { + t.Fatalf("%s invocation=%q, want repository %q", name, invocation, layout.repo) + } + if invocation[1] != "GOWORK=off" || invocation[2] != "GOTOOLCHAIN=local" { + t.Fatalf("%s environment=%q", name, invocation[1:3]) + } } + listArgs := listInvocation[3:] buildArgs := buildInvocation[3:] - if buildArgs[0] != "build" || !slices.Contains(buildArgs, "-mod=readonly") || !slices.Contains(buildArgs, "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit") { + modfile := argumentValue(listArgs, "-modfile=") + if listArgs[0] != "list" || !slices.Contains(listArgs, "-mod=mod") || !slices.Contains(listArgs, "-m") || !slices.Contains(listArgs, "gitea.maximumdirect.net/eric/ReleaseKit") { + t.Fatalf("unexpected module-selection arguments: %q", listArgs) + } + if modfile == "" || argumentValue(buildArgs, "-modfile=") != modfile || !slices.Contains(buildArgs, "-mod=mod") { + t.Fatalf("list/build did not share alternate module metadata: list=%q build=%q", listArgs, buildArgs) + } + if buildArgs[0] != "build" || !slices.Contains(buildArgs, "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit") { t.Fatalf("unexpected build arguments: %q", buildArgs) } + if relative, err := filepath.Rel(layout.repo, modfile); err != nil || relative == "." || (!strings.HasPrefix(relative, ".."+string(filepath.Separator)) && relative != "..") { + t.Fatalf("alternate module file %q is not outside repository %q: relative=%q error=%v", modfile, layout.repo, relative, err) + } + if module := readText(t, layout.moduleLog); !strings.Contains(module, "gitea.maximumdirect.net/eric/ReleaseKit v0.1.0") { + t.Fatalf("temporary module file does not retain selected ReleaseKit version: %q", module) + } + if sum := readText(t, layout.sumLog); !strings.Contains(sum, "fixture checksum") { + t.Fatalf("temporary sum file did not copy source checksums: %q", sum) + } + if sourceModule := readText(t, filepath.Join(layout.repo, "go.mod")); !strings.Contains(sourceModule, "gitea.maximumdirect.net/eric/ReleaseKit v0.1.0") { + t.Fatalf("source go.mod changed: %q", sourceModule) + } + if sourceSum := readText(t, filepath.Join(layout.repo, "go.sum")); sourceSum != "fixture checksum\n" { + t.Fatalf("source go.sum changed: %q", sourceSum) + } + if _, err := os.Stat(modfile); !os.IsNotExist(err) { + t.Fatalf("temporary module file remains after wrapper: %v", err) + } + if _, err := os.Stat(filepath.Join(filepath.Dir(modfile), "releasekit.sum")); !os.IsNotExist(err) { + t.Fatalf("temporary sum file remains after wrapper: %v", err) + } if info, err := os.Stat(filepath.Join(layout.repo, "bin", "releasekit")); err != nil || !info.Mode().IsRegular() { t.Fatalf("ReleaseKit executable was not built in bin: %v", err) } @@ -84,6 +141,86 @@ func TestReleaseWrapperForwardsNativeArgumentsAndStatus(t *testing.T) { } } +func TestDeliveryWorkflowUsesModuleSelectedReleaseKit(t *testing.T) { + data, err := os.ReadFile("../.woodpecker/release.yml") + if err != nil { + t.Fatal(err) + } + var workflow struct { + Matrix struct { + Include []map[string]string `yaml:"include"` + } `yaml:"matrix"` + When []struct { + Event string `yaml:"event"` + Ref string `yaml:"ref"` + } `yaml:"when"` + Steps []struct { + Name string `yaml:"name"` + Image string `yaml:"image"` + Environment map[string]struct { + FromSecret string `yaml:"from_secret"` + } `yaml:"environment"` + Commands []string `yaml:"commands"` + } `yaml:"steps"` + } + if err := yaml.Unmarshal(data, &workflow); err != nil { + t.Fatal(err) + } + if len(workflow.Matrix.Include) != 1 || workflow.Matrix.Include[0]["GO_IMAGE"] != "golang:1.25.5" { + t.Fatalf("workflow Go image = %v, want existing Go 1.25.5 image", workflow.Matrix.Include) + } + if len(workflow.When) != 1 || workflow.When[0].Event != "tag" || workflow.When[0].Ref != "refs/tags/v*" { + t.Fatalf("workflow trigger = %+v", workflow.When) + } + if len(workflow.Steps) != 1 || workflow.Steps[0].Name != "deliver" || workflow.Steps[0].Image != "${GO_IMAGE}" { + t.Fatalf("workflow delivery step = %+v", workflow.Steps) + } + if secret := workflow.Steps[0].Environment["RELEASEKIT_GITEA_TOKEN"].FromSecret; secret != "GITEA_RELEASE_TOKEN" { + t.Fatalf("delivery secret = %q, want existing GITEA_RELEASE_TOKEN", secret) + } + if len(workflow.Steps[0].Commands) != 1 { + t.Fatalf("workflow commands = %q, want one bootstrap/delivery command", workflow.Steps[0].Commands) + } + command := workflow.Steps[0].Commands[0] + for _, fragment := range []string{ + "git fetch origin \"refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}\"", + "git checkout --detach \"$${tag_commit}\"", + "git status --porcelain --untracked-files=all", + "mktemp -d \"$${CI_WORKSPACE%/*}/.releasekit-bootstrap.XXXXXX\"", + "trap 'rm -rf \"$${bootstrap_dir}\"' EXIT", + "cp go.mod \"$${bootstrap_dir}/go.mod\"", + "cp go.sum \"$${bootstrap_dir}/go.sum\"", + "export GOWORK=off", + "export GOTOOLCHAIN=local", + "releasekit_module=gitea.maximumdirect.net/eric/ReleaseKit", + "go list -mod=mod -modfile=", + "-m \"$${releasekit_module}\"", + "go build -mod=mod -modfile=", + "-o \"$${bootstrap_dir}/releasekit\" \"$${releasekit_module}/cmd/releasekit\"", + "\"$${bootstrap_dir}/releasekit\" ci-deliver", + "--repo \"$${CI_WORKSPACE}\"", + "--commit \"$${CI_COMMIT_SHA}\"", + "\"$${CI_COMMIT_TAG}\"", + } { + if !strings.Contains(command, fragment) { + t.Fatalf("workflow command is missing %q: %s", fragment, command) + } + } + if strings.Index(command, "git checkout --detach") > strings.Index(command, "go list -mod=mod") || strings.Index(command, "git status --porcelain") > strings.Index(command, "go list -mod=mod") { + t.Fatalf("workflow resolves the tool before verifying the tagged source: %s", command) + } + if strings.Contains(command, "RELEASEKIT_VERSION") || strings.Contains(command, "cmd/releasekit@") { + t.Fatalf("workflow retains a separate tool version: %s", command) + } + goMod, err := os.ReadFile("../go.mod") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(goMod), "gitea.maximumdirect.net/eric/ReleaseKit v0.1.0") { + t.Fatalf("go.mod does not retain selected supported ReleaseKit version: %s", goMod) + } +} + func TestReleaseWrapperStopsWhenBootstrapFails(t *testing.T) { layout := newReleaseTestLayout(t) cmd := exec.Command("bash", layout.wrapper, "publish", "v4.2.0") @@ -91,6 +228,9 @@ func TestReleaseWrapperStopsWhenBootstrapFails(t *testing.T) { cmd.Env = layout.environment(map[string]string{ "RELEASEKIT_ARGS_LOG": layout.argsLog, "RELEASEKIT_BUILD_LOG": layout.buildLog, + "RELEASEKIT_LIST_LOG": layout.listLog, + "RELEASEKIT_MODULE_LOG": layout.moduleLog, + "RELEASEKIT_SUM_LOG": layout.sumLog, "RELEASEKIT_BOOTSTRAP_STATUS": "42", }) output, err := cmd.CombinedOutput() @@ -100,6 +240,17 @@ func TestReleaseWrapperStopsWhenBootstrapFails(t *testing.T) { if _, err := os.Stat(layout.argsLog); !os.IsNotExist(err) { t.Fatalf("ReleaseKit executed after failed bootstrap; stat error=%v", err) } + if _, err := os.Stat(layout.buildLog); !os.IsNotExist(err) { + t.Fatalf("ReleaseKit build ran after module selection failed; stat error=%v", err) + } + listInvocation := readNULArguments(t, layout.listLog) + modfile := argumentValue(listInvocation[3:], "-modfile=") + if modfile == "" { + t.Fatalf("module selection did not use temporary metadata: %q", listInvocation) + } + if _, err := os.Stat(filepath.Dir(modfile)); !os.IsNotExist(err) { + t.Fatalf("temporary bootstrap directory remains after failed selection: %v", err) + } } func TestReleaseKitProfileDeclaresProductAndDeliverySettings(t *testing.T) { @@ -176,23 +327,29 @@ func TestReleaseKitTargetMembership(t *testing.T) { } type releaseTestLayout struct { - repo string - caller string - wrapper string - argsLog string - buildLog string - path string + repo string + caller string + wrapper string + argsLog string + buildLog string + listLog string + moduleLog string + sumLog string + path string } func newReleaseTestLayout(t *testing.T) releaseTestLayout { t.Helper() root := t.TempDir() layout := releaseTestLayout{ - repo: filepath.Join(root, "repository"), - caller: filepath.Join(root, "caller"), - argsLog: filepath.Join(root, "tool-arguments"), - buildLog: filepath.Join(root, "build-arguments"), - path: filepath.Join(root, "bin"), + repo: filepath.Join(root, "repository"), + caller: filepath.Join(root, "caller"), + argsLog: filepath.Join(root, "tool-arguments"), + buildLog: filepath.Join(root, "build-arguments"), + listLog: filepath.Join(root, "list-arguments"), + moduleLog: filepath.Join(root, "temporary-go.mod"), + sumLog: filepath.Join(root, "temporary-go.sum"), + path: filepath.Join(root, "bin"), } if err := os.MkdirAll(filepath.Join(layout.repo, "scripts"), 0755); err != nil { t.Fatal(err) @@ -210,9 +367,29 @@ func newReleaseTestLayout(t *testing.T) releaseTestLayout { layout.wrapper = filepath.Join(layout.repo, "scripts", "release.sh") writeReleaseTestFile(t, layout.wrapper, wrapper, 0755) writeReleaseTestFile(t, filepath.Join(layout.path, "go"), []byte(goStub), 0755) + writeReleaseTestFile(t, filepath.Join(layout.repo, "go.mod"), []byte("module example.com/audita-fixture\n\ngo 1.25.5\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.1.0\n"), 0644) + writeReleaseTestFile(t, filepath.Join(layout.repo, "go.sum"), []byte("fixture checksum\n"), 0644) return layout } +func readText(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func argumentValue(arguments []string, prefix string) string { + for _, argument := range arguments { + if strings.HasPrefix(argument, prefix) { + return strings.TrimPrefix(argument, prefix) + } + } + return "" +} + func (layout releaseTestLayout) environment(values map[string]string) []string { filtered := make([]string, 0, len(os.Environ())+len(values)+1) for _, value := range os.Environ() {