From 7cd5edd65027c25fd3d1fd927695d403e9dd8311 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 11 Oct 2026 01:51:44 +0000 Subject: [PATCH] Resolve annotated tag events before CI delivery --- .woodpecker/release.yml | 4 +- docs/release.md | 6 ++- scripts/release_test.go | 106 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 111 insertions(+), 5 deletions(-) diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml index 522d20f..b4d2ff7 100644 --- a/.woodpecker/release.yml +++ b/.woodpecker/release.yml @@ -20,7 +20,7 @@ steps: 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:]') + expected_commit=$(git rev-parse --verify "$${CI_COMMIT_SHA}^{commit}") 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}" @@ -46,5 +46,5 @@ steps: "$${bootstrap_dir}/releasekit" ci-deliver \ --repo "$${CI_WORKSPACE}" \ --prepared \ - --commit "$${CI_COMMIT_SHA}" \ + --commit "$${expected_commit}" \ "$${CI_COMMIT_TAG}" diff --git a/docs/release.md b/docs/release.md index 3780396..99a013c 100644 --- a/docs/release.md +++ b/docs/release.md @@ -104,7 +104,9 @@ Woodpecker runs ReleaseKit's `ci-deliver` command for tags under - `SHA256SUMS` 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 +out detached, and confirms the source tree is clean. It peels the event object +ID to its commit before comparing it with the tag's commit, accepting both +annotated tag objects and commit IDs from Woodpecker. 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 @@ -120,7 +122,7 @@ The resulting executable receives: "$${bootstrap_dir}/releasekit" ci-deliver \ --repo "$${CI_WORKSPACE}" \ --prepared \ - --commit "$${CI_COMMIT_SHA}" \ + --commit "$${expected_commit}" \ "$${CI_COMMIT_TAG}" ``` diff --git a/scripts/release_test.go b/scripts/release_test.go index 7e72762..95d8cf7 100644 --- a/scripts/release_test.go +++ b/scripts/release_test.go @@ -118,7 +118,7 @@ func TestDeliveryWorkflowUsesModuleSelectedReleaseKit(t *testing.T) { "git fetch origin", "refs/tags/$${CI_COMMIT_TAG}", "git checkout --detach", "git status --porcelain", "go list", "-modfile=", "-m", "go build", "$${releasekit_module}/cmd/releasekit", - "ci-deliver", "--repo \"$${CI_WORKSPACE}\"", "--prepared", "--commit \"$${CI_COMMIT_SHA}\"", + "ci-deliver", "--repo \"$${CI_WORKSPACE}\"", "--prepared", "--commit", "\"$${CI_COMMIT_TAG}\"", } { if !strings.Contains(command, fragment) { @@ -160,6 +160,110 @@ func TestReleaseWrapperStopsWhenBootstrapFails(t *testing.T) { } } +func TestDeliveryWorkflowAcceptsTagObjectsAndCommits(t *testing.T) { + var workflow struct { + Steps []struct { + Commands []string `yaml:"commands"` + } `yaml:"steps"` + } + if err := yaml.Unmarshal([]byte(readText(t, "../.woodpecker/release.yml")), &workflow); err != nil { + t.Fatal(err) + } + command := strings.ReplaceAll(workflow.Steps[0].Commands[0], "$${", "${") + root := t.TempDir() + source := filepath.Join(root, "source") + layout := releaseTestLayout{ + repo: filepath.Join(root, "checkout"), path: filepath.Join(root, "bin"), + argsLog: filepath.Join(root, "tool-arguments"), + } + for _, directory := range []string{source, layout.path} { + if err := os.MkdirAll(directory, 0755); err != nil { + t.Fatal(err) + } + } + git := func(dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %q: %v\n%s", args, err, output) + } + return strings.TrimSpace(string(output)) + } + git(source, "init", "-b", "main") + git(source, "config", "user.name", "Release test") + git(source, "config", "user.email", "release-test@example.invalid") + writeReleaseTestFile(t, filepath.Join(source, "go.mod"), []byte(readText(t, "../go.mod")), 0644) + writeReleaseTestFile(t, filepath.Join(source, "go.sum"), nil, 0644) + git(source, "add", "go.mod", "go.sum") + git(source, "commit", "-m", "Initial fixture") + unrelated := git(source, "rev-parse", "HEAD") + git(source, "commit", "--allow-empty", "-m", "Release fixture") + commit := git(source, "rev-parse", "HEAD") + git(source, "tag", "-a", "v3.1.0", "-m", "Annotated release") + git(source, "tag", "v3.1.1") + tagObject := git(source, "rev-parse", "v3.1.0") + git(root, "clone", "--quiet", source, layout.repo) + toolStub := filepath.Join(root, "releasekit-stub") + writeReleaseTestFile(t, toolStub, []byte(`#!/bin/sh +printf '%s\0' "$@" > "$RELEASEKIT_ARGS_LOG" +`), 0755) + writeReleaseTestFile(t, filepath.Join(layout.path, "go"), []byte(`#!/bin/sh +set -eu +case "$1" in + list) exit 0 ;; + build) + while [ "$#" -gt 0 ]; do + if [ "$1" = "-o" ]; then + cp "$RELEASEKIT_STUB" "$2" + exit 0 + fi + shift + done ;; +esac +exit 1 +`), 0755) + for _, tc := range []struct { + name, tag, eventID string + success bool + }{ + {"annotated tag object", "v3.1.0", tagObject, true}, + {"annotated tag commit", "v3.1.0", commit, true}, + {"lightweight tag commit", "v3.1.1", commit, true}, + {"unrelated commit", "v3.1.0", unrelated, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := os.Remove(layout.argsLog); err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + cmd := exec.Command("sh", "-c", command) + cmd.Dir = layout.repo + cmd.Env = layout.environment(map[string]string{ + "CI_WORKSPACE": layout.repo, "CI_COMMIT_TAG": tc.tag, "CI_COMMIT_SHA": tc.eventID, + "RELEASEKIT_ARGS_LOG": layout.argsLog, "RELEASEKIT_STUB": toolStub, + }) + output, err := cmd.CombinedOutput() + if tc.success { + if err != nil { + t.Fatalf("workflow failed: %v\n%s", err, output) + } + want := []string{"ci-deliver", "--repo", layout.repo, "--prepared", "--commit", commit, tc.tag} + if got := readNULArguments(t, layout.argsLog); !reflect.DeepEqual(got, want) { + t.Fatalf("delivery arguments = %q, want %q", got, want) + } + } else { + if err == nil { + t.Fatal("workflow accepted an unrelated commit") + } + if _, err := os.Stat(layout.argsLog); !os.IsNotExist(err) { + t.Fatalf("delivery ran after mismatched event; stat error = %v", err) + } + } + }) + } +} + func TestReleaseKitProfileDeclaresProductAndDeliverySettings(t *testing.T) { data, err := os.ReadFile("../releasekit.json") if err != nil {