From 7273d8b794c081e9a9fbb8e2f21c0a92eb8f967b Mon Sep 17 00:00:00 2001 From: Jin Xiao Date: Sun, 4 Oct 2026 21:59:47 +0800 Subject: [PATCH] fix: wait for release write visibility --- docs/CHECKLIST.md | 3 ++- docs/RELEASING.md | 5 +++++ tools/release.py | 27 +++++++++++++++++++-------- tools/test_release.py | 27 +++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index fd8bafd..a18b8e4 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -272,4 +272,5 @@ Race-detector execution is intentionally excluded because CGO must remain disabl - [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. +- [x] REL-VISIBILITY: Bound post-GoReleaser read-after-write waiting to 30 seconds, validate every observed Release against frozen notes, and propagate conflicts/API errors immediately without repeating writes. Thirty-three offline release regressions passed, including delayed draft/finalization, timeout preservation, and immediate error propagation (2026-10-04). Hosted run 37206952188 published and consumer-verified root v0.1.0, then stopped before the new commons/regex draft became visible; issue #20 tracks recovery. +- [ ] REL-PUBLIC: Complete all maintained public modules in the reviewed first-release batch, record fresh public consumer/checksum evidence, and verify Release Issue completion. Root v0.1.0 is published and consumer-verified; commons/regex/v0.1.0 is a preserved draft. The full batch remains incomplete until every selected module passes. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index a9fefda..95a98d6 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -235,6 +235,11 @@ 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. +After a successful GoReleaser write, the publisher waits up to 30 seconds for +the requested draft or published state to become visible. It checks reviewed +content on every observed Release and stops immediately on conflicts or API +errors. This bounded read-after-write wait never repeats a publication write. +If visibility does not converge, existing objects are preserved for recovery. 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. diff --git a/tools/release.py b/tools/release.py index f5062c9..c9c02cf 100644 --- a/tools/release.py +++ b/tools/release.py @@ -8,6 +8,7 @@ import re import subprocess import tempfile +import time from urllib.error import HTTPError, URLError from urllib.parse import quote from urllib.request import Request, urlopen @@ -282,6 +283,22 @@ def release_for_tag(api, tag): return drafts[0] if drafts else None +def await_release_phase(api, directory, item, sha, draft, wait_seconds=30): + """Wait for a successful GoReleaser write to become visible to API reads.""" + tag = tag_name(directory, item["version"]) + deadline = time.monotonic() + wait_seconds + while True: + observed = release_for_tag(api, tag) + check_existing(directory, item, sha, sha, observed) + if observed and observed["draft"] == draft: + return observed + remaining = deadline - time.monotonic() + if remaining <= 0: + phase = "draft" if draft else "published Release" + raise ValueError(f"GoReleaser's expected {phase} is not visible after {wait_seconds}s: {tag}; preserve existing objects and resume after investigating.") + time.sleep(min(2, remaining)) + + def check_existing(directory, item, sha, existing, release): tag = tag_name(directory, item["version"]) if existing and existing != sha: @@ -479,19 +496,13 @@ def publish(args, api): check_existing(directory, item, args.sha, args.sha, release) if not release: run_goreleaser(directory, item, args, output, configs[directory], token=api.token) - 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}") + release = await_release_phase(api, directory, item, args.sha, draft=True) evidence = verify_consumer(directory, item, modules[directory], session) record.update(consumer_verified=True, evidence=evidence) write_json(output / "progress.json", report) if release["draft"]: run_goreleaser(directory, item, args, output, configs[directory], token=api.token, draft=False) - 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}") + release = await_release_phase(api, directory, item, args.sha, draft=False) record.update(release_published=True, url=release["html_url"]) write_json(output / "progress.json", report) report["completed"] = True diff --git a/tools/test_release.py b/tools/test_release.py index d848a48..4196a70 100644 --- a/tools/test_release.py +++ b/tools/test_release.py @@ -260,6 +260,33 @@ def test_published_release_lookup_needs_no_draft_listing(self): self.assertEqual(release.release_for_tag(api, "logger/v0.1.1"), published) self.assertEqual(len(api.calls), 1) + def test_release_phase_waits_for_delayed_draft_and_finalization(self): + selected = item() + draft = {"tag_name": "logger/v0.1.1", "draft": True, "name": "logger/v0.1.1", "body": selected["notes"], "prerelease": False} + published = {**draft, "draft": False} + for phase, observations, expected in ((True, [None, draft], draft), (False, [draft, published], published)): + with self.subTest(draft=phase), patch.object(release, "release_for_tag", side_effect=observations) as lookup, patch.object(release.time, "sleep") as sleep: + self.assertEqual(release.await_release_phase(FakeAPI(), "logger", selected, B, phase), expected) + self.assertEqual(lookup.call_count, 2) + sleep.assert_called_once() + + def test_release_phase_timeout_preserves_objects(self): + with patch.object(release, "release_for_tag", return_value=None) as lookup, patch.object(release.time, "sleep") as sleep: + with self.assertRaisesRegex(ValueError, "preserve existing objects"): + release.await_release_phase(FakeAPI(), "logger", item(), B, True, wait_seconds=0) + lookup.assert_called_once() + sleep.assert_not_called() + + def test_release_phase_errors_and_conflicts_are_not_retried(self): + selected = item() + conflict = {"name": "logger/v0.1.1", "body": "Unreviewed notes", "prerelease": False, "draft": True} + for observation in (RuntimeError("API unavailable"), conflict): + with self.subTest(observation=observation), patch.object(release, "release_for_tag", side_effect=[observation]) as lookup, patch.object(release.time, "sleep") as sleep: + with self.assertRaises((RuntimeError, ValueError)): + release.await_release_phase(FakeAPI(), "logger", selected, B, True) + lookup.assert_called_once() + sleep.assert_not_called() + 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)