From 9e88116a2d8d9c4b76c51c1a5d7824f5ccaf023f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Tue, 21 Jul 2026 14:55:11 +0800 Subject: [PATCH] fix(release): fingerprint allocated tag refs --- .github/workflows/release.yml | 17 +++++--- CHANGELOG.md | 1 + test/scripts/package_script_test.go | 67 ++++++++++++++++++++++++++++- 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 18a3c74f..38a505f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2481,13 +2481,16 @@ jobs: value="$(sed -n "s/^$key=//p" "$allocation")" printf '%s=%s\n' "$key" "$value" >> "$GITHUB_OUTPUT" done - refs_fingerprint="$( - git for-each-ref --format='%(refname)=%(objectname)' \ - refs/tags/v refs/tags/withdrawn/v \ - | LC_ALL=C sort \ - | sha256sum \ - | awk '{print $1}' - )" + refs_unsorted="$RUNNER_TEMP/release-refs.unsorted" + refs_manifest="$RUNNER_TEMP/release-refs" + git for-each-ref --format='%(refname)=%(objectname)' \ + 'refs/tags/v*' 'refs/tags/withdrawn/v*' > "$refs_unsorted" + LC_ALL=C sort "$refs_unsorted" > "$refs_manifest" + test -s "$refs_manifest" || { + echo "release ref manifest is empty after fetching allocated tags" >&2 + exit 1 + } + refs_fingerprint="$(sha256sum "$refs_manifest" | awk '{print $1}')" echo "release_commit=$GITHUB_SHA" >> "$GITHUB_OUTPUT" echo "refs_fingerprint=$refs_fingerprint" >> "$GITHUB_OUTPUT" case "$OSS_MIRROR" in enabled|deferred) ;; *) exit 1 ;; esac diff --git a/CHANGELOG.md b/CHANGELOG.md index 60f83206..29317fd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ This beta validates guarded local release compatibility and tag-bound OSS deferr ### Fixed - **Guarded local release compatibility** — The tag-push Release workflow now accepts the `Channel`-only annotated tags created by the guarded local release entry while continuing to reject any partial cloud-only seal metadata. +- **Cloud release tag allocation fingerprint** — Release planning now fingerprints the actual `v*` and `withdrawn/v*` refs fetched from GitHub, matching the seal job's API view instead of hashing an empty non-wildcard ref prefix and rejecting every publish before tag creation. ## [1.0.53-beta.5] - 2026-07-21 diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 699824d4..047fb527 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "runtime" + "sort" "strings" "testing" ) @@ -836,7 +837,8 @@ func TestReleaseWorkflowPlansAndSealsCurrentMainInTheCloud(t *testing.T) { "persist-credentials: false", `refs/remotes/origin/main)" = "$GITHUB_SHA`, "next-release-version.sh", - "refs/tags/withdrawn/v", + `'refs/tags/v*' 'refs/tags/withdrawn/v*'`, + "release ref manifest is empty after fetching allocated tags", "refs_fingerprint", "Validate the candidate release contract before sealing", "release-contract.sh", @@ -850,6 +852,9 @@ func TestReleaseWorkflowPlansAndSealsCurrentMainInTheCloud(t *testing.T) { if strings.Contains(plan, "contents: write") { t.Error("cloud release planning must remain read-only") } + if strings.Contains(plan, "refs/tags/v refs/tags/withdrawn/v") { + t.Error("cloud release planning must use wildcard ref patterns that match the seal API prefixes") + } for _, required := range []string{ "name: Seal cloud release tag", @@ -884,6 +889,66 @@ func TestReleaseWorkflowPlansAndSealsCurrentMainInTheCloud(t *testing.T) { } } +func TestReleaseFingerprintRefPatternsMatchAllAllocatedTags(t *testing.T) { + t.Parallel() + repo := t.TempDir() + mustRun(t, repo, "git", "init", "-b", "main") + mustRun(t, repo, "git", "config", "user.name", "Release Fingerprint Test") + mustRun(t, repo, "git", "config", "user.email", "release-fingerprint@example.com") + mustWriteFile(t, filepath.Join(repo, "tracked"), []byte("fixture\n"), 0o644) + mustRun(t, repo, "git", "add", "tracked") + mustRun(t, repo, "git", "commit", "-m", "fixture") + + allocatedTags := []string{ + "v1.0.52", + "v1.0.53-beta.5", + "withdrawn/v1.0.51", + } + for _, tag := range allocatedTags { + mustRun(t, repo, "git", "tag", "-a", tag, "-m", "Release "+tag) + } + mustRun(t, repo, "git", "tag", "-a", "release/v1.0.52", "-m", "unrelated namespace") + legacy := exec.Command( + "git", "for-each-ref", "--format=%(refname)=%(objectname)", + "refs/tags/v", "refs/tags/withdrawn/v", + ) + legacy.Dir = repo + legacyOutput, err := legacy.CombinedOutput() + if err != nil { + t.Fatalf("legacy git for-each-ref error = %v\noutput:\n%s", err, legacyOutput) + } + if strings.TrimSpace(string(legacyOutput)) != "" { + t.Fatalf("legacy component patterns unexpectedly matched flat release refs:\n%s", legacyOutput) + } + + cmd := exec.Command( + "git", "for-each-ref", "--format=%(refname)=%(objectname)", + "refs/tags/v*", "refs/tags/withdrawn/v*", + ) + cmd.Dir = repo + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git for-each-ref error = %v\noutput:\n%s", err, output) + } + got := strings.Split(strings.TrimSpace(string(output)), "\n") + sort.Strings(got) + + want := make([]string, 0, len(allocatedTags)) + for _, tag := range allocatedTags { + object := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "refs/tags/"+tag)) + want = append(want, "refs/tags/"+tag+"="+object) + } + sort.Strings(want) + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("release ref set differs from the seal API set\ngot:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + workflowDigest := sha256.Sum256([]byte(strings.Join(got, "\n") + "\n")) + sealDigest := sha256.Sum256([]byte(strings.Join(want, "\n") + "\n")) + if workflowDigest != sealDigest { + t.Fatalf("release ref fingerprint differs from seal fingerprint: workflow=%x seal=%x", workflowDigest, sealDigest) + } +} + func TestReleaseWorkflowAcceptsGuardedLocalTagMetadata(t *testing.T) { t.Parallel() workflow := readReleaseWorkflow(t)