8888 frozenset ({"CI passed (project CI not run)" , "CI passed (run cancelled)" , "CI passed" }),
8989)
9090GATED_RUN_JOB_NAMES = frozenset [str ]().union (* GATED_RUN_JOBS )
91+ CHECK_RUN_NEEDED_JOB_NAME = "Check if the run is needed"
92+ PROJECT_CI_NOT_RUN_JOB_NAME = "CI passed (project CI not run)"
9193BOT_LOGINS = frozenset ({"github-actions" , "github-actions[bot]" })
9294DISCOVERY_WINDOW = timedelta (hours = 24 )
9395PASS_TWO_PAGE_SIZE = 25
@@ -156,6 +158,7 @@ class WorkflowRun:
156158 updated_at : datetime
157159 status : str
158160 event : str = "pull_request"
161+ run_attempt : int = 1
159162
160163 @property
161164 def workflow_file (self ) -> str :
@@ -310,6 +313,25 @@ def jobs_show_project_ci(jobs: Iterable[dict[str, Any]], now: datetime) -> bool:
310313 )
311314
312315
316+ def is_gated_skip_run (jobs : Iterable [dict [str , Any ]], now : datetime ) -> bool :
317+ """
318+ Whether a completed ci-amd.yml run is only the gate leaving the pull request to fork CI.
319+
320+ Such a run lists every project CI job as a skipped check on the pull request, next to the
321+ fork's result in the `Fork CI / Tests (AMD)` status. The gate and the check-run-needed job
322+ succeeded, CI passed reported as not run, and every other job was skipped.
323+ """
324+ jobs = list (jobs )
325+ if jobs_show_project_ci (jobs , now ):
326+ return False
327+ succeeded = {GATE_JOB_NAME , CHECK_RUN_NEEDED_JOB_NAME , PROJECT_CI_NOT_RUN_JOB_NAME }
328+ if sorted (job ["name" ] for job in jobs if job ["name" ] in succeeded ) != sorted (succeeded ):
329+ return False
330+ return all (
331+ job .get ("conclusion" ) == ("success" if job ["name" ] in succeeded else "skipped" ) for job in jobs
332+ )
333+
334+
313335def touches (files : Iterable [str ], prefixes : tuple [str , ...]) -> bool :
314336 return any (path .startswith (prefixes ) for path in files )
315337
@@ -920,6 +942,7 @@ def parse_workflow_run(run: dict[str, Any]) -> WorkflowRun:
920942 updated_at = parse_github_datetime (run ["updated_at" ]), # type: ignore[arg-type]
921943 status = run ["status" ],
922944 event = run ["event" ],
945+ run_attempt = run .get ("run_attempt" ) or 1 ,
923946 )
924947
925948
@@ -988,6 +1011,16 @@ def __init__(
9881011 )
9891012 self ._did_project_ci : dict [int , bool ] = {}
9901013 self ._changes_workflows : dict [tuple [str , str ], bool | None ] = {}
1014+ self ._jobs : dict [int , list [dict [str , Any ]]] = {}
1015+
1016+ def _get_jobs (self , run : WorkflowRun ) -> list [dict [str , Any ]]:
1017+ if run .id not in self ._jobs :
1018+ self ._jobs [run .id ] = self ._client .rest_get (
1019+ f"repos/{ self ._repo } /actions/runs/{ run .id } /jobs?filter=latest&per_page=100" ,
1020+ paginate = True ,
1021+ items_key = "jobs" ,
1022+ )
1023+ return self ._jobs [run .id ]
9911024
9921025 def _did_run_project_ci (self , run : WorkflowRun ) -> bool :
9931026 if run .id not in self ._did_project_ci :
@@ -998,15 +1031,33 @@ def _did_run_project_ci(self, run: WorkflowRun) -> bool:
9981031 elif run .workflow_file != CI_WORKFLOW_FILE or not run .is_long_running (self ._now ):
9991032 result = False
10001033 else :
1001- jobs = self ._client .rest_get (
1002- f"repos/{ self ._repo } /actions/runs/{ run .id } /jobs?filter=latest&per_page=100" ,
1003- paginate = True ,
1004- items_key = "jobs" ,
1005- )
1006- result = jobs_show_project_ci (jobs , self ._now )
1034+ result = jobs_show_project_ci (self ._get_jobs (run ), self ._now )
10071035 self ._did_project_ci [run .id ] = result
10081036 return self ._did_project_ci [run .id ]
10091037
1038+ def find_gated_runs (self , pr : PullRequestSnapshot ) -> list [WorkflowRun ]:
1039+ """
1040+ Completed ci-amd.yml pull_request runs of the pull request's branch that only gated it.
1041+
1042+ A commit that changes workflows could do project CI work under the gate's name within its
1043+ time limit, so its runs are kept as evidence, as are runs whose change set is too large
1044+ to check. The jobs read are those of the latest attempt, and deleting a run deletes every
1045+ attempt, so a re-run is kept: an earlier attempt may have run project CI.
1046+ """
1047+ if not pr .head_repo :
1048+ return []
1049+ return [
1050+ run
1051+ for run in self ._runs_by_branch .get ((pr .head_repo .casefold (), pr .head_ref ), [])
1052+ if run .workflow_file == CI_WORKFLOW_FILE
1053+ and run .event == "pull_request"
1054+ and run .status == "completed"
1055+ and run .run_attempt == 1
1056+ and not self ._did_run_project_ci (run )
1057+ and is_gated_skip_run (self ._get_jobs (run ), self ._now )
1058+ and self ._commit_changes_workflows (run , pr .base_ref ) is False
1059+ ]
1060+
10101061 def _commit_changes_workflows (self , run : WorkflowRun , base_ref : str ) -> bool | None :
10111062 """Whether the run's commit changes workflows against the base; None when not knowable."""
10121063 key = (run .head_sha , base_ref )
@@ -1270,6 +1321,12 @@ def run_reconcile(
12701321 count_behind = functools .partial (count_commits_behind , client , repo , pr ),
12711322 )
12721323 apply_plan (client , pr , plan )
1324+ # The fork's result is in the `Fork CI / Tests (AMD)` status; the gated run's skipped
1325+ # checks only clutter the pull request. Runs with any project CI work stay for the
1326+ # tampering check.
1327+ if plan .state != ForkState .GATE_BYPASSED :
1328+ for run in bypass_finder .find_gated_runs (pr ):
1329+ client .delete_run (pr .number , run .id )
12731330 unknown .extend ((pr .number , reason ) for reason in plan .unknown )
12741331 attention .extend ((pr .number , item ) for item in plan .attention )
12751332 except Exception as e :
0 commit comments