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
6 changes: 5 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions docs/CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,4 +270,6 @@ 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.
- [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.
23 changes: 17 additions & 6 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -228,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.
Expand Down Expand Up @@ -273,16 +280,20 @@ 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.

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.

Expand Down
21 changes: 16 additions & 5 deletions tools/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"))
Expand All @@ -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.")
Expand Down Expand Up @@ -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}")
Expand All @@ -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}")
Expand Down
51 changes: 38 additions & 13 deletions tools/release_prepare.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from pathlib import PurePosixPath
import re
import sys
import time
from urllib.parse import quote

import release
Expand Down Expand Up @@ -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

Expand All @@ -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):
Expand All @@ -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:
Expand Down
26 changes: 23 additions & 3 deletions tools/test_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, {}))
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
Loading