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

potiuk pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-3-test by this push:
     new 001d611a2f7 [v3-3-test] Skip CI duration alerts when the newest run is 
stale (#73831) (#73834)
001d611a2f7 is described below

commit 001d611a2f7a4f8f65586f7685e873f4cb622588
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Mon Sep 28 12:25:18 2026 +0200

    [v3-3-test] Skip CI duration alerts when the newest run is stale (#73831) 
(#73834)
    
    On 2026-09-28 the duration monitor posted a Static checks regression whose
    "latest" run was a five-week-old scheduled run, although the same runs query
    made afterwards returns runs from two days earlier. The Actions API was
    degraded at the time, so it most likely returned an old page of runs, and
    nothing checked that the runs treated as "latest" are actually recent.
    (cherry picked from commit f242c8e05132165daa2df55f09282644b2907e2e)
    
    
    Generated-by: Claude Opus 5
    
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 scripts/ci/analyze_ci_job_durations.py            | 24 +++++++++++-
 scripts/tests/ci/test_analyze_ci_job_durations.py | 46 +++++++++++++++++++++++
 2 files changed, 69 insertions(+), 1 deletion(-)

diff --git a/scripts/ci/analyze_ci_job_durations.py 
b/scripts/ci/analyze_ci_job_durations.py
index 202dfb449e1..9ac2ca59003 100644
--- a/scripts/ci/analyze_ci_job_durations.py
+++ b/scripts/ci/analyze_ci_job_durations.py
@@ -59,6 +59,8 @@ Environment variables (optional):
   JOB_MIN_ABS_INCREASE_MINUTES - Absolute floor for per-job alerts (default: 3)
   IMAGE_BUILD_PERSISTENCE_DAYS - Only report a slow image build once it has 
stayed
                               elevated for at least this many days (default: 2)
+  MAX_LATEST_RUN_AGE_DAYS   - Skip the analysis when the newest usable run is 
older than
+                              this many days (default: 3)
   ANALYZE_JOBS              - Whether to fetch per-job durations 
("true"/"false", default: true)
   ONLY_SUCCESSFUL           - Only consider runs that concluded "success" 
(default: true)
   SLACK_CHANNEL             - Slack channel for the message payload (default: 
internal-airflow-ci-cd)
@@ -73,7 +75,7 @@ import json
 import os
 import subprocess
 import sys
-from datetime import datetime
+from datetime import datetime, timezone
 from pathlib import Path
 from typing import TypedDict
 
@@ -167,6 +169,14 @@ def parse_iso(timestamp: str | None) -> datetime | None:
         return None
 
 
+def compute_run_age_days(run: dict, now: datetime) -> float | None:
+    """Return how many days before *now* the run was created, or None if 
unparsable."""
+    created = parse_iso(run.get("created_at"))
+    if created is None:
+        return None
+    return (now - created).total_seconds() / 86400
+
+
 def duration_seconds(start: str | None, end: str | None) -> float | None:
     """Return the number of seconds between two ISO timestamps, or None if 
unparsable."""
     start_dt = parse_iso(start)
@@ -702,6 +712,7 @@ def main() -> None:
     min_abs_increase_seconds = env_float("MIN_ABS_INCREASE_MINUTES", 5.0) * 60
     job_min_abs_increase_seconds = env_float("JOB_MIN_ABS_INCREASE_MINUTES", 
3.0) * 60
     image_build_persistence_days = env_float("IMAGE_BUILD_PERSISTENCE_DAYS", 
2.0)
+    max_latest_run_age_days = env_float("MAX_LATEST_RUN_AGE_DAYS", 3.0)
     do_analyze_jobs = env_bool("ANALYZE_JOBS", True)
     only_successful = env_bool("ONLY_SUCCESSFUL", True)
     channel = os.environ.get("SLACK_CHANNEL", "internal-airflow-ci-cd")
@@ -712,6 +723,17 @@ def main() -> None:
     print(f"Window: up to {max_runs} completed runs; latest 
{latest_runs_count} vs baseline.")
 
     runs = get_recent_runs(repo, workflow, branch, max_runs, only_successful, 
event)
+    # A degraded Actions API can return an old page of runs, and without this 
check its
+    # weeks-old durations were reported as "latest" and raised a false alert.
+    newest_age_days = compute_run_age_days(runs[0], 
datetime.now(timezone.utc)) if runs else None
+    if newest_age_days is not None and newest_age_days > 
max_latest_run_age_days:
+        print(
+            f"::warning::The newest usable run (#{runs[0]['run_number']}) is "
+            f"{newest_age_days:.1f} days old, over 
MAX_LATEST_RUN_AGE_DAYS={max_latest_run_age_days:g}: "
+            f"the run listing is stale or {branch} has had no usable run 
since. Skipping."
+        )
+        _write_outputs(False, False, 0, False)
+        sys.exit(0)
     if len(runs) < latest_runs_count + min_baseline_runs:
         print(
             f"Not enough runs to establish a trend "
diff --git a/scripts/tests/ci/test_analyze_ci_job_durations.py 
b/scripts/tests/ci/test_analyze_ci_job_durations.py
index be5b3cbc2e1..c6bfdd23ecd 100644
--- a/scripts/tests/ci/test_analyze_ci_job_durations.py
+++ b/scripts/tests/ci/test_analyze_ci_job_durations.py
@@ -659,3 +659,49 @@ class TestFormatSlackMessage:
             channel="internal-airflow-ci-cd",
         )
         assert "image build slow" not in json.dumps(msg).lower()
+
+
+class TestComputeRunAgeDays:
+    @pytest.mark.parametrize(
+        ("created_at", "expected"),
+        [
+            pytest.param("2026-09-28T09:00:00Z", 0.0, id="same-moment"),
+            pytest.param("2026-09-26T21:00:00Z", 1.5, id="fractional-days"),
+            pytest.param("2026-08-21T09:00:00Z", 38.0, id="weeks-old"),
+            pytest.param("", None, id="missing"),
+            pytest.param("not-a-date", None, id="unparsable"),
+        ],
+    )
+    def test_age(self, durations_module, created_at, expected):
+        now = durations_module.parse_iso("2026-09-28T09:00:00Z")
+        assert durations_module.compute_run_age_days({"created_at": 
created_at}, now) == expected
+
+
+class TestMainSkipsStaleRuns:
+    def test_skips_analysis_when_newest_run_is_stale(self, durations_module, 
tmp_path, monkeypatch):
+        stale_runs = [
+            {
+                "id": run_id,
+                "run_number": run_id,
+                "created_at": "2020-01-01T02:00:00Z",
+                "conclusion": "success",
+                "event": "schedule",
+                "html_url": f"https://example/{run_id}";,
+                "duration": 6000,
+            }
+            for run_id in range(10, 4, -1)
+        ]
+        github_output = tmp_path / "github_output"
+        output_file = tmp_path / "slack-message.json"
+        monkeypatch.setenv("GITHUB_OUTPUT", str(github_output))
+        monkeypatch.setenv("OUTPUT_FILE", str(output_file))
+        with (
+            patch.object(durations_module, "get_recent_runs", autospec=True, 
return_value=stale_runs),
+            patch.object(durations_module, "fetch_run_jobs_map", 
autospec=True) as mock_fetch_jobs,
+            pytest.raises(SystemExit) as exc_info,
+        ):
+            durations_module.main()
+        assert exc_info.value.code == 0
+        mock_fetch_jobs.assert_not_called()
+        assert not output_file.exists()
+        assert "has-regression=false" in github_output.read_text()

Reply via email to