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 1b9a32b5283 Do not fail CI passed for a cancelled duplicate CI run 
(#75578)
1b9a32b5283 is described below

commit 1b9a32b528300a545ca71daee3d161da1468347c
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sat Oct 24 23:43:32 2026 +0200

    Do not fail CI passed for a cancelled duplicate CI run (#75578)
    
    * Do not fail CI passed for a cancelled duplicate CI run
    
    A run that concurrency cancels still runs the always() CI passed job, and
    that job failed the required check. When a stacked pull request's base and
    head are force-pushed together, GitHub sends two synchronize events for the
    same commit, so one run is cancelled seconds in and a failed CI passed sits
    next to the passing one of its twin. A cancelled run now reports under a
    name the rulesets do not require, so nothing is red, while a run that was
    only cancelled still never satisfies CI passed and the merge stays blocked.
    
    Generated-by: Claude Opus 5
    
    * Accept the cancelled-run CI passed name in the fork CI tampering check
    
    A cancelled ci-amd.yml run now reports its CI passed job as "CI passed (run
    cancelled)". Without the name in the gated-run jobs, a run of a gated pull
    request that a newer push cancelled would be taken for project CI work and
    close the pull request as tampering with the gate.
    
    Generated-by: Claude Opus 5
    
    * Keep failing CI passed when a CI job times out
    
    A job that hits its timeout is cancelled while the run is not, so the
    cancelled-run rename alone would turn a hung job into a passing check
    and drop it from the scheduled canary's Slack report.
    
    Generated-by: Claude Opus 5
    
    * Make the Slack canary report wait for the CI passed gate job
    
    Generated-by: Claude Opus 5
---
 .github/workflows/ci-amd.yml                       | 34 ++++++++++++++++++----
 .github/workflows/ci-arm.yml                       | 34 ++++++++++++++++++----
 dev/README_AIRFLOW3_DEV.md                         | 10 +++++--
 .../src/airflow_breeze/utils/fork_ci_reconcile.py  |  9 +++---
 dev/breeze/tests/test_fork_ci_reconcile.py         | 10 +++++++
 5 files changed, 80 insertions(+), 17 deletions(-)

diff --git a/.github/workflows/ci-amd.yml b/.github/workflows/ci-amd.yml
index 2f7fde8dffd..92f1903a1b7 100644
--- a/.github/workflows/ci-amd.yml
+++ b/.github/workflows/ci-amd.yml
@@ -1461,11 +1461,20 @@ jobs:
     # pull request to CI in the contributor's fork, and every job here is 
skipped. Reporting
     # "CI passed" then would let it merge on no CI, so the job reports under 
another name and
     # the fork CI reconciler posts "CI passed" from the fork run instead.
+    # A run with cancelled jobs and no failed one -- usually a duplicate run 
of the same commit that
+    # concurrency cancelled, such as the two `synchronize` events GitHub sends 
when a stacked pull
+    # request's base and head are force-pushed together -- reports under 
another name too, so it
+    # never puts a failed "CI passed" next to the passing one of its twin. "CI 
passed" is then
+    # missing for that run, which keeps the merge blocked until another run of 
the commit reports it.
+    # A job cancelled by its timeout gets that name too, since `name` cannot 
test whether the run
+    # itself was cancelled, but the job still fails.
     name: >-
-      ${{ needs.build-info.result == 'skipped' && 'CI passed (project CI not 
run)' || 'CI passed' }}
+      ${{ (!contains(needs.*.result, 'failure') && contains(needs.*.result, 
'cancelled') &&
+      'CI passed (run cancelled)') ||
+      (needs.build-info.result == 'skipped' && 'CI passed (project CI not 
run)') || 'CI passed' }}
     # The one check main's ruleset requires, so auto-merge waits for CI to 
finish. GitHub treats a
     # skipped required check as passed, so this job always runs and fails 
itself when any job it
-    # needs failed or was cancelled. Jobs skipped by selective checks count as 
passed.
+    # needs failed or timed out. Jobs skipped by selective checks count as 
passed.
     if: always()
     needs:
       - additional-ci-image-checks
@@ -1508,12 +1517,25 @@ jobs:
       - tests-with-lowest-direct-resolution-providers
     runs-on: ["ubuntu-slim"]
     steps:
-      - name: "Fail if any CI job failed or was cancelled"
-        if: contains(needs.*.result, 'failure') || contains(needs.*.result, 
'cancelled')
+      - name: "Fail if any CI job failed"
+        if: contains(needs.*.result, 'failure')
         run: |
-          echo "At least one CI job failed or was cancelled."
+          echo "At least one CI job failed."
           exit 1
+      # A job that hits its timeout is cancelled while the run is not, and 
must fail this check, as
+      # the scheduled canary's Slack report lists only failed jobs.
+      - name: "Fail if a CI job was cancelled without the run being cancelled"
+        if: contains(needs.*.result, 'cancelled') && !cancelled()
+        run: |
+          echo "A CI job was cancelled, likely by its timeout, while the run 
was not cancelled."
+          exit 1
+      - name: "Report that CI jobs were cancelled"
+        if: contains(needs.*.result, 'cancelled') && cancelled()
+        run: |
+          echo "At least one CI job was cancelled, so this run does not report 
CI passed."
+          echo 'CI jobs were cancelled: this run does not report `CI passed`.' 
>> "${GITHUB_STEP_SUMMARY}"
       - name: "All CI jobs passed or were skipped"
+        if: "!contains(needs.*.result, 'cancelled')"
         run: echo "All CI jobs passed or were skipped."
 
   notify-slack:
@@ -1521,6 +1543,8 @@ jobs:
     needs:
       - build-info
       - check-run-needed
+      # Waits for the gate job, which is the only failed job when a CI job 
timed out.
+      - ci-passed
       - finalize-tests
       - tests-agent-frameworks
     # A no-op canary (`run-needed` is 'false') does not post; a failed check 
does. A failing gate
diff --git a/.github/workflows/ci-arm.yml b/.github/workflows/ci-arm.yml
index 5eab899803e..180182647a6 100644
--- a/.github/workflows/ci-arm.yml
+++ b/.github/workflows/ci-arm.yml
@@ -1343,11 +1343,20 @@ jobs:
     # pull request to CI in the contributor's fork, and every job here is 
skipped. Reporting
     # "CI passed" then would let it merge on no CI, so the job reports under 
another name and
     # the fork CI reconciler posts "CI passed" from the fork run instead.
+    # A run with cancelled jobs and no failed one -- usually a duplicate run 
of the same commit that
+    # concurrency cancelled, such as the two `synchronize` events GitHub sends 
when a stacked pull
+    # request's base and head are force-pushed together -- reports under 
another name too, so it
+    # never puts a failed "CI passed" next to the passing one of its twin. "CI 
passed" is then
+    # missing for that run, which keeps the merge blocked until another run of 
the commit reports it.
+    # A job cancelled by its timeout gets that name too, since `name` cannot 
test whether the run
+    # itself was cancelled, but the job still fails.
     name: >-
-      ${{ needs.build-info.result == 'skipped' && 'CI passed (project CI not 
run)' || 'CI passed' }}
+      ${{ (!contains(needs.*.result, 'failure') && contains(needs.*.result, 
'cancelled') &&
+      'CI passed (run cancelled)') ||
+      (needs.build-info.result == 'skipped' && 'CI passed (project CI not 
run)') || 'CI passed' }}
     # The one check main's ruleset requires, so auto-merge waits for CI to 
finish. GitHub treats a
     # skipped required check as passed, so this job always runs and fails 
itself when any job it
-    # needs failed or was cancelled. Jobs skipped by selective checks count as 
passed.
+    # needs failed or timed out. Jobs skipped by selective checks count as 
passed.
     if: always()
     needs:
       - additional-ci-image-checks
@@ -1389,12 +1398,25 @@ jobs:
       - tests-with-lowest-direct-resolution-providers
     runs-on: ["ubuntu-slim"]
     steps:
-      - name: "Fail if any CI job failed or was cancelled"
-        if: contains(needs.*.result, 'failure') || contains(needs.*.result, 
'cancelled')
+      - name: "Fail if any CI job failed"
+        if: contains(needs.*.result, 'failure')
         run: |
-          echo "At least one CI job failed or was cancelled."
+          echo "At least one CI job failed."
           exit 1
+      # A job that hits its timeout is cancelled while the run is not, and 
must fail this check, as
+      # the scheduled canary's Slack report lists only failed jobs.
+      - name: "Fail if a CI job was cancelled without the run being cancelled"
+        if: contains(needs.*.result, 'cancelled') && !cancelled()
+        run: |
+          echo "A CI job was cancelled, likely by its timeout, while the run 
was not cancelled."
+          exit 1
+      - name: "Report that CI jobs were cancelled"
+        if: contains(needs.*.result, 'cancelled') && cancelled()
+        run: |
+          echo "At least one CI job was cancelled, so this run does not report 
CI passed."
+          echo 'CI jobs were cancelled: this run does not report `CI passed`.' 
>> "${GITHUB_STEP_SUMMARY}"
       - name: "All CI jobs passed or were skipped"
+        if: "!contains(needs.*.result, 'cancelled')"
         run: echo "All CI jobs passed or were skipped."
 
   notify-slack:
@@ -1402,6 +1424,8 @@ jobs:
     needs:
       - build-info
       - check-run-needed
+      # Waits for the gate job, which is the only failed job when a CI job 
timed out.
+      - ci-passed
       - finalize-tests
       - tests-agent-frameworks
     # A no-op canary (`run-needed` is 'false') does not post; a failed check 
does. A failing gate
diff --git a/dev/README_AIRFLOW3_DEV.md b/dev/README_AIRFLOW3_DEV.md
index 51216378097..c094c9dc2fb 100644
--- a/dev/README_AIRFLOW3_DEV.md
+++ b/dev/README_AIRFLOW3_DEV.md
@@ -196,9 +196,13 @@ Auto-merge is enabled for the whole repository, so every 
branch that PRs target
 then, its PRs never report the check, and a committer merges them by bypassing 
the rule.
 
 The `main` ruleset requires two checks. **CI passed** is a job in the `Tests 
(AMD)` workflow that always
-runs and fails when any other CI job failed or was cancelled; jobs that 
selective checks skip count as
-passed, so PRs that run only part of the tests still get a result. The 
`check-ci-gate-needs` prek hook
-makes sure every new CI job is added to it. `Tests (ARM)`, which runs on 
pushes and on a schedule rather
+runs and fails when any other CI job failed; jobs that selective checks skip 
count as passed, so PRs that
+run only part of the tests still get a result. A run with cancelled jobs and 
no failed one reports as
+**CI passed (run cancelled)** instead, so a duplicate run of the same commit 
that concurrency cancelled
+does not show a failed **CI passed** next to its twin's result. Such a run 
does not report **CI passed**
+at all, so the PR stays blocked until another run of the commit, or a re-run, 
reports it. A job that
+hits its timeout is cancelled while the run is not, so that run's check fails, 
under the same name. The
+`check-ci-gate-needs` prek hook makes sure every new CI job is added to it. 
`Tests (ARM)`, which runs on pushes and on a schedule rather
 than on PRs, has the same job, kept identical by the 
`check-ci-workflows-in-sync` hook. For a PR that the
 fork CI gate (AIP-120) leaves to CI in the contributor's fork, project CI does 
not run, so the job reports
 as **CI passed (project CI not run)** instead, and the fork CI reconciler 
posts **CI passed** once 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 35189f1a8f4..3c4eeafb70d 100644
--- a/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
+++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
@@ -79,12 +79,13 @@ GATE_JOB_NAME = "Fork CI gate"
 # Jobs that also run, without needing the gate, in a run the gate left to fork 
CI: main's
 # check-run-needed (no steps outside schedule and push runs) and the "CI 
passed" gate job of the
 # merge ruleset, which reports as "CI passed (project CI not run)" when 
project CI was skipped and
-# as "CI passed" when a newer push cancelled the run before build-info 
started. Like the gate, each
-# must finish within GATE_MAX_DURATION, so project CI work moved under one of 
these names counts.
-# Each runs once, so the names of one job are grouped: repeating short jobs 
under them counts too.
+# as "CI passed (run cancelled)" when a newer push cancelled the run before 
build-info started --
+# "CI passed" in a workflow from before that name. Like the gate, each must 
finish within
+# GATE_MAX_DURATION, so project CI work moved under one of these names counts. 
Each runs once, so
+# the names of one job are grouped: repeating short jobs under them counts too.
 GATED_RUN_JOBS = (
     frozenset({"Check if the run is needed"}),
-    frozenset({"CI passed (project CI not run)", "CI passed"}),
+    frozenset({"CI passed (project CI not run)", "CI passed (run cancelled)", 
"CI passed"}),
 )
 GATED_RUN_JOB_NAMES = frozenset[str]().union(*GATED_RUN_JOBS)
 BOT_LOGINS = frozenset({"github-actions", "github-actions[bot]"})
diff --git a/dev/breeze/tests/test_fork_ci_reconcile.py 
b/dev/breeze/tests/test_fork_ci_reconcile.py
index be4e1dcb15f..3f7d56f7c14 100644
--- a/dev/breeze/tests/test_fork_ci_reconcile.py
+++ b/dev/breeze/tests/test_fork_ci_reconcile.py
@@ -719,6 +719,16 @@ def _job(
             False,
             id="cancelled-before-the-gate-started",
         ),
+        pytest.param(
+            [
+                _job("Check if the run is needed", "cancelled", started=False),
+                _job("Fork CI gate", "cancelled", started=False),
+                _job("Build info", "cancelled", started=False),
+                _job("CI passed (run cancelled)", "success"),
+            ],
+            False,
+            id="cancelled-run-reporting-ci-passed-run-cancelled",
+        ),
         pytest.param(
             [_job("Fork CI gate", "cancelled", minutes=30, started=False)],
             False,

Reply via email to