Files
TheRock/build_tools/github_actions/bump_automation.py
T
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

473 lines
15 KiB
Python

#!/usr/bin/env python3
# Copyright Advanced Micro Devices, Inc.
# SPDX-License-Identifier: MIT
import argparse
import subprocess
import tempfile
import os
from datetime import datetime, timezone
from typing import Any
import requests
THEROCK_REPO = "ROCm/TheRock"
THEROCK_MAIN_BRANCH = "main"
BOT_NAME = "therockbot"
BOT_EMAIL = "therockbot@amd.com"
CI_LABEL = "ci:run-all-archs"
ROCM_SYSTEMS_FILES = [
".github/workflows/therock-ci-linux.yml",
".github/workflows/therock-ci-windows.yml",
".github/workflows/therock-rccl-ci-linux.yml",
".github/workflows/therock-rccl-test-packages-multi-node.yml",
".github/workflows/therock-rccl-test-packages-single-node.yml",
".github/workflows/therock-test-component.yml",
".github/workflows/therock-test-packages.yml",
]
ROCM_LIBRARIES_FILES = [
".github/actions/ci-env/action.yml",
]
SUBMODULE_CONFIG = {
"rocm-systems": {
"repo": "ROCm/rocm-systems",
"files": ROCM_SYSTEMS_FILES,
"updater": "ref",
"token_key": "systems",
},
"rocm-libraries": {
"repo": "ROCm/rocm-libraries",
"files": ROCM_LIBRARIES_FILES,
"updater": "ci-env",
"token_key": "libraries",
},
"debug-tools/rocgdb/source": {
"repo": "ROCm/rocgdb",
"files": [],
"updater": "submodule-only",
# We will reuse the rocm-systems token for now.
"token_key": "systems",
"branch": "amd-staging-rocgdb-16",
},
}
def _clone_url(repo: str, token: str) -> str:
return f"https://x-access-token:{token}@github.com/{repo}.git"
def run(cmd: list[str]) -> str:
"""Run a shell command and return its stdout, raising on non-zero exit."""
result = subprocess.run(cmd, capture_output=True, text=True)
print(result.stdout)
print(result.stderr)
if result.returncode != 0:
raise RuntimeError(f"Command failed: {' '.join(cmd)}")
return result.stdout.strip()
def get_submodule_sha(commit: str, path: str) -> str:
"""Return SHA of submodule at path in given commit."""
out = run(["git", "ls-tree", commit, path])
return out.split()[2]
def submodule_changed(before: str, after: str, path: str) -> bool:
"""Return True if the submodule at path differs between two commits."""
diff = run(["git", "diff", before, after, "--", path])
return bool(diff.strip())
def gh_api(
token: str, endpoint: str, method: str = "GET", data: dict | None = None
) -> Any:
"""Make a GitHub API request and return the parsed JSON response."""
url = f"https://api.github.com/{endpoint}"
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
}
response = requests.request(method, url, headers=headers, json=data)
if not response.ok:
raise RuntimeError(f"GitHub API failed: {response.status_code} {response.text}")
return response.json()
def latest_commit(repo: str, token: str, branch: str | None = None) -> str:
"""Return the SHA of the latest commit on the given branch, or the default branch."""
url = f"repos/{repo}/commits"
if branch:
url += f"?sha={branch}"
data = gh_api(token, url)
return data[0]["sha"]
def generate_pr_body(repo: str, base: str, head: str) -> str:
base_url = f"https://github.com/{repo}/commit/{base}"
head_url = f"https://github.com/{repo}/commit/{head}"
compare_url = f"https://github.com/{repo}/compare/{base}...{head}"
return f"""
Bumps [{repo}](https://github.com/{repo}) from {base_url} to {head_url}.
See full comparison here: {compare_url}
"""
def update_ref_in_file(file_path: str, new_sha: str) -> None:
"""
Update all ROCm/TheRock refs in a YAML file.
Replaces existing 'ref:' after 'repository: "ROCm/TheRock"'.
"""
with open(file_path, "r") as f:
lines = f.readlines()
updated_lines = []
i = 0
while i < len(lines):
line = lines[i]
updated_lines.append(line)
if line.strip() == 'repository: "ROCm/TheRock"':
# Determine the indentation level of the 'repository:' line
repo_indent = len(line) - len(line.lstrip())
j = i + 1
ref_line_index = None
while j < len(lines):
next_line = lines[j]
# Skip empty lines
if next_line.strip() == "":
j += 1
continue
next_indent = len(next_line) - len(next_line.lstrip())
if next_indent < repo_indent:
break
if next_line.strip().startswith("ref:"):
ref_line_index = j
break
j += 1
if ref_line_index is not None:
# Copy lines between repository and ref as-is (e.g., path: "TheRock")
for k in range(i + 1, ref_line_index):
updated_lines.append(lines[k])
# Replace the existing ref line, preserving indentation and removing old comment
indent = lines[ref_line_index][: lines[ref_line_index].find("ref:")]
date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
updated_lines.append(f"{indent}ref: {new_sha} # {date} commit\n")
# Skip past all lines we've already handled
i = ref_line_index
i += 1
with open(file_path, "w") as f:
f.writelines(updated_lines)
print(f"[INFO] Updated {file_path}")
def update_ci_env_file(file_path: str, new_sha: str) -> None:
"""Update the therock-ref value in a ci-env composite action file.
Matches:
therock-ref:
description: ...
value: "<old_sha>" # <date> commit
"""
with open(file_path, "r") as f:
lines = f.readlines()
updated_lines = []
in_therock_ref = False
for line in lines:
stripped = line.strip()
if stripped == "therock-ref:":
in_therock_ref = True
updated_lines.append(line)
continue
if in_therock_ref and stripped.startswith("value:"):
indent = line[: line.find("value:")]
date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
updated_lines.append(f'{indent}value: "{new_sha}" # {date} commit\n')
in_therock_ref = False
continue
if in_therock_ref and stripped and not stripped.startswith("description:"):
in_therock_ref = False
updated_lines.append(line)
with open(file_path, "w") as f:
f.writelines(updated_lines)
print(f"[INFO] Updated {file_path}")
def close_stale_prs(submodule: str, old_sha: str, token: str) -> None:
"""Close all open PRs on TheRock that originated from old submodule SHA."""
old_short = old_sha[:7]
prs = gh_api(token, f"repos/{THEROCK_REPO}/pulls?state=open")
for pr in prs:
title = pr["title"].lower()
if f"bump {submodule}" in title and f"from {old_short}" in title:
number = pr["number"]
print(f"[INFO] Closing stale PR #{number}")
# Add a comment to the PR being closed
gh_api(
token,
f"repos/{THEROCK_REPO}/issues/{number}/comments",
method="POST",
data={"body": "Closing stale PR."},
)
# Close the PR
gh_api(
token,
f"repos/{THEROCK_REPO}/pulls/{number}",
method="PATCH",
data={"state": "closed"},
)
def _git_commit(title: str) -> None:
"""Create a git commit as the bot identity with the given title."""
run(
[
"git",
"-c",
f"user.name={BOT_NAME}",
"-c",
f"user.email={BOT_EMAIL}",
"commit",
"-m",
title,
]
)
def create_therock_bump(submodule: str, token: str) -> None:
"""Create a bump PR for the given submodule in TheRock."""
config = SUBMODULE_CONFIG[submodule]
repo = config["repo"]
branch = config.get("branch")
original_cwd = os.getcwd()
# Get latest SHA from upstream submodule repo
latest = latest_commit(repo, token, branch)
# The submodule path may contain slashes (e.g. debug-tools/rocgdb/source);
# flatten it so the bump branch name is a single ref component.
branch_name = f"bump-{submodule.replace('/', '-')}-{latest[:7]}"
# Skip if a PR for this exact commit is already open.
open_prs = gh_api(
token,
f"repos/{THEROCK_REPO}/pulls?state=open&head=ROCm:{branch_name}",
)
if open_prs:
print(
f"[INFO] Bump PR for {branch_name} already open"
f" (#{open_prs[0]['number']}), skipping"
)
return
# Use a temp directory for safe cloning
with tempfile.TemporaryDirectory() as tmpdir:
clone_dir = os.path.join(tmpdir, "TheRock")
print(f"[INFO] Cloning TheRock into {clone_dir}")
run(
["git", "clone", "--depth", "1", _clone_url(THEROCK_REPO, token), clone_dir]
)
os.chdir(clone_dir)
run(["git", "checkout", "-b", branch_name])
# Initialize the submodule if needed
if not os.path.exists(os.path.join(submodule, ".git")):
run(["git", "submodule", "update", "--init", "--depth", "1", submodule])
else:
print(f"[INFO] Submodule {submodule} already initialized")
current_sha = get_submodule_sha("HEAD", submodule)
if current_sha == latest:
print(f"[INFO] {submodule} is already at {latest[:7]}, nothing to bump")
os.chdir(original_cwd)
return
# Fetch the exact target commit in the submodule. A plain depth-1 fetch
# only retrieves the default branch tip, which misses commits that live
# on a non-default branch (e.g. rocgdb's amd-staging-rocgdb-16).
print(f"[INFO] Fetching {latest[:7]} for {submodule}")
run(["git", "-C", submodule, "fetch", "--depth=1", "origin", latest])
run(["git", "-C", submodule, "checkout", latest])
# Stage the submodule change
run(["git", "add", submodule])
# Commit and push
title = f"Bump {submodule} from {current_sha[:7]} to {latest[:7]}"
body = generate_pr_body(repo, current_sha, latest)
_git_commit(title)
run(["git", "push", "origin", branch_name])
# Create PR
pr = gh_api(
token,
f"repos/{THEROCK_REPO}/pulls",
method="POST",
data={
"title": title,
"head": branch_name,
"base": THEROCK_MAIN_BRANCH,
"body": body,
},
)
try:
# Add ci:run-all-archs label to the PR
gh_api(
token,
f"repos/{THEROCK_REPO}/issues/{pr['number']}/labels",
method="POST",
data={"labels": [CI_LABEL]},
)
except RuntimeError as e:
print(f"[WARN] Failed to apply ci:run-all-archs to PR #{pr['number']}: {e}")
print(f"[INFO] Created bump PR for {submodule}")
os.chdir(original_cwd)
def handle_schedule(tokens: dict[str, str], submodule: str = "all") -> None:
"""Create bump PRs for the specified submodule(s)."""
if submodule in ("all", "rocm-systems"):
create_therock_bump("rocm-systems", tokens["systems"])
if submodule in ("all", "rocm-libraries"):
create_therock_bump("rocm-libraries", tokens["libraries"])
if submodule in ("all", "rocgdb"):
create_therock_bump("debug-tools/rocgdb/source", tokens["rocgdb"])
def handle_push(before: str, after: str, tokens: dict[str, str]) -> None:
"""Push event: update TheRock refs, close stale PRs, create next bump PR."""
changed = None
for path in SUBMODULE_CONFIG:
if submodule_changed(before, after, path):
changed = path
break
if not changed:
print("[INFO] No monitored submodule changed")
return
config = SUBMODULE_CONFIG[changed]
token = tokens[config["token_key"]]
old_sha = get_submodule_sha(before, changed)
print(f"[INFO] Detected {changed} change: {old_sha[:7]} -> {after[:7]}")
close_stale_prs(changed, old_sha, token)
# submodule-only entries (e.g. rocgdb) have no back-ref files to update in
# the upstream repo; closing stale bump PRs above is all the push handler
# needs to do for them.
if config.get("updater") == "submodule-only":
print(f"[INFO] {changed} uses submodule-only bumping, skipping ref update")
return
# Update workflow YAML
repo_name = config["repo"]
branch = f"update-therock-{changed}-{after[:7]}"
original_cwd = os.getcwd()
with tempfile.TemporaryDirectory() as tmp:
run(["git", "clone", "--depth", "1", _clone_url(repo_name, token), tmp])
os.chdir(tmp) # Change working directory to the cloned repo
# Verify that the file exists before accessing
for f in config["files"]:
if not os.path.exists(f):
print(f"[ERROR] File not found: {f}")
os.chdir(original_cwd)
return
run(["git", "checkout", "-b", branch])
updater = (
update_ci_env_file
if config.get("updater") == "ci-env"
else update_ref_in_file
)
for f in config["files"]:
updater(f, after)
run(["git", "add"] + config["files"])
_git_commit(f"Update TheRock ref to {after[:7]}")
run(["git", "push", "origin", branch])
gh_api(
token,
f"repos/{repo_name}/pulls",
method="POST",
data={
"title": f"Update TheRock reference to ({after[:7]})",
"head": branch,
"base": "develop",
"body": f"Updated TheRock ref to `{after[:7]}` due to submodule bump",
},
)
os.chdir(original_cwd)
# Immediately queue the next bump PR so the cycle continues without
# waiting for the next scheduled run.
print(f"[INFO] Creating next bump PR for {changed} after merge")
try:
create_therock_bump(changed, token)
except Exception as e:
print(f"[WARN] create_therock_bump failed: {e}")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--event_type", required=True, choices=["schedule", "push"])
parser.add_argument(
"--submodule",
default="all",
choices=["all", "rocm-systems", "rocm-libraries", "rocgdb"],
)
parser.add_argument("--before")
parser.add_argument("--after")
parser.add_argument("--systems_token", required=True)
parser.add_argument("--libraries_token", required=True)
parser.add_argument("--rocgdb_token", required=True)
args = parser.parse_args()
run(["git", "config", "--global", "user.name", BOT_NAME])
run(["git", "config", "--global", "user.email", BOT_EMAIL])
tokens = {
"systems": args.systems_token,
"libraries": args.libraries_token,
"rocgdb": args.rocgdb_token,
}
if args.event_type == "schedule":
handle_schedule(tokens, args.submodule)
elif args.event_type == "push":
handle_push(args.before, args.after, tokens)
if __name__ == "__main__":
main()