[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