This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-4-test by this push:
new 1409c90ea1a [v3-4-test] Keep cancelled CI runs from showing a failed
CI passed check (#75720) (#75738)
1409c90ea1a is described below
commit 1409c90ea1a17f2177bb0666b79ef84a2de6eb64
Author: Shahar Epstein <[email protected]>
AuthorDate: Tue Oct 27 11:48:01 2026 +0200
[v3-4-test] Keep cancelled CI runs from showing a failed CI passed check
(#75720) (#75738)
* Keep cancelled CI runs from showing a failed CI passed check
The step meant to fail CI passed when a job hit its timeout tested
`!cancelled()`. Inside the `always()` gate job, a step's `cancelled()` is
false even when the run itself was cancelled, because the job starts after
the cancellation. So every duplicate run that concurrency cancelled, and
every run cancelled by hand, showed a failed "CI passed (run cancelled)"
check next to the passing run of the same commit (runs 40442992585 and
40453574496).
Only a job-level `if` sees that the run was cancelled, so the timeout check
moves into a job of its own. A job that hits its timeout while the run goes
on still fails the gate, which the scheduled canary's Slack report relies
on, and the failed check is now the required CI passed instead of
"CI passed (run cancelled)".
* Check the timed-out-jobs needs only when that job exists
Removing check-timed-out-jobs alone already fails the hook, because the
gate still needs it. Requiring the job as well forced every existing test
case to carry it.
(cherry picked from commit 8eb5357fb136263967c06493323ef026018d7bab)
---
.github/workflows/ci-amd.yml | 68 +++++++++++++++++++----
.github/workflows/ci-arm.yml | 67 ++++++++++++++++++----
dev/README_AIRFLOW3_DEV.md | 5 +-
scripts/ci/prek/check_ci_gate_needs.py | 15 +++++
scripts/ci/prek/check_ci_workflows_in_sync.py | 17 ++++--
scripts/tests/ci/prek/test_check_ci_gate_needs.py | 23 ++++++++
6 files changed, 165 insertions(+), 30 deletions(-)
diff --git a/.github/workflows/ci-amd.yml b/.github/workflows/ci-amd.yml
index 4e6de3a5a8d..b79663bbb9a 100644
--- a/.github/workflows/ci-amd.yml
+++ b/.github/workflows/ci-amd.yml
@@ -1501,6 +1501,58 @@ jobs:
use-uv: ${{ needs.build-info.outputs.use-uv }}
debug-resources: ${{ needs.build-info.outputs.debug-resources }}
+ check-timed-out-jobs:
+ # A job-level `if` is the only place `cancelled()` tells a cancelled run
from a job cancelled by
+ # its timeout: the steps of `ci-passed` start after the cancellation. Its
`needs` must match
+ # those of `ci-passed`, so a timeout in any job fails the gate.
+ name: "Check for timed-out CI jobs"
+ if: always() && !cancelled() && contains(needs.*.result, 'cancelled')
+ needs:
+ - additional-ci-image-checks
+ - additional-prod-image-tests
+ - basic-tests
+ - build-ci-images
+ - build-info
+ - build-java-sdk-docs
+ - build-prod-images
+ - build-ts-sdk-docs
+ - check-run-needed
+ - ci-image-checks
+ - finalize-tests
+ - gate
+ - generate-constraints
+ - migration-round-trip
+ - mypy-providers
+ - print-platform
+ - providers
+ - tests-agent-frameworks
+ - tests-airflow-ctl
+ - tests-go-sdk
+ - tests-helm
+ - tests-integration-system
+ - tests-java-sdk
+ - tests-kubernetes
+ - tests-kustomize-overlays
+ - tests-mysql-core
+ - tests-mysql-providers
+ - tests-non-db-core
+ - tests-non-db-providers
+ - tests-postgres-core
+ - tests-postgres-providers
+ - tests-special
+ - tests-sqlite-core
+ - tests-sqlite-providers
+ - tests-task-sdk
+ - tests-ts-sdk
+ - tests-with-lowest-direct-resolution-core
+ - tests-with-lowest-direct-resolution-providers
+ runs-on: ["ubuntu-slim"]
+ steps:
+ - name: "Fail because a CI job was cancelled without the run being
cancelled"
+ run: |
+ echo "A CI job was cancelled, likely by its timeout, while the run
was not cancelled."
+ exit 1
+
ci-passed:
# A skipped build-info means project CI did not run at all: the fork CI
gate (AIP-120) left the
# pull request to CI in the contributor's fork, and every job here is
skipped. Reporting
@@ -1511,8 +1563,8 @@ jobs:
# 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.
+ # A job cancelled by its timeout, while the run is not, makes
`check-timed-out-jobs` fail, and
+ # so this job fails.
name: >-
${{ (!contains(needs.*.result, 'failure') && contains(needs.*.result,
'cancelled') &&
'CI passed (run cancelled)') ||
@@ -1531,6 +1583,7 @@ jobs:
- build-prod-images
- build-ts-sdk-docs
- check-run-needed
+ - check-timed-out-jobs
- ci-image-checks
- finalize-tests
- gate
@@ -1567,15 +1620,8 @@ jobs:
run: |
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()
+ if: contains(needs.*.result, '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}"
@@ -1588,7 +1634,7 @@ jobs:
needs:
- build-info
- check-run-needed
- # Waits for the gate job, which is the only failed job when a CI job
timed out.
+ # Waits for the gate job, which also fails when a CI job timed out.
- ci-passed
- finalize-tests
- tests-agent-frameworks
diff --git a/.github/workflows/ci-arm.yml b/.github/workflows/ci-arm.yml
index 47c59be2367..9ce7669b669 100644
--- a/.github/workflows/ci-arm.yml
+++ b/.github/workflows/ci-arm.yml
@@ -1384,6 +1384,57 @@ jobs:
use-uv: ${{ needs.build-info.outputs.use-uv }}
debug-resources: ${{ needs.build-info.outputs.debug-resources }}
+ check-timed-out-jobs:
+ # A job-level `if` is the only place `cancelled()` tells a cancelled run
from a job cancelled by
+ # its timeout: the steps of `ci-passed` start after the cancellation. Its
`needs` must match
+ # those of `ci-passed`, so a timeout in any job fails the gate.
+ name: "Check for timed-out CI jobs"
+ if: always() && !cancelled() && contains(needs.*.result, 'cancelled')
+ needs:
+ - additional-ci-image-checks
+ - additional-prod-image-tests
+ - basic-tests
+ - build-ci-images
+ - build-info
+ - build-java-sdk-docs
+ - build-prod-images
+ - build-ts-sdk-docs
+ - check-run-needed
+ - ci-image-checks
+ - finalize-tests
+ - generate-constraints
+ - migration-round-trip
+ - mypy-providers
+ - print-platform
+ - providers
+ - tests-agent-frameworks
+ - tests-airflow-ctl
+ - tests-go-sdk
+ - tests-helm
+ - tests-integration-system
+ - tests-java-sdk
+ - tests-kubernetes
+ - tests-kustomize-overlays
+ - tests-mysql-core
+ - tests-mysql-providers
+ - tests-non-db-core
+ - tests-non-db-providers
+ - tests-postgres-core
+ - tests-postgres-providers
+ - tests-special
+ - tests-sqlite-core
+ - tests-sqlite-providers
+ - tests-task-sdk
+ - tests-ts-sdk
+ - tests-with-lowest-direct-resolution-core
+ - tests-with-lowest-direct-resolution-providers
+ runs-on: ["ubuntu-slim"]
+ steps:
+ - name: "Fail because a CI job was cancelled without the run being
cancelled"
+ run: |
+ echo "A CI job was cancelled, likely by its timeout, while the run
was not cancelled."
+ exit 1
+
ci-passed:
# A skipped build-info means project CI did not run at all: the fork CI
gate (AIP-120) left the
# pull request to CI in the contributor's fork, and every job here is
skipped. Reporting
@@ -1394,8 +1445,8 @@ jobs:
# 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.
+ # A job cancelled by its timeout, while the run is not, makes
`check-timed-out-jobs` fail, and
+ # so this job fails.
name: >-
${{ (!contains(needs.*.result, 'failure') && contains(needs.*.result,
'cancelled') &&
'CI passed (run cancelled)') ||
@@ -1414,6 +1465,7 @@ jobs:
- build-prod-images
- build-ts-sdk-docs
- check-run-needed
+ - check-timed-out-jobs
- ci-image-checks
- finalize-tests
- generate-constraints
@@ -1449,15 +1501,8 @@ jobs:
run: |
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()
+ if: contains(needs.*.result, '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}"
@@ -1470,7 +1515,7 @@ jobs:
needs:
- build-info
- check-run-needed
- # Waits for the gate job, which is the only failed job when a CI job
timed out.
+ # Waits for the gate job, which also fails when a CI job timed out.
- ci-passed
- finalize-tests
- tests-agent-frameworks
diff --git a/dev/README_AIRFLOW3_DEV.md b/dev/README_AIRFLOW3_DEV.md
index c13904dbbec..faec7d728b7 100644
--- a/dev/README_AIRFLOW3_DEV.md
+++ b/dev/README_AIRFLOW3_DEV.md
@@ -201,8 +201,9 @@ run only part of the tests still get a result. A run with
cancelled jobs and no
**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
+hits its timeout is cancelled while the run is not, so the `Check for
timed-out CI jobs` job fails, and
+that run reports a failed **CI passed**. 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/scripts/ci/prek/check_ci_gate_needs.py
b/scripts/ci/prek/check_ci_gate_needs.py
index 63e4207cc1e..810e52295f6 100755
--- a/scripts/ci/prek/check_ci_gate_needs.py
+++ b/scripts/ci/prek/check_ci_gate_needs.py
@@ -26,6 +26,9 @@
``CI passed`` is the check main's ruleset requires, and auto-merge waits for
it. It only fails
when a job it needs failed, so a job missing from its ``needs`` could fail and
the PR would still
auto-merge. Reporting-only jobs that cannot make a PR wrong are exempt.
+
+``Check for timed-out CI jobs`` fails the gate when a job hit its timeout. It
must need the same jobs
+as the gate, or a timeout in a job it misses would pass.
"""
from __future__ import annotations
@@ -38,6 +41,7 @@ import yaml
AIRFLOW_ROOT = Path(__file__).parents[3]
CI_WORKFLOW = AIRFLOW_ROOT / ".github/workflows/ci-amd.yml"
GATE_JOB = "ci-passed"
+TIMEOUT_JOB = "check-timed-out-jobs"
EXEMPT_JOBS = {"notify-slack", "summarize-warnings"}
@@ -47,6 +51,8 @@ def find_problems(jobs: dict) -> list[str]:
needs = jobs[GATE_JOB].get("needs") or []
needs = {needs} if isinstance(needs, str) else set(needs)
expected = set(jobs) - EXEMPT_JOBS - {GATE_JOB}
+ timeout_needs = jobs.get(TIMEOUT_JOB, {}).get("needs") or []
+ timeout_needs = {timeout_needs} if isinstance(timeout_needs, str) else
set(timeout_needs)
problems = [
f"job {job!r} is not in {GATE_JOB}.needs, so its failure would not
stop auto-merge"
for job in sorted(expected - needs)
@@ -55,6 +61,15 @@ def find_problems(jobs: dict) -> list[str]:
problems += [
f"{GATE_JOB}.needs lists exempt reporting job {job!r}" for job in
sorted(needs & EXEMPT_JOBS)
]
+ if TIMEOUT_JOB in jobs:
+ problems += [
+ f"{TIMEOUT_JOB}.needs is missing {job!r}, which {GATE_JOB} needs"
+ for job in sorted(needs - {TIMEOUT_JOB} - timeout_needs)
+ ]
+ problems += [
+ f"{TIMEOUT_JOB}.needs lists {job!r}, which {GATE_JOB} does not
need"
+ for job in sorted(timeout_needs - needs)
+ ]
return problems
diff --git a/scripts/ci/prek/check_ci_workflows_in_sync.py
b/scripts/ci/prek/check_ci_workflows_in_sync.py
index edee96e3a9b..047c3f0dd7c 100755
--- a/scripts/ci/prek/check_ci_workflows_in_sync.py
+++ b/scripts/ci/prek/check_ci_workflows_in_sync.py
@@ -48,9 +48,10 @@ Documented divergences:
``slack_notification_state.py`` keeps independent state for each
platform on the same branch
9. ``gate`` job, the ``needs: [gate]`` / ``if:`` lines of ``build-info``
- and ``gate`` in the needs of ``ci-passed`` — AMD only. The fork CI gate
(AIP-120) decides whether project CI runs
- for pull requests; ARM has no pull_request or push trigger, and the
- ``check-run-needed`` step both share skips a fork's scheduled run.
+ and ``gate`` in the needs of ``ci-passed`` and ``check-timed-out-jobs``
+ — AMD only. The fork CI gate (AIP-120) decides whether project CI
+ runs for pull requests; ARM has no pull_request or push trigger, and
+ the ``check-run-needed`` step both share skips a fork's scheduled run.
Anything else differing between the two files is a drift bug. To
intentionally introduce a new divergence, update the rules in this script
@@ -258,7 +259,8 @@ AMD_ONLY_GATE_BLOCK = """ gate:
needs.gate.outputs.project-ci == 'true' &&
needs.check-run-needed.outputs.run-needed == 'true'
"""
-# The gate is also one of the jobs the "CI passed" gate job of the merge
ruleset needs.
+# The gate is also one of the jobs the "CI passed" gate job of the merge
ruleset needs, and so is
+# in the needs of `check-timed-out-jobs`, which must match them.
AMD_ONLY_CI_PASSED_GATE_NEED = """ - finalize-tests
- gate
- generate-constraints
@@ -334,8 +336,11 @@ def normalize(content: str, *, side: str) -> str:
raise SystemExit("ci-amd.yml contains the ARM-only trigger block —
should not.")
if AMD_ONLY_GATE_BLOCK not in content:
raise SystemExit(f"ci-amd.yml is missing its expected fork CI gate
block:\n{AMD_ONLY_GATE_BLOCK}")
- if content.count(AMD_ONLY_CI_PASSED_GATE_NEED) != 1:
- raise SystemExit("ci-amd.yml must list `gate` once in the needs of
the CI passed job.")
+ if content.count(AMD_ONLY_CI_PASSED_GATE_NEED) != 2:
+ raise SystemExit(
+ "ci-amd.yml must list `gate` once in the needs of the CI
passed job"
+ " and once in those of check-timed-out-jobs."
+ )
content = (
content.replace(AMD_ONLY_BLOCK, "")
.replace(AMD_ONLY_GATE_BLOCK, AMD_ONLY_GATE_REPLACEMENT)
diff --git a/scripts/tests/ci/prek/test_check_ci_gate_needs.py
b/scripts/tests/ci/prek/test_check_ci_gate_needs.py
index 77248def0ab..d1e8f18acf9 100644
--- a/scripts/tests/ci/prek/test_check_ci_gate_needs.py
+++ b/scripts/tests/ci/prek/test_check_ci_gate_needs.py
@@ -54,6 +54,29 @@ def test_real_workflow_gate_needs_every_job():
["ci-passed.needs lists exempt reporting job
'summarize-warnings'"],
id="exempt-job-listed",
),
+ pytest.param(
+ {
+ "build": {},
+ "test": {},
+ "check-timed-out-jobs": {"needs": ["build"]},
+ "ci-passed": {"needs": ["build", "test",
"check-timed-out-jobs"]},
+ },
+ ["check-timed-out-jobs.needs is missing 'test', which ci-passed
needs"],
+ id="timeout-check-missing-job",
+ ),
+ pytest.param(
+ {
+ "build": {},
+ "test": {},
+ "check-timed-out-jobs": {"needs": ["build", "test"]},
+ "ci-passed": {"needs": ["build", "check-timed-out-jobs"]},
+ },
+ [
+ "job 'test' is not in ci-passed.needs, so its failure would
not stop auto-merge",
+ "check-timed-out-jobs.needs lists 'test', which ci-passed does
not need",
+ ],
+ id="timeout-check-extra-job",
+ ),
],
)
def test_find_problems(jobs, expected):