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 437daf6eddc Approve fork CI runs that started before "use fork ci" was
added (#75654)
437daf6eddc is described below
commit 437daf6eddcb0e692cf874600c821fee71db1af8
Author: Jarek Potiuk <[email protected]>
AuthorDate: Mon Oct 26 16:14:27 2026 +0100
Approve fork CI runs that started before "use fork ci" was added (#75654)
When a committer adds "use fork ci" right after a PR opens, the PR's first
runs were created without it, and the workflow-run approver left them for a
maintainer. It had to: the fork CI gate fell back to the labels of the run's
event when it could not read the current ones, and those labels could still
send the run to full project CI on code nobody had reviewed. Anyone able to
exhaust the shared token quota could force that fallback.
The gate now fails when it cannot read the labels, so a run only ever acts
on
the PR's current labels and a re-run decides again. With that, runs started
before the label changed are as safe to approve as later ones.
Generated-by: Claude Opus 5
---
.github/workflows/ci-amd.yml | 1 -
dev/breeze/doc/ci/05_workflows.md | 6 +--
.../doc/ci/08_fork_ci_for_external_contributors.md | 15 +++++---
.../src/airflow_breeze/utils/fork_ci_gate.py | 20 +++++++---
.../airflow_breeze/utils/workflow_run_approval.py | 13 ++-----
dev/breeze/tests/test_fork_ci_gate.py | 45 ++++++++++------------
dev/breeze/tests/test_workflow_run_approval.py | 13 +++----
scripts/ci/prek/check_ci_workflows_in_sync.py | 1 -
8 files changed, 56 insertions(+), 58 deletions(-)
diff --git a/.github/workflows/ci-amd.yml b/.github/workflows/ci-amd.yml
index e99af802e95..49456595214 100644
--- a/.github/workflows/ci-amd.yml
+++ b/.github/workflows/ci-amd.yml
@@ -233,7 +233,6 @@ jobs:
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_AUTHOR_ASSOCIATION: ${{
github.event.pull_request.author_association }}
PR_CREATED_AT: ${{ github.event.pull_request.created_at }}
- PR_EVENT_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name)
}}
# A base branch with neither gate file predates the gate (the pull
request adding it,
# or a release branch cut before it) and keeps running project CI.
With only one of
# them present, the missing file fails the job instead of opening the
gate.
diff --git a/dev/breeze/doc/ci/05_workflows.md
b/dev/breeze/doc/ci/05_workflows.md
index c06d4f8a267..782c8dd3c80 100644
--- a/dev/breeze/doc/ci/05_workflows.md
+++ b/dev/breeze/doc/ci/05_workflows.md
@@ -138,9 +138,9 @@ run started before a label that changes which tests run was
added or removed
(`full tests needed`, `all versions`, `default versions only`, `latest
versions only`, `canary`,
`upgrade to newer dependencies`, `area:e2e-tests`, `area:kubernetes-tests`) is
left for a
maintainer, since it still runs the selection it started with; the next push
is decided afresh.
-For a PR whose CI runs in the fork the selection does not matter, but the gate
falls back to the
-labels a run started with when it cannot read the current ones, so a run
started before
-`use fork ci` or `use project ci` was added or removed is left for a
maintainer instead. Other PRs get the
+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
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 5f58617cf56..85eb012b520 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
@@ -310,9 +310,11 @@ directly with the runner's `python3` without installing
breeze. The reconciler i
full project CI as `base_without_gate`.
2. For pull requests, one read-only API call for the current labels
(`gh api repos/apache/airflow/pulls/<n> --jq '.labels[].name'`), so
override labels added
- after the event are seen on a re-run. If that call fails, the gate uses the
labels of the
- event payload instead. It never falls back to running project CI: anyone
able to exhaust
- the shared token quota would otherwise switch the gate off for every pull
request.
+ after the event are seen on a re-run. If that call fails, the gate job
fails and nothing
+ after it runs; re-running the job decides again. It falls back neither to
running project
+ CI, since anyone able to exhaust the shared token quota would otherwise
switch the gate off
+ for every pull request, nor to the labels of the event payload, since runs
started before
+ `use fork ci` was added are approved automatically (see "Approving gated
runs").
3. `python3 dev/breeze/src/airflow_breeze/utils/fork_ci_gate.py` with the
event payload
fields and the labels passed as environment variables. It writes
`project-ci=true|false`
and `reason=...` to `GITHUB_OUTPUT`. A base branch with neither file skips
the script and
@@ -367,9 +369,10 @@ applies instead:
`github-actions[bot]` with the marker count. A change to `.github/` can
remove the gate or add a
workflow that does run, so such a run must not start without a review.
-A run started before `use fork ci` or `use project ci` was last added or
removed is not approved
-automatically: the gate falls back to the labels of the run's event when it
cannot read the current
-ones, and those can still send the run to project CI. The next push is decided
afresh.
+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
+approved. The gate decides from the labels the PR has when the run starts,
never from those of the
+run's event, and fails when it cannot read them.
Both rules use the same list as "Trusting a green fork run", so a gated pull
request whose runs are
approved automatically is also one whose green fork run is trusted, and one
that changes CI is both
diff --git a/dev/breeze/src/airflow_breeze/utils/fork_ci_gate.py
b/dev/breeze/src/airflow_breeze/utils/fork_ci_gate.py
index dc05a3b67a5..4816e81bfd3 100644
--- a/dev/breeze/src/airflow_breeze/utils/fork_ci_gate.py
+++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_gate.py
@@ -214,6 +214,10 @@ class Decision:
reason: Reason
+class LabelsUnavailableError(RuntimeError):
+ """The current labels of a pull request could not be read."""
+
+
def _is_canonical_branch(ref: str) -> bool:
branch = ref.removeprefix("refs/heads/")
return any(pattern.match(branch) for pattern in
CANONICAL_PUSH_BRANCH_PATTERNS)
@@ -300,10 +304,12 @@ def evaluate_from_env(env: Mapping[str, str]) -> Decision:
if event_name == "pull_request":
fetched = fetch_pull_request_labels(repository, env.get("PR_NUMBER",
""))
if fetched is None:
- # Never fail open: anyone able to exhaust the shared token quota
would otherwise switch
- # the gate off for every pull request. The event's labels miss
only labels added since.
- print("Falling back to the labels of the triggering event",
file=sys.stderr)
- fetched = tuple(json.loads(env.get("PR_EVENT_LABELS") or "[]"))
+ # Neither project CI nor the event's labels: the workflow-run
approver approves runs
+ # started before `use fork ci` was added, which the event's labels
would send to project
+ # CI, and anyone able to exhaust the shared token quota could
force that path.
+ raise LabelsUnavailableError(
+ f"Could not read the labels of pull request
#{env.get('PR_NUMBER', '')}; re-run the job"
+ )
labels = fetched
event = GateEvent(
event_name=event_name,
@@ -321,7 +327,11 @@ def evaluate_from_env(env: Mapping[str, str]) -> Decision:
def main(env: Mapping[str, str] | None = None) -> int:
env = os.environ if env is None else env
- decision = evaluate_from_env(env)
+ try:
+ decision = evaluate_from_env(env)
+ except LabelsUnavailableError as e:
+ print(e, file=sys.stderr)
+ return 1
project_ci = "true" if decision.run else "false"
print(f"project-ci={project_ci} reason={decision.reason.value}")
_write_line("GITHUB_OUTPUT", f"project-ci={project_ci}", env)
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 2f7fc1fe34b..5b0289d2e19 100644
--- a/dev/breeze/src/airflow_breeze/utils/workflow_run_approval.py
+++ b/dev/breeze/src/airflow_breeze/utils/workflow_run_approval.py
@@ -49,8 +49,6 @@ from airflow_breeze.utils.ci_minutes_estimate import
CiMinutesEstimate, estimate
from airflow_breeze.utils.console import console_print
from airflow_breeze.utils.fork_ci_gate import (
GATED_REASONS,
- LABEL_USE_FORK_CI,
- LABEL_USE_PROJECT_CI,
GateConfig,
GateEvent,
decide,
@@ -132,11 +130,6 @@ TEST_SELECTION_LABELS = frozenset(
}
)
-# Labels that move a PR between project CI and fork CI. The gate reads them
when it runs, but falls
-# back to the labels of the run's event when it cannot, so a run started
before one changed could
-# still run project CI.
-GATE_OVERRIDE_LABELS = frozenset({LABEL_USE_FORK_CI, LABEL_USE_PROJECT_CI})
-
# Read from the trusted main checkout, which is also the base branch whose
gate job decides the
# PRs that are approved automatically.
FORK_CI_GATE_CONFIG_PATH = AIRFLOW_ROOT_PATH / ".github" / "fork-ci-gate.toml"
@@ -225,7 +218,9 @@ def decide_gated_approval(*, changed_files:
list[ChangedFile]) -> ApprovalDecisi
return ApprovalDecision(
approve=True,
reasons=["its CI runs in the contributor's fork, so its project CI run
only executes the gate"],
- decision_labels=GATE_OVERRIDE_LABELS,
+ # The gate reads `use fork ci` and `use project ci` when the run
starts and fails when it
+ # cannot, so runs started before one of them changed are approved too.
+ decision_labels=frozenset(),
)
@@ -655,7 +650,7 @@ def find_runs_started_for_current_pr(
:param decision_labels: the labels the decision depends on. It is made
from the current labels,
while a run can act on those of its event: with ``full tests needed``
removed, or
``default versions only`` added to a run of all versions, the estimate
is smaller than the
- run, and without ``use fork ci`` in its event a run can fall back to
project CI.
+ run.
"""
started_after = parse_github_time(pr["created_at"])
changes = [
diff --git a/dev/breeze/tests/test_fork_ci_gate.py
b/dev/breeze/tests/test_fork_ci_gate.py
index e641e45ca82..ca4194b99d2 100644
--- a/dev/breeze/tests/test_fork_ci_gate.py
+++ b/dev/breeze/tests/test_fork_ci_gate.py
@@ -391,28 +391,14 @@ def _outputs(env: dict[str, str]) -> list[str]:
@mock.patch("airflow_breeze.utils.fork_ci_gate.subprocess.run", autospec=True)
@pytest.mark.parametrize(
- ("returncode", "stdout", "event_labels", "expected"),
+ ("stdout", "expected"),
[
- pytest.param(0, "[]\n", "[]", ["project-ci=false", "reason=external"],
id="external"),
- pytest.param(
- 0, '["use project ci"]\n', "[]", ["project-ci=true",
"reason=label_project_ci"], id="labelled"
- ),
- pytest.param(1, "", "[]", ["project-ci=false", "reason=external"],
id="gh-fails-stays-gated"),
- pytest.param(
- 0, "not json", "[]", ["project-ci=false", "reason=external"],
id="bad-output-stays-gated"
- ),
- pytest.param(
- 1,
- "",
- '["use project ci"]',
- ["project-ci=true", "reason=label_project_ci"],
- id="gh-fails-uses-event-labels",
- ),
+ pytest.param("[]\n", ["project-ci=false", "reason=external"],
id="external"),
+ pytest.param('["use project ci"]\n', ["project-ci=true",
"reason=label_project_ci"], id="labelled"),
],
)
-def test_main_pull_request(mock_run, gate_env, returncode, stdout,
event_labels, expected):
- mock_run.return_value = subprocess.CompletedProcess([], returncode,
stdout=stdout, stderr="boom")
- gate_env["PR_EVENT_LABELS"] = event_labels
+def test_main_pull_request(mock_run, gate_env, stdout, expected):
+ mock_run.return_value = subprocess.CompletedProcess([], 0, stdout=stdout,
stderr="")
assert main(gate_env) == 0
@@ -420,15 +406,24 @@ def test_main_pull_request(mock_run, gate_env,
returncode, stdout, event_labels,
assert mock_run.call_args_list[0].args[0][:3] == ["gh", "api",
"repos/apache/airflow/pulls/1"]
[email protected](
- "airflow_breeze.utils.fork_ci_gate.subprocess.run", autospec=True,
side_effect=FileNotFoundError("gh")
[email protected]("airflow_breeze.utils.fork_ci_gate.subprocess.run", autospec=True)
[email protected](
+ ("returncode", "stdout", "side_effect"),
+ [
+ pytest.param(1, "", None, id="gh-fails"),
+ pytest.param(0, "not json", None, id="bad-output"),
+ pytest.param(0, "", FileNotFoundError("gh"), id="no-gh"),
+ ],
)
-def test_main_without_gh_uses_the_event_labels(mock_run, gate_env):
- gate_env["PR_EVENT_LABELS"] = '["use project ci"]'
+def test_main_pull_request_fails_when_labels_cannot_be_read(
+ mock_run, gate_env, returncode, stdout, side_effect
+):
+ mock_run.return_value = subprocess.CompletedProcess([], returncode,
stdout=stdout, stderr="boom")
+ mock_run.side_effect = side_effect
- main(gate_env)
+ assert main(gate_env) == 1
- assert _outputs(gate_env) == ["project-ci=true", "reason=label_project_ci"]
+ assert not Path(gate_env["GITHUB_OUTPUT"]).exists()
@mock.patch("airflow_breeze.utils.fork_ci_gate.subprocess.run", autospec=True)
diff --git a/dev/breeze/tests/test_workflow_run_approval.py
b/dev/breeze/tests/test_workflow_run_approval.py
index 0877efa90d3..87e68302911 100644
--- a/dev/breeze/tests/test_workflow_run_approval.py
+++ b/dev/breeze/tests/test_workflow_run_approval.py
@@ -31,7 +31,6 @@ 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 (
- GATE_OVERRIDE_LABELS,
MAX_AUTO_APPROVED_JOB_MINUTES,
MAX_AUTO_APPROVED_SOURCE_LINES,
MAX_ERROR_TEXT_LENGTH,
@@ -995,13 +994,11 @@ def
test_get_last_label_change_ignores_other_labels_and_reads_the_timeline_once(
@mock.patch(f"{MODULE}.load_config", autospec=True, return_value=GATE_ENABLED)
@mock.patch(f"{MODULE}.get_changed_files", autospec=True,
return_value=CORE_CHANGE)
@mock.patch(f"{MODULE}.fetch_merge_commit", autospec=True,
return_value="merge")
-def test_gated_approval_depends_on_the_gate_override_labels(
- mock_fetch, mock_get_changed_files, mock_load_config
-):
+def test_gated_approval_depends_on_no_label(mock_fetch,
mock_get_changed_files, mock_load_config):
api = mock.MagicMock(spec=GitHubApi)
api.github_repository = "apache/airflow"
- assert decide_for_pull_request(api=api, pr=_gated_pr()).decision_labels ==
GATE_OVERRIDE_LABELS
+ assert decide_for_pull_request(api=api, pr=_gated_pr()).decision_labels ==
frozenset()
@pytest.mark.parametrize("event", ["labeled", "unlabeled"])
@@ -1016,14 +1013,14 @@ def
test_get_last_label_change_counts_every_test_selection_label(label, event):
@mock.patch(f"{MODULE}.decide_for_pull_request", autospec=True)
-def
test_process_waiting_runs_leaves_gated_runs_started_before_use_fork_ci_was_added(mock_decide):
+def
test_process_waiting_runs_approves_gated_runs_started_before_use_fork_ci_was_added(mock_decide):
api = GitHubApi(github_repository="apache/airflow", github_token="token",
report_only=False)
timeline = [
{"event": "labeled", "created_at": "2026-10-22T10:30:00Z", "label":
{"name": "use fork ci"}},
{"event": "unlabeled", "created_at": "2026-10-22T10:40:00Z", "label":
{"name": "full tests needed"}},
]
mock_decide.return_value = ApprovalDecision(
- approve=True, reasons=["fork CI"], decision_labels=GATE_OVERRIDE_LABELS
+ approve=True, reasons=["fork CI"], decision_labels=frozenset()
)
with (
mock.patch.object(
@@ -1041,4 +1038,4 @@ def
test_process_waiting_runs_leaves_gated_runs_started_before_use_fork_ci_was_a
):
process_waiting_runs(api, created_since=CREATED_SINCE)
- assert approve_run.call_args_list == [mock.call(3)]
+ assert approve_run.call_args_list == [mock.call(2), mock.call(3)]
diff --git a/scripts/ci/prek/check_ci_workflows_in_sync.py
b/scripts/ci/prek/check_ci_workflows_in_sync.py
index 321ca94194c..edee96e3a9b 100755
--- a/scripts/ci/prek/check_ci_workflows_in_sync.py
+++ b/scripts/ci/prek/check_ci_workflows_in_sync.py
@@ -229,7 +229,6 @@ AMD_ONLY_GATE_BLOCK = """ gate:
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_AUTHOR_ASSOCIATION: ${{
github.event.pull_request.author_association }}
PR_CREATED_AT: ${{ github.event.pull_request.created_at }}
- PR_EVENT_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name)
}}
# A base branch with neither gate file predates the gate (the pull
request adding it,
# or a release branch cut before it) and keeps running project CI.
With only one of
# them present, the missing file fails the job instead of opening the
gate.