This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch fork-ci-6-reconciler-followups
in repository https://gitbox.apache.org/repos/asf/airflow.git

commit bb9616cd47ee3bccfd675abefd7af8f86a5894e5
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sat Oct 17 03:01:39 2026 +0200

    Attribute fork CI runs to the right pull request in the tampering check
    
    Runs from forks carry no pull request, so they were matched by fork branch
    alone. Legitimate project CI against a base branch without the gate, made
    before a retarget, or a run of another pull request from the same branch,
    could close a gated pull request for gate tampering.
    
    Generated-by: Claude Opus 5
---
 .../doc/ci/08_fork_ci_for_external_contributors.md |  9 ++-
 .../src/airflow_breeze/utils/fork_ci_reconcile.py  | 82 +++++++++++++++++--
 dev/breeze/tests/test_fork_ci_reconcile.py         | 93 +++++++++++++++++++++-
 3 files changed, 174 insertions(+), 10 deletions(-)

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 0820e007ebf..d5703324a12 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
@@ -509,8 +509,13 @@ ignored, because they ran legitimately while the label was 
on. So are runs creat
 most recent reopen of the pull request by someone other than its author, which 
is how a
 maintainer brings back a pull request closed for tampering; reopening one 
whose gate is still
 missing triggers a new run, which is caught again. A reopen by the author does 
not count, so
-closing and reopening one's own pull request cannot hide a tampered run. A run 
counts as a
-bypass when both signals hold:
+closing and reopening one's own pull request cannot hide a tampered run. Runs 
are listed per
+fork branch, because GitHub records no pull request on runs from forks. A run 
made while the
+pull request targeted a base branch without the gate, known from its retargets 
in the
+timeline, is project CI by design and ignored; its changes are compared with 
that base. When
+another pull request from the same branch was open within the window, a 
matching run could be
+that one's, so it is reported to Slack as an attention item rather than 
closing the pull
+request. A run counts as a bypass when both signals hold:
 
 1. **The run did project CI work.** Either the default branch's version of the 
run's workflow
    is not triggered by the run's event, which covers an injected workflow and 
a trigger the
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 faef461b4a8..81f71b19cce 100644
--- a/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
+++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
@@ -168,6 +168,8 @@ class PullRequestSnapshot:
     last_reopened_at: datetime | None = None
     last_reopened_by_anyone_at: datetime | None = None
     last_base_change_at: datetime | None = None
+    # (changed at, previous base, new base) for each retarget, oldest first.
+    base_changes: tuple[tuple[datetime, str, str], ...] = ()
     current_status: tuple[str, str, str] | None = None
     status_created_at: datetime | None = None
     marker: Marker | None = None
@@ -557,7 +559,7 @@ query($q: String!, $cursor: String) {
           ... on ConvertToDraftEvent { createdAt actor { login } }
           ... on UnlabeledEvent { createdAt label { name } }
           ... on ReopenedEvent { createdAt actor { login } }
-          ... on BaseRefChangedEvent { createdAt }
+          ... on BaseRefChangedEvent { createdAt previousRefName 
currentRefName }
         }
       }
       commits(last: 1) { nodes { commit {
@@ -609,11 +611,15 @@ def parse_pull_request(node: dict[str, Any]) -> 
PullRequestSnapshot:
     last_reopened = None
     last_reopened_by_anyone = None
     last_base_change = None
+    base_changes = []
     for item in (node.get("timelineItems") or {}).get("nodes") or []:
         if item["__typename"] == "ConvertToDraftEvent":
             last_draft_actor = _login(item.get("actor"))
         elif item["__typename"] == "BaseRefChangedEvent":
             last_base_change = parse_github_datetime(item["createdAt"])
+            base_changes.append(
+                (last_base_change, item.get("previousRefName") or "", 
item.get("currentRefName") or "")
+            )
         elif item["__typename"] == "ReopenedEvent":
             last_reopened_by_anyone = parse_github_datetime(item["createdAt"])
             # Only a reopen by someone else counts for the gate tampering 
check: an author closing
@@ -665,6 +671,7 @@ def parse_pull_request(node: dict[str, Any]) -> 
PullRequestSnapshot:
         last_reopened_at=last_reopened,
         last_reopened_by_anyone_at=last_reopened_by_anyone,
         last_base_change_at=last_base_change,
+        base_changes=tuple(base_changes),
         current_status=current_status,
         status_created_at=status_created_at,
         marker=marker,
@@ -870,6 +877,15 @@ def load_workflow_triggers(workflows_dir: Path) -> 
dict[str, frozenset[str]]:
     return triggers
 
 
+def base_at(pr: PullRequestSnapshot, when: datetime) -> str:
+    """The base branch the pull request had at ``when``, from its retargets."""
+    base = pr.base_ref
+    for changed_at, previous, _ in reversed(pr.base_changes):
+        if when < changed_at:
+            base = previous
+    return base
+
+
 class BypassFinder:
     """
     Finds project CI runs that bypassed the gate, from the runs apache/airflow 
recorded.
@@ -885,12 +901,23 @@ class BypassFinder:
     """
 
     def __init__(
-        self, client: GitHubClient, *, repo: str, now: datetime, 
base_triggers: dict[str, frozenset[str]]
+        self,
+        client: GitHubClient,
+        *,
+        repo: str,
+        now: datetime,
+        base_triggers: dict[str, frozenset[str]],
+        is_gated_base: Callable[[str], bool] = lambda base_ref: True,
     ):
+        """:param is_gated_base: whether a base branch has the gate; runs 
against one without it are project CI by design."""
         self._client = client
         self._repo = repo
         self._now = now
         self._base_triggers = base_triggers
+        self._is_gated_base = is_gated_base
+        # Pull requests whose matching run could belong to another pull 
request from the same
+        # branch; reported instead of closed.
+        self.ambiguous: list[tuple[int, str]] = []
         since = (now - TAMPER_WINDOW).strftime("%Y-%m-%dT%H:%M:%SZ")
         self._runs_by_branch: dict[tuple[str, str], list[WorkflowRun]] = {}
         incomplete = []
@@ -961,11 +988,26 @@ class BypassFinder:
                 self._changes_workflows[key] = None if len(files) >= 
COMPARE_FILES_CAP else False
         return self._changes_workflows[key]
 
+    def _other_pull_requests(self, pr: PullRequestSnapshot) -> list[int]:
+        """Other pull requests from the same branch that were open within the 
tampering window."""
+        owner = pr.head_repo.split("/", 1)[0]  # type: ignore[union-attr]
+        pulls = self._client.rest_get(
+            f"repos/{self._repo}/pulls?head={quote(owner, 
safe='')}:{quote(pr.head_ref, safe='')}"
+            "&state=all&per_page=100",
+            paginate=True,
+        )
+        window_start = self._now - TAMPER_WINDOW
+        return [
+            pull["number"]
+            for pull in pulls
+            if pull["number"] != pr.number
+            and (pull.get("closed_at") is None or 
parse_github_datetime(pull["closed_at"]) >= window_start)
+        ]
+
     def find(self, pr: PullRequestSnapshot) -> str | None:
         if not pr.head_repo:
             return None
-        # Matching by fork branch alone also picks up runs of another pull 
request from that branch
-        # or from before a retarget; tracked at 
https://github.com/apache/airflow/issues/74906
+        # Runs are listed per fork branch: GitHub records no pull request on 
runs from forks.
         runs = self._runs_by_branch.get((pr.head_repo.casefold(), 
pr.head_ref), [])
         # Runs before `use project ci` was removed were legitimate. Runs 
before a maintainer
         # reopened the pull request were already acted on; reopening without 
restoring the gate
@@ -976,11 +1018,26 @@ class BypassFinder:
         for run in sorted(runs, key=lambda r: r.created_at, reverse=True):
             if not_before is not None and run.created_at <= not_before:
                 continue
+            base_ref = base_at(pr, run.created_at)
+            # Project CI against a base branch without the gate is what its 
gate job decides.
+            if not self._is_gated_base(base_ref):
+                continue
             if not self._did_run_project_ci(run):
                 continue
             # A change set too large to list fails closed: padding a tampered 
commit past the
             # compare API's cap must not hide the workflow change.
-            if self._commit_changes_workflows(run, pr.base_ref) is not False:
+            if self._commit_changes_workflows(run, base_ref) is not False:
+                others = self._other_pull_requests(pr)
+                if others:
+                    self.ambiguous.append(
+                        (
+                            pr.number,
+                            f"project CI run {run.html_url} may have bypassed 
the gate, but it could also "
+                            f"belong to {', '.join(f'#{n}' for n in others)} 
from the same branch; not closed, "
+                            "check it",
+                        )
+                    )
+                    return None
                 return run.html_url
         return None
 
@@ -1144,8 +1201,20 @@ def run_reconcile(
         )
     ]
     observations = fetch_fork_observations(client, prs)
+
+    def is_gated_base(base_ref: str) -> bool:
+        # A base branch whose configuration cannot be read is treated as gated.
+        try:
+            return configs.get(base_ref) is not None
+        except (GitHubError, ValueError):
+            return True
+
     bypass_finder = BypassFinder(
-        client, repo=repo, now=now, 
base_triggers=load_workflow_triggers(workflows_dir)
+        client,
+        repo=repo,
+        now=now,
+        base_triggers=load_workflow_triggers(workflows_dir),
+        is_gated_base=is_gated_base,
     )
 
     shared_heads = find_heads_shared_across_bases(prs)
@@ -1178,6 +1247,7 @@ def run_reconcile(
             console_print(f"[error]#{listed.number}: {e}[/]")
             unknown.append((listed.number, f"{type(e).__name__}: {e}"))
 
+    attention.extend(bypass_finder.ambiguous)
     notes = [bypass_finder.incomplete] if bypass_finder.incomplete else []
     # The full list for the run log; Slack may show only part of a very long 
one.
     for number, reason in [*unknown, *attention]:
diff --git a/dev/breeze/tests/test_fork_ci_reconcile.py 
b/dev/breeze/tests/test_fork_ci_reconcile.py
index e5b28b13572..c01d28c4ab5 100644
--- a/dev/breeze/tests/test_fork_ci_reconcile.py
+++ b/dev/breeze/tests/test_fork_ci_reconcile.py
@@ -651,6 +651,8 @@ def _finder(
     jobs=(),
     compare_files=(".github/workflows/ci-amd.yml",),
     total_count=None,
+    other_pulls=(),
+    is_gated_base=lambda base_ref: True,
 ):
     def rest_get_pages(path):
         event = path.split("event=", 1)[1].split("&", 1)[0]
@@ -663,11 +665,13 @@ def _finder(
             return list(jobs)
         if "/compare/" in path:
             return [{"filename": name} for name in compare_files]
+        if "/pulls?head=" in path:
+            return [{"number": 42, "closed_at": None}, *other_pulls]
         raise AssertionError(path)
 
     client.rest_get_pages.side_effect = rest_get_pages
     client.rest_get.side_effect = rest_get
-    return BypassFinder(client, repo=REPO, now=NOW, 
base_triggers=BASE_TRIGGERS)
+    return BypassFinder(client, repo=REPO, now=NOW, 
base_triggers=BASE_TRIGGERS, is_gated_base=is_gated_base)
 
 
 @pytest.fixture
@@ -782,11 +786,44 @@ def 
test_finder_fails_closed_on_a_change_set_it_cannot_check(client):
     assert finder.find(_pr()) == 
"https://github.com/apache/airflow/actions/runs/7";
 
 
+def test_finder_ignores_runs_made_while_the_base_had_no_gate(client):
+    finder = _finder(
+        client, [_run(7)], jobs=TAMPERED_JOBS, is_gated_base=lambda base_ref: 
base_ref != "v2-10-test"
+    )
+    pr = _pr(base_changes=((NOW - timedelta(minutes=30), "v2-10-test", 
"main"),))
+
+    assert finder.find(pr) is None
+
+
[email protected](
+    "other_pulls",
+    [
+        pytest.param([{"number": 43, "closed_at": None}], id="open"),
+        pytest.param(
+            [{"number": 43, "closed_at": (NOW - 
timedelta(hours=2)).isoformat()}], id="closed-recently"
+        ),
+    ],
+)
+def test_finder_reports_a_run_another_pull_request_may_own(client, 
other_pulls):
+    finder = _finder(client, [_run(7)], jobs=TAMPERED_JOBS, 
other_pulls=other_pulls)
+
+    assert finder.find(_pr()) is None
+    assert finder.ambiguous == [
+        (
+            42,
+            "project CI run https://github.com/apache/airflow/actions/runs/7 
may have bypassed the gate, "
+            "but it could also belong to #43 from the same branch; not closed, 
check it",
+        )
+    ]
+
+
 def test_finder_catches_a_workflow_moved_away(client):
     finder = _finder(client, [_run(7)], jobs=TAMPERED_JOBS)
     client.rest_get.side_effect = lambda path, paginate=False, items_key=None: 
(
         TAMPERED_JOBS
         if path.endswith("jobs?filter=latest&per_page=100")
+        else [{"number": 42, "closed_at": None}]
+        if "/pulls?head=" in path
         else [{"filename": "docs/x.yml", "previous_filename": 
".github/workflows/x.yml"}]
     )
 
@@ -867,7 +904,12 @@ def 
test_parse_pull_request_reads_timeline_status_and_marker():
                     "createdAt": "2026-11-06T00:00:00Z",
                     "actor": {"login": "someone"},
                 },
-                {"__typename": "BaseRefChangedEvent", "createdAt": 
"2026-11-07T00:00:00Z"},
+                {
+                    "__typename": "BaseRefChangedEvent",
+                    "createdAt": "2026-11-07T00:00:00Z",
+                    "previousRefName": "v3-1-test",
+                    "currentRefName": "main",
+                },
             ]
         },
         commits={
@@ -906,6 +948,7 @@ def 
test_parse_pull_request_reads_timeline_status_and_marker():
     assert pr.last_reopened_at == datetime(2026, 11, 5, tzinfo=UTC)
     assert pr.last_reopened_by_anyone_at == datetime(2026, 11, 6, tzinfo=UTC)
     assert pr.last_base_change_at == datetime(2026, 11, 7, tzinfo=UTC)
+    assert pr.base_changes == ((datetime(2026, 11, 7, tzinfo=UTC), 
"v3-1-test", "main"),)
     assert pr.current_status == ("pending", "u", "d")
     assert pr.status_created_at == datetime(2026, 11, 8, tzinfo=UTC)
     assert pr.marker == marker
@@ -1234,3 +1277,49 @@ def 
test_finder_ignores_an_added_workflow_whose_jobs_all_skipped(client):
     finder = _finder(client, [run], jobs=[_job("Fork only"), _job("Also fork 
only")])
 
     assert finder.find(_pr()) is None
+
+
[email protected]("airflow_breeze.utils.fork_ci_reconcile.BypassFinder", 
autospec=True)
+def test_run_reconcile_reports_ambiguous_runs(mock_finder, client, tmp_path):
+    output = tmp_path / "slack-message.json"
+    client.graphql.side_effect = [_search_result(), _search_result(), 
_search_result()]
+    mock_finder.return_value.incomplete = None
+    mock_finder.return_value.ambiguous = [(42, "could also belong to #43")]
+
+    has_alerts = run_reconcile(
+        client, CONFIG, now=NOW, run_url="https://run";, output_file=output, 
workflows_dir=tmp_path
+    )
+
+    assert has_alerts is True
+    assert "|#42>: could also belong to #43" in output.read_text()
+
+
[email protected](
+    ("base_ref", "rest_get", "expected"),
+    [
+        pytest.param("main", None, True, id="default-branch"),
+        pytest.param("v2-10-test", _contents({}), False, id="no-gate"),
+        pytest.param("v3-1-test", GitHubError("gh api failed: HTTP 500"), 
True, id="unreadable"),
+        pytest.param(
+            "v3-1-test", _contents({(GATE_FILES[1], "v3-1-test"): "# 
gate\n"}), True, id="half-gate"
+        ),
+    ],
+)
[email protected]("airflow_breeze.utils.fork_ci_reconcile.BypassFinder", 
autospec=True)
+def test_run_reconcile_tells_the_finder_which_bases_are_gated(
+    mock_finder, client, tmp_path, base_ref, rest_get, expected
+):
+    client.graphql.side_effect = [_search_result(), _search_result(), 
_search_result()]
+    mock_finder.return_value.incomplete = None
+    mock_finder.return_value.ambiguous = []
+    run_reconcile(
+        client,
+        CONFIG,
+        now=NOW,
+        run_url="https://run";,
+        output_file=tmp_path / "s.json",
+        workflows_dir=tmp_path,
+    )
+    client.rest_get.side_effect = rest_get
+
+    assert mock_finder.call_args.kwargs["is_gated_base"](base_ref) is expected

Reply via email to