Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 5 additions & 0 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 19 additions & 8 deletions tools/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions tools/test_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down