This is an automated email from the ASF dual-hosted git repository.
shahar1 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-4-test by this push:
new dac5d28c8e4 [v3-4-test] Hold back only build-info changes in fork CI
and stop drafting them (#76282) (#76394)
dac5d28c8e4 is described below
commit dac5d28c8e4dbaa61d1dc1ae539536ddc68343a7
Author: Shahar Epstein <[email protected]>
AuthorDate: Fri Nov 6 12:18:22 2026 +0200
[v3-4-test] Hold back only build-info changes in fork CI and stop drafting
them (#76282) (#76394)
A docs-only change under dev/breeze/ drafted the pull request and waited
for a maintainer as if it changed CI, although only the files that decide which
jobs run and on which runners (workflows, breeze sources, the breeze
installation, and scripts/ci/prek/ whose provider dependencies script selective
checks runs) can let a pull request steer its own CI. Such pull requests still
wait for a maintainer to run project CI, but are no longer drafted, so they
stay visible for review. Changes [...]
Generated-by: Claude Opus 5
(cherry picked from commit 3edd0521b6c950312fb390ccd4633e725236d980)
Co-authored-by: Jarek Potiuk <[email protected]>
---
contributing-docs/05_pull_requests.rst | 20 ++--
contributing-docs/18_contribution_workflow.rst | 4 +-
contributing-docs/25_maintainer_pr_triage.md | 16 ++--
...0-run-ci-for-external-contributions-in-forks.md | 5 +-
dev/breeze/doc/ci/05_workflows.md | 10 +-
.../doc/ci/08_fork_ci_for_external_contributors.md | 42 ++++++---
.../src/airflow_breeze/utils/fork_ci_messages.py | 35 ++++++-
.../src/airflow_breeze/utils/fork_ci_reconcile.py | 37 +++++---
.../airflow_breeze/utils/provider_dependencies.py | 6 +-
.../airflow_breeze/utils/workflow_run_approval.py | 72 +++++++++++---
dev/breeze/tests/test_fork_ci_messages.py | 29 +++++-
dev/breeze/tests/test_fork_ci_reconcile.py | 34 +++++--
dev/breeze/tests/test_provider_dependencies.py | 11 +++
dev/breeze/tests/test_workflow_run_approval.py | 105 +++++++++++++++++++--
14 files changed, 340 insertions(+), 86 deletions(-)
diff --git a/contributing-docs/05_pull_requests.rst
b/contributing-docs/05_pull_requests.rst
index 2a4aaf1a04a..7320241b18c 100644
--- a/contributing-docs/05_pull_requests.rst
+++ b/contributing-docs/05_pull_requests.rst
@@ -252,10 +252,12 @@ and approves them automatically when all of these hold:
* The PR targets ``main``. PRs to release branches, such as ``v3-4-test``,
always wait for a
maintainer.
-* The PR does not change files that define CI: anything under ``.github/``,
``dev/breeze/``,
- ``scripts/ci/``, ``scripts/docker/`` or ``scripts/in_container/``, a
top-level ``Dockerfile*``,
- or a ``.pre-commit-config.yaml`` in any directory. Those files define the CI
workflows, what
- their jobs run and the runners they use.
+* The PR does not change files that decide which CI jobs run and on which
runners: anything under
+ ``.github/``, ``dev/breeze/src/`` or ``scripts/ci/prek/``, or
``dev/breeze/pyproject.toml``,
+ ``dev/breeze/uv.lock`` or ``scripts/ci/install_breeze.sh``. Changes to the
other files CI uses
+ (the rest of ``dev/breeze/`` and ``scripts/ci/``, ``scripts/docker/``,
``scripts/in_container/``,
+ ``Dockerfile*``, ``.pre-commit-config.yaml``) are not held, but a one-time
comment on the PR
+ points them out to the reviewer.
* The change does not need the full set of tests, does not change dependencies
(``pyproject.toml``,
lock files, ``package.json``, ``go.mod``, Gradle build files) and does not
add a new provider.
* The CI jobs your change selects are estimated to take at most 310
job-minutes, about a quarter of
@@ -271,10 +273,12 @@ and approves them automatically when all of these hold:
If your PR's CI runs in your fork (it carries the ``fork ci required`` label),
the CI run in
``apache/airflow`` only checks that, so none of the rules above apply. The
``CI run started`` check
waits until the fork CI monitor sees your fork's run, usually within 15
minutes, and then links it. Its runs are approved
-automatically unless the PR changes files that define CI: anything under
``.github/``,
-``dev/breeze/``, ``scripts/ci/``, ``scripts/docker/`` or
``scripts/in_container/``, a top-level
-``Dockerfile*``, or a ``.pre-commit-config.yaml`` in any directory. Then the
runs wait for a maintainer and a comment on the PR explains
-why. CI in your fork runs either way.
+automatically unless the PR changes files that decide which CI jobs run and on
which runners:
+anything under ``.github/``, ``dev/breeze/src/`` or ``scripts/ci/prek/``, or
+``dev/breeze/pyproject.toml``, ``dev/breeze/uv.lock`` or
``scripts/ci/install_breeze.sh``. Then
+the runs wait for a maintainer and a comment on the PR explains why. Changes
to the other files
+CI uses are not held, but a one-time comment points them out to the reviewer.
CI in your fork
+runs either way.
The runs of PRs opened from the repositories of trusted stakeholders
(``astronomer/airflow``,
``aws-mwaa/upstream-to-airflow`` and ``VladaZakharova/airflow``) are always
approved, so none of
diff --git a/contributing-docs/18_contribution_workflow.rst
b/contributing-docs/18_contribution_workflow.rst
index d49d732c5d2..4578e3cabe2 100644
--- a/contributing-docs/18_contribution_workflow.rst
+++ b/contributing-docs/18_contribution_workflow.rst
@@ -173,8 +173,8 @@ request explains the current state. Once the run is green,
a pull request the mo
marked ready for review and labelled ``ready for maintainer review``; a pull
request you drafted
yourself stays a draft, and gets the label, once you mark it ready. A pull
request whose fork CI
does not run for 7 days is closed with an explanation; you can reopen it after
setting up fork CI. If your pull request changes
-files that define what CI runs, such as ``.github/``, ``dev/breeze/`` or
``scripts/ci/``, a green
-fork run is not enough: a maintainer reviews the change and runs project CI
for it.
+files that decide which CI jobs run and where, such as ``.github/``, the
breeze sources or
+``scripts/ci/prek/``, a green fork run is not enough: a maintainer reviews the
change and runs project CI for it.
**Joining the pilot.** Open a pull request that adds your GitHub login to the
``[pilot]`` table in
``.github/fork-ci-gate.toml``, with a join instant after the expected merge,
for example the next day
diff --git a/contributing-docs/25_maintainer_pr_triage.md
b/contributing-docs/25_maintainer_pr_triage.md
index ddbc647895d..333faefdd1f 100644
--- a/contributing-docs/25_maintainer_pr_triage.md
+++ b/contributing-docs/25_maintainer_pr_triage.md
@@ -260,7 +260,9 @@ for them, and the triage skill must treat those pull
requests as out of scope fo
removes it when a new push starts a new run.
- **`needs project ci`.** The reconciler adds it when the fork run is green
but the pull request
changes CI-defining files, and removes it once the state is anything else.
These pull requests
- stay in draft and wait for a maintainer to review the CI change and add `use
project ci`.
+ are not drafted and get no `ready for maintainer review` label. Their `Fork
CI / Tests (AMD)`
+ status stays pending without `CI passed`, and they keep `needs project ci`
until a maintainer
+ reviews the CI change and adds `use project ci`.
- **Closing for changing the gate.** The reconciler closes a pull request that
changes the fork
CI gate (its code, configuration other than the author's own `[pilot]`
entry, the `gate` job
of `ci-amd.yml` or the monitor), and reports it on Slack. Add `use project
ci` to accept such
@@ -275,11 +277,13 @@ releases it on its next run; removing `fork ci required`
by hand is not enough,
labels a gated pull request again. Adding the label does not start project CI
by itself, and the
reconciler deletes the gated `Tests (AMD)` runs, so there is usually nothing
to re-run: close and
reopen the pull request (`gh pr close <n> -R apache/airflow && gh pr reopen
<n> -R apache/airflow`),
-or ask the author to push. Pull requests that change CI-defining files stay in
draft even
-when the fork run is green and are labelled `needs project ci`, for a
maintainer to review and run
-project CI with `use project ci`. When the fork run is green and trusted, the
reconciler also posts
-the `CI passed` status the merge ruleset requires, so these pull requests can
be merged or
-auto-merged without project CI. See
+or ask the author to push. Pull requests that change CI-defining files are not
drafted
+and get no `ready for maintainer review` label even when the fork run is
green. Their
+`Fork CI / Tests (AMD)` status stays pending without `CI passed`, and they are
labelled
+`needs project ci` until a maintainer reviews the CI change and adds `use
project ci`. For
+every other pull request, when the fork run is green and trusted, the
reconciler also posts the
+`CI passed` status the merge ruleset requires, so it can be merged or
auto-merged without project
+CI. See
[Fork CI for external
contributors](../dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md).
## For contributors
diff --git
a/dev/breeze/doc/adr/0020-run-ci-for-external-contributions-in-forks.md
b/dev/breeze/doc/adr/0020-run-ci-for-external-contributions-in-forks.md
index 7c2b7a998a6..2b7ee1a2738 100644
--- a/dev/breeze/doc/adr/0020-run-ci-for-external-contributions-in-forks.md
+++ b/dev/breeze/doc/adr/0020-run-ci-for-external-contributions-in-forks.md
@@ -102,8 +102,9 @@ record captures the decisions that shape it and the
alternatives rejected.
``.github/workflows/``, and legitimate workflow edits that keep the gate
intact, are never
closed. A pull request that is gated again after legitimate project CI runs
and also edits
workflows can still be closed while those runs are in the tampering window.
A green fork run is trusted only when the pull request does not
- change what fork CI runs (``.github/``, ``dev/breeze/``, ``scripts/ci/``
and the like);
- otherwise it stays a draft until a maintainer runs project CI. A trusted
green fork run also
+ change the ``build-info`` outputs that decide which jobs run and where
(``.github/``, the
+ breeze sources, the breeze installation and ``scripts/ci/prek/``);
otherwise it is labelled ``needs project ci``
+ and cannot merge until a maintainer runs project CI. A trusted green fork
run also
gets a ``CI passed`` commit status, the check the merge ruleset requires,
because a gated
pull request never runs project CI to report it; it is only ever posted as
success, and
overwritten when the head commit is no longer green. Commit statuses were
chosen over check
diff --git a/dev/breeze/doc/ci/05_workflows.md
b/dev/breeze/doc/ci/05_workflows.md
index ebc42af64fc..8ea6810bb20 100644
--- a/dev/breeze/doc/ci/05_workflows.md
+++ b/dev/breeze/doc/ci/05_workflows.md
@@ -107,10 +107,12 @@ them, for the contributors the repository's approval
setting covers. The
- the PR targets `main`; PRs to release branches (`v3-4-test`,
`airflow-ctl/v0-1-test`, ...) run those
branches' own workflows and always wait for a maintainer
-- the PR changes no file the fork CI reconciler counts as CI-defining
(`.github/`, `dev/breeze/`,
- `scripts/ci/`, `scripts/docker/`, `scripts/in_container/`, a top-level
`Dockerfile*`, a
- `.pre-commit-config.yaml` in any directory),
- which define the workflows, what their jobs run and the runners they use
+- the PR changes no file the fork CI reconciler counts as CI-defining
(`.github/`, `dev/breeze/src/`,
+ `dev/breeze/pyproject.toml`, `dev/breeze/uv.lock`,
`scripts/ci/install_breeze.sh` and
+ `scripts/ci/prek/`), which decide which jobs run and on which runners.
Changes to the other files
+ CI uses (the rest of `dev/breeze/` and `scripts/ci/`, `scripts/docker/`,
`scripts/in_container/`,
+ `Dockerfile*`, `.pre-commit-config.yaml`) are not held, but get a one-time
+ `<!-- workflow-approval: ci-modification -->` note comment on the PR for the
reviewer
- the PR changes no dependency manifest or lock file (`pyproject.toml`,
`uv.lock`, `package.json`,
`pnpm-lock.yaml`, `go.mod`, Gradle build files, ...) and adds no new
provider (selective checks
run from `main`'s tree and do not know it)
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 781da587905..4627372f5fa 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
@@ -380,9 +380,9 @@ created after it are approved this way, while earlier ones,
whose gate ran proje
maintainer. One rule applies instead:
- The waiting runs of the head commit are approved when the pull request's
merge commit changes no
- file that the reconciler counts as CI-defining: `.github/`, `dev/breeze/`,
`scripts/ci/`,
- `scripts/docker/`, `scripts/in_container/`, `Dockerfile*` and
`.pre-commit-config.yaml` (see
- "Trusting a green fork run"). The approved `ci-amd.yml` run executes only
the gate, and the other
+ file that the reconciler counts as CI-defining: `.github/`,
`dev/breeze/src/`,
+ `dev/breeze/pyproject.toml`, `dev/breeze/uv.lock`,
`scripts/ci/install_breeze.sh` and
+ `scripts/ci/prek/` (see "Trusting a green fork run"). The approved
`ci-amd.yml` run executes only the gate, and the other
`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, its head commit gets a pending
`Workflow approval`
@@ -393,6 +393,12 @@ maintainer. One rule applies instead:
workflow that does run, so such a run must not start without a review. The
pull request does
not get the `need workflow approval` label, and loses it if it has it.
Approving its runs does
not start project CI. A maintainer adds `use project ci` for that.
+- A pull request that changes other files CI uses (the rest of `dev/breeze/`
and `scripts/ci/`,
+ `scripts/docker/`, `scripts/in_container/`, `Dockerfile*` or
`.pre-commit-config.yaml`) is not
+ held for them, but gets one comment listing them, so the reviewer checks the
CI change with the
+ rest of the diff. It starts with the marker `<!-- workflow-approval:
ci-modification -->` and is
+ posted once per pull request, for pull requests that run project CI as well.
Like the rest of
+ this workflow, it only reaches pull requests whose runs waited for approval.
A run started before `use fork ci` or `use project ci` was last added or
removed is approved too,
so a committer can add `use fork ci` after the PR's first runs were created
and before they are
@@ -532,7 +538,7 @@ pull requests without a fork run for their head commit, not
by the total.
| **running**: run in progress | unchanged (a rebase of an approved pull
request does not draft it; a failed run does) | remove | `pending` (yellow),
link to the run | update state and run link |
| **failed**: conclusion is failure, cancelled or timed out | draft | remove |
`failure` (red), link to the run | update with the run link |
| **green**: conclusion success and the pull request does not change
CI-defining files | undraft\* | add | `success` (green), link to the run |
update: ready for review, run link |
-| **green_unverified**: conclusion success, but the pull request changes
CI-defining files (see "Trusting a green fork run") | draft | remove |
`pending` (yellow), link to the run | update: a maintainer must run project CI;
labelled `needs project ci` |
+| **green_unverified**: conclusion success, but the pull request changes
CI-defining files (see "Trusting a green fork run") | undraft\* | remove |
`pending` (yellow), link to the run | update: a maintainer must run project CI;
labelled `needs project ci` |
| setup_required or awaiting_push for `close_after_days` | unchanged |
unchanged | unchanged | close with a new comment; reopening restarts the clock |
| **ungated**: the base branch has no gate, for example after retargeting to
an older release branch | undraft\* | unchanged | `success` (green), link to
the pull request's checks | update: project CI is in use; `fork ci required` is
removed |
| **gate_modified**: the pull request changes the fork CI gate (see "Gate
protection") | draft | remove | `error` (red), link to the pull request's
changed files | post a **new** comment explaining why, then close; checked
after gate tampering, before the fork state |
@@ -582,17 +588,26 @@ Rules that sit under the table:
### Trusting a green fork run
-The fork run executes the workflow files, the breeze sources and the CI
scripts of the pushed
-commit. A pull request that changes them decides what its own fork CI checks,
and could make it
-pass trivially. When a new head commit turns green, the reconciler lists the
pull request's
+The fork run executes the workflow files and the breeze sources of the pushed
commit, and the
+`build-info` job decides from them which jobs run and on which runners. A pull
request that
+changes them decides what its own fork CI checks, and could make it pass
trivially. CI-defining
+files are only those that change the `build-info` outputs: the workflows and
actions, the breeze
+sources whose selective checks compute the outputs, the files that install
breeze, and
+`scripts/ci/prek/`. Selective checks runs
`scripts/ci/prek/update_providers_dependencies.py` to
+generate the gitignored `generated/provider_dependencies.json`, and the script
runs with its
+directory first on `sys.path`, so any module added there would run as well.
Other CI
+scripts, Dockerfiles and prek configuration change only what a selected job
does, so they are
+reviewed with the rest of the diff and do not hold the pull request back. When
a new head commit turns green, the reconciler lists the pull request's
changed files once (`GET /repos/apache/airflow/pulls/<n>/files`) and then
reads the head commit
again: the files endpoint lists the current head, so when the head moved
meanwhile nothing is
decided or recorded for the old commit, and the next run decides for the new
one. If any file is under `.github/`,
-`dev/breeze/`, `scripts/ci/`, `scripts/docker/` or `scripts/in_container/`, is
a
-`Dockerfile*` or a `.pre-commit-config.yaml`, or the list reaches GitHub's
3000-file cap so the
-rest cannot be seen, the state is `green_unverified` instead of `green`. The
pull request stays
-a draft, the status is pending, and it is labelled `needs project ci` so
maintainers can list
-these pull requests, review the change and add `use project ci`. It is not
posted to Slack:
+`dev/breeze/src/` or `scripts/ci/prek/`, is `dev/breeze/pyproject.toml`,
`dev/breeze/uv.lock` or
+`scripts/ci/install_breeze.sh`, or the list reaches GitHub's 3000-file cap so
the rest cannot be
+seen, the state is `green_unverified` instead of `green`. The pull request is
not drafted (a
+draft the reconciler made earlier is undone, as for `green`), but it gets no
review label, the
+status is pending, and it is labelled `needs project ci` so maintainers can
list these pull
+requests, review the change and add `use project ci`. Without `CI passed` it
cannot be merged
+until then. It is not posted to Slack:
the label already lists these pull requests, and a post for every new green
commit would bury
the CI breakages that channel is for.
The reconciler removes `needs project ci` as soon as the state is anything
else. A renamed file
@@ -1243,7 +1258,8 @@ as a failed check rather than a crash.
the conflicting-labels alert, the close clock reset (including a reopen
after an inactivity
close), a fork with a name other than `airflow`, marker round-trips, status
desired-vs-current comparison, the unknown-failure collection, green trust
(a change to
- CI-defining files keeps the pull request a draft, decided once per commit)
and the gate
+ CI-defining files keeps the status pending without drafting the pull
request, decided once
+ per commit) and the gate
tampering check: a tampered run on an earlier commit is caught after a clean
push, each
signal alone does not close, short runs and gate-only jobs do not count, an
in-progress
non-gate job, a removed, duplicated or long-running gate job and an injected
workflow with a
diff --git a/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py
b/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py
index 6fb8d04aac5..10cca162083 100644
--- a/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py
+++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py
@@ -155,9 +155,10 @@ _STATE_TEXTS = {
"green": "**Fork CI: green.** CI for the latest commit passed in your
fork. The pull request is "
"ready for maintainer review.",
"green_unverified": "**Fork CI: green, needs a maintainer.** CI for the
latest commit passed in your "
- "fork, but this pull request changes files that define what CI runs (such
as `.github/`, "
- "`dev/breeze/` or `scripts/ci/`), and the fork run used those changed
files. A maintainer will "
- "review the change and run project CI for it; the pull request stays a
draft until then.",
+ "fork, but this pull request changes files that decide which CI jobs run
and where (`.github/`, "
+ "the breeze sources, the breeze installation or `scripts/ci/prek/`), and
the fork run used those "
+ "changed files. A maintainer will review the change and run project CI for
it before it can be "
+ "merged.",
"closed": "**Fork CI: closed after inactivity.** CI did not run in your
fork in time; see the "
"comment below for how to continue.",
"gate_bypassed": "**Fork CI: closed.** Project CI ran for this pull
request although its CI runs in "
@@ -275,22 +276,46 @@ def _sanitize_path(path: str) -> str:
return "".join(char if char.isprintable() and char != "`" else "?" for
char in path)
-def ci_change_approval_comment(*, ci_files: list[str]) -> str:
+def build_ci_file_list(ci_files: list[str]) -> str:
listed = [f"- `{_sanitize_path(path)}`" for path in
ci_files[:MAX_LISTED_CI_FILES]]
if len(ci_files) > MAX_LISTED_CI_FILES:
listed.append(f"- and {len(ci_files) - MAX_LISTED_CI_FILES} more")
+ return "\n".join(listed)
+
+
+def ci_change_approval_comment(*, ci_files: list[str]) -> str:
return (
f"{CI_CHANGE_MARKER}\n\n"
"**Your CI change needs a maintainer.** CI for this pull request runs
in your fork, and the "
"workflow runs it starts in apache/airflow are approved automatically
while they only run the "
"fork CI gate. This pull request changes files that define what CI
runs:\n\n"
- + "\n".join(listed)
+ + build_ci_file_list(ci_files)
+ "\n\nSuch a change can make project CI run on the project's runners,
so its workflow runs in "
"apache/airflow wait for a maintainer to review the change and approve
them. CI in your fork "
"is not affected, and its result stays visible on this pull request. "
+ WHY_FORK_CI + FOOTER
)
+CI_MODIFICATION_MARKER = "<!-- workflow-approval: ci-modification -->"
+
+
+GATED_NOTE_SENTENCE = (
+ " CI for this pull request runs in the author's fork, so any green result
and `CI passed` status will "
+ "come from a run that executed these changed files."
+)
+
+
+def ci_modification_note_comment(*, ci_files: list[str], gated: bool) -> str:
+ return (
+ f"{CI_MODIFICATION_MARKER}\n\n"
+ "**This pull request changes CI files.** It changes files that CI
uses:\n\n"
+ + build_ci_file_list(ci_files)
+ + "\n\nThey do not decide which CI jobs run or on which runners, so
its workflow runs are not "
+ "held for this. A maintainer reviewing the pull request will check the
CI change together with "
+ "the rest of it." + (GATED_NOTE_SENTENCE if gated else "") + FOOTER
+ )
+
+
_STATUS_DESCRIPTIONS = {
"setup_required": "Fork CI not set up - see the pull request comment",
"awaiting_push": "Fork CI enabled, waiting for a push - see the pull
request comment",
diff --git a/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
b/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
index 845638b3ca2..fc316385adf 100644
--- a/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
+++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
@@ -134,17 +134,26 @@ TAMPER_WINDOW = timedelta(hours=6)
# can have done project CI work and are worth listing jobs for.
SUSPICIOUS_RUN_DURATION = timedelta(minutes=5)
GATE_MAX_DURATION = timedelta(minutes=5)
-# Paths whose content defines what fork CI runs. A pull request changing them
controls its own
-# fork CI result, so a green fork run cannot be trusted for it.
+# Paths that decide the build-info outputs (which jobs run and on which
runners): the workflows
+# and actions, the breeze sources whose selective checks compute those
outputs, the files that
+# install breeze, and scripts/ci/prek/, which holds the script selective
checks runs to generate
+# the gitignored generated/provider_dependencies.json. That script runs with
its directory first
+# on sys.path, so any module added there, such as a `yaml.py`, would run too.
A pull request
+# changing them controls its own fork CI result and could change where project
CI runs. Other CI
+# scripts, Dockerfiles and prek configuration only change what a selected job
does, which review
+# of the diff covers like any other change.
CI_DEFINING_PREFIXES = (
".github/",
- "dev/breeze/",
- "scripts/ci/",
- "scripts/docker/",
- "scripts/in_container/",
- "Dockerfile",
+ "dev/breeze/src/",
+ "scripts/ci/prek/",
+)
+CI_DEFINING_FILES = frozenset(
+ {
+ "dev/breeze/pyproject.toml",
+ "dev/breeze/uv.lock",
+ "scripts/ci/install_breeze.sh",
+ }
)
-CI_DEFINING_SUFFIXES = (".pre-commit-config.yaml",)
PULL_REQUEST_FILES_CAP = 3000
COMPARE_FILES_CAP = 300
SLACK_SECTION_LIMIT = 3000
@@ -467,7 +476,7 @@ def changes_ci_definition(files: Sequence[str], *,
file_count: int | None = None
return (
(len(files) if file_count is None else file_count) >=
PULL_REQUEST_FILES_CAP
or touches(files, CI_DEFINING_PREFIXES)
- or any(path.endswith(CI_DEFINING_SUFFIXES) for path in files)
+ or any(path in CI_DEFINING_FILES for path in files)
)
@@ -759,12 +768,16 @@ def classify(
plan.close = True
return plan
- if state == ForkState.GREEN:
+ if state in (ForkState.GREEN, ForkState.GREEN_UNVERIFIED):
+ # A green run that changes CI files still needs a maintainer's review,
which a draft would
+ # hide; the `needs project ci` label marks it instead of the review
label.
if pr.is_draft and _is_bot(pr.last_draft_actor):
plan.draft = False
ready = not pr.is_draft or plan.draft is False
- if ready and LABEL_READY_FOR_REVIEW not in labels:
+ if state == ForkState.GREEN and ready and LABEL_READY_FOR_REVIEW not
in labels:
plan.add_labels.append(LABEL_READY_FOR_REVIEW)
+ elif state == ForkState.GREEN_UNVERIFIED and LABEL_READY_FOR_REVIEW in
labels:
+ plan.remove_labels.append(LABEL_READY_FOR_REVIEW)
else:
# A run in progress needs nothing from the author, and drafting on it
would draft every
# approved pull request the moment it is rebased; it is drafted only
if the run then fails.
@@ -773,7 +786,7 @@ def classify(
if LABEL_READY_FOR_REVIEW in labels:
plan.remove_labels.append(LABEL_READY_FOR_REVIEW)
# Lets maintainers list the pull requests that only wait for them to run
project CI, which
- # otherwise look like any other draft with a pending fork CI status.
+ # otherwise look like any other pull request with a pending fork CI status.
_set_label(plan, labels, LABEL_NEEDS_PROJECT_CI, present=state ==
ForkState.GREEN_UNVERIFIED)
target_url = fork.run_url or fork_actions_url(pr.head_repo)
diff --git a/dev/breeze/src/airflow_breeze/utils/provider_dependencies.py
b/dev/breeze/src/airflow_breeze/utils/provider_dependencies.py
index 201f43c97b7..2a878587d10 100644
--- a/dev/breeze/src/airflow_breeze/utils/provider_dependencies.py
+++ b/dev/breeze/src/airflow_breeze/utils/provider_dependencies.py
@@ -89,8 +89,12 @@ def regenerate_provider_dependencies_once() -> None:
with _regenerate_provider_deps_lock:
# Run the regeneration command from the repository root to ensure
correct
# relative paths if the script expects to be run from
AIRFLOW_ROOT_PATH.
+ # CI runs this while deciding which jobs run, from the pull request's
checkout: --no-config
+ # keeps a `[tool.uv]` index the pull request adds to pyproject.toml or
uv.toml from choosing
+ # where the script's dependencies come from.
subprocess.check_call(
- ["uv", "run", UPDATE_PROVIDER_DEPENDENCIES_SCRIPT.as_posix()],
cwd=AIRFLOW_ROOT_PATH
+ ["uv", "run", "--no-config",
UPDATE_PROVIDER_DEPENDENCIES_SCRIPT.as_posix()],
+ cwd=AIRFLOW_ROOT_PATH,
)
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 2dba4b3842a..40a72eba97e 100644
--- a/dev/breeze/src/airflow_breeze/utils/workflow_run_approval.py
+++ b/dev/breeze/src/airflow_breeze/utils/workflow_run_approval.py
@@ -60,7 +60,12 @@ from airflow_breeze.utils.fork_ci_gate import (
load_config,
parse_github_datetime,
)
-from airflow_breeze.utils.fork_ci_messages import CI_CHANGE_MARKER,
ci_change_approval_comment
+from airflow_breeze.utils.fork_ci_messages import (
+ CI_CHANGE_MARKER,
+ CI_MODIFICATION_MARKER,
+ ci_change_approval_comment,
+ ci_modification_note_comment,
+)
from airflow_breeze.utils.fork_ci_reconcile import changes_ci_definition
from airflow_breeze.utils.path_utils import AIRFLOW_ROOT_PATH
from airflow_breeze.utils.run_utils import run_command
@@ -170,6 +175,8 @@ class ApprovalDecision:
comment: str | None = None
# Whether a PR whose runs are left for a maintainer gets the ``need
workflow approval`` label.
add_approval_label: bool = True
+ # Posted on the PR once whatever the decision, unless ``comment`` is
posted instead.
+ note: str | None = None
# The labels the decision depends on: runs started before one of them
changed are not approved.
decision_labels: frozenset[str] = TEST_SELECTION_LABELS
# Runs started before this are not approved either.
@@ -236,6 +243,27 @@ def decide_trusted_approval(*, stakeholder: str) ->
ApprovalDecision:
)
+# Files CI uses that do not decide the build-info outputs: a change to them is
reviewed with the rest
+# of the pull request, and only noted on it.
+CI_MODIFYING_PREFIXES = (
+ ".github/",
+ "dev/breeze/",
+ "scripts/ci/",
+ "scripts/docker/",
+ "scripts/in_container/",
+ "Dockerfile",
+)
+CI_MODIFYING_SUFFIXES = (".pre-commit-config.yaml",)
+
+
+def find_ci_modifying_files(files: list[str]) -> list[str]:
+ return [
+ file
+ for file in files
+ if file.startswith(CI_MODIFYING_PREFIXES) or
file.endswith(CI_MODIFYING_SUFFIXES)
+ ]
+
+
def find_fork_ci_definition_files(files: list[str]) -> list[str]:
"""The files the fork CI reconciler counts as defining CI, so both decide
alike."""
return [file for file in files if changes_ci_definition([file],
file_count=1)]
@@ -292,8 +320,8 @@ def decide_approval(
)
paths = [file.path for file in changed_files]
# CI definition files can change which runner a job uses (``runs-on:``,
``runner-type``
- # computed by selective checks, the scripts build-info runs) or what the
jobs run, so they
- # always need a maintainer's look.
+ # computed by selective checks, the breeze installation build-info runs)
or which jobs run,
+ # so they always need a maintainer's look.
ci_files = find_fork_ci_definition_files(paths)
if ci_files:
return ApprovalDecision(
@@ -690,6 +718,7 @@ def process_waiting_runs(api: GitHubApi, *, created_since:
datetime) -> list[Pul
"or changed a label its decision depends on"
],
estimate=decision.estimate,
+ note=decision.note,
)
pr_run_ids = run_ids
if decision.approve:
@@ -793,18 +822,24 @@ def decide_for_pull_request(
):
decision = decide_gated_approval(changed_files=changed_files)
decision.not_before = joined_at
- return decision
- pr_labels = tuple(label["name"] for label in pr.get("labels", []))
- return decide_approval(
- base_branch=pr["base"]["ref"],
- changed_files=changed_files,
- selective_checks=build_selective_checks(
- merge_sha=merge_sha,
- changed_files=changed_files,
- pr_labels=pr_labels,
+ gated = True
+ else:
+ gated = False
+ pr_labels = tuple(label["name"] for label in pr.get("labels", []))
+ decision = decide_approval(
base_branch=pr["base"]["ref"],
- ),
- )
+ changed_files=changed_files,
+ selective_checks=build_selective_checks(
+ merge_sha=merge_sha,
+ changed_files=changed_files,
+ pr_labels=pr_labels,
+ base_branch=pr["base"]["ref"],
+ ),
+ )
+ paths = [file.path for file in changed_files]
+ if not find_fork_ci_definition_files(paths) and (ci_files :=
find_ci_modifying_files(paths)):
+ decision.note = ci_modification_note_comment(ci_files=ci_files,
gated=gated)
+ return decision
def build_failure_reason(error: Exception) -> str:
@@ -872,6 +907,7 @@ def apply_decision(
api.approve_run(run_id)
if has_label:
api.remove_label(pr_number, NEED_WORKFLOW_APPROVAL_LABEL)
+ post_note(api, pr_number=pr_number, decision=decision)
return
# Reasons can contain contributor-chosen file names, which must not be
read as Rich markup.
console_print(f"[warning]PR #{pr_number} needs a maintainer: {escape(';
'.join(decision.reasons))}[/]")
@@ -887,10 +923,18 @@ def apply_decision(
description=build_status_description("Waits for a maintainer",
decision.reasons),
)
if (label_added or status_changed) and clear_if_runs_were_approved(api,
pr=pr, run_ids=run_ids):
+ post_note(api, pr_number=pr_number, decision=decision)
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)
+ post_note(api, pr_number=pr_number, decision=decision)
+
+
+def post_note(api: GitHubApi, *, pr_number: int, decision: ApprovalDecision)
-> None:
+ """Post the decision's note once per PR; every later push would otherwise
repeat it."""
+ if decision.note is not None and not api.has_bot_comment(pr_number,
CI_MODIFICATION_MARKER):
+ api.create_comment(pr_number, decision.note)
MARKDOWN_SPECIAL_CHARACTERS = re.compile(r"([\\`*_{}\[\]()<>#+!|~])")
diff --git a/dev/breeze/tests/test_fork_ci_messages.py
b/dev/breeze/tests/test_fork_ci_messages.py
index f7c205c0973..6bf940c69df 100644
--- a/dev/breeze/tests/test_fork_ci_messages.py
+++ b/dev/breeze/tests/test_fork_ci_messages.py
@@ -16,6 +16,7 @@
# under the License.
from __future__ import annotations
+import functools
from dataclasses import replace
from datetime import UTC, datetime
@@ -26,14 +27,17 @@ from airflow_breeze.utils.fork_ci_messages import (
_STATUS_DESCRIPTIONS,
BEHIND_WARNING_COMMITS,
CI_CHANGE_MARKER,
+ CI_MODIFICATION_MARKER,
CI_PASSED_DESCRIPTION,
CI_PASSED_REVOKED_DESCRIPTION,
FOOTER,
+ GATED_NOTE_SENTENCE,
JOB_NAME_MAX_LENGTH,
JOB_STATUS_CONTEXT_PREFIX,
MAX_LISTED_CI_FILES,
Marker,
ci_change_approval_comment,
+ ci_modification_note_comment,
gate_modified_close_comment,
inactivity_close_comment,
job_status_context,
@@ -159,16 +163,31 @@ def test_job_status_context(name, expected):
assert job_status_context(name) == f"{JOB_STATUS_CONTEXT_PREFIX}{expected}"
-def test_ci_change_approval_comment_lists_the_files():
[email protected](
+ ("build_comment", "marker"),
+ [
+ pytest.param(ci_change_approval_comment, CI_CHANGE_MARKER,
id="approval"),
+ pytest.param(ci_modification_note_comment, CI_MODIFICATION_MARKER,
id="note"),
+ ],
+)
+def test_ci_file_comments_list_the_files(build_comment, marker):
+ if build_comment is ci_modification_note_comment:
+ build_comment = functools.partial(build_comment, gated=False)
paths = [f".github/workflows/w{i}.yml" for i in range(MAX_LISTED_CI_FILES
+ 2)]
- comment = ci_change_approval_comment(
- ci_files=["dev/breeze/`evil`.py", ".github/a\n\[email protected]",
*paths]
- )
+ comment = build_comment(ci_files=["dev/breeze/`evil`.py",
".github/a\n\[email protected]", *paths])
- assert comment.startswith(CI_CHANGE_MARKER)
+ assert comment.startswith(marker)
assert comment.endswith(FOOTER)
assert "- `dev/breeze/?evil?.py`" in comment
assert "- `.github/[email protected]`" in comment
assert "\n@someone" not in comment
assert "- and 4 more" in comment
assert f"`{paths[-1]}`" not in comment
+
+
[email protected]("gated", [True, False])
+def test_ci_modification_note_mentions_fork_execution_only_when_gated(gated):
+ comment = ci_modification_note_comment(ci_files=["Dockerfile.ci"],
gated=gated)
+
+ assert (GATED_NOTE_SENTENCE in comment) is gated
+ assert comment.endswith(FOOTER)
diff --git a/dev/breeze/tests/test_fork_ci_reconcile.py
b/dev/breeze/tests/test_fork_ci_reconcile.py
index 9a624b1005a..a26a3cd21fc 100644
--- a/dev/breeze/tests/test_fork_ci_reconcile.py
+++ b/dev/breeze/tests/test_fork_ci_reconcile.py
@@ -277,7 +277,7 @@ def test_green_is_not_trusted_when_ci_files_change():
plan, _, changes = _classify(pr, GREEN, changes_ci_files=True)
assert plan.state == ForkState.GREEN_UNVERIFIED
- assert plan.draft is True
+ assert plan.draft is None
assert plan.add_labels == ["needs project ci"]
assert plan.remove_labels == ["ready for maintainer review"]
assert plan.status == (
@@ -290,6 +290,21 @@ def test_green_is_not_trusted_when_ci_files_change():
changes.assert_called_once_with()
[email protected](
+ ("last_draft_actor", "expected_draft"),
+ [
+ pytest.param("github-actions[bot]", False, id="bot-draft-undrafted"),
+ pytest.param("someone", None, id="author-draft-kept"),
+ ],
+)
+def test_green_unverified_does_not_keep_a_bot_draft(last_draft_actor,
expected_draft):
+ plan, _, _ = _classify(_pr(last_draft_actor=last_draft_actor), GREEN,
changes_ci_files=True)
+
+ assert plan.state == ForkState.GREEN_UNVERIFIED
+ assert plan.draft is expected_draft
+ assert plan.add_labels == ["needs project ci"]
+
+
LABELLED = ("fork ci required", "needs project ci")
@@ -474,11 +489,18 @@ def test_find_heads_shared_across_bases():
pytest.param([".github/workflows/ci-amd.yml"], True, id="workflow"),
pytest.param([".github/actions/breeze/action.yml"], True, id="action"),
pytest.param(["dev/breeze/src/airflow_breeze/utils/selective_checks.py"], True,
id="breeze"),
- pytest.param(["scripts/ci/prek/check_x.py"], True, id="ci-scripts"),
- pytest.param(["Dockerfile.ci"], True, id="dockerfile"),
- pytest.param(["scripts/in_container/run_ci_tests.sh"], True,
id="in-container-scripts"),
- pytest.param([".pre-commit-config.yaml"], True, id="root-prek-config"),
- pytest.param(["dev/.pre-commit-config.yaml"], True,
id="nested-prek-config"),
+ pytest.param(["dev/breeze/pyproject.toml"], True, id="breeze-project"),
+ pytest.param(["dev/breeze/uv.lock"], True, id="breeze-lock"),
+ pytest.param(["scripts/ci/install_breeze.sh"], True,
id="breeze-install"),
+ pytest.param(["scripts/ci/prek/update_providers_dependencies.py"],
True, id="provider-deps-script"),
+ pytest.param(["scripts/ci/prek/yaml.py"], True,
id="module-next-to-provider-deps-script"),
+
pytest.param(["dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md"],
False, id="breeze-doc"),
+ pytest.param(["dev/breeze/tests/test_selective_checks.py"], False,
id="breeze-tests"),
+ pytest.param(["scripts/ci/testing/run_unit_tests.sh"], False,
id="ci-scripts"),
+ pytest.param(["Dockerfile.ci"], False, id="dockerfile"),
+ pytest.param(["scripts/docker/entrypoint_ci.sh"], False,
id="docker-scripts"),
+ pytest.param(["scripts/in_container/run_ci_tests.sh"], False,
id="in-container-scripts"),
+ pytest.param([".pre-commit-config.yaml"], False, id="prek-config"),
pytest.param([f"airflow-core/f{i}.py" for i in range(3000)], True,
id="too-many-files-to-list"),
],
)
diff --git a/dev/breeze/tests/test_provider_dependencies.py
b/dev/breeze/tests/test_provider_dependencies.py
index bf77f6dd29d..a1aaa8650d8 100644
--- a/dev/breeze/tests/test_provider_dependencies.py
+++ b/dev/breeze/tests/test_provider_dependencies.py
@@ -128,3 +128,14 @@ def
test_hash_not_written_when_regeneration_fails(mock_hash, mock_regenerate, pr
generate_provider_dependencies_if_needed()
assert not hash_path.exists()
+
+
[email protected](provider_dependencies_module.subprocess, "check_call",
autospec=True)
+def
test_regenerate_provider_dependencies_ignores_uv_config_from_the_checkout(mock_check_call):
+
provider_dependencies_module.regenerate_provider_dependencies_once.cache_clear()
+ try:
+ provider_dependencies_module.regenerate_provider_dependencies_once()
+ finally:
+
provider_dependencies_module.regenerate_provider_dependencies_once.cache_clear()
+
+ assert mock_check_call.call_args.args[0][:3] == ["uv", "run",
"--no-config"]
diff --git a/dev/breeze/tests/test_workflow_run_approval.py
b/dev/breeze/tests/test_workflow_run_approval.py
index 86c9511cc74..f1c9c4b47a2 100644
--- a/dev/breeze/tests/test_workflow_run_approval.py
+++ b/dev/breeze/tests/test_workflow_run_approval.py
@@ -27,7 +27,11 @@ from click.testing import CliRunner
from airflow_breeze.commands.ci_commands import approve_workflow_runs
from airflow_breeze.utils.ci_minutes_estimate import CiMinutesEstimate,
EstimatedJobGroup
from airflow_breeze.utils.fork_ci_gate import GateConfig
-from airflow_breeze.utils.fork_ci_messages import CI_CHANGE_MARKER
+from airflow_breeze.utils.fork_ci_messages import (
+ CI_CHANGE_MARKER,
+ CI_MODIFICATION_MARKER,
+ GATED_NOTE_SENTENCE,
+)
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 (
@@ -146,11 +150,9 @@ def mock_estimate():
id=f"fork-ci-defining-{path}",
)
for path in (
- ".pre-commit-config.yaml",
- "airflow-core/.pre-commit-config.yaml",
- "Dockerfile.ci",
- "scripts/docker/entrypoint_ci.sh",
- "scripts/in_container/run_ci_tests.sh",
+ "dev/breeze/pyproject.toml",
+ "dev/breeze/uv.lock",
+ "scripts/ci/install_breeze.sh",
)
),
pytest.param(
@@ -887,9 +889,9 @@ def _gated_pr(*, base_ref="main",
author_association="CONTRIBUTOR", labels=()) -
id="workflow change",
),
pytest.param(
- [ChangedFile("scripts/docker/entrypoint_ci.sh", 1)],
+ [ChangedFile("scripts/ci/install_breeze.sh", 1)],
False,
- ["scripts/docker/entrypoint_ci.sh"],
+ ["scripts/ci/install_breeze.sh"],
id="file the fork CI reconciler counts as CI",
),
],
@@ -956,6 +958,75 @@ def
test_decide_for_pull_request_running_project_ci_keeps_the_size_rules(
assert decision is mock_decide_approval.return_value
[email protected](
+ ("changed_files", "noted_files"),
+ [
+ pytest.param(CORE_CHANGE, [], id="no CI file"),
+ pytest.param(
+ [ChangedFile("scripts/docker/entrypoint_ci.sh", 1),
ChangedFile("Dockerfile.ci", 1)],
+ ["scripts/docker/entrypoint_ci.sh", "Dockerfile.ci"],
+ id="CI files outside build-info",
+ ),
+ pytest.param(
+ [ChangedFile("dev/breeze/doc/ci/04_selective_checks.md", 1)],
+ ["dev/breeze/doc/ci/04_selective_checks.md"],
+ id="breeze doc",
+ ),
+ pytest.param(
+ [ChangedFile(".github/workflows/ci-amd.yml", 1),
ChangedFile("Dockerfile.ci", 1)],
+ [],
+ id="build-info change gets the approval comment instead",
+ ),
+ ],
+)
[email protected]("gated", [True, False])
[email protected](f"{MODULE}.decide_approval", autospec=True)
[email protected](f"{MODULE}.build_selective_checks", autospec=True)
[email protected](f"{MODULE}.load_config", autospec=True)
[email protected](f"{MODULE}.get_changed_files", autospec=True)
[email protected](f"{MODULE}.fetch_merge_commit", autospec=True,
return_value="merge")
+def test_decide_for_pull_request_notes_ci_files_that_do_not_hold_the_runs(
+ mock_fetch,
+ mock_get_changed_files,
+ mock_load_config,
+ mock_selective_checks,
+ mock_decide_approval,
+ gated,
+ changed_files,
+ noted_files,
+):
+ mock_get_changed_files.return_value = changed_files
+ mock_load_config.return_value = GATE_ENABLED if gated else GateConfig()
+ mock_decide_approval.return_value = ApprovalDecision(approve=True)
+ api = mock.MagicMock(spec=GitHubApi)
+ api.github_repository = "apache/airflow"
+
+ decision = decide_for_pull_request(api=api, pr=_gated_pr())
+
+ if noted_files:
+ assert decision.note is not None
+ assert decision.note.startswith(CI_MODIFICATION_MARKER)
+ assert all(f"`{path}`" in decision.note for path in noted_files)
+ assert (GATED_NOTE_SENTENCE in decision.note) is gated
+ else:
+ assert decision.note is None
+
+
[email protected]("approve", [True, False])
[email protected](("already_noted", "expect_note"), [(False, True),
(True, False)])
+def test_apply_decision_posts_the_note_once(approve, already_noted,
expect_note):
+ api = mock.MagicMock(spec=GitHubApi)
+ api.has_bot_comment.return_value = already_noted
+ api.get_approval_status.return_value = None
+ api.is_run_waiting_for_approval.return_value = True
+ decision = ApprovalDecision(approve=approve, reasons=["reason"],
note="note")
+
+ apply_decision(api=api, pr=_pr(10, "aaa"), run_ids=[1], decision=decision)
+
+ api.has_bot_comment.assert_called_once_with(10, CI_MODIFICATION_MARKER)
+ assert api.create_comment.call_args_list == ([mock.call(10, "note")] if
expect_note else [])
+
+
@pytest.mark.parametrize(("already_commented", "expect_comment"), [(False,
True), (True, False)])
def test_apply_decision_comments_once_on_a_ci_change(already_commented,
expect_comment):
api = mock.MagicMock(spec=GitHubApi)
@@ -1019,6 +1090,24 @@ def
test_process_waiting_runs_skips_runs_started_before_a_test_selection_label_c
assert api.approve_run.call_args_list == [mock.call(run_id) for run_id in
approved_runs]
[email protected](f"{MODULE}.decide_for_pull_request", autospec=True)
+def
test_process_waiting_runs_keeps_the_note_when_every_run_started_before_a_label_changed(mock_decide):
+ api = mock.MagicMock(spec=GitHubApi)
+ api.github_repository = "apache/airflow"
+ api.get_runs_waiting_for_approval.return_value = [_run(2, "aaa",
created_at="2026-10-22T10:00:00Z")]
+ api.get_open_pull_requests.return_value = [_pr(10, "aaa")]
+ api.get_last_base_change.return_value = None
+ api.get_last_label_change.return_value = datetime(2026, 10, 22, 12, 0,
tzinfo=UTC)
+ api.has_bot_comment.return_value = False
+ api.get_approval_status.return_value = None
+ mock_decide.return_value = ApprovalDecision(approve=True,
reasons=["cheap"], note="note")
+
+ process_waiting_runs(api, created_since=CREATED_SINCE)
+
+ api.approve_run.assert_not_called()
+ assert api.create_comment.call_args_list == [mock.call(10, "note")]
+
+
def
test_get_last_label_change_ignores_other_labels_and_reads_the_timeline_once():
api = GitHubApi(github_repository="apache/airflow", github_token="token",
report_only=True)
events = [