[ROCm][CI] Add AMD CI Pull-Request Commands (#52822)

Signed-off-by: Andreas Karatzas <akaratza@amd.com>

Andreas Karatzas committed Aug 18, 2026 at 17:16 UTC 203926c4778698f11904beec88f6a91b94b7122d
5 files changed +406 -50
.github/workflows/new_pr_bot.yml
+2 -2
@@ -80,9 +80,9 @@ jobs:
80 '',
81 '\u{1f4ac} Join our developer Slack at https://slack.vllm.ai to discuss your PR in `#pr-reviews`, coordinate on features in `#feat-` channels, or join special interest groups in `#sig-` channels.',
82 '',
83 - 'PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment `/ci run` whenever CI signals are needed.',
83 + 'PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment `/ci run` for upstream CI or `/amd-ci run` for AMD CI only whenever CI signals are needed.',
84 '',
85 - 'Once the PR is approved or has the `ready` label, the PR author can also use `/ci run`, `/ci retry`, or `/ci cancel`. New commits do not start CI automatically.',
85 + 'Once the PR is approved or has the `ready` label, the PR author can also use the corresponding `/ci run`, `/ci retry`, and `/ci cancel` commands, or their `/amd-ci` variants. New commits do not start upstream CI automatically.',
86 '',
87 'If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.',
88 '',
.github/workflows/run-ci-command.yml
+7 -1
@@ -21,7 +21,12 @@ jobs:
21 github.event.comment.body == '/ci run all' ||
22 github.event.comment.body == '/ci run nightly' ||
23 github.event.comment.body == '/ci retry' ||
24 - github.event.comment.body == '/ci cancel')
24 + github.event.comment.body == '/ci cancel' ||
25 + github.event.comment.body == '/amd-ci run' ||
26 + github.event.comment.body == '/amd-ci run all' ||
27 + github.event.comment.body == '/amd-ci run nightly' ||
28 + github.event.comment.body == '/amd-ci retry' ||
29 + github.event.comment.body == '/amd-ci cancel')
30 runs-on: [self-hosted, linux, x64, vllm-runners]
31 steps:
32 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -37,6 +42,7 @@ jobs:
42 .github/workflows/scripts/run_ci_command.py
43 env:
44 BUILDKITE_API_TOKEN: ${{ secrets.BUILDKITE_API_TOKEN }}
45 + BUILDKITE_AMD_PIPELINE: amd-ci
46 BUILDKITE_ORGANIZATION: vllm
47 BUILDKITE_PIPELINE: ci
48 CI_TRUSTED_USERS: ${{ vars.CI_TRUSTED_USERS }}
.github/workflows/scripts/run_ci_command.py
+172 -35
@@ -17,11 +17,40 @@ COMMAND_RUN_CI_ALL = "/ci run all"
17 COMMAND_RUN_CI_NIGHTLY = "/ci run nightly"
18 COMMAND_RETRY_FAILED = "/ci retry"
19 COMMAND_CANCEL_CI = "/ci cancel"
20 +COMMAND_RUN_AMD_CI = "/amd-ci run"
21 +COMMAND_RUN_AMD_CI_ALL = "/amd-ci run all"
22 +COMMAND_RUN_AMD_CI_NIGHTLY = "/amd-ci run nightly"
23 +COMMAND_RETRY_AMD_FAILED = "/amd-ci retry"
24 +COMMAND_CANCEL_AMD_CI = "/amd-ci cancel"
25 RUN_CI_COMMAND_ENV = {
26 COMMAND_RUN_CI: {},
27 COMMAND_RUN_CI_ALL: {"RUN_ALL": "1"},
28 COMMAND_RUN_CI_NIGHTLY: {"RUN_ALL": "1", "NIGHTLY": "1"},
29 + COMMAND_RUN_AMD_CI: {},
30 + COMMAND_RUN_AMD_CI_ALL: {"RUN_ALL": "1"},
31 + COMMAND_RUN_AMD_CI_NIGHTLY: {"RUN_ALL": "1", "NIGHTLY": "1"},
32 }
33 +UPSTREAM_CI_COMMANDS = frozenset(
34 + {
35 + COMMAND_RUN_CI,
36 + COMMAND_RUN_CI_ALL,
37 + COMMAND_RUN_CI_NIGHTLY,
38 + COMMAND_RETRY_FAILED,
39 + COMMAND_CANCEL_CI,
40 + }
41 +)
42 +AMD_CI_COMMANDS = frozenset(
43 + {
44 + COMMAND_RUN_AMD_CI,
45 + COMMAND_RUN_AMD_CI_ALL,
46 + COMMAND_RUN_AMD_CI_NIGHTLY,
47 + COMMAND_RETRY_AMD_FAILED,
48 + COMMAND_CANCEL_AMD_CI,
49 + }
50 +)
51 +RETRY_COMMANDS = frozenset({COMMAND_RETRY_FAILED, COMMAND_RETRY_AMD_FAILED})
52 +CANCEL_COMMANDS = frozenset({COMMAND_CANCEL_CI, COMMAND_CANCEL_AMD_CI})
53 +ALL_CI_COMMANDS = UPSTREAM_CI_COMMANDS | AMD_CI_COMMANDS
54 CI_AUTHORIZED_COMMENT_MARKER = "<!-- vllm-ci-authorized -->"
55 READY_LABELS = {"ready", "ready-run-all-tests"}
56 TRUSTED_PERMISSIONS = {"admin", "maintain", "write"}
@@ -408,11 +437,48 @@ class BuildkiteClient:
437
438
439 def parse_command(body: str) -> str | None:
411 - if body in {*RUN_CI_COMMAND_ENV, COMMAND_RETRY_FAILED, COMMAND_CANCEL_CI}:
440 + if body in ALL_CI_COMMANDS:
441 return body
442 return None
443
444
445 +def pipeline_for_command(
446 + command: str,
447 + *,
448 + amd_ci_pipeline: str = "amd-ci",
449 + upstream_ci_pipeline: str = "ci",
450 +) -> str:
451 + if command in AMD_CI_COMMANDS:
452 + return amd_ci_pipeline
453 + if command in UPSTREAM_CI_COMMANDS:
454 + return upstream_ci_pipeline
455 + raise ValueError(f"Unsupported CI command: {command}")
456 +
457 +
458 +def ci_name_for_command(command: str) -> str:
459 + if command in AMD_CI_COMMANDS:
460 + return "AMD CI"
461 + if command in UPSTREAM_CI_COMMANDS:
462 + return "CI"
463 + raise ValueError(f"Unsupported CI command: {command}")
464 +
465 +
466 +def run_command_for_command(command: str) -> str:
467 + if command in AMD_CI_COMMANDS:
468 + return COMMAND_RUN_AMD_CI
469 + if command in UPSTREAM_CI_COMMANDS:
470 + return COMMAND_RUN_CI
471 + raise ValueError(f"Unsupported CI command: {command}")
472 +
473 +
474 +def retry_command_for_command(command: str) -> str:
475 + if command in AMD_CI_COMMANDS:
476 + return COMMAND_RETRY_AMD_FAILED
477 + if command in UPSTREAM_CI_COMMANDS:
478 + return COMMAND_RETRY_FAILED
479 + raise ValueError(f"Unsupported CI command: {command}")
480 +
481 +
482 def parse_trusted_users(value: str = "") -> set[str]:
483 return {
484 user.casefold() for item in value.split(",") for user in item.split() if user
@@ -432,6 +498,7 @@ def authorize(
498 actor: str,
499 permission: str,
500 pr: Mapping[str, Any],
501 + run_command: str = COMMAND_RUN_CI,
502 trusted_approval: bool = False,
503 trusted_users: set[str] | None = None,
504 ) -> tuple[bool, str]:
@@ -454,7 +521,7 @@ def authorize(
521 return True, "approval from a trusted reviewer"
522 return (
523 False,
457 - "A reviewer with write access must run `/ci run`, approve the PR, "
524 + f"A reviewer with write access must run `{run_command}`, approve the PR, "
525 "or add the `ready` label first.",
526 )
527
@@ -499,6 +566,21 @@ def is_active_build(build: Mapping[str, Any]) -> bool:
566 return bool(build.get("blocked")) or build.get("state") in ACTIVE_BUILD_STATES
567
568
569 +def is_comment_triggered_build(build: Mapping[str, Any]) -> bool:
570 + metadata = build.get("meta_data") or {}
571 + return bool(metadata.get("github-comment-id"))
572 +
573 +
574 +def blocks_new_run(command: str, build: Mapping[str, Any]) -> bool:
575 + if command in AMD_CI_COMMANDS:
576 + return (
577 + is_comment_triggered_build(build)
578 + and build.get("state") in ACTIVE_BUILD_STATES
579 + and build.get("state") != "blocked"
580 + )
581 + return is_active_build(build)
582 +
583 +
584 def select_latest_build(
585 builds: Sequence[dict[str, Any]],
586 pr_number: int,
@@ -544,17 +626,21 @@ def create_retry_build_payload(
626 *,
627 actor: str,
628 comment_id: int,
629 + command: str = COMMAND_RETRY_FAILED,
630 pr: Mapping[str, Any],
631 source_build: Mapping[str, Any],
632 step_keys: Sequence[str],
633 ) -> dict[str, Any]:
634 + if command not in RETRY_COMMANDS:
635 + raise ValueError(f"Unsupported retry command: {command}")
636 payload = create_build_payload(
637 actor=actor,
638 comment_id=comment_id,
639 + command=run_command_for_command(command),
640 pr=pr,
641 )
642 source_number = str(source_build["number"])
557 - payload["message"] = f"PR #{pr['number']} {COMMAND_RETRY_FAILED} by @{actor}"
643 + payload["message"] = f"PR #{pr['number']} {command} by @{actor}"
644 payload["env"]["VLLM_CI_ONLY_STEP_KEYS"] = json.dumps(
645 step_keys, separators=(",", ":")
646 )
@@ -657,13 +743,15 @@ def notify_authorized(
743 pr["number"],
744 (
745 f"✅ @{author}, CI is now available for this PR.\n\n"
660 - "- `/ci run` starts a CI build.\n"
746 + "- `/ci run` starts upstream CI; `/amd-ci run` starts AMD CI only.\n"
747 "- `/ci retry` retries failed jobs in the CI build for the current "
748 "PR head. If the current head has no CI build, it starts a new CI "
749 "build for the current head containing only jobs that failed in "
750 "the latest earlier CI build for this PR.\n"
751 + "- `/amd-ci retry` retries failed jobs in AMD CI for the current PR "
752 + "head. Use `/amd-ci run` when the current head has no AMD CI build.\n"
753 "- `/ci cancel` cancels scheduled or running CI builds for this PR "
666 - "branch.\n\n"
754 + "branch; `/amd-ci cancel` does the same for AMD CI only.\n\n"
755 f"{CI_AUTHORIZED_COMMENT_MARKER}"
756 ),
757 )
@@ -712,25 +800,30 @@ def handle_run_ci(
800 github: GitHubClient,
801 pr: Mapping[str, Any],
802 ) -> str:
803 + ci_name = ci_name_for_command(command)
804 duplicate_builds = buildkite.list_builds(
805 pr["head"]["sha"],
806 metadata=("github-comment-id", str(comment_id)),
807 )
808 duplicate = select_latest_build(duplicate_builds, pr["number"])
809 if duplicate:
721 - return f"CI was already requested by this comment: {duplicate['web_url']}"
810 + return (
811 + f"{ci_name} was already requested by this comment: {duplicate['web_url']}"
812 + )
813
814 current_builds = buildkite.list_builds(pr["head"]["sha"])
815 active_build = next(
816 (
817 build
818 for build in current_builds
728 - if is_build_for_pr(build, pr["number"]) and is_active_build(build)
819 + if is_build_for_pr(build, pr["number"]) and blocks_new_run(command, build)
820 ),
821 None,
822 )
823 if active_build:
733 - return f"CI is already running for this commit: {active_build['web_url']}"
824 + return (
825 + f"{ci_name} is already running for this commit: {active_build['web_url']}"
826 + )
827
828 current_pr = github.get_pr(pr["number"])
829 if current_pr["state"] != "open" or current_pr["head"]["sha"] != pr["head"]["sha"]:
@@ -748,7 +841,7 @@ def handle_run_ci(
841 )
842 )
843 return (
751 - f"Triggered [Buildkite CI #{build['number']}]({build['web_url']}) "
844 + f"Triggered [Buildkite {ci_name} #{build['number']}]({build['web_url']}) "
845 f"for commit `{current_pr['head']['sha'][:12]}`."
846 )
847
@@ -758,15 +851,21 @@ def handle_retry_failed(
851 actor: str,
852 buildkite: BuildkiteClient,
853 comment_id: int,
854 + command: str,
855 github: GitHubClient,
856 pr: Mapping[str, Any],
857 ) -> str:
858 + ci_name = ci_name_for_command(command)
859 + run_command = run_command_for_command(command)
860 + retry_command = retry_command_for_command(command)
861 builds = buildkite.list_builds(pr["head"]["sha"])
862 build = select_latest_build(builds, pr["number"])
863 if build:
864 metadata = build.get("meta_data") or {}
865 if str(metadata.get("github-comment-id")) == str(comment_id):
769 - return f"CI was already requested by this comment: {build['web_url']}"
866 + return (
867 + f"{ci_name} was already requested by this comment: {build['web_url']}"
868 + )
869
870 retried = buildkite.retry_failed_jobs(build["number"], RETRY_STATES)
871 if retried["retried_jobs_count"] == 0:
@@ -776,7 +875,13 @@ def handle_retry_failed(
875 )
876 return (
877 f"Queued {retried['retried_jobs_count']} failed job(s) for retry in "
779 - f"[Buildkite CI #{build['number']}]({build['web_url']})."
878 + f"[Buildkite {ci_name} #{build['number']}]({build['web_url']})."
879 + )
880 +
881 + if command == COMMAND_RETRY_AMD_FAILED:
882 + return (
883 + "No AMD CI build exists for the current PR head. "
884 + f"Use `{run_command}` first."
885 )
886
887 previous_builds = buildkite.list_builds(
@@ -790,18 +895,23 @@ def handle_retry_failed(
895 ]
896 source_build = select_latest_build(previous_builds, pr["number"])
897 if not source_build:
793 - return "No earlier CI build exists for this PR. Use `/ci run` first."
898 + return (
899 + f"No earlier {ci_name} build exists for this PR. Use `{run_command}` first."
900 + )
901 if not source_build.get("finished_at") or is_active_build(source_build):
795 - return f"The previous CI build is still running: {source_build['web_url']}"
902 + return (
903 + f"The previous {ci_name} build is still running: {source_build['web_url']}"
904 + )
905
906 failed_jobs = buildkite.list_failed_jobs(source_build["number"])
907 failed_script_jobs = [job for job in failed_jobs if job.get("type") == "script"]
908 missing_step_keys = [job for job in failed_script_jobs if not job.get("step_key")]
909 if missing_step_keys:
910 return (
802 - f"[Buildkite CI #{source_build['number']}]"
911 + f"[Buildkite {ci_name} #{source_build['number']}]"
912 f"({source_build['web_url']}) has failed jobs without stable step "
804 - "keys, so they cannot be retried on a new commit. Use `/ci run`."
913 + "keys, so they cannot be retried on a new commit. "
914 + f"Use `{run_command}`."
915 )
916
917 failed_step_keys = {str(job["step_key"]) for job in failed_script_jobs}
@@ -812,16 +922,16 @@ def handle_retry_failed(
922 )
923 if setup_failures:
924 return (
815 - f"[Buildkite CI #{source_build['number']}]"
925 + f"[Buildkite {ci_name} #{source_build['number']}]"
926 f"({source_build['web_url']}) failed during CI setup, so its test "
817 - "failure set is incomplete. Use `/ci run` for the new commit."
927 + f"failure set is incomplete. Use `{run_command}` for the new commit."
928 )
929
930 step_keys = sorted(failed_step_keys)
931 if not step_keys:
932 return (
933 "No failed, timed-out, or expired jobs need retrying in "
824 - f"[Buildkite CI #{source_build['number']}]"
934 + f"[Buildkite {ci_name} #{source_build['number']}]"
935 f"({source_build['web_url']})."
936 )
937
@@ -829,23 +939,24 @@ def handle_retry_failed(
939 if current_pr["state"] != "open" or current_pr["head"]["sha"] != pr["head"]["sha"]:
940 return (
941 "The PR head changed while processing the command. "
832 - "Comment `/ci retry` again."
942 + f"Comment `{retry_command}` again."
943 )
944
945 retry_build = buildkite.create_build(
946 create_retry_build_payload(
947 actor=actor,
948 comment_id=comment_id,
949 + command=command,
950 pr=current_pr,
951 source_build=source_build,
952 step_keys=step_keys,
953 )
954 )
955 return (
845 - f"Triggered [Buildkite CI #{retry_build['number']}]"
956 + f"Triggered [Buildkite {ci_name} #{retry_build['number']}]"
957 f"({retry_build['web_url']}) for commit "
958 f"`{current_pr['head']['sha'][:12]}`, running {len(step_keys)} failed "
848 - f"step(s) from [Buildkite CI #{source_build['number']}]"
959 + f"step(s) from [Buildkite {ci_name} #{source_build['number']}]"
960 f"({source_build['web_url']})."
961 )
962
@@ -853,23 +964,36 @@ def handle_retry_failed(
964 def handle_cancel_ci(
965 *,
966 buildkite: BuildkiteClient,
967 + command: str = COMMAND_CANCEL_CI,
968 pr: Mapping[str, Any],
969 ) -> str:
970 + ci_name = ci_name_for_command(command)
971 branch = pr["head"]["ref"]
859 - builds = buildkite.list_builds(
860 - None,
861 - branch=branch,
862 - states=CANCELABLE_BUILD_STATES,
863 - )
972 + branches = [branch]
973 + states = CANCELABLE_BUILD_STATES
974 + if command in AMD_CI_COMMANDS:
975 + head_label = pr["head"].get("label")
976 + if head_label and head_label not in branches:
977 + branches.append(str(head_label))
978 +
979 + builds_by_number: dict[int, dict[str, Any]] = {}
980 + for candidate_branch in branches:
981 + for build in buildkite.list_builds(
982 + None,
983 + branch=candidate_branch,
984 + states=states,
985 + ):
986 + builds_by_number[build["number"]] = build
987 +
988 cancelable_builds = [
989 build
866 - for build in builds
867 - if build.get("branch") == branch
990 + for build in builds_by_number.values()
991 + if build.get("branch") in branches
992 and is_build_for_pr(build, pr["number"])
869 - and build.get("state") in CANCELABLE_BUILD_STATES
993 + and build.get("state") in states
994 ]
995 if not cancelable_builds:
872 - return f"No cancelable CI build is running for branch `{branch}`."
996 + return f"No cancelable {ci_name} build is running for branch `{branch}`."
997
998 for build in cancelable_builds:
999 buildkite.cancel_build(build["number"])
@@ -879,7 +1003,9 @@ def handle_cancel_ci(
1003 )
1004 count = len(cancelable_builds)
1005 noun = "build" if count == 1 else "builds"
882 - return f"Requested cancellation of {count} CI {noun} for `{branch}`: {links}."
1006 + return (
1007 + f"Requested cancellation of {count} {ci_name} {noun} for `{branch}`: {links}."
1008 + )
1009
1010
1011 def run(
@@ -929,6 +1055,7 @@ def run(
1055 actor=actor,
1056 permission=permission,
1057 pr=pr,
1058 + run_command=run_command_for_command(command),
1059 trusted_approval=trusted_approval,
1060 trusted_users=trusted_users,
1061 )
@@ -949,19 +1076,23 @@ def run(
1076 github=github,
1077 pr=pr,
1078 )
952 - elif command == COMMAND_RETRY_FAILED:
1079 + elif command in RETRY_COMMANDS:
1080 message = handle_retry_failed(
1081 actor=actor,
1082 buildkite=buildkite,
1083 comment_id=comment_id,
1084 + command=command,
1085 github=github,
1086 pr=pr,
1087 )
960 - else:
1088 + elif command in CANCEL_COMMANDS:
1089 message = handle_cancel_ci(
1090 buildkite=buildkite,
1091 + command=command,
1092 pr=pr,
1093 )
1094 + else:
1095 + raise ValueError(f"Unsupported CI command: {command}")
1096 add_reaction_safely(github, comment_id, "rocket")
1097 github.add_comment(issue_number, f"✅ {message}")
1098 except Exception:
@@ -1002,13 +1133,19 @@ def main() -> None:
1133 )
1134 return
1135
1005 - if not parse_command(event["comment"]["body"]):
1136 + command = parse_command(event["comment"]["body"])
1137 + if not command:
1138 return
1139
1140 + pipeline = pipeline_for_command(
1141 + command,
1142 + amd_ci_pipeline=os.environ.get("BUILDKITE_AMD_PIPELINE", "amd-ci"),
1143 + upstream_ci_pipeline=os.environ.get("BUILDKITE_PIPELINE", "ci"),
1144 + )
1145 buildkite = BuildkiteClient(
1146 os.environ.get("BUILDKITE_API_TOKEN", ""),
1147 os.environ.get("BUILDKITE_ORGANIZATION", "vllm"),
1011 - os.environ.get("BUILDKITE_PIPELINE", "ci"),
1148 + pipeline,
1149 )
1150 run(
1151 event,
.github/workflows/scripts/test_run_ci_command.py
+220 -9
@@ -11,8 +11,13 @@ from unittest.mock import patch
11 from run_ci_command import (
12 CANCELABLE_BUILD_STATES,
13 CI_AUTHORIZED_COMMENT_MARKER,
14 + COMMAND_CANCEL_AMD_CI,
15 COMMAND_CANCEL_CI,
16 + COMMAND_RETRY_AMD_FAILED,
17 COMMAND_RETRY_FAILED,
18 + COMMAND_RUN_AMD_CI,
19 + COMMAND_RUN_AMD_CI_ALL,
20 + COMMAND_RUN_AMD_CI_NIGHTLY,
21 COMMAND_RUN_CI,
22 COMMAND_RUN_CI_ALL,
23 COMMAND_RUN_CI_NIGHTLY,
@@ -28,6 +33,7 @@ from run_ci_command import (
33 notify_authorized,
34 parse_command,
35 parse_trusted_users,
36 + pipeline_for_command,
37 resolve_workflow_run_pr,
38 run,
39 select_latest_build,
@@ -137,6 +143,7 @@ class FakeBuildkite:
143 self.failed_job_lists = failed_job_lists or []
144 self.job_list_calls: list[int] = []
145 self.list_calls: list[tuple[str | None, tuple[str, str] | None]] = []
146 + self.list_requests: list[dict[str, Any]] = []
147 self.retry_calls: list[tuple[int, str]] = []
148 self.cancel_calls: list[int] = []
149
@@ -149,6 +156,14 @@ class FakeBuildkite:
156 states: tuple[str, ...] = (),
157 ) -> list[dict[str, Any]]:
158 self.list_calls.append((commit, metadata))
159 + self.list_requests.append(
160 + {
161 + "branch": branch,
162 + "commit": commit,
163 + "metadata": metadata,
164 + "states": states,
165 + }
166 + )
167 return self.build_lists.pop(0)
168
169 def create_build(self, body: dict[str, Any]) -> dict[str, Any]:
@@ -291,21 +306,52 @@ class RunCiCommandTest(unittest.TestCase):
306 self.assertEqual(urlopen.call_count, 1)
307
308 def test_only_exact_ci_commands_are_accepted(self) -> None:
294 - self.assertEqual(parse_command(COMMAND_RUN_CI), COMMAND_RUN_CI)
295 - self.assertEqual(parse_command(COMMAND_RUN_CI_ALL), COMMAND_RUN_CI_ALL)
296 - self.assertEqual(
297 - parse_command(COMMAND_RUN_CI_NIGHTLY),
309 + commands = (
310 + COMMAND_RUN_CI,
311 + COMMAND_RUN_CI_ALL,
312 COMMAND_RUN_CI_NIGHTLY,
299 - )
300 - self.assertEqual(
301 - parse_command(COMMAND_RETRY_FAILED),
313 COMMAND_RETRY_FAILED,
314 + COMMAND_CANCEL_CI,
315 + COMMAND_RUN_AMD_CI,
316 + COMMAND_RUN_AMD_CI_ALL,
317 + COMMAND_RUN_AMD_CI_NIGHTLY,
318 + COMMAND_RETRY_AMD_FAILED,
319 + COMMAND_CANCEL_AMD_CI,
320 )
304 - self.assertEqual(parse_command(COMMAND_CANCEL_CI), COMMAND_CANCEL_CI)
321 + for command in commands:
322 + with self.subTest(command=command):
323 + self.assertEqual(parse_command(command), command)
324 +
325 self.assertIsNone(parse_command("/ci run please"))
326 self.assertIsNone(parse_command("/ci run all please"))
327 self.assertIsNone(parse_command("/ci cancel please"))
328 self.assertIsNone(parse_command(" /ci run"))
329 + self.assertIsNone(parse_command("/amd-ci run please"))
330 + self.assertIsNone(parse_command("/amd-ci retry "))
331 + self.assertIsNone(parse_command("/AMD-CI run"))
332 + self.assertIsNone(parse_command("/amdci run"))
333 +
334 + def test_commands_select_only_their_configured_pipeline(self) -> None:
335 + cases = (
336 + (COMMAND_RUN_CI, "ci"),
337 + (COMMAND_RUN_CI_ALL, "ci"),
338 + (COMMAND_RUN_CI_NIGHTLY, "ci"),
339 + (COMMAND_RETRY_FAILED, "ci"),
340 + (COMMAND_CANCEL_CI, "ci"),
341 + (COMMAND_RUN_AMD_CI, "amd-ci"),
342 + (COMMAND_RUN_AMD_CI_ALL, "amd-ci"),
343 + (COMMAND_RUN_AMD_CI_NIGHTLY, "amd-ci"),
344 + (COMMAND_RETRY_AMD_FAILED, "amd-ci"),
345 + (COMMAND_CANCEL_AMD_CI, "amd-ci"),
346 + )
347 + for command, expected_pipeline in cases:
348 + with self.subTest(command=command):
349 + self.assertEqual(
350 + pipeline_for_command(command),
351 + expected_pipeline,
352 + )
353 + with self.assertRaisesRegex(ValueError, "Unsupported CI command"):
354 + pipeline_for_command("/amd-ci run arbitrary-pipeline")
355
356 def test_write_access_authorizes_reviewers_and_authors(self) -> None:
357 allowed, _ = authorize(
@@ -466,6 +512,45 @@ class RunCiCommandTest(unittest.TestCase):
512 self.assertTrue(github.comments[0].startswith("✅ "))
513 self.assertIn("Buildkite CI #123", github.comments[0])
514
515 + def test_amd_run_ignores_blocked_builds(self) -> None:
516 + for metadata in ({}, {"github-comment-id": "98"}):
517 + with self.subTest(metadata=metadata):
518 + github = FakeGitHub()
519 + blocked_build = {
520 + "blocked": True,
521 + "created_at": "2026-08-18T01:00:00Z",
522 + "meta_data": metadata,
523 + "number": 122,
524 + "pull_request": {"id": 42},
525 + "state": "blocked",
526 + "web_url": "https://buildkite.example/amd-ci/builds/122",
527 + }
528 + buildkite = FakeBuildkite([[], [blocked_build]])
529 +
530 + run(make_event(COMMAND_RUN_AMD_CI_ALL), github, buildkite)
531 +
532 + self.assertEqual(len(buildkite.created_builds), 1)
533 + self.assertEqual(buildkite.created_builds[0]["env"]["RUN_ALL"], "1")
534 +
535 + def test_amd_run_deduplicates_comment_triggered_active_build(self) -> None:
536 + github = FakeGitHub()
537 + command_build = {
538 + "blocked": False,
539 + "created_at": "2026-08-18T01:00:00Z",
540 + "meta_data": {"github-comment-id": "98"},
541 + "number": 122,
542 + "pull_request": {"id": 42},
543 + "source": "api",
544 + "state": "running",
545 + "web_url": "https://buildkite.example/amd-ci/builds/122",
546 + }
547 + buildkite = FakeBuildkite([[], [command_build]])
548 +
549 + run(make_event(COMMAND_RUN_AMD_CI_ALL), github, buildkite)
550 +
551 + self.assertEqual(buildkite.created_builds, [])
552 + self.assertIn("AMD CI is already running", github.comments[0])
553 +
554 def test_run_all_sets_buildkite_environment(self) -> None:
555 github = FakeGitHub()
556 buildkite = FakeBuildkite([[], []])
@@ -488,6 +573,32 @@ class RunCiCommandTest(unittest.TestCase):
573 self.assertEqual(payload["env"]["RUN_ALL"], "1")
574 self.assertEqual(payload["env"]["NIGHTLY"], "1")
575
576 + def test_amd_run_variants_set_buildkite_environment(self) -> None:
577 + cases = (
578 + (COMMAND_RUN_AMD_CI, {}),
579 + (COMMAND_RUN_AMD_CI_ALL, {"RUN_ALL": "1"}),
580 + (
581 + COMMAND_RUN_AMD_CI_NIGHTLY,
582 + {"RUN_ALL": "1", "NIGHTLY": "1"},
583 + ),
584 + )
585 + for command, expected_env in cases:
586 + with self.subTest(command=command):
587 + github = FakeGitHub()
588 + buildkite = FakeBuildkite([[], []])
589 +
590 + run(make_event(command), github, buildkite)
591 +
592 + payload = buildkite.created_builds[0]
593 + self.assertEqual(payload["message"], f"PR #42 {command} by @reviewer")
594 + command_env = {
595 + key: value
596 + for key, value in payload["env"].items()
597 + if key in {"RUN_ALL", "NIGHTLY"}
598 + }
599 + self.assertEqual(command_env, expected_env)
600 + self.assertIn("Buildkite AMD CI #123", github.comments[0])
601 +
602 def test_unapproved_authors_are_denied_without_buildkite(self) -> None:
603 github = FakeGitHub(
604 permission="read",
@@ -505,6 +616,20 @@ class RunCiCommandTest(unittest.TestCase):
616 run(make_event(COMMAND_RUN_CI, "author"), github, buildkite)
617 self.assertEqual(len(github.comments), 1)
618
619 + def test_unapproved_author_gets_amd_specific_guidance(self) -> None:
620 + github = FakeGitHub(
621 + permission="read",
622 + pr=make_pr(),
623 + review_decision="REVIEW_REQUIRED",
624 + )
625 + buildkite = FakeBuildkite()
626 +
627 + run(make_event(COMMAND_RUN_AMD_CI, "author"), github, buildkite)
628 +
629 + self.assertEqual(buildkite.list_calls, [])
630 + self.assertIn("`/amd-ci run`", github.comments[0])
631 + self.assertNotIn("`/ci run`", github.comments[0])
632 +
633 def test_untrusted_approval_cannot_launch_ci(self) -> None:
634 github = FakeGitHub(
635 permission="read",
@@ -540,9 +665,12 @@ class RunCiCommandTest(unittest.TestCase):
665 self.assertEqual(len(github.comments), 1)
666 comment = github.comments[0]
667 self.assertTrue(comment.startswith("✅ @author"))
543 - self.assertIn("`/ci run` starts a CI build", comment)
668 + self.assertIn("`/ci run` starts upstream CI", comment)
669 self.assertIn("`/ci retry` retries failed jobs", comment)
670 self.assertIn("`/ci cancel` cancels scheduled or running", comment)
671 + self.assertIn("`/amd-ci run` starts AMD CI only", comment)
672 + self.assertIn("`/amd-ci retry` retries failed jobs in AMD CI", comment)
673 + self.assertIn("`/amd-ci cancel` does the same for AMD CI only", comment)
674 self.assertIn("CI build for the current PR head", comment)
675 self.assertIn("only jobs that failed in the latest earlier CI build", comment)
676 self.assertNotIn(COMMAND_RUN_CI_ALL, comment)
@@ -723,6 +851,47 @@ class RunCiCommandTest(unittest.TestCase):
851 self.assertEqual(buildkite.retry_calls, [(123, RETRY_STATES)])
852 self.assertIn("Queued 3 failed job", github.comments[0])
853
854 + def test_amd_ci_retry_retries_only_the_current_head_build(self) -> None:
855 + github = FakeGitHub(
856 + permission="read",
857 + pr=make_pr(labels=[{"name": "ready"}]),
858 + )
859 + buildkite = FakeBuildkite(
860 + [
861 + [
862 + {
863 + "created_at": "2026-07-28T01:00:00Z",
864 + "number": 321,
865 + "pull_request": {"id": 42},
866 + "state": "failing",
867 + "web_url": "https://buildkite.example/amd-ci/builds/321",
868 + }
869 + ]
870 + ]
871 + )
872 +
873 + run(make_event(COMMAND_RETRY_AMD_FAILED, "author"), github, buildkite)
874 +
875 + self.assertEqual(buildkite.retry_calls, [(321, RETRY_STATES)])
876 + self.assertIn("Buildkite AMD CI #321", github.comments[0])
877 +
878 + def test_amd_ci_retry_requires_a_build_for_the_current_head(self) -> None:
879 + github = FakeGitHub(
880 + permission="read",
881 + pr=make_pr(labels=[{"name": "ready"}]),
882 + )
883 + buildkite = FakeBuildkite([[]])
884 +
885 + run(make_event(COMMAND_RETRY_AMD_FAILED, "author"), github, buildkite)
886 +
887 + self.assertEqual(buildkite.list_calls, [("0123456789abcdef", None)])
888 + self.assertEqual(buildkite.retry_calls, [])
889 + self.assertEqual(buildkite.created_builds, [])
890 + self.assertIn(
891 + "No AMD CI build exists for the current PR head", github.comments[0]
892 + )
893 + self.assertIn("Use `/amd-ci run`", github.comments[0])
894 +
895 def test_ci_retry_creates_filtered_build_for_new_head(self) -> None:
896 github = FakeGitHub(
897 permission="read",
@@ -926,6 +1095,48 @@ class RunCiCommandTest(unittest.TestCase):
1095 self.assertEqual(buildkite.cancel_calls, [])
1096 self.assertIn("No cancelable CI build is running", github.comments[0])
1097
1098 + def test_amd_ci_cancel_handles_command_and_fork_webhook_branches(self) -> None:
1099 + pr = make_pr()
1100 + pr["head"]["label"] = "contributor:feature"
1101 + github = FakeGitHub(pr=pr)
1102 + buildkite = FakeBuildkite(
1103 + [
1104 + [
1105 + {
1106 + "branch": "feature",
1107 + "number": 321,
1108 + "pull_request": {"id": 42},
1109 + "state": "running",
1110 + "web_url": "https://buildkite.example/amd-ci/builds/321",
1111 + }
1112 + ],
1113 + [
1114 + {
1115 + "branch": "contributor:feature",
1116 + "number": 322,
1117 + "pull_request": {"id": 42},
1118 + "state": "failing",
1119 + "web_url": "https://buildkite.example/amd-ci/builds/322",
1120 + }
1121 + ],
1122 + ]
1123 + )
1124 +
1125 + run(make_event(COMMAND_CANCEL_AMD_CI), github, buildkite)
1126 +
1127 + self.assertEqual(buildkite.cancel_calls, [321, 322])
1128 + self.assertEqual(
1129 + [request["branch"] for request in buildkite.list_requests],
1130 + ["feature", "contributor:feature"],
1131 + )
1132 + self.assertTrue(
1133 + all(
1134 + request["states"] == CANCELABLE_BUILD_STATES
1135 + for request in buildkite.list_requests
1136 + )
1137 + )
1138 + self.assertIn("cancellation of 2 AMD CI builds", github.comments[0])
1139 +
1140 def test_buildkite_cancel_uses_cancel_build_endpoint(self) -> None:
1141 transport = FakeTransport({"number": 123, "state": "canceling"})
1142 client = BuildkiteClient(
docs/contributing/README.md
+5 -3
@@ -303,9 +303,11 @@ review process:
303 clarification or discuss the suggestion.
304 - Note that not all CI checks will be executed due to limited computational
305 resources. Reviewers with write access and configured trusted contributors
306 - can comment `/ci run` when CI signals are needed before a PR is ready. After
307 - the PR is approved or has the `ready` label, the PR author can use `/ci run`
308 - or `/ci retry`. New commits do not start CI automatically.
306 + can comment `/ci run` for upstream CI or `/amd-ci run` for AMD CI only when
307 + CI signals are needed before a PR is ready. After the PR is approved or has
308 + the `ready` label, the PR author can use `/ci run`, `/ci retry`, `/ci cancel`,
309 + or the corresponding `/amd-ci` variants. New commits do not start upstream
310 + CI automatically.
311
312 ### Pull Request Limits and Escalation
313