From 163763271c92fe4f9310b7f715e83d8736817fd2 Mon Sep 17 00:00:00 2001 From: Jin Xiao Date: Sun, 4 Oct 2026 21:12:02 +0800 Subject: [PATCH 1/3] fix(release): activate native PR checks instead of dispatch substitutes --- docs/RELEASING.md | 15 +++++-- tools/release_prepare.py | 51 +++++++++++++++------ tools/test_release_automation.py | 76 ++++++++++++++++++++++++++++++-- 3 files changed, 122 insertions(+), 20 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index c6be5b2..96ad98d 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -62,7 +62,11 @@ development modules and the frozen `tracer/xray` adapter are excluded. Review the generated PR's module/version table, included dependencies, notes, initial compatibility statements, and automatic-publication setting. Required -checks are dispatched explicitly on its branch using the built-in GitHub token. +checks use the native PR workflows. Preparation waits for those runs to appear +and attempts to authorize pending runs with its Actions write permission. If +GitHub requires maintainer authorization, select **Approve workflows to run** +in the PR. A manually dispatched job check does not satisfy PR rulesets, even +when it passes on the same commit; see [required-check troubleshooting](https://docs.github.com/en/pull-requests/how-tos/merge-and-close-pull-requests/troubleshooting-required-status-checks). Merge the preparation PR with squash or merge after the checks pass. The recorded source must still be its first parent; if main advances first, rerun preparation against current main and use the newly generated PR. @@ -76,7 +80,7 @@ The repository's **Settings → Actions → General → Workflow permissions** m allow **GitHub Actions to create and approve pull requests** for automated PR creation. The preparation workflow does not approve or merge PRs. Its explicit job permissions grant the built-in token only the operations needed for issue, -branch, PR, and check creation; no additional secret is required. +branch, PR, and native workflow activation; no additional secret is required. An organization or enterprise policy can prohibit that repository setting. If GitHub reports that the organization does not allow Actions to create or @@ -273,8 +277,11 @@ gate, and CLI compatibility coverage together. ## Failure and recovery For preparation failures, rerun **Prepare release** with the same inputs on -the same main source. An existing preparation PR is reused; only missing or -failed checks are redispatched. A pushed preparation branch whose PR creation +the same main source. An existing preparation PR is reused; successful and +pending native checks are retained, runs needing authorization are activated, +and failed native runs are rerun while preserving their PR event association. +If automatic authorization is forbidden, approve workflows in the PR and use +the existing runs. A pushed preparation branch whose PR creation failed is reused after its source/request identity is verified. It is never force-pushed. Local metadata is restored on generation/tidy failures. If main advances, preparation creates a fresh plan and PR for the new source. diff --git a/tools/release_prepare.py b/tools/release_prepare.py index 5ce4348..a7e0310 100644 --- a/tools/release_prepare.py +++ b/tools/release_prepare.py @@ -7,6 +7,7 @@ from pathlib import PurePosixPath import re import sys +import time from urllib.parse import quote import release @@ -242,7 +243,7 @@ def preparation_body(plan, name): ## Testing -Internal dependency metadata was tidied with CGO disabled and no module-file replacements. Required contribution, module, and documentation checks are dispatched on this branch; review their results before merging. +Internal dependency metadata was tidied with CGO disabled and no module-file replacements. Native contribution, module, and documentation PR checks are activated for this exact head; review their results before merging. If GitHub requires maintainer authorization, select **Approve workflows to run** in this PR. ## Risks and compatibility @@ -254,22 +255,46 @@ def preparation_body(plan, name): """ -def dispatch_checks(pr, branch, api, resume=False): - checks = release.latest_checks(api, pr["head"]["sha"]) if resume else {} - workflows = (("contribution.yml", "PR contribution policy", {"pr": str(pr["number"])}), - ("ci.yml", "Modules and Lambda artifacts", {}), ("docs.yml", "Build documentation", {})) - for workflow, name, inputs in workflows: - existing = checks.get(name) - if existing and (existing["status"] != "completed" or existing["conclusion"] == "success"): - continue - api.repo(f"actions/workflows/{workflow}/dispatches", method="POST", data={"ref": branch, "inputs": inputs}) +def start_pr_checks(pr, api, resume=False, wait_seconds=60): + """Native PR events satisfy rulesets; workflow_dispatch job checks do not.""" + expected = {".github/workflows/" + filename for filename in ("contribution.yml", "ci.yml", "docs.yml")} + deadline = time.monotonic() + wait_seconds + while True: + latest, page = {}, 1 + while True: + runs = api.repo(f"actions/runs?event=pull_request&head_sha={pr['head']['sha']}&per_page=100&page={page}")["workflow_runs"] + for run in runs: + path = run["path"].split("@", 1)[0] + if (path in expected and run["event"] == "pull_request" and run["head_sha"] == pr["head"]["sha"] + and run["head_repository"]["full_name"] == release.REPOSITORY + and any(item["number"] == pr["number"] for item in run["pull_requests"]) + and run["id"] > latest.get(path, {}).get("id", -1)): + latest[path] = run + if len(runs) < 100: + break + page += 1 + if set(latest) == expected: + break + if time.monotonic() >= deadline: + raise ValueError("Native PR checks have not appeared yet. Open the preparation PR, approve workflows if requested, then rerun preparation to resume; dispatch checks cannot replace required PR checks.") + time.sleep(2) + for run in latest.values(): + if run["conclusion"] == "action_required": + try: + api.repo(f"actions/runs/{run['id']}/approve", method="POST", data={}) + except RuntimeError as error: + if "HTTP 403" not in str(error): + raise + raise ValueError("GitHub requires maintainer authorization: open the preparation PR and select Approve workflows to run. Existing native checks were retained; no substitute checks were dispatched.") from None + elif resume and run["status"] == "completed" and run["conclusion"] in {"failure", "cancelled", "timed_out"}: + api.repo(f"actions/runs/{run['id']}/rerun", method="POST", data={}) def open_preparation(plan, name, branch, api): pr = api.repo("pulls", method="POST", data={"title": "chore(release): prepare " + name, "head": branch, "base": "main", "body": preparation_body(plan, name)}) print("Preparation PR: " + pr["html_url"]) - dispatch_checks(pr, branch, api) - print("Required checks dispatched. Review and merge the preparation PR.") + start_pr_checks(pr, api) + print("Native PR checks activated. Review and merge the preparation PR.") def prepare(args, api): @@ -290,7 +315,7 @@ def prepare(args, api): existing = list(api.pages("pulls?state=open&head=" + quote(release.REPOSITORY.split("/")[0] + ":" + branch, safe=""))) if existing: print(f"Preparation already exists: {existing[0]['html_url']}") - dispatch_checks(existing[0], branch, api, resume=True) + start_pr_checks(existing[0], api, resume=True) return remote_branch = api.repo("git/ref/heads/" + branch, missing=True) if remote_branch: diff --git a/tools/test_release_automation.py b/tools/test_release_automation.py index 3751507..bacfd56 100644 --- a/tools/test_release_automation.py +++ b/tools/test_release_automation.py @@ -23,12 +23,21 @@ BASE = "github.com/" + release.REPOSITORY +def native_runs(sha, conclusion="success"): + return [{"id": number, "path": ".github/workflows/" + filename, "event": "pull_request", "head_sha": sha, + "head_repository": {"full_name": release.REPOSITORY}, "pull_requests": [{"number": 21}], + "status": "completed", "conclusion": conclusion} + for number, filename in enumerate(("contribution.yml", "ci.yml", "docs.yml"), 101)] + + class API: def __init__(self): self.releases, self.tags, self.prs, self.files, self.associations = [], [], {}, {}, {} self.writes = [] self.open_prs = [] self.issue_state = "open" + self.native = None + self.pr_head = None def request(self, path, **kwargs): return {"login": "maintainer"} @@ -39,7 +48,9 @@ def repo(self, path, **kwargs): if path == "issues": return {"number": 20, "state": "open"} if path == "pulls": - return {"number": 21, "html_url": "https://example.com/pull/21"} + sha = self.pr_head or release.git("rev-parse", "HEAD") + self.native = native_runs(sha, "action_required") + return {"number": 21, "html_url": "https://example.com/pull/21", "head": {"sha": sha}} return None if path.startswith("collaborators/"): return {"permission": "admin"} @@ -49,6 +60,9 @@ def repo(self, path, **kwargs): return self.prs[int(path.split("/")[1])] if path.startswith("git/ref/heads/"): return None + if path.startswith("actions/runs?"): + sha = path.split("head_sha=", 1)[1].split("&", 1)[0] + return {"workflow_runs": self.native if self.native is not None else native_runs(sha)} if path.startswith("commits/") and "/check-runs?" in path: return {"check_runs": [{"id": number, "name": name, "app": {"slug": "github-actions"}, "status": "completed", "conclusion": "success"} for number, name in enumerate(("PR contribution policy", "Modules and Lambda artifacts", "Build documentation"))]} if path.startswith("git/ref/tags/"): @@ -240,12 +254,12 @@ def git(*command): return original_git(*command) with patch.object(release, "git", side_effect=git): preparation.prepare(args(issue=None, local=False, auto_publish=True), api) - self.assertEqual([path for path, _ in api.writes], ["issues", "pulls", "actions/workflows/contribution.yml/dispatches", "actions/workflows/ci.yml/dispatches", "actions/workflows/docs.yml/dispatches"]) + self.assertEqual([path for path, _ in api.writes], ["issues", "pulls", "actions/runs/101/approve", "actions/runs/102/approve", "actions/runs/103/approve"]) pr = api.writes[1][1] self.assertEqual(pr["base"], "main") self.assertIn("Refs #20", pr["body"]) self.assertIn("**enabled**", pr["body"]) - self.assertEqual(api.writes[2][1]["inputs"], {"pr": "21"}) + self.assertEqual(api.writes[2][1], {}) self.assertEqual(command(root, "git", "rev-parse", "HEAD^1"), source) self.assertEqual(command(root, "git", "status", "--porcelain"), "") @@ -269,6 +283,7 @@ def repo(path, **kwargs): result = original_repo(path, **kwargs) if path == "pulls" and not saved: saved["sha"] = command(root, "git", "rev-parse", "HEAD") + api.pr_head = saved["sha"] raise RuntimeError("Synthetic PR creation failure") return result with patch.object(release, "git", side_effect=git), patch.object(api, "repo", side_effect=repo): @@ -291,6 +306,61 @@ def test_duplicate_request_reuses_preparation_pr_without_writes(self): self.assertEqual(api.writes, []) +class NativeCheckTests(unittest.TestCase): + pr = {"number": 21, "head": {"sha": "b" * 40}} + + def test_only_this_pr_head_and_native_events_are_eligible(self): + for mismatch in ("event", "head_sha", "repository", "pr"): + api = API() + api.native = native_runs(self.pr["head"]["sha"]) + invalid = api.native[0] + if mismatch == "repository": + invalid["head_repository"]["full_name"] = "external/fork" + elif mismatch == "pr": + invalid["pull_requests"][0]["number"] = 22 + else: + invalid[mismatch] = "workflow_dispatch" if mismatch == "event" else "a" * 40 + with self.subTest(mismatch=mismatch), self.assertRaisesRegex(ValueError, "Native PR checks have not appeared"): + preparation.start_pr_checks(self.pr, api, wait_seconds=0) + self.assertEqual(api.writes, []) + + def test_successful_and_pending_runs_are_retained(self): + api = API() + api.native = native_runs(self.pr["head"]["sha"]) + for index, status in ((1, "in_progress"), (2, "queued")): + api.native[index].update(status=status, conclusion=None) + preparation.start_pr_checks(self.pr, api, resume=True) + self.assertEqual(api.writes, []) + + def test_approval_and_failure_recovery_keep_native_event_association(self): + api = API() + api.native = native_runs(self.pr["head"]["sha"]) + api.native[0]["conclusion"] = "action_required" + api.native[1]["conclusion"] = "failure" + api.native.append({**api.native[2], "id": 50, "conclusion": "failure"}) + preparation.start_pr_checks(self.pr, api, resume=True) + self.assertEqual([path for path, _ in api.writes], ["actions/runs/101/approve", "actions/runs/102/rerun"]) + + def test_asynchronous_native_run_creation_is_awaited(self): + api = API() + responses = [{"workflow_runs": []}, {"workflow_runs": native_runs(self.pr["head"]["sha"])}] + with patch.object(api, "repo", side_effect=responses), patch.object(preparation.time, "sleep") as sleep: + preparation.start_pr_checks(self.pr, api) + sleep.assert_called_once_with(2) + + def test_forbidden_approval_reports_the_actual_maintainer_action(self): + api = API() + api.native = native_runs(self.pr["head"]["sha"], "action_required") + original = api.repo + def repo(path, **kwargs): + if path.endswith("/approve"): + raise RuntimeError("GitHub request failed (HTTP 403)") + return original(path, **kwargs) + with patch.object(api, "repo", side_effect=repo), self.assertRaisesRegex(ValueError, "Approve workflows to run"): + preparation.start_pr_checks(self.pr, api) + self.assertEqual(api.writes, []) + + class CompletionTests(unittest.TestCase): def merged(self, root, api, source, automatic=True): plan = {"schema_version": 1, "issue": 20, "source_sha": source, "auto_publish": automatic, "modules": []} From e073328c48ea95a56f9bd50a4e21b55d84e00c3f Mon Sep 17 00:00:00 2001 From: Jin Xiao Date: Sun, 4 Oct 2026 21:17:25 +0800 Subject: [PATCH 2/3] docs(release): record native PR check rollout acceptance --- docs/CHECKLIST.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index 916933f..e4e2f0c 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -270,4 +270,5 @@ Race-detector execution is intentionally excluded because CGO must remain disabl - [x] REL-NOTES: Implement accumulated per-module release notes from merged PRs, Git ancestry boundaries, deduplication, categorized user-facing entries, historical overrides, explicit initial scope, and module-scoped direct-commit acknowledgements. Offline release regressions and 17 contribution/form regressions pass; a read-only live-history preparation collected PRs #2/#6 and three Logger fixes from #6, with seven pre-policy commits identified for explicit review (2026-10-04). See [Releasing modules](RELEASING.md). - [x] REL-WORKFLOW: Implement reviewed release plans, a Release Issue form, explicit manual publication/preflight, exact merged-SHA and required-check gates, dependency ordering, conflict rejection, fresh public consumer/checksum verification, progress artifacts, and resumable publication. GoReleaser OSS v2.18.2 creates draft Releases and finalizes them after consumer verification; its prefixed-tag adapter replaces skipped built-in validation with clean-worktree/version/tag checks. Validate 28 offline release regressions including the real GoReleaser CLI for root/nested/prerelease tags and draft creation/finalization against a local GitHub API fixture, actionlint v1.7.12, and a clean strict documentation build (2026-10-04). This records implementation/configuration acceptance; it does not claim real public publication. - [x] REL-AUTO: Implement one-component, multiple-component, and all-maintained-module preparation with automatic independent versions, transitive unpublished dependency inclusion, workspace consumer requirements/sums and version mappings, accumulated historical notes, tracking issues/PRs, and explicit required-check dispatch. Authorized preparation merges publish automatically after main checks; manual recovery requires only the PR number. Verify 14 isolated automation regressions with real Git/Go metadata and fake GitHub writes, including rollback, reserved versions, duplicate PR/check recovery, pushed-branch recovery, and automatic-publication gating; 28 release regressions including real GoReleaser draft/finalization, 18 contribution regressions, actionlint v1.7.12, and a clean strict documentation build pass (2026-10-04). Hosted automation and real publication remain unverified. See [Releasing modules](RELEASING.md). +- [x] REL-NATIVE: Replace preparation's manual workflow dispatches with native PR-run activation, exact-head/repository/PR filtering, bounded waiting for asynchronous run creation, authorization requests, and native failure recovery. Nineteen automation regressions passed locally. Hosted rollout established that manually dispatched job checks do not satisfy required rulesets: PR #16 was rejected despite green dispatches, then accepted after all three native PR runs were authorized through the Actions API and passed (2026-10-04). Default-token authorization remains subject to GitHub policy; report the maintainer's Approve workflows to run action when it is forbidden. See [Releasing modules](RELEASING.md) and issue #17. - [ ] REL-PUBLIC: After the workflow is merged, verify hosted forms and dispatch controls in GitHub, approve and merge a real module/version preparation PR with publication authorized (or use explicit manual publication), and record successful public consumer retrieval and Release Issue completion. Actual module tags and Releases have not been created by workflow installation. From f64b42a347fdd7022c34b5366fd9451ba94210ce Mon Sep 17 00:00:00 2001 From: Jin Xiao Date: Sun, 4 Oct 2026 21:35:38 +0800 Subject: [PATCH 3/3] fix(release): discover drafts through authenticated release listing --- .github/workflows/release.yml | 6 +++++- docs/CHECKLIST.md | 1 + docs/RELEASING.md | 8 ++++++-- tools/release.py | 21 ++++++++++++++++----- tools/test_release.py | 26 +++++++++++++++++++++++--- 5 files changed, 51 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c1efca..111c600 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,6 +65,10 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-progress-${{ github.run_id }} - path: dist/releases/ + path: | + dist/releases/*/progress.json + dist/releases/*/*.md + dist/releases/*/*/*.json + dist/releases/*/*/*.log if-no-files-found: ignore retention-days: 30 diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index e4e2f0c..fd8bafd 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -271,4 +271,5 @@ Race-detector execution is intentionally excluded because CGO must remain disabl - [x] REL-WORKFLOW: Implement reviewed release plans, a Release Issue form, explicit manual publication/preflight, exact merged-SHA and required-check gates, dependency ordering, conflict rejection, fresh public consumer/checksum verification, progress artifacts, and resumable publication. GoReleaser OSS v2.18.2 creates draft Releases and finalizes them after consumer verification; its prefixed-tag adapter replaces skipped built-in validation with clean-worktree/version/tag checks. Validate 28 offline release regressions including the real GoReleaser CLI for root/nested/prerelease tags and draft creation/finalization against a local GitHub API fixture, actionlint v1.7.12, and a clean strict documentation build (2026-10-04). This records implementation/configuration acceptance; it does not claim real public publication. - [x] REL-AUTO: Implement one-component, multiple-component, and all-maintained-module preparation with automatic independent versions, transitive unpublished dependency inclusion, workspace consumer requirements/sums and version mappings, accumulated historical notes, tracking issues/PRs, and explicit required-check dispatch. Authorized preparation merges publish automatically after main checks; manual recovery requires only the PR number. Verify 14 isolated automation regressions with real Git/Go metadata and fake GitHub writes, including rollback, reserved versions, duplicate PR/check recovery, pushed-branch recovery, and automatic-publication gating; 28 release regressions including real GoReleaser draft/finalization, 18 contribution regressions, actionlint v1.7.12, and a clean strict documentation build pass (2026-10-04). Hosted automation and real publication remain unverified. See [Releasing modules](RELEASING.md). - [x] REL-NATIVE: Replace preparation's manual workflow dispatches with native PR-run activation, exact-head/repository/PR filtering, bounded waiting for asynchronous run creation, authorization requests, and native failure recovery. Nineteen automation regressions passed locally. Hosted rollout established that manually dispatched job checks do not satisfy required rulesets: PR #16 was rejected despite green dispatches, then accepted after all three native PR runs were authorized through the Actions API and passed (2026-10-04). Default-token authorization remains subject to GitHub policy; report the maintainer's Approve workflows to run action when it is forbidden. See [Releasing modules](RELEASING.md) and issue #17. +- [x] REL-DRAFT: Resolve matching Release drafts through authenticated listing because GitHub's by-tag endpoint returns published Releases. Reject duplicate matches and retain reviewed-note/tag conflict checks before publication. Thirty release regressions passed, including the real GoReleaser OSS v2.18.2 draft/finalization fixture with draft-by-tag 404 responses (2026-10-04). The first hosted attempt created the reviewed root tag/draft at PR #16's SHA and then stopped at the old draft lookup; preserve those objects for recovery. Restrict progress artifacts to reports, notes, configurations, and logs. Public consumer acceptance remains separate below; see issue #19. - [ ] REL-PUBLIC: After the workflow is merged, verify hosted forms and dispatch controls in GitHub, approve and merge a real module/version preparation PR with publication authorized (or use explicit manual publication), and record successful public consumer retrieval and Release Issue completion. Actual module tags and Releases have not been created by workflow installation. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 96ad98d..a9fefda 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -232,6 +232,9 @@ the shared configuration defaults to draft releases. GoReleaser owns GitHub Release creation and finalization. The Python tooling prepares accumulated PR notes, enforces the reviewed plan, orders dependencies, creates exact tags, verifies public consumers, and completes the tracking issue. +GitHub's by-tag API returns published Releases. The adapter finds matching +drafts through authenticated release listing, rejects duplicate matches, and +validates existing notes/tag targets before resuming. It passes each frozen Markdown file using `--release-notes`; GoReleaser does not replace it with a repository-wide commit changelog. Existing notes are kept; conflict detection tolerates only terminal newline formatting differences. @@ -288,8 +291,9 @@ advances, preparation creates a fresh plan and PR for the new source. For publication failures, inspect workflow logs and the `release-progress-RUN_ID` artifact, including per-module GoReleaser phase -configurations, metadata, and logs. Failed consumer checks preserve tags/drafts -and leave the tracking issue open. A tag already makes a Go version publicly +configurations, metadata, and logs. Public-consumer dependency/build caches +stay on the runner. Failed consumer checks preserve tags/drafts and leave the +tracking issue open. A tag already makes a Go version publicly addressable; a draft Release is not rollback. Never delete, move, or rewrite a conflicting version. diff --git a/tools/release.py b/tools/release.py index 289b868..f5062c9 100644 --- a/tools/release.py +++ b/tools/release.py @@ -271,6 +271,17 @@ def remote_tag(api, tag): return obj["sha"] +def release_for_tag(api, tag): + """GitHub's tag endpoint returns published Releases, not drafts.""" + published = api.repo("releases/tags/" + quote(tag, safe=""), missing=True) + if published: + return published + drafts = [item for item in api.pages("releases") if item["tag_name"] == tag] + if len(drafts) > 1: + raise ValueError(f"Multiple Releases match {tag}; resolve the conflict before publishing.") + return drafts[0] if drafts else None + + def check_existing(directory, item, sha, existing, release): tag = tag_name(directory, item["version"]) if existing and existing != sha: @@ -333,7 +344,7 @@ def preflight(args, api): if not any(requirement["Path"] == update["path"] and requirement["Version"] == update["to"] for requirement in requirements[directory]): raise ValueError("Reviewed dependency updates disagree with go.mod.") def available(tag): - release = api.repo("releases/tags/" + quote(tag, safe=""), missing=True) + release = release_for_tag(api, tag) return bool(release and not release["draft"] and remote_tag(api, tag)) order = dependency_order(selected, modules, requirements, available) releases = list(api.pages("releases")) @@ -347,7 +358,7 @@ def available(tag): if git("tag", "--list", tag) and commit_of_tag(tag) != args.sha: raise ValueError(f"Local tag {tag} conflicts with the reviewed SHA.") existing = remote_tag(api, tag) - release = api.repo("releases/tags/" + quote(tag, safe=""), missing=True) + release = release_for_tag(api, tag) check_existing(directory, item, args.sha, existing, release) if issue["state"] != "open" and not (existing and release and not release["draft"]): raise ValueError("Release tracking issue is closed before publication completed.") @@ -464,11 +475,11 @@ def publish(args, api): api.repo("git/refs", method="POST", data={"ref": "refs/tags/" + tag, "sha": args.sha}) record["tag_created"] = True write_json(output / "progress.json", report) - release = api.repo("releases/tags/" + quote(tag, safe=""), missing=True) + release = release_for_tag(api, tag) check_existing(directory, item, args.sha, args.sha, release) if not release: run_goreleaser(directory, item, args, output, configs[directory], token=api.token) - release = api.repo("releases/tags/" + quote(tag, safe="")) + release = release_for_tag(api, tag) check_existing(directory, item, args.sha, args.sha, release) if not release or not release["draft"]: raise ValueError(f"GoReleaser did not create the expected draft: {tag}") @@ -477,7 +488,7 @@ def publish(args, api): write_json(output / "progress.json", report) if release["draft"]: run_goreleaser(directory, item, args, output, configs[directory], token=api.token, draft=False) - release = api.repo("releases/tags/" + quote(tag, safe="")) + release = release_for_tag(api, tag) check_existing(directory, item, args.sha, args.sha, release) if not release or release["draft"]: raise ValueError(f"GoReleaser did not publish the expected Release: {tag}") diff --git a/tools/test_release.py b/tools/test_release.py index aae6b87..d848a48 100644 --- a/tools/test_release.py +++ b/tools/test_release.py @@ -57,7 +57,8 @@ def repo(self, path, **kwargs): self.calls.append((path, kwargs)) if kwargs.get("method", "GET") != "GET": return {"id": 1, "draft": True, "html_url": "https://example.com/release"} if path == "releases" else {} - return self.responses.get(path) + value = self.responses.get(path) + return None if path.startswith("releases/tags/") and value and value.get("draft") else value def pages(self, path): self.calls.append((path, {})) @@ -190,7 +191,9 @@ def context(self, temporary, data, api=None): stack.enter_context(patch.object(release, "git", return_value="")) def cli(directory, selected, args, output, config, token=None, draft=True): if token and api: - api.responses["releases/tags/logger%2Fv0.1.1"] = {"draft": draft, "html_url": "https://example.com/release", "name": "logger/v0.1.1", "body": selected["notes"], "prerelease": False} + record = {"tag_name": "logger/v0.1.1", "draft": draft, "html_url": "https://example.com/release", "name": "logger/v0.1.1", "body": selected["notes"], "prerelease": False} + api.responses["releases/tags/logger%2Fv0.1.1"] = record + api.responses["releases"] = [record] stack.enter_context(patch.object(release, "run_goreleaser", side_effect=cli)) return stack @@ -242,6 +245,21 @@ def test_latest_check_result_must_pass(self): with self.assertRaisesRegex(ValueError, "has not passed"): release.check_runs(api, B, ["Build documentation"]) + def test_draft_lookup_uses_authenticated_listing_and_rejects_duplicates(self): + draft = {"tag_name": "logger/v0.1.1", "draft": True} + api = FakeAPI({"releases": [{"tag_name": "metrics/v0.1.0", "draft": True}, draft]}) + self.assertEqual(release.release_for_tag(api, "logger/v0.1.1"), draft) + self.assertEqual([path for path, _ in api.calls], ["releases/tags/logger%2Fv0.1.1", "releases"]) + api.responses["releases"].append(draft.copy()) + with self.assertRaisesRegex(ValueError, "Multiple Releases"): + release.release_for_tag(api, "logger/v0.1.1") + + def test_published_release_lookup_needs_no_draft_listing(self): + published = {"tag_name": "logger/v0.1.1", "draft": False} + api = FakeAPI({"releases/tags/logger%2Fv0.1.1": published}) + self.assertEqual(release.release_for_tag(api, "logger/v0.1.1"), published) + self.assertEqual(len(api.calls), 1) + def test_annotated_tags_are_peeled(self): api = FakeAPI({"git/ref/tags/logger/v0.1.0": {"object": {"type": "tag", "sha": A}}, f"git/tags/{A}": {"object": {"type": "commit", "sha": B}}}) self.assertEqual(release.remote_tag(api, "logger/v0.1.0"), B) @@ -424,7 +442,9 @@ def do_GET(self): elif path.endswith("/releases"): self.respond(200, [state["release"]] if state else []) elif "/releases/tags/" in path: - self.respond(200 if state else 404, state.get("release", {"message": "Not Found"})) + published = state.get("release") + found = published and not published["draft"] + self.respond(200 if found else 404, published if found else {"message": "Not Found"}) elif path == "/repos/" + release.REPOSITORY: self.respond(200, {"full_name": release.REPOSITORY, "permissions": {"push": True}, "archived": False}) else: