This is an automated email from the ASF dual-hosted git repository. potiuk pushed a commit to branch strip-backport-labels-from-upgrade-prs in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 5063adb5aae65c50e47a8b19d21ea2811f543378 Author: Jarek Potiuk <[email protected]> AuthorDate: Fri Sep 18 11:47:58 2026 +0200 Stop backporting the scheduled CI environment upgrade Each maintenance branch upgrades its own CI environment on its own schedule, so backporting main's upgrade races that with a change that conflicts on the very files it regenerates — which is why none of the labelled upgrade PRs has ever produced a backport. boring-cyborg labels by path and cannot tell this PR from a hand-written one touching dev/ or .github/, so the label has to come off afterwards instead. Generated-by: Claude Code (Opus 5) --- .github/workflows/upgrade-check.yml | 23 ++++++ .../src/airflow_breeze/commands/ci_commands.py | 46 ++++++++++++ dev/breeze/tests/test_remove_backport_labels.py | 85 ++++++++++++++++++++++ 3 files changed, 154 insertions(+) diff --git a/.github/workflows/upgrade-check.yml b/.github/workflows/upgrade-check.yml index 572f1d7e6b6..0fbaff6c2c9 100644 --- a/.github/workflows/upgrade-check.yml +++ b/.github/workflows/upgrade-check.yml @@ -97,6 +97,29 @@ jobs: --json url \ --jq '.[0].url' 2>/dev/null || true) echo "pr-url=${PR_URL}" >> "${GITHUB_OUTPUT}" + - name: >- + [${{ inputs.target-branch }}] Drop backport labels + # breeze already removed these, but boring-cyborg labels on a webhook and can land + # after it exits. Each branch upgrades itself on its own schedule, so a backport + # would race that with a change conflicting on the files it regenerates. + if: steps.find-pr.outputs.pr-url != '' + env: + PR_URL: ${{ steps.find-pr.outputs.pr-url }} + run: | + sleep 30 + mapfile -t LABELS < <(gh pr view "${PR_URL}" \ + --json labels \ + --jq '.labels[].name | select(startswith("backport-to-"))') + if [[ ${#LABELS[@]} -eq 0 ]]; then + echo "No backport labels to remove." + exit 0 + fi + ARGS=() + for label in "${LABELS[@]}"; do + ARGS+=(--remove-label "${label}") + done + gh pr edit "${PR_URL}" "${ARGS[@]}" + echo "Removed: ${LABELS[*]}" - name: >- [${{ inputs.target-branch }}] Notify Slack on success if: success() && steps.find-pr.outputs.pr-url != '' diff --git a/dev/breeze/src/airflow_breeze/commands/ci_commands.py b/dev/breeze/src/airflow_breeze/commands/ci_commands.py index 00c961d2c63..c75861334d0 100644 --- a/dev/breeze/src/airflow_breeze/commands/ci_commands.py +++ b/dev/breeze/src/airflow_breeze/commands/ci_commands.py @@ -549,6 +549,50 @@ def _sync_k8s_schemas_to_airflow_site(airflow_site: Path, force: bool, command_e run_command(cmd, check=False, env=command_env) +def remove_backport_labels(*, branch_name: str, command_env: dict[str, str]) -> None: + """Drop any ``backport-to-*`` label boring-cyborg put on the upgrade PR. + + The CI environment is upgraded on each maintenance branch by that branch's own scheduled + run, so backporting main's upgrade would race it with a change that conflicts on the very + files it regenerates. boring-cyborg labels by path and cannot tell this PR apart from a + hand-written one touching ``dev/`` or ``.github/``, so the label is removed here instead. + + Labelling happens on a webhook and may land after this runs; ``upgrade-check.yml`` sweeps + again once the PR has settled, so a miss here is not the last word. + """ + labels_result = run_command( + [ + "gh", + "pr", + "view", + branch_name, + "--repo", + "apache/airflow", + "--json", + "labels", + "--jq", + '[.labels[].name | select(startswith("backport-to-"))] | join(",")', + ], + capture_output=True, + text=True, + check=False, + env=command_env, + ) + if labels_result.returncode != 0: + console_print("[warning]Could not read PR labels - leaving any backport labels in place.[/]") + return + labels = [label for label in labels_result.stdout.strip().split(",") if label] + if not labels: + return + remove_cmd = ["gh", "pr", "edit", branch_name, "--repo", "apache/airflow"] + for label in labels: + remove_cmd.extend(["--remove-label", label]) + if run_command(remove_cmd, capture_output=True, text=True, check=False, env=command_env).returncode: + console_print(f"[warning]Could not remove backport labels: {', '.join(labels)}[/]") + else: + console_print(f"[success]Removed backport labels: {', '.join(labels)}.[/]") + + @ci_group.command( name="upgrade", help="Perform important upgrade steps of the CI environment. And create a PR", @@ -1019,6 +1063,8 @@ def upgrade( else: console_print(f"[success]PR created successfully: {pr_result.stdout.strip()}.[/]") + remove_backport_labels(branch_name=branch_name, command_env=command_env) + # Switch back to appropriate branch and delete the temporary branch console_print(f"[info]Cleaning up temporary branch {branch_name}...[/]") if user_switched_to_target: diff --git a/dev/breeze/tests/test_remove_backport_labels.py b/dev/breeze/tests/test_remove_backport_labels.py new file mode 100644 index 00000000000..369669bab3e --- /dev/null +++ b/dev/breeze/tests/test_remove_backport_labels.py @@ -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. +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from airflow_breeze.commands.ci_commands import remove_backport_labels + +MODULE = "airflow_breeze.commands.ci_commands" + + +def _result(returncode: int = 0, stdout: str = "") -> MagicMock: + return MagicMock(returncode=returncode, stdout=stdout) + + +def _call(run_command, **kwargs): + with patch(f"{MODULE}.run_command", run_command): + remove_backport_labels(branch_name="ci-upgrade-main", command_env={}, **kwargs) + + +def _edit_call(run_command): + """Return the argv of the `gh pr edit` invocation, or None when it never ran.""" + for call in run_command.call_args_list: + argv = call.args[0] + if "edit" in argv: + return argv + return None + + [email protected]( + "labels", + ["backport-to-v3-3-test", "backport-to-v3-3-test,backport-to-airflow-ctl/v0-1-test"], +) +def test_every_backport_label_is_removed_in_one_call(labels): + run_command = MagicMock(side_effect=[_result(stdout=labels), _result()]) + + _call(run_command) + + argv = _edit_call(run_command) + assert argv is not None + removed = {argv[index + 1] for index, arg in enumerate(argv) if arg == "--remove-label"} + assert removed == set(labels.split(",")) + assert run_command.call_count == 2 + + +def test_nothing_is_edited_when_no_backport_label_is_present(): + run_command = MagicMock(side_effect=[_result(stdout="")]) + + _call(run_command) + + assert _edit_call(run_command) is None + + +def test_labels_are_left_alone_when_they_cannot_be_read(): + """Without the label list we cannot tell which to remove - never guess and edit blindly.""" + run_command = MagicMock(side_effect=[_result(returncode=1)]) + + _call(run_command) + + assert _edit_call(run_command) is None + + +def test_a_failed_edit_does_not_raise(): + """The upgrade PR itself is already pushed; a label left behind must not fail the run.""" + run_command = MagicMock(side_effect=[_result(stdout="backport-to-v3-3-test"), _result(returncode=1)]) + + _call(run_command) + + assert _edit_call(run_command) is not None
