This is an automated email from the ASF dual-hosted git repository. potiuk pushed a commit to branch backport-ci-sync-v3-4-test in repository https://gitbox.apache.org/repos/asf/airflow.git
commit dc6650c4c2a39f1287a62b7de2d7afcfdaf6069e Author: Jarek Potiuk <[email protected]> AuthorDate: Sun Oct 25 19:41:10 2026 +0100 Delete the project CI runs that only gate a pull request to fork CI (#75588) * Delete the project CI runs that only gate a pull request to fork CI A gated Tests (AMD) run lists every project CI job as a skipped check on the pull request, next to the fork's result. GitHub cannot delete or update single Actions check runs, so the fork CI monitor deletes the whole run once only the gate ran in it. Generated-by: Claude Opus 5 * Keep gated runs whose commit changes workflows as tampering evidence Generated-by: Claude Opus 5 * Tell maintainers to close and reopen a gated pull request to start project CI The fork CI monitor now deletes gated runs, so there is no run left to re-run after adding use project ci. Generated-by: Claude Opus 5 * Keep re-run gated runs, whose earlier attempts may have run project CI Generated-by: Claude Opus 5 (cherry picked from commit daa2a86fdbd69a1cd4039e59f5941daaa7f18eba) --- .github/workflows/fork-ci-monitor.yml | 8 +- contributing-docs/25_maintainer_pr_triage.md | 7 +- .../doc/ci/08_fork_ci_for_external_contributors.md | 28 +++-- .../src/airflow_breeze/utils/fork_ci_github.py | 7 ++ .../src/airflow_breeze/utils/fork_ci_messages.py | 3 +- .../src/airflow_breeze/utils/fork_ci_reconcile.py | 69 ++++++++++-- dev/breeze/tests/test_fork_ci_github.py | 10 ++ dev/breeze/tests/test_fork_ci_reconcile.py | 119 +++++++++++++++++++++ 8 files changed, 232 insertions(+), 19 deletions(-) diff --git a/.github/workflows/fork-ci-monitor.yml b/.github/workflows/fork-ci-monitor.yml index 2613f32a07b..e94f8fd0457 100644 --- a/.github/workflows/fork-ci-monitor.yml +++ b/.github/workflows/fork-ci-monitor.yml @@ -18,7 +18,8 @@ --- # Drives pull requests whose CI runs in contributor forks (AIP-120): labels, draft state, the # state comment, the `Fork CI / Tests (AMD)` commit status and closing. It is the only writer for -# those pull requests and never checks out or runs pull request code. See +# those pull requests, and deletes their project CI runs that only skipped every job. It never checks +# out or runs pull request code. See # dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md. name: "Fork CI monitor" on: # yamllint disable-line rule:truthy @@ -31,8 +32,9 @@ on: # yamllint disable-line rule:truthy type: boolean default: false permissions: - # Listing the runs and jobs of the gate tampering check; unlisted scopes are none. - actions: read + # Listing the runs and jobs of the gate tampering check, and deleting the runs that only gated a + # pull request to fork CI; unlisted scopes are none. + actions: write contents: read # Labels, draft state, comments and closing of gated pull requests. pull-requests: write diff --git a/contributing-docs/25_maintainer_pr_triage.md b/contributing-docs/25_maintainer_pr_triage.md index ab12c1f877d..e5f5313c5b7 100644 --- a/contributing-docs/25_maintainer_pr_triage.md +++ b/contributing-docs/25_maintainer_pr_triage.md @@ -267,9 +267,10 @@ Everything else the skill does (quality criteria, pings, suspicious-change flags usual. The `Fork CI / Tests (AMD)` commit status shows the fork CI state and links to the run in the fork. To move a single pull request back to project CI, add `use project ci`; the reconciler releases it on its next run; removing `fork ci required` by hand is not enough, the next run -labels a gated pull request again. Adding the label does not start project CI by itself: re-run -the pull request's latest `Tests (AMD)` run (`gh run rerun <run-id> -R apache/airflow`, or -**Re-run all jobs** on the run's page), or ask the author to push. Pull requests that change CI-defining files stay in draft even +labels a gated pull request again. Adding the label does not start project CI by itself, and the +reconciler deletes the gated `Tests (AMD)` runs, so there is usually nothing to re-run: close and +reopen the pull request (`gh pr close <n> -R apache/airflow && gh pr reopen <n> -R apache/airflow`), +or ask the author to push. Pull requests that change CI-defining files stay in draft even when the fork run is green and are labelled `needs project ci`, for a maintainer to review and run project CI with `use project ci`. When the fork run is green and trusted, the reconciler also posts the `CI passed` status the merge ruleset requires, so these pull requests can be merged or diff --git a/dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md b/dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md index 6e352fea115..effe96065e7 100644 --- a/dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md +++ b/dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md @@ -221,14 +221,16 @@ Consequences for the other components: - The gate job reads the pull request's labels live from the API (one read-only call) rather than from the event payload, so labelling a pull request and pressing "Re-run all jobs" - switches it without a new push. + switches it without a new push. The reconciler deletes gated runs (see "Gated project CI runs + are deleted"), so for a gated pull request closing and reopening it takes the place of the + re-run. - Discovery in the reconciler also picks up open pull requests carrying `use fork ci` without `fork ci required`, regardless of `enabled`. - A pull request carrying both `fork ci required` and `use project ci` is *released*: the reconciler removes `fork ci required`, undrafts it if the bot drafted it, edits the state comment to say project CI is in use, and posts a `success` status with the description - "Project CI in use (label `use project ci`)". Project CI then runs on the next push or - re-run. + "Project CI in use (label `use project ci`)". Project CI then runs on the next push, or when + a maintainer closes and reopens the pull request. - Both override labels on one pull request resolve to project CI and are reported to Slack as a conflict. @@ -690,6 +692,20 @@ is the source the ruleset accepts. job must not report success there; it reports under another name when `build-info` is skipped, and the reconciler's status is the only `CI passed` the pull request has. +### Gated project CI runs are deleted + +A gated run of `ci-amd.yml` lists every project CI job on the pull request as a skipped check, +next to the fork's result in `Fork CI / Tests (AMD)`. GitHub cannot delete or update single check +runs created by GitHub Actions, so the reconciler deletes the whole run once it completed: the +`ci-amd.yml` `pull_request` runs of a gated pull request's branch from the tamper window in which +the gate, `Check if the run is needed` and `CI passed (project CI not run)` succeeded and every +other job was skipped. A run in which anything else happened, including a run the tampering check +counts as project CI work, is kept, and so is a run whose commit changes `.github/workflows/` or +whose change set is too large to check: such a commit could do project CI work under the gate's +name within its time limit, and its run is the evidence. A re-run is kept too: deleting a run +deletes every attempt, and an earlier attempt may have run project CI. Nothing is deleted for a +pull request in the `gate_bypassed` state. A dry run only reports the deletions. + ### Far behind the base branch A fork run tests the pushed commit as it is, while project CI tests it merged with the base @@ -745,11 +761,11 @@ budget stays observable. `.github/workflows/fork-ci-monitor.yml`: - `on: schedule: cron '*/15 * * * *'` and `workflow_dispatch` with a `dry-run` input. -- `permissions: actions: read, contents: read, pull-requests: write, statuses: write` with a +- `permissions: actions: write, contents: read, pull-requests: write, statuses: write` with a justification comment. Comments and labels on pull requests are covered by `pull-requests: write`; the commit status needs `statuses: write`; the gate tampering check - lists runs and their jobs, which needs `actions: read` because every scope a `permissions` - block leaves out is `none`. + lists runs and their jobs, and the reconciler deletes gated runs, which needs `actions: write` + because every scope a `permissions` block leaves out is `none`. - `runs-on: ubuntu-latest` (the breeze setup action frees disk space with `sudo` and `docker`, which `ubuntu-slim` does not provide), `timeout-minutes: 20`, `concurrency: fork-ci-monitor` with `cancel-in-progress: false` so runs never overlap. diff --git a/dev/breeze/src/airflow_breeze/utils/fork_ci_github.py b/dev/breeze/src/airflow_breeze/utils/fork_ci_github.py index 6f6203e7e78..73501627e77 100644 --- a/dev/breeze/src/airflow_breeze/utils/fork_ci_github.py +++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_github.py @@ -187,6 +187,13 @@ class GitHubClient: {"state": "closed"}, ) + def delete_run(self, pr: int, run_id: int) -> None: + self._mutate( + f"#{pr}: delete workflow run {run_id}", + ["api", "-X", "DELETE", f"repos/{self.repo}/actions/runs/{run_id}"], + None, + ) + def set_status( self, pr: int, diff --git a/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py b/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py index f3e2cf76f2e..d28e5f54283 100644 --- a/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py +++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py @@ -166,7 +166,8 @@ def released_comment(marker: Marker) -> str: return ( f"{render_marker(marker)}\n\n" "**Project CI in use.** A maintainer added the `use project ci` label, so CI for this pull " - "request runs in apache/airflow. It starts on the next push or re-run." + FOOTER + "request runs in apache/airflow. It starts on the next push, or when a maintainer closes and " + "reopens this pull request." + FOOTER ) diff --git a/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py b/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py index 3c4eeafb70d..61b44ff7899 100644 --- a/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py +++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py @@ -88,6 +88,8 @@ GATED_RUN_JOBS = ( frozenset({"CI passed (project CI not run)", "CI passed (run cancelled)", "CI passed"}), ) GATED_RUN_JOB_NAMES = frozenset[str]().union(*GATED_RUN_JOBS) +CHECK_RUN_NEEDED_JOB_NAME = "Check if the run is needed" +PROJECT_CI_NOT_RUN_JOB_NAME = "CI passed (project CI not run)" BOT_LOGINS = frozenset({"github-actions", "github-actions[bot]"}) DISCOVERY_WINDOW = timedelta(hours=24) PASS_TWO_PAGE_SIZE = 25 @@ -156,6 +158,7 @@ class WorkflowRun: updated_at: datetime status: str event: str = "pull_request" + run_attempt: int = 1 @property def workflow_file(self) -> str: @@ -310,6 +313,25 @@ def jobs_show_project_ci(jobs: Iterable[dict[str, Any]], now: datetime) -> bool: ) +def is_gated_skip_run(jobs: Iterable[dict[str, Any]], now: datetime) -> bool: + """ + Whether a completed ci-amd.yml run is only the gate leaving the pull request to fork CI. + + Such a run lists every project CI job as a skipped check on the pull request, next to the + fork's result in the `Fork CI / Tests (AMD)` status. The gate and the check-run-needed job + succeeded, CI passed reported as not run, and every other job was skipped. + """ + jobs = list(jobs) + if jobs_show_project_ci(jobs, now): + return False + succeeded = {GATE_JOB_NAME, CHECK_RUN_NEEDED_JOB_NAME, PROJECT_CI_NOT_RUN_JOB_NAME} + if sorted(job["name"] for job in jobs if job["name"] in succeeded) != sorted(succeeded): + return False + return all( + job.get("conclusion") == ("success" if job["name"] in succeeded else "skipped") for job in jobs + ) + + def touches(files: Iterable[str], prefixes: tuple[str, ...]) -> bool: return any(path.startswith(prefixes) for path in files) @@ -920,6 +942,7 @@ def parse_workflow_run(run: dict[str, Any]) -> WorkflowRun: updated_at=parse_github_datetime(run["updated_at"]), # type: ignore[arg-type] status=run["status"], event=run["event"], + run_attempt=run.get("run_attempt") or 1, ) @@ -988,6 +1011,16 @@ class BypassFinder: ) self._did_project_ci: dict[int, bool] = {} self._changes_workflows: dict[tuple[str, str], bool | None] = {} + self._jobs: dict[int, list[dict[str, Any]]] = {} + + def _get_jobs(self, run: WorkflowRun) -> list[dict[str, Any]]: + if run.id not in self._jobs: + self._jobs[run.id] = self._client.rest_get( + f"repos/{self._repo}/actions/runs/{run.id}/jobs?filter=latest&per_page=100", + paginate=True, + items_key="jobs", + ) + return self._jobs[run.id] def _did_run_project_ci(self, run: WorkflowRun) -> bool: if run.id not in self._did_project_ci: @@ -998,15 +1031,33 @@ class BypassFinder: elif run.workflow_file != CI_WORKFLOW_FILE or not run.is_long_running(self._now): result = False else: - jobs = self._client.rest_get( - f"repos/{self._repo}/actions/runs/{run.id}/jobs?filter=latest&per_page=100", - paginate=True, - items_key="jobs", - ) - result = jobs_show_project_ci(jobs, self._now) + result = jobs_show_project_ci(self._get_jobs(run), self._now) self._did_project_ci[run.id] = result return self._did_project_ci[run.id] + def find_gated_runs(self, pr: PullRequestSnapshot) -> list[WorkflowRun]: + """ + Completed ci-amd.yml pull_request runs of the pull request's branch that only gated it. + + A commit that changes workflows could do project CI work under the gate's name within its + time limit, so its runs are kept as evidence, as are runs whose change set is too large + to check. The jobs read are those of the latest attempt, and deleting a run deletes every + attempt, so a re-run is kept: an earlier attempt may have run project CI. + """ + if not pr.head_repo: + return [] + return [ + run + for run in self._runs_by_branch.get((pr.head_repo.casefold(), pr.head_ref), []) + if run.workflow_file == CI_WORKFLOW_FILE + and run.event == "pull_request" + and run.status == "completed" + and run.run_attempt == 1 + and not self._did_run_project_ci(run) + and is_gated_skip_run(self._get_jobs(run), self._now) + and self._commit_changes_workflows(run, pr.base_ref) is False + ] + def _commit_changes_workflows(self, run: WorkflowRun, base_ref: str) -> bool | None: """Whether the run's commit changes workflows against the base; None when not knowable.""" key = (run.head_sha, base_ref) @@ -1270,6 +1321,12 @@ def run_reconcile( count_behind=functools.partial(count_commits_behind, client, repo, pr), ) apply_plan(client, pr, plan) + # The fork's result is in the `Fork CI / Tests (AMD)` status; the gated run's skipped + # checks only clutter the pull request. Runs with any project CI work stay for the + # tampering check. + if plan.state != ForkState.GATE_BYPASSED: + for run in bypass_finder.find_gated_runs(pr): + client.delete_run(pr.number, run.id) unknown.extend((pr.number, reason) for reason in plan.unknown) attention.extend((pr.number, item) for item in plan.attention) except Exception as e: diff --git a/dev/breeze/tests/test_fork_ci_github.py b/dev/breeze/tests/test_fork_ci_github.py index cf314e8e05d..d993add8bb5 100644 --- a/dev/breeze/tests/test_fork_ci_github.py +++ b/dev/breeze/tests/test_fork_ci_github.py @@ -127,10 +127,20 @@ def test_dry_run_does_not_mutate(mock_run): client.close(1) client.remove_label(1, "fork ci required") + client.delete_run(1, 7) mock_run.assert_not_called() [email protected]("airflow_breeze.utils.fork_ci_github.subprocess.run", autospec=True) +def test_delete_run(mock_run): + mock_run.return_value = _completed() + + GitHubClient().delete_run(1, 7) + + assert mock_run.call_args.args[0] == ["gh", "api", "-X", "DELETE", "repos/apache/airflow/actions/runs/7"] + + @mock.patch("airflow_breeze.utils.fork_ci_github.subprocess.run", autospec=True) def test_remove_label_quotes_the_name(mock_run): mock_run.return_value = _completed() diff --git a/dev/breeze/tests/test_fork_ci_reconcile.py b/dev/breeze/tests/test_fork_ci_reconcile.py index 3f7d56f7c14..aaa5e40bbc6 100644 --- a/dev/breeze/tests/test_fork_ci_reconcile.py +++ b/dev/breeze/tests/test_fork_ci_reconcile.py @@ -51,6 +51,7 @@ from airflow_breeze.utils.fork_ci_reconcile import ( fetch_fork_observations, fetch_workflow_state, find_state_comment, + is_gated_skip_run, jobs_show_project_ci, load_workflow_triggers, parse_pull_request, @@ -781,6 +782,7 @@ def _run( age_hours=1.0, event="pull_request", status="completed", + run_attempt=1, ): created = NOW - timedelta(hours=age_hours) return { @@ -795,6 +797,7 @@ def _run( "updated_at": (created + timedelta(minutes=minutes)).isoformat(), "status": status, "event": event, + "run_attempt": run_attempt, } @@ -989,6 +992,76 @@ def test_finder_reports_an_incomplete_run_listing(client, total_count, incomplet assert (finder.incomplete is not None) is incomplete +GATED_JOBS = [ + _job("Check if the run is needed", "success"), + _job("Fork CI gate", "success"), + _job("Build info", started=False), + _job("CI passed (project CI not run)", "success"), +] + + [email protected]( + ("jobs", "expected"), + [ + pytest.param(GATED_JOBS, True, id="gated"), + pytest.param([*GATED_JOBS[:2], _job("Build info", "success"), GATED_JOBS[3]], False, id="ci-ran"), + pytest.param( + [*GATED_JOBS[:3], _job("CI passed (run cancelled)", "success")], False, id="run-cancelled" + ), + pytest.param(GATED_JOBS[1:], False, id="check-run-needed-missing"), + pytest.param( + [GATED_JOBS[0], _job("Fork CI gate", "failure"), *GATED_JOBS[2:]], False, id="gate-failed" + ), + pytest.param( + [*GATED_JOBS[:2], _job("Build info", "cancelled"), GATED_JOBS[3]], False, id="cancelled" + ), + ], +) +def test_is_gated_skip_run(jobs, expected): + assert is_gated_skip_run(jobs, NOW) is expected + + [email protected]( + ("runs", "jobs", "compare_files", "expected_ids"), + [ + pytest.param([_run(7, minutes=1)], GATED_JOBS, ("README.md",), [7], id="gated"), + pytest.param([_run(7, minutes=1)], TAMPERED_JOBS, ("README.md",), [], id="project-ci-ran"), + pytest.param( + [_run(7, minutes=1, status="in_progress")], GATED_JOBS, ("README.md",), [], id="in-progress" + ), + pytest.param( + [_run(7, minutes=1, path=".github/workflows/codeql-analysis.yml")], + GATED_JOBS, + ("README.md",), + [], + id="other-workflow", + ), + pytest.param( + [_run(7, minutes=1, event="pull_request_review")], + GATED_JOBS, + ("README.md",), + [], + id="other-event", + ), + pytest.param( + [_run(7, minutes=1)], GATED_JOBS, (".github/workflows/ci-amd.yml",), [], id="workflows-changed" + ), + pytest.param([_run(7, minutes=1, run_attempt=2)], GATED_JOBS, ("README.md",), [], id="re-run"), + pytest.param( + [_run(7, minutes=1)], + GATED_JOBS, + tuple(f"f{i}.py" for i in range(300)), + [], + id="too-large-to-check", + ), + ], +) +def test_finder_finds_gated_runs(client, runs, jobs, compare_files, expected_ids): + finder = _finder(client, runs, jobs=jobs, compare_files=compare_files) + + assert [run.id for run in finder.find_gated_runs(_pr())] == expected_ids + + # --------------------------------------------------------------------------------------------- # GitHub data gathering and the run loop # --------------------------------------------------------------------------------------------- @@ -1397,6 +1470,52 @@ def test_run_reconcile_isolates_failures_and_reports_them(client, tmp_path): assert client.set_status.call_args.args[:3] == (2, "sha2", "error") [email protected]( + ("state", "deletes"), + [ + pytest.param(ForkState.AWAITING_PUSH, True, id="gated"), + pytest.param(ForkState.GATE_BYPASSED, False, id="gate-bypassed"), + ], +) [email protected]("airflow_breeze.utils.fork_ci_reconcile.classify", autospec=True) +def test_run_reconcile_deletes_gated_runs(mock_classify, client, tmp_path, state, deletes): + mock_classify.return_value = Plan(state=state) + client.graphql.side_effect = [ + _search_result(), + _search_result(), + _search_result( + _pr_node(1, headRefName="my-change", headRepository={"nameWithOwner": "someone/airflow"}) + ), + {"pr0": {"object": None}}, + ] + client.rest_get_pages.side_effect = lambda path: [ + {"total_count": 1, "workflow_runs": [_run(7, minutes=1)] if "event=pull_request&" in path else []} + ] + + def rest_get(path, paginate=False, items_key=None): + if path.endswith("/actions/workflows/ci-amd.yml"): + return {"state": "active"} + if path.endswith("jobs?filter=latest&per_page=100"): + return GATED_JOBS + if "/compare/" in path: + return {"files": [{"filename": "README.md"}]} + raise AssertionError(path) + + client.rest_get.side_effect = rest_get + (tmp_path / "ci-amd.yml").write_text("on: [pull_request]\n") + + run_reconcile( + client, + CONFIG, + now=NOW, + run_url="https://run", + output_file=tmp_path / "s.json", + workflows_dir=tmp_path, + ) + + assert client.delete_run.call_args_list == ([mock.call(1, 7)] if deletes else []) + + @mock.patch("airflow_breeze.utils.fork_ci_reconcile.run_reconcile", autospec=True, return_value=True) def test_command_writes_has_alerts_output(mock_run, tmp_path, monkeypatch): github_output = tmp_path / "github_output"
