This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-2-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
commit e97f41091b36c03839d1c8bd304e80eb4060286f
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Mon May 25 19:13:18 2026 +0530
[v3-2-test] CI: fix milestone-tag-assistant race when labels change
post-merge (#67337) (#67468)
The `milestone-tag-assistant.yml` workflow snapshots PR labels at the
`get-pr-info` job (via `listPullRequestsAssociatedWithCommit`) and then
spends ~1.5 minutes installing Breeze and running `breeze ci
set-milestone`. If a maintainer adds and removes a backport label
inside that window, the action commits to the stale-snapshot decision
and sets the wrong milestone — see the incident on PR #67301 where a
backport label that lived for 49 seconds caused an Airflow-3.2.3
milestone to be set on a `main`-only documentation PR.
Re-read `issue.labels` from the freshly-fetched issue before computing
the milestone. If the labels changed since the snapshot:
- Honour any skip label that appeared after the snapshot.
- Re-run `_determine_milestone_version` with the current labels and
use the fresh decision; if the decision flips to "no milestone",
bail out before posting the comment.
Adds three regression tests covering the three race-window cases
(backport label removed, replaced, skip label added) and updates two
existing happy-path tests to populate `mock_issue.labels` so the
re-read sees the same labels as the snapshot.
(cherry picked from commit 6ecae6853e650fbcf8a67225eec8915eb91523f5)
Co-authored-by: Jarek Potiuk <[email protected]>
---
.../src/airflow_breeze/commands/ci_commands.py | 34 +++++
dev/breeze/tests/test_set_milestone.py | 147 +++++++++++++++++++++
2 files changed, 181 insertions(+)
diff --git a/dev/breeze/src/airflow_breeze/commands/ci_commands.py
b/dev/breeze/src/airflow_breeze/commands/ci_commands.py
index 1e3746f410f..c81620252b0 100644
--- a/dev/breeze/src/airflow_breeze/commands/ci_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/ci_commands.py
@@ -1283,6 +1283,40 @@ def set_milestone(
console_print(f"[error]Failed to check existing milestone: {e}[/]")
return
+ # Re-read labels from the freshly-fetched issue to close the race between
+ # the workflow's initial label snapshot (taken by the get-pr-info job a
+ # couple of minutes ago) and the actual milestone-set step. Maintainers
+ # sometimes add and remove a backport label inside that window; honour
+ # the latest state, not the stale snapshot.
+ try:
+ current_labels = [label.name for label in issue.labels]
+ except Exception as e:
+ console_print(f"[warning]Could not re-read PR labels; falling back to
snapshot decision: {e}[/]")
+ current_labels = labels
+
+ if set(current_labels) != set(labels):
+ console_print("[info]Labels changed since workflow snapshot;
re-evaluating.[/]")
+ console_print(f"[info]Snapshot labels: {sorted(labels)}[/]")
+ console_print(f"[info]Current labels: {sorted(current_labels)}[/]")
+
+ if _should_skip_milestone_tagging(current_labels):
+ console_print(
+ f"[info]Skipping milestone tagging - PR now has skip label(s):
"
+ f"{set(current_labels) & MILESTONE_SKIP_LABELS}[/]"
+ )
+ return
+
+ new_version, new_reason = _determine_milestone_version(current_labels,
pr_title, base_branch)
+ if (new_version, new_reason) != (version, reason):
+ console_print(
+ f"[info]Determination changed after re-read: was ({version},
{reason!r}); "
+ f"now ({new_version}, {new_reason!r}). Using current
labels.[/]"
+ )
+ version, reason = new_version, new_reason
+ if version is None:
+ console_print(f"[info]No milestone to set after re-evaluation:
{reason}[/]")
+ return
+
major, minor = version
milestone_prefix = _get_milestone_prefix(major, minor)
console_print(f"[info]Looking for milestone with prefix:
{milestone_prefix}[/]")
diff --git a/dev/breeze/tests/test_set_milestone.py
b/dev/breeze/tests/test_set_milestone.py
index 78aa8f19325..7c1f8b433ff 100644
--- a/dev/breeze/tests/test_set_milestone.py
+++ b/dev/breeze/tests/test_set_milestone.py
@@ -38,6 +38,13 @@ from airflow_breeze.commands.ci_commands import (
)
+def _label(name: str) -> MagicMock:
+ """Build a mock that quacks like a PyGithub ``Label`` for
``issue.labels``."""
+ m = MagicMock()
+ m.name = name
+ return m
+
+
class TestParseVersionFromBranch:
"""Test cases for _parse_version_from_branch."""
@@ -420,6 +427,8 @@ class TestSetMilestoneCommand:
mock_gh, mock_repo, mock_issue = mock_github_setup
mock_issue.milestone = None
+ # Fresh-issue labels match the workflow snapshot — no race, no
re-evaluation.
+ mock_issue.labels = [_label(name) for name in pr_labels]
mock_milestone = MagicMock()
mock_milestone.title = milestone_title
mock_milestone.number = 42
@@ -530,6 +539,8 @@ If this milestone is not correct, please update it to the
appropriate milestone.
mock_gh, mock_repo, mock_issue = mock_github_setup
mock_issue.milestone = None
+ # Fresh-issue labels match the workflow snapshot — no race, no
re-evaluation.
+ mock_issue.labels = [_label(name) for name in pr_labels]
captured_comments: list[str] = []
mock_issue.create_comment.side_effect = lambda c:
captured_comments.append(c)
@@ -571,3 +582,139 @@ However, **no open milestone was found** matching:
{expected_search_criteria}
"""
assert captured_comments[0] == expected_comment
assert "No open milestone found" in result.output
+
+ @patch("airflow_breeze.commands.ci_commands._get_github_client")
+ def test_backport_label_removed_after_snapshot_should_skip(
+ self, mock_get_client, cli_runner, mock_github_setup
+ ):
+ """If a backport label is removed between the workflow snapshot and
the action,
+ the action must re-read labels from the issue and honour the current
state —
+ skip milestone-set when the only signal that triggered it (the
backport label)
+ is gone. Regression test for PR #67301 race.
+ """
+ from airflow_breeze.commands.ci_commands import ci_group
+
+ mock_gh, mock_repo, mock_issue = mock_github_setup
+ mock_issue.milestone = None
+ mock_issue.labels = [_label("kind:documentation")]
+ mock_get_client.return_value = mock_gh
+
+ result = cli_runner.invoke(
+ ci_group,
+ [
+ "set-milestone",
+ "--pr-number",
+ "67301",
+ "--pr-title",
+ "fix: typo",
+ "--pr-labels",
+ json.dumps(["backport-to-v3-2-test", "kind:documentation"]),
+ "--base-branch",
+ "main",
+ "--merged-by",
+ "shahar1",
+ "--github-token",
+ "fake-token",
+ "--github-repository",
+ "apache/airflow",
+ ],
+ )
+
+ # Snapshot still has the backport label, but the fresh issue.labels
does not.
+ # The action must re-read, notice the change, and skip the
milestone-set.
+ mock_issue.edit.assert_not_called()
+ mock_issue.create_comment.assert_not_called()
+ assert "Labels changed since workflow snapshot" in result.output
+ assert "No milestone to set after re-evaluation" in result.output
+ assert result.exit_code == 0
+
+ @patch("airflow_breeze.commands.ci_commands._get_github_client")
+ def test_backport_label_changed_after_snapshot_should_use_current(
+ self, mock_get_client, cli_runner, mock_github_setup
+ ):
+ """If the backport label is replaced with a different version between
+ snapshot and action (e.g. someone fixes the version target), the action
+ must re-determine the version using the current label, not the stale
one.
+ """
+ from airflow_breeze.commands.ci_commands import ci_group
+
+ mock_gh, mock_repo, mock_issue = mock_github_setup
+ mock_issue.milestone = None
+ # Fresh state: now targets v3-2-test, not v3-1-test.
+ mock_issue.labels = [_label("backport-to-v3-2-test"),
_label("kind:bug")]
+ mock_milestone = MagicMock()
+ mock_milestone.title = "Airflow 3.2.3"
+ mock_milestone.number = 140
+ mock_get_client.return_value = mock_gh
+ mock_repo.get_milestones.return_value = [mock_milestone]
+
+ captured_comments: list[str] = []
+ mock_issue.create_comment.side_effect = lambda c:
captured_comments.append(c)
+
+ result = cli_runner.invoke(
+ ci_group,
+ [
+ "set-milestone",
+ "--pr-number",
+ "12345",
+ "--pr-title",
+ "Fix: scheduler issue",
+ "--pr-labels",
+ json.dumps(["backport-to-v3-1-test", "kind:bug"]),
+ "--base-branch",
+ "main",
+ "--merged-by",
+ "testuser",
+ "--github-token",
+ "fake-token",
+ "--github-repository",
+ "apache/airflow",
+ ],
+ )
+
+ mock_issue.edit.assert_called_once_with(milestone=mock_milestone)
+ assert "Labels changed since workflow snapshot" in result.output
+ assert "Determination changed after re-read" in result.output
+ assert "Airflow 3.2.3" in captured_comments[0]
+ assert "backport label targeting v3-2-test" in captured_comments[0]
+ assert result.exit_code == 0
+
+ @patch("airflow_breeze.commands.ci_commands._get_github_client")
+ def test_skip_label_added_after_snapshot_should_skip(
+ self, mock_get_client, cli_runner, mock_github_setup
+ ):
+ """A skip label added after the snapshot must also halt the action."""
+ from airflow_breeze.commands.ci_commands import ci_group
+
+ mock_gh, mock_repo, mock_issue = mock_github_setup
+ mock_issue.milestone = None
+ # Snapshot had no skip label; fresh state added area:CI.
+ mock_issue.labels = [_label("backport-to-v3-1-test"),
_label("area:CI")]
+ mock_get_client.return_value = mock_gh
+
+ result = cli_runner.invoke(
+ ci_group,
+ [
+ "set-milestone",
+ "--pr-number",
+ "12345",
+ "--pr-title",
+ "CI tweak",
+ "--pr-labels",
+ json.dumps(["backport-to-v3-1-test"]),
+ "--base-branch",
+ "main",
+ "--merged-by",
+ "testuser",
+ "--github-token",
+ "fake-token",
+ "--github-repository",
+ "apache/airflow",
+ ],
+ )
+
+ mock_issue.edit.assert_not_called()
+ mock_issue.create_comment.assert_not_called()
+ assert "Skipping milestone tagging" in result.output
+ assert "area:CI" in result.output
+ assert result.exit_code == 0