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    </text><text
class="breeze-ci-r1" x="305" y="459.2" textLength="1134.6"
clip-path="url(#breeze-ci-line-18)">Reconcile pull requests whose CI runs in contributor forks (AIP-120). Run 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                </text><text
class="breeze-ci-r1" x="305" y="483.6" textLength="1134.6"
clip-path="url(#breeze-ci-line-19)">Check that your checkout and fork are
[...]
</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 the waiting workflow runs of external contributors' PRs whose changes are safe 
[...]
-</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 a maintainer's look, and label the others 'need workflow approval'. Used by the      </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 workflow.                                       
[...]
+</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 a maintainer's look, and label the others 'need workflow approval' with a 'Workflow  </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' commit status saying why. Used by the auto-approve-workflow-runs workflow.         </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: ci 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: ci 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 ci 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 the waiting workflow runs of external contributors' PRs whose changes are safe to run without a maintainer's </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, and label the others 'need workflow approval'. Used by the auto-approve-workflow-runs 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)"> Approval flags </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 decide 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: 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 RANGE 
[...]
-</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      </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)"> GitHub parameters </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    
[...]
-</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)"> Common options </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   </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, and label the others 'need workflow approval' with a 'Workflow approval' commit status saying why. Used by the </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 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)"> Approval flags </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 decide 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: 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 RANGE 
[...]
+</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      </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)"> GitHub parameters </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    
[...]
+</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)"> Common options </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   </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)