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()