-
Notifications
You must be signed in to change notification settings - Fork 184
chore(ci): automated chore_release PR cascade #18572
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: edge
Are you sure you want to change the base?
Changes from 10 commits
b400edd
5f7f190
998b9b8
da56d90
453c2a0
becec10
fa97f15
7ffd5fd
d9a6586
a8f77d6
cfc2403
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
name: Chore Release PR Automation | ||
|
||
on: | ||
pull_request: | ||
# edited is necessary so this runs if a PR is re-targeted | ||
types: [opened, reopened, edited] | ||
|
||
jobs: | ||
handle-chore-release: | ||
runs-on: ubuntu-latest | ||
|
||
steps: | ||
- name: Checkout repository | ||
uses: actions/checkout@v4 | ||
|
||
- name: Set up Python | ||
uses: actions/setup-python@v5 | ||
with: | ||
python-version: '3.10' | ||
|
||
- name: Determine downstream chore release PRs and necessity | ||
id: downstream | ||
run: | | ||
python scripts/chore_release_pr_gen.py \ | ||
--branch-list "${{ vars.CHORE_RELEASE_BRANCHES }}" \ | ||
--target-branch "${{ github.event.pull_request.base.ref }}" \ | ||
--output-file "$GITHUB_ENV" | ||
|
||
- name: Open downstream PRs | ||
if: env.should_create_prs == 'true' | ||
env: | ||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
run: | | ||
IFS=, | ||
for branch in $downstream_branches; do | ||
pr_url=$(gh pr create \ | ||
--base "$branch" \ | ||
--head "${{ github.event.pull_request.head.ref }}" \ | ||
--title "${{ github.event.pull_request.title }} into $branch" \ | ||
--body "${{ github.event.pull_request.body }} \ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it worth mentioning the original PR number somewhere in the new PRs, so that future readers know how they're related? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes I think I will add it at the top of the body. |
||
--assignee "${{ github.event.pull_request.user.login }}" \ | ||
--repo "$GITHUB_REPOSITORY" 2>/dev/null || true) | ||
if [ -n "$pr_url" ]; then | ||
echo "Opened PR to $branch: $pr_url" >> $GITHUB_STEP_SUMMARY | ||
else | ||
echo "PR already exists or failed for $branch" >> $GITHUB_STEP_SUMMARY | ||
fi | ||
done |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,171 @@ | ||
""" | ||
This script generates a list of downstream branches for a given branch list and target branch. | ||
Run locally with: | ||
python chore_release_pr_gen.py --branch-list "branch1, branch2, branch3" --target-branch "branch2" | ||
outputs: | ||
downstream_branches=branch3 | ||
should_create_prs=true | ||
""" | ||
|
||
import argparse | ||
import os | ||
import sys | ||
from typing import List | ||
import unittest | ||
|
||
is_ci = os.environ.get("CI") == "true" | ||
|
||
|
||
def parse_branch_list(branch_list_str: str) -> List[str]: | ||
"""Splits a comma-separated branch list into a clean Python list.""" | ||
return [b.strip() for b in branch_list_str.split(",") if b.strip()] | ||
|
||
|
||
def get_downstream_branches( | ||
branch_list: List[str], | ||
target_branch: str, | ||
) -> List[str]: | ||
""" | ||
Returns all branches after target_branch in branch_list | ||
(the order in the input matters!). | ||
If target_branch is the last branch, returns []. | ||
""" | ||
if not branch_list: | ||
raise ValueError( | ||
"Branch list cannot be empty! Please check repository variable." | ||
) | ||
if not target_branch: | ||
raise ValueError("Target branch cannot be empty!") | ||
|
||
try: | ||
index = branch_list.index(target_branch) | ||
except ValueError: | ||
# Not in the branch list, return empty list | ||
return [] | ||
# Slicing the last element | ||
# As will be the case for us in most PRs, is safe. | ||
# There is no error, the result is [] | ||
return branch_list[index + 1 :] | ||
|
||
|
||
def write_output(downstream_str: str, should_create_prs: str, output_file: str): | ||
summary_file = os.environ.get("GITHUB_STEP_SUMMARY") | ||
|
||
output_lines = [ | ||
f"downstream_branches={downstream_str}", | ||
f"should_create_prs={should_create_prs}", | ||
] | ||
|
||
if output_file == "-": | ||
output_stream = sys.stdout | ||
else: | ||
output_stream = open(output_file, "w") | ||
|
||
lines = [ | ||
"| Input | Value |", | ||
"|------------------------------------|-------------------------------|", | ||
f"| vars.CHORE_RELEASE_BRANCHES | {os.environ.get('CHORE_RELEASE_BRANCHES', '')} |", | ||
f"| github.event.pull_request.base.ref | {os.environ.get('PR_TARGET_BRANCH', '')} |", | ||
f"| downstream_branches | {downstream_str} |", | ||
f"| should_create_prs | {should_create_prs} |", | ||
f"| github.event_name | {os.environ.get('GITHUB_EVENT_NAME', '')} |", | ||
f"| github.actor | {os.environ.get('GITHUB_ACTOR', '')} |", | ||
"", | ||
] | ||
if not downstream_str: | ||
lines.append("### No downstream PRs to open.") | ||
else: | ||
lines.append("### Downstream PRs should be opened for:") | ||
lines.append(f"`{downstream_str}`") | ||
|
||
with output_stream as f: | ||
for line in output_lines: | ||
f.write(line + "\n") | ||
if summary_file and is_ci: | ||
with open(summary_file, "a") as f: | ||
for line in lines: | ||
f.write(line + "\n") | ||
else: | ||
for line in lines: | ||
print(line) | ||
|
||
|
||
def main(): | ||
parser = argparse.ArgumentParser() | ||
parser.add_argument( | ||
"--branch-list", | ||
type=str, | ||
required=False, | ||
help="Comma-separated branch list in stream order", | ||
) | ||
parser.add_argument( | ||
"--target-branch", type=str, required=False, help="Branch this PR is targeting" | ||
) | ||
parser.add_argument( | ||
"--output-file", | ||
type=str, | ||
default="-", | ||
help="Write outputs to this file for GitHub Actions. Use '-' for stdout.", | ||
) | ||
args = parser.parse_args() | ||
|
||
branch_list_str = args.branch_list or os.environ.get("CHORE_RELEASE_BRANCHES", "") | ||
target_branch = args.target_branch or os.environ.get("PR_TARGET_BRANCH", "") | ||
|
||
branch_list = parse_branch_list(branch_list_str) | ||
downstream = get_downstream_branches(branch_list, target_branch) | ||
downstream_str = ",".join(downstream) | ||
should_create_prs = "true" if downstream else "false" | ||
|
||
write_output(downstream_str, should_create_prs, args.output_file) | ||
|
||
|
||
# ============================================================ | ||
# Unit tests for the get_downstream_branches function | ||
# python chore_release_pr_gen.py test | ||
# ============================================================ | ||
|
||
hotfix = "chore_release-8.4.1" | ||
isolation = "chore_release-8.5.0" | ||
pd = "chore_release-pd-8.5.0" | ||
default = "edge" | ||
other = "main" | ||
|
||
branches = [hotfix, isolation, pd, default] | ||
|
||
|
||
class TestGetDownstreamBranches(unittest.TestCase): | ||
|
||
def test_middle_branch(self): | ||
self.assertEqual(get_downstream_branches(branches, isolation), [pd, default]) | ||
|
||
def test_first_branch(self): | ||
self.assertEqual( | ||
get_downstream_branches(branches, hotfix), [isolation, pd, default] | ||
) | ||
|
||
def test_last_branch(self): | ||
self.assertEqual(get_downstream_branches(branches, default), []) | ||
|
||
def test_not_in_list(self): | ||
self.assertEqual(get_downstream_branches(branches, other), []) | ||
|
||
def test_empty_branch_list(self): | ||
with self.assertRaises(ValueError): | ||
get_downstream_branches([], hotfix) | ||
|
||
def test_empty_target_branch(self): | ||
with self.assertRaises(ValueError): | ||
get_downstream_branches(branches, "") | ||
|
||
def test_single_element(self): | ||
self.assertEqual(get_downstream_branches([default], default), []) | ||
|
||
|
||
if __name__ == "__main__": | ||
import sys | ||
|
||
if len(sys.argv) > 1 and sys.argv[1] == "test": | ||
unittest.main(argv=[sys.argv[0]]) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oh this is cool, i like it |
||
else: | ||
main() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm, I'm not so familiar with Github's model, but does this mean that as far as Github knows, the same author's branch is going to be used for all the PRs?
What happens if the code needs to be tweaked differently for the different downstream_branches?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, the same branch is used and the author will be expected to create a commit or commits to resolve conflicts and tweak as needed. The PR(s) are the TODO to make sure that gets done. As discussed if we move to normal merges, folks will need to be careful to preserve all the commits as they proceed through the PRs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As I have now remarked in the epic slack thread, if we are normal merging, I do not see this automation as helpful in its current form.