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 ee62251fc26 Show why external contributors' workflow runs wait for 
approval (#75832)
ee62251fc26 is described below

commit ee62251fc26aebc83df51c2e0ba9943cae57973f
Author: Jarek Potiuk <[email protected]>
AuthorDate: Wed Oct 28 05:27:26 2026 +0100

    Show why external contributors' workflow runs wait for approval (#75832)
    
    * Show why external contributors' workflow runs wait for approval
    
    Contributors whose runs the auto-approve workflow leaves for a maintainer
    only saw a label, with no reason, and a comment for each would notify
    every subscriber. A pending "Workflow approval" commit status shows the
    reason in the PR's checks without notifying anyone.
    
    GitHub has no event for approving runs, but a waiting run never starts,
    so a run that starts is the signal to clear the status and the label,
    also for runs a maintainer approves by hand, without listing every
    waiting run.
    
    Generated-by: Claude Opus 5
    
    * Keep the approval status in sync with runs approved during a tick
    
    Generated-by: Claude Opus 5
    
    * Re-check approved runs after the failure path writes the approval state
    
    Generated-by: Claude Opus 5
---
 .github/workflows/auto-approve-workflow-runs.yml   |  12 +-
 .github/workflows/mark-workflow-runs-approved.yml  |  85 +++++++++
 contributing-docs/05_pull_requests.rst             |   6 +-
 contributing-docs/25_maintainer_pr_triage.md       |   2 +-
 dev/breeze/doc/08_ci_tasks.rst                     |   8 +-
 dev/breeze/doc/ci/05_workflows.md                  |  24 ++-
 .../doc/ci/08_fork_ci_for_external_contributors.md |   3 +-
 dev/breeze/doc/images/output_ci.svg                |   4 +-
 dev/breeze/doc/images/output_ci.txt                |   2 +-
 .../doc/images/output_ci_approve-workflow-runs.svg |  46 +++--
 .../doc/images/output_ci_approve-workflow-runs.txt |   2 +-
 .../src/airflow_breeze/commands/ci_commands.py     |   8 +-
 .../airflow_breeze/utils/workflow_run_approval.py  | 103 ++++++++++-
 dev/breeze/tests/test_workflow_run_approval.py     | 198 ++++++++++++++++++++-
 14 files changed, 457 insertions(+), 46 deletions(-)

diff --git a/.github/workflows/auto-approve-workflow-runs.yml 
b/.github/workflows/auto-approve-workflow-runs.yml
index c6885dccb2b..ef635daf243 100644
--- a/.github/workflows/auto-approve-workflow-runs.yml
+++ b/.github/workflows/auto-approve-workflow-runs.yml
@@ -17,10 +17,12 @@
 #
 ---
 # 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. PRs from the repositories of trusted 
stakeholders (Amazon, Google,
-# Astronomer) are always approved. The rules are in
+# run without a maintainer's look, and labels the others "need workflow 
approval" with a pending
+# "Workflow approval" commit status saying why. A PR whose CI runs in the 
contributor's fork (AIP-120)
+# is approved unless it changes CI definition files, and then also gets a 
comment saying why its runs
+# wait. When the runs start, mark-workflow-runs-approved.yml removes the label 
and turns the status
+# green. 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.
 #
@@ -43,6 +45,8 @@ permissions:
   # Adding and removing the "need workflow approval" label, and commenting on 
a fork CI PR whose
   # CI change keeps its runs waiting.
   pull-requests: write
+  # The "Workflow approval" status telling the author why the runs wait.
+  statuses: write
   contents: read
 concurrency:
   group: auto-approve-workflow-runs
diff --git a/.github/workflows/mark-workflow-runs-approved.yml 
b/.github/workflows/mark-workflow-runs-approved.yml
new file mode 100644
index 00000000000..5484333e46d
--- /dev/null
+++ b/.github/workflows/mark-workflow-runs-approved.yml
@@ -0,0 +1,85 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+---
+# When the "Tests (AMD)" run of an external contributor's PR starts, turns the 
pending "Workflow
+# approval" status that auto-approve-workflow-runs.yml set on its commit green 
and removes the
+# "need workflow approval" label. GitHub has no event for approving runs, but 
a run waiting for
+# approval never starts, so a started run was approved, by a maintainer or 
automatically.
+#
+# workflow_run runs with this repository's token whatever the PR, so this 
workflow checks out
+# nothing and only calls the GitHub API. The head branch is chosen by the 
contributor and reaches
+# the script only through the environment.
+name: Mark workflow runs approved
+on:  # yamllint disable-line rule:truthy
+  workflow_run:  # zizmor: ignore[dangerous-triggers]
+    workflows: ["Tests (AMD)"]
+    types: [in_progress]
+permissions:
+  # Removing the "need workflow approval" label.
+  pull-requests: write
+  # Turning the "Workflow approval" status green.
+  statuses: write
+concurrency:
+  group: mark-workflow-runs-approved-${{ github.event.workflow_run.head_sha }}
+  cancel-in-progress: false
+jobs:
+  mark-workflow-runs-approved:
+    name: "Mark workflow runs approved"
+    runs-on: ["ubuntu-slim"]
+    if: >-
+      github.repository == 'apache/airflow' &&
+      github.event.workflow_run.event == 'pull_request' &&
+      github.event.workflow_run.head_repository.full_name != github.repository
+    timeout-minutes: 5
+    steps:
+      - name: "Mark workflow runs approved"
+        env:
+          GH_TOKEN: ${{ github.token }}
+          REPO: ${{ github.repository }}
+          HEAD_REPOSITORY: ${{ 
github.event.workflow_run.head_repository.full_name }}
+          HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
+          HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
+          STATUS_URL: 
"https://github.com/apache/airflow/blob/main/contributing-docs/\
+            05_pull_requests.rst#ci-workflow-runs-of-external-contributors"
+        run: |
+          # Keep in sync with APPROVAL_STATUS_CONTEXT and 
NEED_WORKFLOW_APPROVAL_LABEL in
+          # dev/breeze/src/airflow_breeze/utils/workflow_run_approval.py.
+          context="Workflow approval"
+          label="need workflow approval"
+          # The sha is put into jq filters below.
+          [[ "${HEAD_SHA}" =~ ^[0-9a-f]{40}$ ]] || { echo "Unexpected head 
sha: ${HEAD_SHA}"; exit 1; }
+          # Paginated: the fork CI reconciler posts a status per job, so a 
commit can have many contexts.
+          state=$(gh api --paginate 
"repos/${REPO}/commits/${HEAD_SHA}/status?per_page=100" \
+            --jq ".statuses[] | select(.context == \"${context}\") | .state")
+          if [[ "${state}" == "pending" ]]; then
+            gh api -X POST "repos/${REPO}/statuses/${HEAD_SHA}" \
+              -f state=success -f context="${context}" -f 
description="Workflow runs approved" \
+              -f target_url="${STATUS_URL}" \
+              --silent
+            echo "${HEAD_SHA}: status turned green"
+          fi
+          # -f with GET encodes the branch into the query string. A PR pushed 
again after the run
+          # started has newer runs, which may wait, so it keeps its label.
+          prs=$(gh api -X GET --paginate "repos/${REPO}/pulls" \
+            -f state=open -f per_page=100 -f 
head="${HEAD_REPOSITORY%%/*}:${HEAD_BRANCH}" \
+            --jq ".[] | select(.head.sha == \"${HEAD_SHA}\")
+              | select(any(.labels[]; .name == \"${label}\")) | .number")
+          for number in ${prs}; do
+            gh api -X DELETE "repos/${REPO}/issues/${number}/labels/${label// 
/%20}" --silent
+            echo "#${number}: label removed"
+          done
diff --git a/contributing-docs/05_pull_requests.rst 
b/contributing-docs/05_pull_requests.rst
index f3e32816dd7..0904333ed0e 100644
--- a/contributing-docs/05_pull_requests.rst
+++ b/contributing-docs/05_pull_requests.rst
@@ -282,8 +282,10 @@ 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
-looking at the change. That can take a while, so please do not ping 
maintainers about it; it is
-not a judgement of your PR. The label is removed when a later commit is 
approved automatically.
+looking at the change. The pending ``Workflow approval`` status in the PR's 
checks says why the
+runs wait. That can take a while, so please do not ping maintainers about it; 
it is not a judgement
+of your PR. Once the runs are approved, by a maintainer or automatically for a 
later commit, the
+label is removed and the status turns green.
 
 Maintainers can see each decision, the CI jobs selective checks expect to run 
and their estimated
 time in the summary of the ``Auto approve workflow runs`` workflow. The rules 
are described in
diff --git a/contributing-docs/25_maintainer_pr_triage.md 
b/contributing-docs/25_maintainer_pr_triage.md
index e340f98275e..3d873e17942 100644
--- a/contributing-docs/25_maintainer_pr_triage.md
+++ b/contributing-docs/25_maintainer_pr_triage.md
@@ -238,7 +238,7 @@ The triage workflow uses the following labels and states:
 | Label / State | Meaning |
 |---|---|
 | `ready for maintainer review` | PR has passed Stage 1 and is queued for 
human review. Applied only after the skill has verified that no workflow runs 
are awaiting approval. |
-| `need workflow approval` | The PR's CI workflow runs wait for a maintainer, 
because the `Auto approve workflow runs` workflow did not approve them: a PR to 
a branch other than `main`, a change to CI definition files, a full-tests or 
dependency change, a change whose CI jobs are estimated at more than 310 
job-minutes, or a change of more than 100 lines of source code (see [Pull 
Requests](05_pull_requests.rst)). For a PR whose CI runs in the contributor's 
fork, only a change to CI defini [...]
+| `need workflow approval` | The PR's CI workflow runs wait for a maintainer, 
because the `Auto approve workflow runs` workflow did not approve them: a PR to 
a branch other than `main`, a change to CI definition files, a full-tests or 
dependency change, a change whose CI jobs are estimated at more than 310 
job-minutes, or a change of more than 100 lines of source code (see [Pull 
Requests](05_pull_requests.rst)). For a PR whose CI runs in the contributor's 
fork, only a change to CI defini [...]
 | `closed because of multiple quality violations` | PR was closed because the 
author has multiple open PRs with quality issues. |
 | `suspicious changes detected` | PR (and all open PRs by the same author) was 
closed because the diff contained suspicious patterns (secret exfiltration, 
malicious CI modifications, etc.). |
 | `closed because of open PR limit` | PR was closed in the one-time closure 
that introduced the [open PR limit](32_open_pull_request_limit.rst). Applied by 
`dev/close_prs_over_open_pr_limit.py`, never by the triage skill. If the author 
reopens the PR, triage it like any other. |
diff --git a/dev/breeze/doc/08_ci_tasks.rst b/dev/breeze/doc/08_ci_tasks.rst
index 7147b615096..36eb3743eb4 100644
--- a/dev/breeze/doc/08_ci_tasks.rst
+++ b/dev/breeze/doc/08_ci_tasks.rst
@@ -204,13 +204,17 @@ Approving workflow runs of external contributors
 
 The ``auto-approve-workflow-runs`` workflow runs ``approve-workflow-runs`` 
every 15 minutes. It approves
 the waiting workflow runs of external contributors' PRs to ``main`` whose CI 
jobs are estimated at no more
-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
+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``, with a pending
+``Workflow approval`` commit status on their head commit that gives the 
reason. Once a maintainer
+approves the runs and they start, the ``mark-workflow-runs-approved`` workflow 
removes the label and
+turns the status green. 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. 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
+summary. Use ``--report-only`` to see the decisions without approving runs or 
changing labels or
+statuses. The rules
 are described in `CI workflows <ci/05_workflows.md>`_.
 
 These are all available flags of ``approve-workflow-runs`` command:
diff --git a/dev/breeze/doc/ci/05_workflows.md 
b/dev/breeze/doc/ci/05_workflows.md
index a98d3a15861..0bbc0ffe5bb 100644
--- a/dev/breeze/doc/ci/05_workflows.md
+++ b/dev/breeze/doc/ci/05_workflows.md
@@ -150,10 +150,26 @@ maintainer, since it still runs the selection it started 
with; the next push is
 For a PR whose CI runs in the fork neither the selection nor the labels a run 
started with matter:
 the gate reads the current labels when the run starts and fails when it 
cannot, so a run started
 before `use fork ci` or `use project ci` was added or removed is approved too. 
Other PRs get the
-`need workflow approval` label and wait for a maintainer. When processing a PR 
fails (for example on
-a GitHub API error), the workflow tries to add the label to it and the 
workflow run fails, so the
-failure stays visible even if the label could not be added. The label is 
removed when a later commit
-of the PR is approved automatically.
+`need workflow approval` label and wait for a maintainer. Their head commit 
also gets a pending
+`Workflow approval` commit status whose description gives the reason (cut to 
GitHub's 140
+characters), so the author sees why in the PR's checks without a notifying 
comment. The status is
+not a required check and does not block merging. When processing a PR fails 
(for example on a
+GitHub API error), the workflow tries to add the label and a pending status 
with a generic reason,
+and the workflow run fails, so the failure stays visible even if the label 
could not be added.
+
+The label is removed when a later commit of the PR is approved automatically. 
GitHub has no event
+for approving runs, but a run waiting for approval never starts, so the 
`mark-workflow-runs-approved.yml`
+workflow follows the `Tests (AMD)` runs of fork PRs instead: when one reaches 
`in_progress`, a
+`ubuntu-slim` job makes a few `gh api` calls that turn the commit's pending 
status green and remove
+the label of the open PRs whose head is that commit. This covers runs a 
maintainer approves by hand
+as well as automatic approvals, without listing the waiting runs. A PR pushed 
again after the run
+started keeps its label, because its newer runs may wait. The job checks out 
nothing, and the
+contributor-chosen head branch reaches its script only through the 
environment. A maintainer can
+approve the runs after the approval workflow listed them and before it writes 
the label and status,
+so after writing either it checks the runs again and clears both if none still 
waits. Only a
+started `Tests (AMD)` run clears them, so a PR that removes or renames that 
workflow keeps its
+label and pending status after its other runs are approved; such a PR changes 
CI definition files
+and is reviewed by a maintainer anyway, and the status does not block merging.
 
 The workflow uses only `main`'s code. It reads the PR's metadata and fetches 
its merge commit as git
 objects to list the changed files; it never checks out or runs the PR's code. 
The merge commit must
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 10a96244cfe..2de7db7f14c 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
@@ -363,7 +363,8 @@ applies instead:
   `pull_request` workflows (CodeQL for the changed languages, the newsfragment 
check, dependency
   review) run on GitHub-hosted runners.
 - Otherwise the runs wait for a maintainer, the pull request is labelled `need 
workflow approval`,
-  and the workflow posts one comment explaining that the change to the listed 
CI files needs a
+  its head commit gets a pending `Workflow approval` status naming the CI 
files (as for every
+  pull request whose runs wait), and the workflow posts one comment explaining 
that the change to the listed CI files needs a
   maintainer to review it and approve the runs. The comment starts with the 
marker
   `<!-- workflow-approval: ci-change -->` and is posted once per pull request; 
only comments by
   `github-actions[bot]` with the marker count. A change to `.github/` can 
remove the gate or add a
diff --git a/dev/breeze/doc/images/output_ci.svg 
b/dev/breeze/doc/images/output_ci.svg
index d175e86af7b..f0dfe263de9 100644
--- a/dev/breeze/doc/images/output_ci.svg
+++ b/dev/breeze/doc/images/output_ci.svg
@@ -146,8 +146,8 @@
 </text><text class="breeze-ci-r5" x="0" y="459.2" textLength="12.2" 
clip-path="url(#breeze-ci-line-18)">│</text><text class="breeze-ci-r4" x="24.4" 
y="459.2" textLength="256.2" 
clip-path="url(#breeze-ci-line-18)">fork-ci-reconcile&#160;&#160;&#160;&#160;</text><text
 class="breeze-ci-r1" x="305" y="459.2" textLength="1134.6" 
clip-path="url(#breeze-ci-line-18)">Reconcile&#160;pull&#160;requests&#160;whose&#160;CI&#160;runs&#160;in&#160;contributor&#160;forks&#160;(AIP-120).&#160;Run&#160;o
 [...]
 </text><text class="breeze-ci-r5" x="0" y="483.6" textLength="12.2" 
clip-path="url(#breeze-ci-line-19)">│</text><text class="breeze-ci-r4" x="24.4" 
y="483.6" textLength="256.2" 
clip-path="url(#breeze-ci-line-19)">audit&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-ci-r1" x="305" y="483.6" textLength="1134.6" 
clip-path="url(#breeze-ci-line-19)">Check&#160;that&#160;your&#160;checkout&#160;and&#160;fork&#160;are&#1
 [...]
 </text><text class="breeze-ci-r5" x="0" y="508" textLength="12.2" 
clip-path="url(#breeze-ci-line-20)">│</text><text class="breeze-ci-r4" x="24.4" 
y="508" textLength="256.2" 
clip-path="url(#breeze-ci-line-20)">approve-workflow-runs</text><text 
class="breeze-ci-r1" x="305" y="508" textLength="1134.6" 
clip-path="url(#breeze-ci-line-20)">Approve&#160;the&#160;waiting&#160;workflow&#160;runs&#160;of&#160;external&#160;contributors&#x27;&#160;PRs&#160;whose&#160;changes&#160;are&#160;safe&#160
 [...]
-</text><text class="breeze-ci-r5" x="0" y="532.4" textLength="12.2" 
clip-path="url(#breeze-ci-line-21)">│</text><text class="breeze-ci-r1" x="305" 
y="532.4" textLength="1134.6" 
clip-path="url(#breeze-ci-line-21)">without&#160;a&#160;maintainer&#x27;s&#160;look,&#160;and&#160;label&#160;the&#160;others&#160;&#x27;need&#160;workflow&#160;approval&#x27;.&#160;Used&#160;by&#160;the&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-ci-r5" x="1451.8" y="532.4" textLength="12.2" cli [...]
-</text><text class="breeze-ci-r5" x="0" y="556.8" textLength="12.2" 
clip-path="url(#breeze-ci-line-22)">│</text><text class="breeze-ci-r1" x="305" 
y="556.8" textLength="1134.6" 
clip-path="url(#breeze-ci-line-22)">auto-approve-workflow-runs&#160;workflow.&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#16
 [...]
+</text><text class="breeze-ci-r5" x="0" y="532.4" textLength="12.2" 
clip-path="url(#breeze-ci-line-21)">│</text><text class="breeze-ci-r1" x="305" 
y="532.4" textLength="1134.6" 
clip-path="url(#breeze-ci-line-21)">without&#160;a&#160;maintainer&#x27;s&#160;look,&#160;and&#160;label&#160;the&#160;others&#160;&#x27;need&#160;workflow&#160;approval&#x27;&#160;with&#160;a&#160;&#x27;Workflow&#160;&#160;</text><text
 class="breeze-ci-r5" x="1451.8" y="532.4" textLength="12.2" clip-path="url(#br 
[...]
+</text><text class="breeze-ci-r5" x="0" y="556.8" textLength="12.2" 
clip-path="url(#breeze-ci-line-22)">│</text><text class="breeze-ci-r1" x="305" 
y="556.8" textLength="1134.6" 
clip-path="url(#breeze-ci-line-22)">approval&#x27;&#160;commit&#160;status&#160;saying&#160;why.&#160;Used&#160;by&#160;the&#160;auto-approve-workflow-runs&#160;workflow.&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-ci-r5" x="1451.8" y="556.8" textLength="12.2" clip-path="url(#br 
[...]
 </text><text class="breeze-ci-r5" x="0" y="581.2" textLength="1464" 
clip-path="url(#breeze-ci-line-23)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ci-r1" x="1464" y="581.2" textLength="12.2" 
clip-path="url(#breeze-ci-line-23)">
 </text>
     </g>
diff --git a/dev/breeze/doc/images/output_ci.txt 
b/dev/breeze/doc/images/output_ci.txt
index ab2dfd33d8e..f2021370546 100644
--- a/dev/breeze/doc/images/output_ci.txt
+++ b/dev/breeze/doc/images/output_ci.txt
@@ -1 +1 @@
-eb626e1c2737eaba69f91123d1077d16
+bc527ecfffabbbd21bdcf1fbbfe25bc5
diff --git a/dev/breeze/doc/images/output_ci_approve-workflow-runs.svg 
b/dev/breeze/doc/images/output_ci_approve-workflow-runs.svg
index 929558eb134..587901db1b5 100644
--- a/dev/breeze/doc/images/output_ci_approve-workflow-runs.svg
+++ b/dev/breeze/doc/images/output_ci_approve-workflow-runs.svg
@@ -1,4 +1,4 @@
-<svg class="rich-terminal" viewBox="0 0 1482 538.0" 
xmlns="http://www.w3.org/2000/svg";>
+<svg class="rich-terminal" viewBox="0 0 1482 586.8" 
xmlns="http://www.w3.org/2000/svg";>
     <!-- Generated with Rich https://www.textualize.io -->
     <style>
 
@@ -45,7 +45,7 @@
 
     <defs>
     <clipPath id="breeze-ci-approve-workflow-runs-clip-terminal">
-      <rect x="0" y="0" width="1463.0" height="487.0" />
+      <rect x="0" y="0" width="1463.0" height="535.8" />
     </clipPath>
     <clipPath id="breeze-ci-approve-workflow-runs-line-0">
     <rect x="0" y="1.5" width="1464" height="24.65"/>
@@ -104,9 +104,15 @@
 <clipPath id="breeze-ci-approve-workflow-runs-line-18">
     <rect x="0" y="440.7" width="1464" height="24.65"/>
             </clipPath>
+<clipPath id="breeze-ci-approve-workflow-runs-line-19">
+    <rect x="0" y="465.1" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-approve-workflow-runs-line-20">
+    <rect x="0" y="489.5" width="1464" height="24.65"/>
+            </clipPath>
     </defs>
 
-    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="1480" height="536" rx="8"/><text 
class="breeze-ci-approve-workflow-runs-title" fill="#c5c8c6" 
text-anchor="middle" x="740" 
y="27">Command:&#160;ci&#160;approve-workflow-runs</text>
+    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="1480" height="584.8" rx="8"/><text 
class="breeze-ci-approve-workflow-runs-title" fill="#c5c8c6" 
text-anchor="middle" x="740" 
y="27">Command:&#160;ci&#160;approve-workflow-runs</text>
             <g transform="translate(26,22)">
             <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
             <circle cx="22" cy="0" r="7" fill="#febc2e"/>
@@ -120,22 +126,24 @@
 </text><text class="breeze-ci-approve-workflow-runs-r2" x="12.2" y="44.4" 
textLength="73.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-1)">Usage:</text><text 
class="breeze-ci-approve-workflow-runs-r3" x="97.6" y="44.4" textLength="378.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-1)">breeze&#160;ci&#160;approve-workflow-runs</text><text
 class="breeze-ci-approve-workflow-runs-r1" x="488" y="44.4" textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-1) [...]
 </text><text class="breeze-ci-approve-workflow-runs-r1" x="1464" y="68.8" 
textLength="12.2" clip-path="url(#breeze-ci-approve-workflow-runs-line-2)">
 </text><text class="breeze-ci-approve-workflow-runs-r1" x="12.2" y="93.2" 
textLength="1427.4" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-3)">Approve&#160;the&#160;waiting&#160;workflow&#160;runs&#160;of&#160;external&#160;contributors&#x27;&#160;PRs&#160;whose&#160;changes&#160;are&#160;safe&#160;to&#160;run&#160;without&#160;a&#160;maintainer&#x27;s&#160;</text><text
 class="breeze-ci-approve-workflow-runs-r1" x="1464" y="93.2" textLength="12.2" 
clip-path="url(#breeze-ci-approv [...]
-</text><text class="breeze-ci-approve-workflow-runs-r1" x="12.2" y="117.6" 
textLength="1232.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-4)">look,&#160;and&#160;label&#160;the&#160;others&#160;&#x27;need&#160;workflow&#160;approval&#x27;.&#160;Used&#160;by&#160;the&#160;auto-approve-workflow-runs&#160;workflow.</text><text
 class="breeze-ci-approve-workflow-runs-r1" x="1464" y="117.6" 
textLength="12.2" clip-path="url(#breeze-ci-approve-workflow-runs-line-4)">
-</text><text class="breeze-ci-approve-workflow-runs-r1" x="1464" y="142" 
textLength="12.2" clip-path="url(#breeze-ci-approve-workflow-runs-line-5)">
-</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="166.4" 
textLength="24.4" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-6)">╭─</text><text 
class="breeze-ci-approve-workflow-runs-r5" x="24.4" y="166.4" 
textLength="195.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-6)">&#160;Approval&#160;flags&#160;</text><text
 class="breeze-ci-approve-workflow-runs-r5" x="219.6" y="166.4" 
textLength="1220" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-6)">────────── [...]
-</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="190.8" 
textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-7)">│</text><text 
class="breeze-ci-approve-workflow-runs-r4" x="24.4" y="190.8" 
textLength="231.8" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-7)">--max-run-age-hours</text><text
 class="breeze-ci-approve-workflow-runs-r1" x="280.6" y="190.8" 
textLength="1159" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-7)">Only&#160;decide&#160;o
 [...]
-</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="215.2" 
textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-8)">│</text><text 
class="breeze-ci-approve-workflow-runs-r5" x="280.6" y="215.2" 
textLength="158.6" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-8)">[default:&#160;24]</text><text
 class="breeze-ci-approve-workflow-runs-r6" x="451.4" y="215.2" 
textLength="244" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-8)">(INTEGER&#160;RANGE&#160
 [...]
-</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="239.6" 
textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-9)">│</text><text 
class="breeze-ci-approve-workflow-runs-r4" x="24.4" y="239.6" 
textLength="231.8" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-9)">--report-only&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-ci-approve-workflow-runs-r1" x="280.6" y="239.6" 
textLength="1012.6" clip-path="url(#breeze-ci-approve-workflow-runs- [...]
-</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="264" 
textLength="1464" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-10)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ci-approve-workflow-runs-r1" x="1464" y="264" textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-10)">
-</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="288.4" 
textLength="24.4" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-11)">╭─</text><text 
class="breeze-ci-approve-workflow-runs-r5" x="24.4" y="288.4" 
textLength="231.8" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-11)">&#160;GitHub&#160;parameters&#160;</text><text
 class="breeze-ci-approve-workflow-runs-r5" x="256.2" y="288.4" 
textLength="1183.4" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-11)">── [...]
-</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="312.8" 
textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-12)">│</text><text 
class="breeze-ci-approve-workflow-runs-r7" x="24.4" y="312.8" textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-12)">*</text><text 
class="breeze-ci-approve-workflow-runs-r4" x="61" y="312.8" textLength="231.8" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-12)">--github-token&#160;&#160;&#160;&#160;&#1
 [...]
-</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="337.2" 
textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-13)">│</text><text 
class="breeze-ci-approve-workflow-runs-r4" x="61" y="337.2" textLength="231.8" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-13)">--github-repository</text><text
 class="breeze-ci-approve-workflow-runs-r9" x="317.2" y="337.2" 
textLength="24.4" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-13)">-g</text><text 
class=" [...]
-</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="361.6" 
textLength="1464" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-14)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ci-approve-workflow-runs-r1" x="1464" y="361.6" 
textLength="12.2" clip-path="url(#breeze-ci-approve-workflow-runs-line-14)">
-</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="386" 
textLength="24.4" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-15)">╭─</text><text 
class="breeze-ci-approve-workflow-runs-r5" x="24.4" y="386" textLength="195.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-15)">&#160;Common&#160;options&#160;</text><text
 class="breeze-ci-approve-workflow-runs-r5" x="219.6" y="386" textLength="1220" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-15)">───────────── [...]
-</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="410.4" 
textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-16)">│</text><text 
class="breeze-ci-approve-workflow-runs-r4" x="24.4" y="410.4" 
textLength="109.8" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-16)">--verbose</text><text 
class="breeze-ci-approve-workflow-runs-r9" x="158.6" y="410.4" 
textLength="24.4" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-16)">-v</text><text 
class="breeze-c [...]
-</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="434.8" 
textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-17)">│</text><text 
class="breeze-ci-approve-workflow-runs-r4" x="24.4" y="434.8" 
textLength="109.8" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-17)">--dry-run</text><text 
class="breeze-ci-approve-workflow-runs-r9" x="158.6" y="434.8" 
textLength="24.4" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-17)">-D</text><text 
class="breeze-c [...]
-</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="459.2" 
textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-18)">│</text><text 
class="breeze-ci-approve-workflow-runs-r4" x="24.4" y="459.2" 
textLength="109.8" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-18)">--help&#160;&#160;&#160;</text><text
 class="breeze-ci-approve-workflow-runs-r9" x="158.6" y="459.2" 
textLength="24.4" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-18)">-h</text><text  [...]
-</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="483.6" 
textLength="1464" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-19)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ci-approve-workflow-runs-r1" x="1464" y="483.6" 
textLength="12.2" clip-path="url(#breeze-ci-approve-workflow-runs-line-19)">
+</text><text class="breeze-ci-approve-workflow-runs-r1" x="12.2" y="117.6" 
textLength="1427.4" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-4)">look,&#160;and&#160;label&#160;the&#160;others&#160;&#x27;need&#160;workflow&#160;approval&#x27;&#160;with&#160;a&#160;&#x27;Workflow&#160;approval&#x27;&#160;commit&#160;status&#160;saying&#160;why.&#160;Used&#160;by&#160;the&#160;</text><text
 class="breeze-ci-approve-workflow-runs-r1" x="1464" y="117.6" 
textLength="12.2" clip-path="url( [...]
+</text><text class="breeze-ci-approve-workflow-runs-r1" x="12.2" y="142" 
textLength="439.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-5)">auto-approve-workflow-runs&#160;workflow.</text><text
 class="breeze-ci-approve-workflow-runs-r1" x="1464" y="142" textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-5)">
+</text><text class="breeze-ci-approve-workflow-runs-r1" x="1464" y="166.4" 
textLength="12.2" clip-path="url(#breeze-ci-approve-workflow-runs-line-6)">
+</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="190.8" 
textLength="24.4" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-7)">╭─</text><text 
class="breeze-ci-approve-workflow-runs-r5" x="24.4" y="190.8" 
textLength="195.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-7)">&#160;Approval&#160;flags&#160;</text><text
 class="breeze-ci-approve-workflow-runs-r5" x="219.6" y="190.8" 
textLength="1220" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-7)">────────── [...]
+</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="215.2" 
textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-8)">│</text><text 
class="breeze-ci-approve-workflow-runs-r4" x="24.4" y="215.2" 
textLength="231.8" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-8)">--max-run-age-hours</text><text
 class="breeze-ci-approve-workflow-runs-r1" x="280.6" y="215.2" 
textLength="1159" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-8)">Only&#160;decide&#160;o
 [...]
+</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="239.6" 
textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-9)">│</text><text 
class="breeze-ci-approve-workflow-runs-r5" x="280.6" y="239.6" 
textLength="158.6" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-9)">[default:&#160;24]</text><text
 class="breeze-ci-approve-workflow-runs-r6" x="451.4" y="239.6" 
textLength="244" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-9)">(INTEGER&#160;RANGE&#160
 [...]
+</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="264" 
textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-10)">│</text><text 
class="breeze-ci-approve-workflow-runs-r4" x="24.4" y="264" textLength="231.8" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-10)">--report-only&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-ci-approve-workflow-runs-r1" x="280.6" y="264" textLength="1159" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-1 [...]
+</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="288.4" 
textLength="1464" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-11)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ci-approve-workflow-runs-r1" x="1464" y="288.4" 
textLength="12.2" clip-path="url(#breeze-ci-approve-workflow-runs-line-11)">
+</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="312.8" 
textLength="24.4" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-12)">╭─</text><text 
class="breeze-ci-approve-workflow-runs-r5" x="24.4" y="312.8" 
textLength="231.8" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-12)">&#160;GitHub&#160;parameters&#160;</text><text
 class="breeze-ci-approve-workflow-runs-r5" x="256.2" y="312.8" 
textLength="1183.4" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-12)">── [...]
+</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="337.2" 
textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-13)">│</text><text 
class="breeze-ci-approve-workflow-runs-r7" x="24.4" y="337.2" textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-13)">*</text><text 
class="breeze-ci-approve-workflow-runs-r4" x="61" y="337.2" textLength="231.8" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-13)">--github-token&#160;&#160;&#160;&#160;&#1
 [...]
+</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="361.6" 
textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-14)">│</text><text 
class="breeze-ci-approve-workflow-runs-r8" x="366" y="361.6" textLength="122" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-14)">[required]</text><text
 class="breeze-ci-approve-workflow-runs-r6" x="500.2" y="361.6" 
textLength="73.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-14)">(TEXT)</text><text 
class="breeze [...]
+</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="386" 
textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-15)">│</text><text 
class="breeze-ci-approve-workflow-runs-r4" x="61" y="386" textLength="231.8" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-15)">--github-repository</text><text
 class="breeze-ci-approve-workflow-runs-r9" x="317.2" y="386" textLength="24.4" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-15)">-g</text><text 
class="breeze [...]
+</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="410.4" 
textLength="1464" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-16)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ci-approve-workflow-runs-r1" x="1464" y="410.4" 
textLength="12.2" clip-path="url(#breeze-ci-approve-workflow-runs-line-16)">
+</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="434.8" 
textLength="24.4" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-17)">╭─</text><text 
class="breeze-ci-approve-workflow-runs-r5" x="24.4" y="434.8" 
textLength="195.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-17)">&#160;Common&#160;options&#160;</text><text
 class="breeze-ci-approve-workflow-runs-r5" x="219.6" y="434.8" 
textLength="1220" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-17)">─────── [...]
+</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="459.2" 
textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-18)">│</text><text 
class="breeze-ci-approve-workflow-runs-r4" x="24.4" y="459.2" 
textLength="109.8" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-18)">--verbose</text><text 
class="breeze-ci-approve-workflow-runs-r9" x="158.6" y="459.2" 
textLength="24.4" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-18)">-v</text><text 
class="breeze-c [...]
+</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="483.6" 
textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-19)">│</text><text 
class="breeze-ci-approve-workflow-runs-r4" x="24.4" y="483.6" 
textLength="109.8" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-19)">--dry-run</text><text 
class="breeze-ci-approve-workflow-runs-r9" x="158.6" y="483.6" 
textLength="24.4" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-19)">-D</text><text 
class="breeze-c [...]
+</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="508" 
textLength="12.2" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-20)">│</text><text 
class="breeze-ci-approve-workflow-runs-r4" x="24.4" y="508" textLength="109.8" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-20)">--help&#160;&#160;&#160;</text><text
 class="breeze-ci-approve-workflow-runs-r9" x="158.6" y="508" textLength="24.4" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-20)">-h</text><text class= 
[...]
+</text><text class="breeze-ci-approve-workflow-runs-r5" x="0" y="532.4" 
textLength="1464" 
clip-path="url(#breeze-ci-approve-workflow-runs-line-21)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ci-approve-workflow-runs-r1" x="1464" y="532.4" 
textLength="12.2" clip-path="url(#breeze-ci-approve-workflow-runs-line-21)">
 </text>
     </g>
     </g>
diff --git a/dev/breeze/doc/images/output_ci_approve-workflow-runs.txt 
b/dev/breeze/doc/images/output_ci_approve-workflow-runs.txt
index f8caf2b0fe0..6c465f8905d 100644
--- a/dev/breeze/doc/images/output_ci_approve-workflow-runs.txt
+++ b/dev/breeze/doc/images/output_ci_approve-workflow-runs.txt
@@ -1 +1 @@
-485b9682543dc0dd1e1922e32196b2ee
+ce8a402cbd0ec478242207455696d881
diff --git a/dev/breeze/src/airflow_breeze/commands/ci_commands.py 
b/dev/breeze/src/airflow_breeze/commands/ci_commands.py
index b7d2943b483..583f999bc96 100644
--- a/dev/breeze/src/airflow_breeze/commands/ci_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/ci_commands.py
@@ -1803,12 +1803,12 @@ def audit(fix: bool):
 @ci_group.command(
     name="approve-workflow-runs",
     help="Approve the waiting workflow runs of external contributors' PRs 
whose changes are safe "
-    "to run without a maintainer's look, and label the others 'need workflow 
approval'. "
-    "Used by the auto-approve-workflow-runs workflow.",
+    "to run without a maintainer's look, and label the others 'need workflow 
approval' with a "
+    "'Workflow approval' commit status saying why. Used by the 
auto-approve-workflow-runs workflow.",
 )
 @click.option(
     "--github-token",
-    help="GitHub token able to approve workflow runs and label PRs.",
+    help="GitHub token able to approve workflow runs, label PRs and set commit 
statuses.",
     envvar="GITHUB_TOKEN",
     required=True,
 )
@@ -1823,7 +1823,7 @@ def audit(fix: bool):
 @click.option(
     "--report-only",
     is_flag=True,
-    help="Only report the decisions and estimates, without approving runs or 
changing labels.",
+    help="Only report the decisions and estimates, without approving runs or 
changing labels or statuses.",
     envvar="REPORT_ONLY",
 )
 @option_github_repository
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 107fee1e4d8..ec30ba279a4 100644
--- a/dev/breeze/src/airflow_breeze/utils/workflow_run_approval.py
+++ b/dev/breeze/src/airflow_breeze/utils/workflow_run_approval.py
@@ -21,7 +21,9 @@ GitHub holds the workflow runs of a fork PR in 
``action_required`` until someone
 approves them. This module approves the runs of PRs to ``main`` that do not 
touch anything that
 defines CI or chooses runners, do not need the full set of tests or a 
dependency upgrade, and whose
 jobs, as selected by selective checks, are estimated to take a limited number 
of job-minutes.
-Everything else gets the ``need workflow approval`` label and waits for a 
maintainer.
+Everything else gets the ``need workflow approval`` label and waits for a 
maintainer, and a pending
+``Workflow approval`` commit status that tells the author why, without 
notifying anyone. When the
+runs start, ``mark-workflow-runs-approved.yml`` turns the status green and 
removes the label.
 
 A PR whose CI runs in the contributor's fork (AIP-120) is decided by one rule 
instead: its project
 CI run only executes the fork CI gate, so it is approved whatever its size, 
unless it changes files
@@ -73,8 +75,17 @@ from airflow_breeze.utils.selective_checks import (
     UPGRADE_TO_NEWER_DEPENDENCIES_LABEL,
 )
 
+# The label and the status context are also used by 
.github/workflows/mark-workflow-runs-approved.yml.
 NEED_WORKFLOW_APPROVAL_LABEL = "need workflow approval"
 
+APPROVAL_STATUS_CONTEXT = "Workflow approval"
+APPROVAL_STATUS_URL = (
+    
"https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst";
+    "#ci-workflow-runs-of-external-contributors"
+)
+# GitHub rejects longer commit status descriptions.
+MAX_STATUS_DESCRIPTION_LENGTH = 140
+
 # Only PRs to main are approved automatically. PRs to release branches 
(``v3-4-test``,
 # ``airflow-ctl/v0-1-test``, ...) run those branches' own, older workflows and 
always need a maintainer.
 AUTO_APPROVABLE_BASE_BRANCH = "main"
@@ -410,6 +421,39 @@ class GitHubApi:
         )
         response.raise_for_status()
 
+    def get_approval_status(self, sha: str) -> dict[str, Any] | None:
+        """The latest ``Workflow approval`` status of the commit."""
+        # Paginated: the fork CI reconciler posts a status per job, so a 
commit can have many contexts.
+        statuses = self._get_paginated(f"commits/{sha}/status?per_page=100", 
item_key="statuses")
+        return next((status for status in statuses if status["context"] == 
APPROVAL_STATUS_CONTEXT), None)
+
+    def is_run_waiting_for_approval(self, run_id: int) -> bool:
+        response = self._session.get(
+            
f"{GITHUB_API_URL}/repos/{self.github_repository}/actions/runs/{run_id}", 
timeout=60
+        )
+        response.raise_for_status()
+        run = response.json()
+        # A run waiting for approval is reported as completed with the 
action_required conclusion.
+        return "action_required" in (run.get("status"), run.get("conclusion"))
+
+    def set_approval_status(self, sha: str, state: str, description: str) -> 
None:
+        if self.report_only:
+            console_print(
+                f"[info]Report only: would set status {state} on {sha[:10]}: 
{escape(description)}[/]"
+            )
+            return
+        response = self._session.post(
+            f"{GITHUB_API_URL}/repos/{self.github_repository}/statuses/{sha}",
+            json={
+                "state": state,
+                "target_url": APPROVAL_STATUS_URL,
+                "description": description,
+                "context": APPROVAL_STATUS_CONTEXT,
+            },
+            timeout=60,
+        )
+        response.raise_for_status()
+
     def _get_timeline(self, pr_number: int) -> list[dict[str, Any]]:
         if pr_number not in self._timelines:
             self._timelines[pr_number] = 
self._get_paginated(f"issues/{pr_number}/timeline?per_page=100")
@@ -643,8 +687,18 @@ def process_waiting_runs(api: GitHubApi, *, created_since: 
datetime) -> list[Pul
                 pr_run_ids = run_ids
                 # Without the label a PR that keeps failing drops out of the 
query window unnoticed.
                 try:
-                    if not has_need_workflow_approval_label(pr):
+                    has_label = has_need_workflow_approval_label(pr)
+                    if not has_label:
                         api.add_label(pr["number"], 
NEED_WORKFLOW_APPROVAL_LABEL)
+                    # The error itself is only in the job summary: it can show 
the workflow's internals.
+                    status_changed = update_approval_status(
+                        api,
+                        sha=head_sha,
+                        state="pending",
+                        description="Waits for a maintainer: the automatic 
check failed",
+                    )
+                    if not has_label or status_changed:
+                        clear_if_runs_were_approved(api, pr=pr, 
run_ids=pr_run_ids)
                 except requests.RequestException as label_error:
                     console_print(
                         f"[warning]PR #{pr['number']}: adding the label 
failed: {escape(str(label_error))}[/]"
@@ -733,6 +787,43 @@ def build_failure_reason(error: Exception) -> str:
     return f"{reason}: {text}" if text else reason
 
 
+def build_status_description(prefix: str, reasons: list[str]) -> str:
+    description = f"{prefix}: {'; '.join(reasons)}"
+    if len(description) > MAX_STATUS_DESCRIPTION_LENGTH:
+        description = f"{description[: MAX_STATUS_DESCRIPTION_LENGTH - 
len('...')]}..."
+    return description
+
+
+def update_approval_status(api: GitHubApi, *, sha: str, state: str, 
description: str) -> bool:
+    """
+    Set the commit's ``Workflow approval`` status unless it already has this 
one; return whether it did.
+
+    Every tick decides again on the runs of the last 24 hours, and repeating 
the same status would
+    only spend the API's stricter write limit.
+    """
+    current = api.get_approval_status(sha)
+    if current is not None and (current["state"], current["description"]) == 
(state, description):
+        return False
+    api.set_approval_status(sha, state, description)
+    return True
+
+
+def clear_if_runs_were_approved(api: GitHubApi, *, pr: dict[str, Any], 
run_ids: list[int]) -> bool:
+    """
+    Undo the label and pending status just written if no run waits any more; 
return whether it did.
+
+    A maintainer may have approved the runs since they were listed, and the 
run that started may
+    already have cleared the label and status that were just written. An 
approval after this check
+    starts its run after the writes, so mark-workflow-runs-approved.yml clears 
them.
+    """
+    if any(api.is_run_waiting_for_approval(run_id) for run_id in run_ids):
+        return False
+    console_print(f"[info]PR #{pr['number']}: its runs were approved 
meanwhile[/]")
+    api.set_approval_status(pr["head"]["sha"], "success", "Workflow runs 
approved")
+    api.remove_label(pr["number"], NEED_WORKFLOW_APPROVAL_LABEL)
+    return True
+
+
 def has_need_workflow_approval_label(pr: dict[str, Any]) -> bool:
     return any(label["name"] == NEED_WORKFLOW_APPROVAL_LABEL for label in 
pr.get("labels", []))
 
@@ -756,6 +847,14 @@ def apply_decision(
     console_print(f"[warning]PR #{pr_number} needs a maintainer: {escape('; 
'.join(decision.reasons))}[/]")
     if not has_label:
         api.add_label(pr_number, NEED_WORKFLOW_APPROVAL_LABEL)
+    status_changed = update_approval_status(
+        api,
+        sha=pr["head"]["sha"],
+        state="pending",
+        description=build_status_description("Waits for a maintainer", 
decision.reasons),
+    )
+    if (not has_label or status_changed) and clear_if_runs_were_approved(api, 
pr=pr, run_ids=run_ids):
+        return
     # Once per PR: every later push would otherwise repeat it while the change 
stays.
     if decision.comment is not None and not api.has_bot_comment(pr_number, 
CI_CHANGE_MARKER):
         api.create_comment(pr_number, decision.comment)
diff --git a/dev/breeze/tests/test_workflow_run_approval.py 
b/dev/breeze/tests/test_workflow_run_approval.py
index d82a5aabe93..408aeca15f9 100644
--- a/dev/breeze/tests/test_workflow_run_approval.py
+++ b/dev/breeze/tests/test_workflow_run_approval.py
@@ -31,9 +31,11 @@ from airflow_breeze.utils.fork_ci_messages import 
CI_CHANGE_MARKER
 from airflow_breeze.utils.selective_checks import SelectiveChecks
 from airflow_breeze.utils.shared_options import set_dry_run
 from airflow_breeze.utils.workflow_run_approval import (
+    APPROVAL_STATUS_CONTEXT,
     MAX_AUTO_APPROVED_JOB_MINUTES,
     MAX_AUTO_APPROVED_SOURCE_LINES,
     MAX_ERROR_TEXT_LENGTH,
+    MAX_STATUS_DESCRIPTION_LENGTH,
     NEED_WORKFLOW_APPROVAL_LABEL,
     TEST_SELECTION_LABELS,
     ApprovalDecision,
@@ -43,6 +45,7 @@ from airflow_breeze.utils.workflow_run_approval import (
     apply_decision,
     build_failure_reason,
     build_selective_checks,
+    build_status_description,
     count_source_lines,
     decide_approval,
     decide_for_pull_request,
@@ -423,11 +426,19 @@ def 
test_apply_decision_labels_prs_that_need_a_maintainer(labels, expected_add_l
     assert api.add_label.call_args_list == expected_add_label_calls
 
 
[email protected]("method", ["approve_run", "add_label", 
"remove_label", "create_comment"])
-def test_report_only_api_does_not_change_anything(method):
[email protected](
+    ("method", "args"),
+    [
+        ("approve_run", (1,)),
+        ("add_label", (1, NEED_WORKFLOW_APPROVAL_LABEL)),
+        ("remove_label", (1, NEED_WORKFLOW_APPROVAL_LABEL)),
+        ("create_comment", (1, "text")),
+        ("set_approval_status", ("aaa", "pending", "Waits for a maintainer: 
big")),
+    ],
+)
+def test_report_only_api_does_not_change_anything(method, args):
     api = GitHubApi(github_repository="apache/airflow", github_token="token", 
report_only=True)
     with mock.patch.object(api, "_session", autospec=True) as session:
-        args = (1,) if method == "approve_run" else (1, 
NEED_WORKFLOW_APPROVAL_LABEL)
         getattr(api, method)(*args)
 
     assert session.method_calls == []
@@ -1116,3 +1127,184 @@ def 
test_process_waiting_runs_does_not_trust_a_repository_by_its_name(mock_decid
     mock_decide.assert_called_once()
     assert not results[0].decision.approve
     api.approve_run.assert_not_called()
+
+
+def _status(state: str, description: str = "Waits for a maintainer: big") -> 
dict:
+    return {"context": APPROVAL_STATUS_CONTEXT, "state": state, "description": 
description}
+
+
[email protected](
+    ("current_status", "expect_set"),
+    [
+        pytest.param(None, True, id="no-status"),
+        pytest.param(_status("pending", "Waits for a maintainer: big"), False, 
id="same-status"),
+        pytest.param(_status("pending", "Waits for a maintainer: other"), 
True, id="other-reason"),
+        pytest.param(_status("success", "Approved by a maintainer"), True, 
id="was-approved"),
+    ],
+)
+def 
test_apply_decision_sets_the_pending_status_with_the_reason(current_status, 
expect_set):
+    api = mock.MagicMock(spec=GitHubApi)
+    api.get_approval_status.return_value = current_status
+
+    apply_decision(
+        api=api, pr=_pr(10, "aaa"), run_ids=[1], 
decision=ApprovalDecision(approve=False, reasons=["big"])
+    )
+
+    api.get_approval_status.assert_called_once_with("aaa")
+    assert api.set_approval_status.call_args_list == (
+        [mock.call("aaa", "pending", "Waits for a maintainer: big")] if 
expect_set else []
+    )
+
+
+def test_build_status_description_fits_the_github_limit():
+    reasons = [
+        f"it changes CI definition files: {', 
'.join(f'.github/workflows/w{i}.yml' for i in range(20))}"
+    ]
+
+    description = build_status_description("Waits for a maintainer", reasons)
+
+    assert len(description) == MAX_STATUS_DESCRIPTION_LENGTH
+    assert description.startswith("Waits for a maintainer: it changes CI 
definition files: .github/")
+    assert description.endswith("...")
+
+
+def test_build_status_description_keeps_a_short_reason():
+    assert build_status_description("Waits for a maintainer", ["big", "slow"]) 
== (
+        "Waits for a maintainer: big; slow"
+    )
+
+
[email protected](f"{MODULE}.decide_for_pull_request", autospec=True)
+def 
test_process_waiting_runs_sets_a_generic_pending_status_when_a_pull_request_fails(mock_decide):
+    api = _api_with_prs([_pr(10, "aaa")], [_run(1, "aaa")])
+    api.get_approval_status.return_value = None
+    mock_decide.side_effect = requests.HTTPError("502 Server Error for url: 
https://internal";)
+
+    process_waiting_runs(api, created_since=CREATED_SINCE)
+
+    api.set_approval_status.assert_called_once_with(
+        "aaa", "pending", "Waits for a maintainer: the automatic check failed"
+    )
+
+
+def test_get_approval_status_reads_every_page_of_statuses():
+    api = GitHubApi(github_repository="apache/airflow", github_token="token", 
report_only=False)
+    approval = _status("pending")
+    first_page = mock.MagicMock(spec=requests.Response)
+    first_page.json.return_value = {
+        "statuses": [{"context": f"job {i}", "state": "success", 
"description": ""} for i in range(100)]
+    }
+    first_page.links = {"next": {"url": "https://api.github.com/next-page"}}
+    second_page = mock.MagicMock(spec=requests.Response)
+    second_page.json.return_value = {"statuses": [approval]}
+    second_page.links = {}
+    with mock.patch.object(api, "_session", autospec=True) as session:
+        session.get.side_effect = [first_page, second_page]
+        assert api.get_approval_status("aaa") == approval
+
+    assert (
+        session.get.call_args_list[0]
+        .args[0]
+        .endswith("/repos/apache/airflow/commits/aaa/status?per_page=100")
+    )
+
+
+def test_get_approval_status_without_the_context():
+    api = GitHubApi(github_repository="apache/airflow", github_token="token", 
report_only=False)
+    statuses = [{"context": "CI passed", "state": "success", "description": 
""}]
+    with mock.patch.object(api, "_get_paginated", autospec=True, 
return_value=statuses):
+        assert api.get_approval_status("aaa") is None
+
+
+def test_set_approval_status_posts_the_workflow_approval_context():
+    api = GitHubApi(github_repository="apache/airflow", github_token="token", 
report_only=False)
+    with mock.patch.object(api, "_session", autospec=True) as session:
+        api.set_approval_status("aaa", "pending", "Waits for a maintainer: 
big")
+
+    assert 
session.post.call_args.args[0].endswith("/repos/apache/airflow/statuses/aaa")
+    payload = session.post.call_args.kwargs["json"]
+    assert (payload["context"], payload["state"], payload["description"]) == (
+        APPROVAL_STATUS_CONTEXT,
+        "pending",
+        "Waits for a maintainer: big",
+    )
+    assert 
payload["target_url"].endswith("#ci-workflow-runs-of-external-contributors")
+
+
[email protected](
+    ("run", "waiting"),
+    [
+        pytest.param({"status": "completed", "conclusion": "action_required"}, 
True, id="waiting"),
+        pytest.param({"status": "action_required", "conclusion": None}, True, 
id="waiting-status"),
+        pytest.param({"status": "queued", "conclusion": None}, False, 
id="approved"),
+        pytest.param({"status": "completed", "conclusion": "success"}, False, 
id="finished"),
+    ],
+)
+def test_is_run_waiting_for_approval(run, waiting):
+    api = GitHubApi(github_repository="apache/airflow", github_token="token", 
report_only=False)
+    with mock.patch.object(api, "_session", autospec=True) as session:
+        session.get.return_value.json.return_value = run
+        assert api.is_run_waiting_for_approval(1) is waiting
+
+    assert 
session.get.call_args.args[0].endswith("/repos/apache/airflow/actions/runs/1")
+
+
+def 
test_apply_decision_clears_what_it_wrote_when_the_runs_were_approved_meanwhile():
+    api = mock.MagicMock(spec=GitHubApi)
+    api.get_approval_status.return_value = None
+    api.is_run_waiting_for_approval.return_value = False
+    decision = ApprovalDecision(approve=False, reasons=["big"], 
comment="explanation")
+
+    apply_decision(api=api, pr=_pr(10, "aaa"), run_ids=[1, 2], 
decision=decision)
+
+    assert api.set_approval_status.call_args_list == [
+        mock.call("aaa", "pending", "Waits for a maintainer: big"),
+        mock.call("aaa", "success", "Workflow runs approved"),
+    ]
+    api.add_label.assert_called_once_with(10, NEED_WORKFLOW_APPROVAL_LABEL)
+    api.remove_label.assert_called_once_with(10, NEED_WORKFLOW_APPROVAL_LABEL)
+    api.create_comment.assert_not_called()
+
+
+def test_apply_decision_keeps_what_it_wrote_while_a_run_still_waits():
+    api = mock.MagicMock(spec=GitHubApi)
+    api.get_approval_status.return_value = None
+    api.is_run_waiting_for_approval.side_effect = [False, True]
+
+    apply_decision(
+        api=api, pr=_pr(10, "aaa"), run_ids=[1, 2], 
decision=ApprovalDecision(approve=False, reasons=["big"])
+    )
+
+    assert api.is_run_waiting_for_approval.call_args_list == [mock.call(1), 
mock.call(2)]
+    api.set_approval_status.assert_called_once_with("aaa", "pending", "Waits 
for a maintainer: big")
+    api.remove_label.assert_not_called()
+
+
+def test_apply_decision_does_not_recheck_the_runs_when_nothing_was_written():
+    api = mock.MagicMock(spec=GitHubApi)
+    api.get_approval_status.return_value = _status("pending", "Waits for a 
maintainer: big")
+
+    apply_decision(
+        api=api,
+        pr=_pr(10, "aaa", labels=[NEED_WORKFLOW_APPROVAL_LABEL]),
+        run_ids=[1],
+        decision=ApprovalDecision(approve=False, reasons=["big"]),
+    )
+
+    api.is_run_waiting_for_approval.assert_not_called()
+    api.set_approval_status.assert_not_called()
+
+
[email protected](f"{MODULE}.decide_for_pull_request", autospec=True)
+def 
test_process_waiting_runs_clears_a_failed_pull_request_whose_runs_were_approved_meanwhile(mock_decide):
+    api = _api_with_prs([_pr(10, "aaa")], [_run(1, "aaa")])
+    api.get_approval_status.return_value = None
+    api.is_run_waiting_for_approval.return_value = False
+    mock_decide.side_effect = requests.HTTPError("502 Server Error")
+
+    results = process_waiting_runs(api, created_since=CREATED_SINCE)
+
+    assert results[0].failed
+    api.is_run_waiting_for_approval.assert_called_once_with(1)
+    assert api.set_approval_status.call_args_list[-1] == mock.call("aaa", 
"success", "Workflow runs approved")
+    api.remove_label.assert_called_once_with(10, NEED_WORKFLOW_APPROVAL_LABEL)

Reply via email to