Skip to content

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

Open
wants to merge 11 commits into
base: edge
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 10 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
48 changes: 48 additions & 0 deletions .github/workflows/chore-release-pr-automation.yaml
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 }}" \
Copy link
Contributor

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?

Copy link
Member Author

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.

Copy link
Member Author

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.

--title "${{ github.event.pull_request.title }} into $branch" \
--body "${{ github.event.pull_request.body }} \
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Member Author

Choose a reason for hiding this comment

The 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
171 changes: 171 additions & 0 deletions scripts/chore_release_pr_gen.py
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]])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh this is cool, i like it

else:
main()
Loading