This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new ffd9398e4b4 Always approve workflow runs of PRs from trusted 
stakeholder repositories (#75677)
ffd9398e4b4 is described below

commit ffd9398e4b44e8e7c896be0a4788cdde825b1d55
Author: Jarek Potiuk <[email protected]>
AuthorDate: Mon Oct 26 20:42:25 2026 +0100

    Always approve workflow runs of PRs from trusted stakeholder repositories 
(#75677)
    
    Amazon, Google and Astronomer maintain large parts of Airflow and open many
    PRs from their own repositories. Their PRs often touch large providers or CI
    and wait for a maintainer to approve every push, although only people those
    stakeholders gave write access can push the commits of these PRs.
    
    Generated-by: Claude Opus 5
---
 .github/workflows/auto-approve-workflow-runs.yml   |  3 +-
 contributing-docs/05_pull_requests.rst             |  4 ++
 dev/breeze/doc/08_ci_tasks.rst                     |  3 +-
 dev/breeze/doc/ci/05_workflows.md                  |  9 ++++
 .../airflow_breeze/utils/workflow_run_approval.py  | 43 +++++++++++++++-
 dev/breeze/tests/test_workflow_run_approval.py     | 60 +++++++++++++++++++++-
 6 files changed, 117 insertions(+), 5 deletions(-)

diff --git a/.github/workflows/auto-approve-workflow-runs.yml 
b/.github/workflows/auto-approve-workflow-runs.yml
index 61d86be7a64..c6885dccb2b 100644
--- a/.github/workflows/auto-approve-workflow-runs.yml
+++ b/.github/workflows/auto-approve-workflow-runs.yml
@@ -19,7 +19,8 @@
 # Approves the waiting workflow runs of external contributors' PRs to main 
when the change is safe to
 # run without a maintainer's look, and labels the others "need workflow 
approval". A PR whose CI runs
 # in the contributor's fork (AIP-120) is approved unless it changes CI 
definition files, and then gets
-# a comment saying why its runs wait. The rules are in
+# a comment saying why its runs wait. PRs from the repositories of trusted 
stakeholders (Amazon, Google,
+# Astronomer) are always approved. The rules are in
 # dev/breeze/src/airflow_breeze/utils/workflow_run_approval.py and documented 
in
 # contributing-docs/05_pull_requests.rst.
 #
diff --git a/contributing-docs/05_pull_requests.rst 
b/contributing-docs/05_pull_requests.rst
index 593180aba6f..56b0ea7825f 100644
--- a/contributing-docs/05_pull_requests.rst
+++ b/contributing-docs/05_pull_requests.rst
@@ -273,6 +273,10 @@ automatically unless the PR changes files that define CI: 
anything under ``.gith
 or ``.pre-commit-config.yaml``. Then the runs wait for a maintainer and a 
comment on the PR explains
 why. CI in your fork runs either way.
 
+The runs of PRs opened from the repositories of trusted stakeholders 
(``astronomer/airflow``,
+``aws-mwaa/upstream-to-airflow`` and ``VladaZakharova/airflow``) are always 
approved, so none of
+the rules above apply to them.
+
 Each new push is checked again, because approval applies to one commit. Only 
runs started in the
 last 24 hours are considered, so older waiting runs stay with maintainers. If 
your PR does not
 qualify, it gets the ``need workflow approval`` label and a maintainer 
approves the runs after
diff --git a/dev/breeze/doc/08_ci_tasks.rst b/dev/breeze/doc/08_ci_tasks.rst
index d54eadbaa69..5e1b9311f32 100644
--- a/dev/breeze/doc/08_ci_tasks.rst
+++ b/dev/breeze/doc/08_ci_tasks.rst
@@ -207,7 +207,8 @@ the waiting workflow runs of external contributors' PRs to 
``main`` whose CI job
 than 310 job-minutes, that change at most 100 lines of source code and that do 
not touch CI definition files, and labels the other PRs ``need workflow 
approval``. PRs whose CI runs in
 the contributor's fork (AIP-120) only run the fork CI gate in 
``apache/airflow``, so their runs are
 approved whatever their size unless they change CI definition files; such a PR 
also gets a comment
-explaining that its CI change needs a maintainer. For every PR it also
+explaining that its CI change needs a maintainer. The runs of PRs from the 
repositories of trusted
+stakeholders (Amazon, Google, Astronomer) are always approved. For every PR it 
also
 estimates the CI jobs selective checks will run and how long they take, and 
writes them to the job
 summary. Use ``--report-only`` to see the decisions without approving runs or 
changing labels. The rules
 are described in `CI workflows <ci/05_workflows.md>`_.
diff --git a/dev/breeze/doc/ci/05_workflows.md 
b/dev/breeze/doc/ci/05_workflows.md
index 264bf7378e6..4c323ab1bf3 100644
--- a/dev/breeze/doc/ci/05_workflows.md
+++ b/dev/breeze/doc/ci/05_workflows.md
@@ -128,6 +128,15 @@ the `need workflow approval` label and one comment telling 
the author that the C
 maintainer; see
 [Approving gated 
runs](08_fork_ci_for_external_contributors.md#approving-gated-runs).
 
+The runs of PRs whose head repository belongs to a trusted stakeholder 
(`astronomer/airflow` for
+Astronomer, `aws-mwaa/upstream-to-airflow` for Amazon, 
`VladaZakharova/airflow` for Google; listed in
+`TRUSTED_STAKEHOLDER_REPOSITORIES`) are approved whatever the PR changes or 
targets: only people with
+write access to those repositories can push the PR's commits. The repositories 
are matched by their
+numeric id, so a repository that later takes over one of these names is not 
trusted. None of the
+rules above apply to them, and of the rules below only the 24-hour window 
does: their runs are
+approved even when several open PRs share the head commit, or when the runs 
started before the PR
+was opened, retargeted or relabelled.
+
 Only runs created in the last 24 hours (`--max-run-age-hours`) are considered, 
so the workflow never
 approves a backlog of older waiting runs at once; those stay with maintainers. 
A commit that is the
 head of several open PRs is never approved automatically, because its runs 
cannot be told apart by
diff --git a/dev/breeze/src/airflow_breeze/utils/workflow_run_approval.py 
b/dev/breeze/src/airflow_breeze/utils/workflow_run_approval.py
index 5b0289d2e19..f11760bb495 100644
--- a/dev/breeze/src/airflow_breeze/utils/workflow_run_approval.py
+++ b/dev/breeze/src/airflow_breeze/utils/workflow_run_approval.py
@@ -27,6 +27,9 @@ A PR whose CI runs in the contributor's fork (AIP-120) is 
decided by one rule in
 CI run only executes the fork CI gate, so it is approved whatever its size, 
unless it changes files
 that define CI, in which case it waits for a maintainer and the author gets a 
comment saying why.
 
+The runs of PRs from the repositories of trusted stakeholders (Amazon, Google, 
Astronomer) are
+always approved.
+
 Only API metadata and git objects of the PR are read. The PR's code is never 
checked out or run:
 selective checks come from the trusted ``main`` checkout and only see the list 
of changed files
 and the PR's ``pyproject.toml`` contents through ``git show``.
@@ -130,6 +133,17 @@ TEST_SELECTION_LABELS = frozenset(
     }
 )
 
+# Repositories of stakeholders that maintain large parts of Airflow, with the 
stakeholder's display
+# name. Only people with write access to such a repository can push the 
commits of its PRs, so their
+# runs are always approved, whatever the PR changes or targets. Keyed by 
repository id rather than
+# name: after a rename or deletion someone else could register the name. Find 
the id with
+# ``gh api repos/<owner>/<repo> --jq .id``.
+TRUSTED_STAKEHOLDER_REPOSITORIES: dict[int, tuple[str, str]] = {
+    65323404: ("astronomer/airflow", "Astronomer"),
+    484591340: ("aws-mwaa/upstream-to-airflow", "Amazon"),
+    733432432: ("VladaZakharova/airflow", "Google"),
+}
+
 # Read from the trusted main checkout, which is also the base branch whose 
gate job decides the
 # PRs that are approved automatically.
 FORK_CI_GATE_CONFIG_PATH = AIRFLOW_ROOT_PATH / ".github" / "fork-ci-gate.toml"
@@ -195,6 +209,20 @@ def is_gated_to_fork_ci(*, pr: dict[str, Any], 
github_repository: str, config: G
     return decide(event, config).reason in GATED_REASONS
 
 
+def find_trusted_stakeholder(run: dict[str, Any]) -> str | None:
+    repository_id = (run.get("head_repository") or {}).get("id")
+    trusted = TRUSTED_STAKEHOLDER_REPOSITORIES.get(repository_id) if 
isinstance(repository_id, int) else None
+    return trusted[1] if trusted else None
+
+
+def decide_trusted_approval(*, stakeholder: str) -> ApprovalDecision:
+    return ApprovalDecision(
+        approve=True,
+        reasons=[f"it comes from a repository of {stakeholder}, a trusted 
stakeholder"],
+        decision_labels=frozenset(),
+    )
+
+
 def find_fork_ci_definition_files(files: list[str]) -> list[str]:
     """The files the fork CI reconciler counts as defining CI, so both decide 
alike."""
     return [file for file in files if changes_ci_definition([file], 
file_count=1)]
@@ -576,11 +604,17 @@ def process_waiting_runs(api: GitHubApi, *, 
created_since: datetime) -> list[Pul
                 f"leaving runs {run_ids} alone[/]"
             )
             continue
+        # The runs of a group share their head repository.
+        stakeholder = find_trusted_stakeholder(head_runs[0])
+        # Approving a run again fails, and the PRs that share a trusted head 
share its runs.
+        approved_run_ids: set[int] = set()
         for pr in matching_prs:
             failed = False
             decision: ApprovalDecision | None = None
             try:
-                if len(matching_prs) > 1:
+                if stakeholder is not None:
+                    decision = decide_trusted_approval(stakeholder=stakeholder)
+                elif len(matching_prs) > 1:
                     # The runs cannot be told apart by PR, so one PR's 
decision must not approve them all.
                     decision = ApprovalDecision(
                         approve=False, reasons=["its head commit is the head 
of several open PRs"]
@@ -588,7 +622,8 @@ def process_waiting_runs(api: GitHubApi, *, created_since: 
datetime) -> list[Pul
                 else:
                     decision = decide_for_pull_request(api=api, pr=pr)
                 pr_run_ids = run_ids
-                if decision.approve:
+                # A trusted repository's runs do not depend on the base branch 
or labels they started with.
+                if decision.approve and stakeholder is None:
                     pr_run_ids = find_runs_started_for_current_pr(
                         api=api, pr=pr, runs=head_runs, 
decision_labels=decision.decision_labels
                     )
@@ -602,7 +637,11 @@ def process_waiting_runs(api: GitHubApi, *, created_since: 
datetime) -> list[Pul
                             estimate=decision.estimate,
                         )
                         pr_run_ids = run_ids
+                if decision.approve:
+                    pr_run_ids = [run_id for run_id in pr_run_ids if run_id 
not in approved_run_ids]
                 apply_decision(api=api, pr=pr, run_ids=pr_run_ids, 
decision=decision)
+                if decision.approve:
+                    approved_run_ids.update(pr_run_ids)
             except (requests.RequestException, subprocess.CalledProcessError) 
as e:
                 # One failing PR must not stop the others; the failure is 
reported through ``failed``.
                 failed = True
diff --git a/dev/breeze/tests/test_workflow_run_approval.py 
b/dev/breeze/tests/test_workflow_run_approval.py
index 6286cda6c39..39e07a02123 100644
--- a/dev/breeze/tests/test_workflow_run_approval.py
+++ b/dev/breeze/tests/test_workflow_run_approval.py
@@ -346,13 +346,14 @@ def _run(
     head_repo="contributor/airflow",
     head_branch="fix",
     created_at="2026-10-22T10:00:00Z",
+    head_repo_id=1,
 ) -> dict:
     return {
         "id": run_id,
         "head_sha": head_sha,
         "head_branch": head_branch,
         "created_at": created_at,
-        "head_repository": {"full_name": head_repo},
+        "head_repository": {"full_name": head_repo, "id": head_repo_id},
     }
 
 
@@ -1041,3 +1042,60 @@ def 
test_process_waiting_runs_approves_gated_runs_started_before_use_fork_ci_was
         process_waiting_runs(api, created_since=CREATED_SINCE)
 
     assert approve_run.call_args_list == [mock.call(2), mock.call(3)]
+
+
[email protected](
+    ("head_repo", "head_repo_id", "stakeholder"),
+    [
+        ("astronomer/airflow", 65323404, "Astronomer"),
+        ("aws-mwaa/upstream-to-airflow", 484591340, "Amazon"),
+        ("VladaZakharova/airflow", 733432432, "Google"),
+    ],
+)
[email protected](f"{MODULE}.decide_for_pull_request", autospec=True)
+def test_process_waiting_runs_always_approves_trusted_stakeholder_repositories(
+    mock_decide, head_repo, head_repo_id, stakeholder
+):
+    api = mock.MagicMock(spec=GitHubApi)
+    api.github_repository = "apache/airflow"
+    api.get_runs_waiting_for_approval.return_value = [
+        _run(1, "aaa", head_repo=head_repo, head_repo_id=head_repo_id, 
created_at="2026-10-22T08:30:00Z"),
+        _run(2, "aaa", head_repo=head_repo, head_repo_id=head_repo_id),
+    ]
+    # Several PRs share the head and a run started before the PRs were opened.
+    api.get_open_pull_requests.return_value = [
+        _pr(10, "aaa", head_repo=head_repo, 
labels=[NEED_WORKFLOW_APPROVAL_LABEL]),
+        _pr(11, "aaa", head_repo=head_repo, 
labels=[NEED_WORKFLOW_APPROVAL_LABEL]),
+    ]
+
+    results = process_waiting_runs(api, created_since=CREATED_SINCE)
+
+    mock_decide.assert_not_called()
+    assert [(r.pr_number, r.run_ids, r.decision.approve) for r in results] == [
+        (10, [1, 2], True),
+        (11, [], True),
+    ]
+    assert stakeholder in results[0].decision.reasons[0]
+    assert api.approve_run.call_args_list == [mock.call(1), mock.call(2)]
+    api.add_label.assert_not_called()
+    assert api.remove_label.call_args_list == [
+        mock.call(10, NEED_WORKFLOW_APPROVAL_LABEL),
+        mock.call(11, NEED_WORKFLOW_APPROVAL_LABEL),
+    ]
+
+
[email protected](f"{MODULE}.decide_for_pull_request", autospec=True)
+def 
test_process_waiting_runs_does_not_trust_a_repository_by_its_name(mock_decide):
+    api = mock.MagicMock(spec=GitHubApi)
+    api.github_repository = "apache/airflow"
+    api.get_last_base_change.return_value = None
+    api.get_last_label_change.return_value = None
+    api.get_runs_waiting_for_approval.return_value = [_run(1, "aaa", 
head_repo="astronomer/airflow")]
+    api.get_open_pull_requests.return_value = [_pr(10, "aaa", 
head_repo="astronomer/airflow")]
+    mock_decide.return_value = ApprovalDecision(approve=False, reasons=["too 
big"])
+
+    results = process_waiting_runs(api, created_since=CREATED_SINCE)
+
+    mock_decide.assert_called_once()
+    assert not results[0].decision.approve
+    api.approve_run.assert_not_called()

Reply via email to