Skip to content

Commit 501f36d

Browse files
ci(github-release): comment the shipped version on released pull requests (#1289)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2684d5f commit 501f36d

4 files changed

Lines changed: 414 additions & 3 deletions

File tree

.github/workflows/release-tags.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ jobs:
6363
permissions:
6464
contents: write
6565
packages: read
66+
# Releases comment the version they shipped on the pull requests that went
67+
# into them. Needed for the github.token fallback; a PAT carries its own.
68+
pull-requests: write
6669
steps:
6770
# ref is the branch, not the pushed SHA. Two reasons, both about the gap
6871
# between when a push happens and when this job actually runs:

docs/dev/github-release-process.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,24 @@ src/compute-plane-services/nvca/v<X.Y.Z>-dev.N
108108
On a matching release branch, the workflow creates the next stable
109109
patch tag for that train.
110110

111+
Every stable release the workflow creates from a version file comments
112+
the version it shipped on the pull requests that release covers, which
113+
is the note `@semantic-release/github` posts for the services it
114+
manages:
115+
116+
```text
117+
This PR is included in version 3.2.14.
118+
```
119+
120+
Dev prerelease tags stay silent, and a re-run over a release that
121+
already exists does not comment again. The commented range starts at
122+
the closest release tag reachable from the branch rather than the
123+
highest-sorting tag, because a release branch is cut with a synthetic
124+
root and never contained most default-branch tags. It covers every
125+
commit since that tag rather than only the tagged commit, because the
126+
workflow's concurrency group cancels queued runs and a superseded push
127+
is first tagged by the next run to finish.
128+
111129
The self-managed stack is not in this auto-tag set until it has a
112130
monorepo version source. Its release config currently keeps default
113131
branch release tagging disabled.

tools/ci/github-release

Lines changed: 136 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ JAVA_COMPONENT_KINDS = (JAVA_FRAMEWORK_KIND, JAVA_SERVICE_KIND)
4242
# Framework commits quoted in dependency-triggered release notes. A service that
4343
# is many framework commits behind should still get readable notes.
4444
MAX_QUOTED_FRAMEWORK_COMMITS = 20
45+
# Commits resolved to pull requests when commenting a published release. Each one
46+
# costs an API call, so an unexpectedly wide range is truncated rather than left
47+
# to fan out. The cap is reported when it is hit.
48+
MAX_RELEASE_COMMENT_COMMITS = 50
4549
# Conventional Commit subject prefix, for example "fix(nv-boot)!: subject".
4650
COMMIT_SUBJECT_PATTERN = re.compile(r"^(?P<type>[a-zA-Z]+)(?:\([^)]*\))?(?P<breaking>!)?:")
4751

@@ -619,18 +623,140 @@ def validate_version_file(root, service):
619623

620624

621625
def create_release(tag, title, notes, draft, dry_run):
626+
"""Create the GitHub release. True when this call created it, False otherwise."""
622627
if dry_run:
623628
print(f"[github-release] dry-run: would create GitHub release {tag}")
624629
print(notes)
625-
return
630+
return False
626631
view = subprocess.run(["gh", "release", "view", tag], text=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
627632
if view.returncode == 0:
628633
print(f"[github-release] GitHub release {tag} already exists; leaving it unchanged")
629-
return
634+
return False
630635
cmd = ["gh", "release", "create", tag, "--verify-tag", "--title", title, "--notes", notes]
631636
if draft:
632637
cmd.append("--draft")
633638
run(cmd)
639+
return True
640+
641+
642+
def repo_slug():
643+
slug = os.environ.get("GITHUB_REPOSITORY", "").strip()
644+
if slug:
645+
return slug
646+
return run(
647+
["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
648+
capture=True,
649+
check=False,
650+
).strip()
651+
652+
653+
def release_url(slug, tag):
654+
server = os.environ.get("GITHUB_SERVER_URL", "https://github.com").rstrip("/")
655+
return f"{server}/{slug}/releases/tag/{tag}"
656+
657+
658+
def ancestor_service_tag(root, service):
659+
"""Closest service tag reachable from HEAD, by ancestry rather than by version sort.
660+
661+
Release branches are cut with a synthetic root (see linear_release_branch_base),
662+
so the highest-sorting tag for a service can be a main-line tag that this branch
663+
never contained. Ancestry is what actually bounds the commits a release ships.
664+
665+
Every supported prefix is matched in a single describe so the closest tag wins.
666+
Taking the first prefix to match instead would return a more distant tag whenever
667+
a service's newest release used another of its prefixes, and the range would then
668+
re-resolve commits an earlier release already covered.
669+
670+
Called before the new tag is created, so HEAD itself is never the answer.
671+
"""
672+
matches = []
673+
for prefix in tag_prefixes(service, root):
674+
matches.extend(["--match", f"{prefix}*"])
675+
return run(
676+
["git", "describe", "--tags", "--abbrev=0", *matches, "HEAD"],
677+
cwd=root,
678+
capture=True,
679+
check=False,
680+
).strip()
681+
682+
683+
def released_commits(root, since_tag):
684+
"""Commits a release covers, oldest bound exclusive.
685+
686+
More than one merge can land in a single release: the workflow's concurrency
687+
group cancels queued runs, so a superseded push never gets a run of its own and
688+
its commits are first tagged by the next run to finish. Resolving only HEAD
689+
would drop those pull requests silently.
690+
"""
691+
if not since_tag:
692+
# Nothing bounds the range, and walking all of history would be thousands
693+
# of API lookups. Only the tagged commit is resolved.
694+
return [run(["git", "rev-parse", "--verify", "HEAD^{commit}"], cwd=root, capture=True).strip()]
695+
raw = run(["git", "rev-list", f"{since_tag}..HEAD"], cwd=root, capture=True, check=False)
696+
commits = [line.strip() for line in raw.splitlines() if line.strip()]
697+
if len(commits) > MAX_RELEASE_COMMENT_COMMITS:
698+
print(
699+
f"[github-release] {len(commits)} commits since {since_tag}; "
700+
f"only the newest {MAX_RELEASE_COMMENT_COMMITS} are resolved to pull requests"
701+
)
702+
return commits[:MAX_RELEASE_COMMENT_COMMITS]
703+
return commits
704+
705+
706+
def pull_requests_for_commit(slug, sha):
707+
raw = run(
708+
["gh", "api", f"repos/{slug}/commits/{sha}/pulls", "--jq", ".[].number"],
709+
capture=True,
710+
check=False,
711+
)
712+
return [line.strip() for line in raw.splitlines() if line.strip()]
713+
714+
715+
def comment_release_on_pull_requests(root, service, tag, version, since_tag):
716+
"""Post the "included in version" note on the pull requests a release shipped.
717+
718+
Services that publish from a VERSION file never run semantic-release, so nothing
719+
posted the note that @semantic-release/github posts for the services it manages.
720+
Release branches publish exclusively through this path, so without it a backport
721+
merge gets no version feedback at all.
722+
723+
A failure here never fails the job. The tag, the push, and the GitHub release
724+
have already succeeded; losing a courtesy comment must not mark a shipped
725+
release as broken.
726+
"""
727+
slug = repo_slug()
728+
if not slug:
729+
print("[github-release] WARNING: could not resolve the repository; skipping release comments")
730+
return []
731+
732+
body = (
733+
f"This PR is included in version {version}.\n"
734+
"\n"
735+
f"The release is available on [GitHub release]({release_url(slug, tag)})."
736+
)
737+
commented = []
738+
for sha in released_commits(root, since_tag):
739+
for number in pull_requests_for_commit(slug, sha):
740+
if number in commented:
741+
continue
742+
result = subprocess.run(
743+
["gh", "pr", "comment", number, "--repo", slug, "--body", body],
744+
text=True,
745+
stdout=subprocess.PIPE,
746+
stderr=subprocess.STDOUT,
747+
)
748+
if result.returncode != 0:
749+
print(
750+
f"[github-release] WARNING: {service['id']}: could not comment {tag} on #{number}: "
751+
f"{(result.stdout or '').strip()}"
752+
)
753+
continue
754+
commented.append(number)
755+
if commented:
756+
print(f"[github-release] {service['id']}: commented {tag} on {', '.join('#' + n for n in commented)}")
757+
else:
758+
print(f"[github-release] {service['id']}: no pull requests found for {tag}")
759+
return commented
634760

635761

636762
def publish_tag_for_version(root, service, version, dry_run, draft, reason):
@@ -657,9 +783,16 @@ def publish_tag_for_version(root, service, version, dry_run, draft, reason):
657783
print(f"[github-release] {service['id']}: would create {tag} at HEAD")
658784
print(notes)
659785
return
786+
# Resolved before tagging so HEAD's own tag cannot bound the range.
787+
since_tag = ancestor_service_tag(root, service)
660788
run(["git", "tag", tag, "HEAD"], cwd=root)
661789
run(["git", "push", "origin", f"refs/tags/{tag}"], cwd=root)
662-
create_release(tag, tag, notes, draft, dry_run=False)
790+
created = create_release(tag, tag, notes, draft, dry_run=False)
791+
# Only for a release this run actually created, so a re-run does not comment
792+
# twice, and only for a stable version: dev prereleases are internal
793+
# checkpoints, not something to announce on a pull request.
794+
if created and not draft and re.fullmatch(STABLE_SEMVER_PATTERN, version):
795+
comment_release_on_pull_requests(root, service, tag, version, since_tag)
663796

664797

665798
def java_ci_components(root):

0 commit comments

Comments
 (0)