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 fbea214082d Monitor the workflow run breeze actually dispatched 
(#71305)
fbea214082d is described below

commit fbea214082de098fb79aabe0803fd97fce367ae2
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sat Aug 29 16:16:11 2026 +0200

    Monitor the workflow run breeze actually dispatched (#71305)
    
    * Monitor the workflow run breeze actually dispatched
    
    The docs-publish driver looked up the newest run of the workflow right
    after dispatching it. When the dispatch had not registered yet, that is
    the previous run - typically already finished - so the driver reported
    success and moved straight on to the airflow-site refresh and the
    S3-to-GitHub sync while the docs build it was supposed to gate on was
    still running. A scheduled run, or a run someone else started, was picked
    up the same way.
    
    Terminal conclusions other than success and failure were also treated as
    nothing to report, so a cancelled or timed-out run let the rest of the
    chain proceed as if the docs had been published.
    
    * Keep breeze workflow-run monitoring on the dispatched run only
    
    Several of the workflows breeze dispatches - apache/airflow-site's build.yml
    among them - also run on push and pull request. Such a run registering while
    we poll would be taken for the one we dispatched, so only workflow_dispatch
    runs are considered now.
    
    A transient gh failure during that poll also aborted the whole publishing
    chain, even though the caller is already retrying.
---
 .../src/airflow_breeze/utils/gh_workflow_utils.py  | 77 +++++++++++++----
 dev/breeze/tests/test_gh_workflow_utils.py         | 96 +++++++++++++++++++++-
 2 files changed, 156 insertions(+), 17 deletions(-)

diff --git a/dev/breeze/src/airflow_breeze/utils/gh_workflow_utils.py 
b/dev/breeze/src/airflow_breeze/utils/gh_workflow_utils.py
index 78d19080ea5..bb60f49ed20 100644
--- a/dev/breeze/src/airflow_breeze/utils/gh_workflow_utils.py
+++ b/dev/breeze/src/airflow_breeze/utils/gh_workflow_utils.py
@@ -28,6 +28,9 @@ from airflow_breeze.utils.console import console_print
 from airflow_breeze.utils.github import run_gh_command
 from airflow_breeze.utils.shared_options import get_dry_run
 
+NEW_RUN_TIMEOUT_SECONDS = 180
+NEW_RUN_POLL_SECONDS = 5
+
 
 def tigger_workflow(workflow_name: str, repo: str, branch: str = "main", 
**kwargs):
     """
@@ -93,12 +96,20 @@ def make_sure_gh_is_installed():
         sys.exit(1)
 
 
-def get_workflow_run_id(workflow_name: str, repo: str) -> int:
+def get_latest_workflow_run_id(workflow_name: str, repo: str, *, 
exit_on_error: bool = True) -> int | None:
     """
-    Get the latest workflow run ID for a given workflow name and repository.
+    Get the latest dispatched workflow run ID for a given workflow name and 
repository.
+
+    Only ``workflow_dispatch`` runs are considered - several of the workflows 
we drive here also run
+    on push or pull request (apache/airflow-site's `build.yml`, for one), and 
such a run registering
+    between our two lookups would otherwise be mistaken for the one we 
dispatched.
 
     :param workflow_name: The name of the workflow to check.
     :param repo: The repository in the format 'owner/repo'.
+    :param exit_on_error: Whether a failing `gh` call should terminate breeze. 
Pass False when the
+        caller polls and can afford to retry a transient failure.
+    :return: The run id, or None when the workflow has never been dispatched 
(or when the lookup
+        failed and ``exit_on_error`` is False).
     """
     make_sure_gh_is_installed()
     command = [
@@ -109,6 +120,8 @@ def get_workflow_run_id(workflow_name: str, repo: str) -> 
int:
         workflow_name,
         "--repo",
         repo,
+        "--event",
+        "workflow_dispatch",
         "--limit",
         "1",
         "--json",
@@ -117,21 +130,49 @@ def get_workflow_run_id(workflow_name: str, repo: str) -> 
int:
 
     result = run_gh_command(command, capture_output=True)
     if result.returncode != 0:
+        if not exit_on_error:
+            console_print(f"[yellow]Error fetching workflow run ID: 
{result.stderr} - retrying.[/yellow]")
+            return None
         console_print(f"[red]Error fetching workflow run ID: 
{result.stderr}[/red]")
         sys.exit(1)
 
     runs_data = result.stdout.strip()
     if not runs_data:
-        console_print("[red]No workflow runs found.[/red]")
-        sys.exit(1)
+        return None
 
-    run_id = json.loads(runs_data)[0].get("databaseId")
+    runs = json.loads(runs_data)
+    return runs[0].get("databaseId") if runs else None
 
-    console_print(
-        f"[blue]Running workflow {workflow_name} at 
https://github.com/{repo}/actions/runs/{run_id}[/blue]";,
-    )
 
-    return run_id
+def wait_for_new_workflow_run(workflow_name: str, repo: str, previous_run_id: 
int | None) -> int:
+    """
+    Wait until a run newer than ``previous_run_id`` shows up and return its id.
+
+    Run ids increase monotonically, so anything above the id observed just 
before the dispatch is
+    the run we started. Taking whatever run is newest would instead latch onto 
an unrelated one -
+    a scheduled run, or another maintainer's - whenever ours has not 
registered yet, and report
+    that run's result as ours.
+
+    :param workflow_name: The name of the workflow that was dispatched.
+    :param repo: The repository in the format 'owner/repo'.
+    :param previous_run_id: The newest run id seen before dispatching, or None 
if there was none.
+    """
+    deadline = time.monotonic() + NEW_RUN_TIMEOUT_SECONDS
+    while True:
+        run_id = get_latest_workflow_run_id(workflow_name, repo, 
exit_on_error=False)
+        if run_id is not None and (previous_run_id is None or run_id > 
previous_run_id):
+            console_print(
+                f"[blue]Running workflow {workflow_name} at "
+                f"https://github.com/{repo}/actions/runs/{run_id}[/blue]";,
+            )
+            return run_id
+        if time.monotonic() >= deadline:
+            console_print(
+                f"[red]Timed out after {NEW_RUN_TIMEOUT_SECONDS}s waiting for 
the dispatched run of "
+                f"{workflow_name} in {repo} to appear.[/red]"
+            )
+            sys.exit(1)
+        time.sleep(NEW_RUN_POLL_SECONDS)
 
 
 def get_workflow_run_info(run_id: str, repo: str, fields: str) -> dict:
@@ -188,12 +229,14 @@ def monitor_workflow_run(run_id: str, repo: str):
         if status == "completed":
             if conclusion == "success":
                 console_print(f"[green]Workflow {name} run {run_id} completed 
successfully.[/green]")
-            elif conclusion == "failure":
-                console_print(
-                    f"[red]Workflow {name} run {run_id} failed, see for more 
info: https://github.com/{repo}/actions/runs/{run_id}[/red]";
-                )
-                sys.exit(1)
-            break
+                break
+            # Anything else - failure, cancelled, timed_out, action_required - 
means the run did not
+            # produce what the caller is about to chain further work onto.
+            console_print(
+                f"[red]Workflow {name} run {run_id} finished with conclusion 
'{conclusion}', "
+                f"see for more info: 
https://github.com/{repo}/actions/runs/{run_id}[/red]";
+            )
+            sys.exit(1)
 
         # Check status of jobs every 30 seconds
         time.sleep(30)
@@ -203,6 +246,7 @@ def trigger_workflow_and_monitor(
     workflow_name: str, repo: str, branch: str = "main", monitor=True, 
**workflow_fields
 ):
     make_sure_gh_is_installed()
+    previous_run_id = None if get_dry_run() else 
get_latest_workflow_run_id(workflow_name, repo)
     tigger_workflow(
         workflow_name=workflow_name,
         repo=repo,
@@ -213,9 +257,10 @@ def trigger_workflow_and_monitor(
     if get_dry_run():
         return
 
-    workflow_run_id = get_workflow_run_id(
+    workflow_run_id = wait_for_new_workflow_run(
         workflow_name=workflow_name,
         repo=repo,
+        previous_run_id=previous_run_id,
     )
 
     console_print(
diff --git a/dev/breeze/tests/test_gh_workflow_utils.py 
b/dev/breeze/tests/test_gh_workflow_utils.py
index 084944a525f..2bc2d54267e 100644
--- a/dev/breeze/tests/test_gh_workflow_utils.py
+++ b/dev/breeze/tests/test_gh_workflow_utils.py
@@ -16,9 +16,19 @@
 # under the License.
 from __future__ import annotations
 
+import contextlib
+import subprocess
 from unittest import mock
 
-from airflow_breeze.utils.gh_workflow_utils import trigger_workflow_and_monitor
+import pytest
+
+from airflow_breeze.utils.gh_workflow_utils import (
+    NEW_RUN_TIMEOUT_SECONDS,
+    get_latest_workflow_run_id,
+    monitor_workflow_run,
+    trigger_workflow_and_monitor,
+    wait_for_new_workflow_run,
+)
 from airflow_breeze.utils.shared_options import set_dry_run
 
 
@@ -38,3 +48,87 @@ def 
test_trigger_workflow_and_monitor_stops_after_the_dispatch_in_dry_run(_, moc
         set_dry_run(False)
 
     mock_monitor.assert_not_called()
+
+
[email protected]("airflow_breeze.utils.gh_workflow_utils.run_gh_command")
[email protected]("airflow_breeze.utils.gh_workflow_utils.make_sure_gh_is_installed")
+def test_get_latest_workflow_run_id_only_looks_at_dispatched_runs(_, 
mock_run_gh_command):
+    """Push and pull_request runs of the same workflow must not be mistaken 
for the dispatched one."""
+    mock_run_gh_command.return_value = subprocess.CompletedProcess(
+        args=[], returncode=0, stdout='[{"databaseId": 123}]', stderr=""
+    )
+
+    assert get_latest_workflow_run_id("build.yml", "apache/airflow-site") == 
123
+
+    command = mock_run_gh_command.call_args.args[0]
+    assert command[command.index("--event") + 1] == "workflow_dispatch"
+
+
[email protected](
+    ("exit_on_error", "expectation"),
+    [(True, pytest.raises(SystemExit)), (False, contextlib.nullcontext())],
+)
[email protected]("airflow_breeze.utils.gh_workflow_utils.run_gh_command")
[email protected]("airflow_breeze.utils.gh_workflow_utils.make_sure_gh_is_installed")
+def 
test_get_latest_workflow_run_id_lets_a_polling_caller_survive_a_failed_lookup(
+    _, mock_run_gh_command, exit_on_error, expectation
+):
+    mock_run_gh_command.return_value = subprocess.CompletedProcess(
+        args=[], returncode=1, stdout="", stderr="could not connect to 
api.github.com"
+    )
+
+    with expectation:
+        assert get_latest_workflow_run_id("build.yml", "apache/airflow", 
exit_on_error=exit_on_error) is None
+
+
[email protected]("airflow_breeze.utils.gh_workflow_utils.time.sleep")
[email protected]("airflow_breeze.utils.gh_workflow_utils.get_latest_workflow_run_id")
+def test_wait_for_new_workflow_run_retries_after_a_failed_lookup(mock_latest, 
_):
+    """A transient `gh` failure while polling must not abort the whole 
publishing chain."""
+    mock_latest.side_effect = [None, 222]
+
+    assert wait_for_new_workflow_run("build.yml", "apache/airflow-site", 
previous_run_id=111) == 222
+    assert all(call.kwargs["exit_on_error"] is False for call in 
mock_latest.call_args_list)
+
+
[email protected]("airflow_breeze.utils.gh_workflow_utils.time.sleep")
[email protected]("airflow_breeze.utils.gh_workflow_utils.get_latest_workflow_run_id")
+def 
test_wait_for_new_workflow_run_ignores_the_run_that_predates_the_dispatch(mock_latest,
 _):
+    mock_latest.side_effect = [111, 111, 222]
+
+    assert wait_for_new_workflow_run("publish-docs-to-s3.yml", 
"apache/airflow", previous_run_id=111) == 222
+
+
[email protected]("airflow_breeze.utils.gh_workflow_utils.time.sleep")
[email protected]("airflow_breeze.utils.gh_workflow_utils.get_latest_workflow_run_id")
+def test_wait_for_new_workflow_run_accepts_the_first_ever_run(mock_latest, _):
+    mock_latest.return_value = 42
+
+    assert wait_for_new_workflow_run("build.yml", "apache/airflow-site", 
previous_run_id=None) == 42
+
+
[email protected]("airflow_breeze.utils.gh_workflow_utils.time.monotonic")
[email protected]("airflow_breeze.utils.gh_workflow_utils.time.sleep")
[email protected]("airflow_breeze.utils.gh_workflow_utils.get_latest_workflow_run_id")
+def 
test_wait_for_new_workflow_run_gives_up_when_no_new_run_appears(mock_latest, _, 
mock_monotonic):
+    mock_latest.return_value = 111
+    mock_monotonic.side_effect = [0, 0, NEW_RUN_TIMEOUT_SECONDS]
+
+    with pytest.raises(SystemExit) as exc_info:
+        wait_for_new_workflow_run("publish-docs-to-s3.yml", "apache/airflow", 
previous_run_id=111)
+
+    assert exc_info.value.code == 1
+
+
[email protected]("conclusion", ["failure", "cancelled", "timed_out", 
"action_required"])
[email protected]("airflow_breeze.utils.gh_workflow_utils.get_workflow_run_info")
+def test_monitor_workflow_run_fails_on_any_unsuccessful_conclusion(mock_info, 
conclusion):
+    mock_info.side_effect = [
+        {"jobs": []},
+        {"status": "completed", "conclusion": conclusion, "name": "Publish 
Docs to S3"},
+    ]
+
+    with pytest.raises(SystemExit) as exc_info:
+        monitor_workflow_run(run_id="123", repo="apache/airflow")
+
+    assert exc_info.value.code == 1

Reply via email to