@@ -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.
4444MAX_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".
4650COMMIT_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
621625def 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
636762def 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
665798def java_ci_components (root ):
0 commit comments