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 b3403ae3412 [v3-4-test] Block merging on fork CI once a PR is moved to
project CI (#76672) (#76981)
b3403ae3412 is described below
commit b3403ae3412fc94db93bfd750dc4e7478b093cf3
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Thu Nov 12 14:08:54 2026 +0000
[v3-4-test] Block merging on fork CI once a PR is moved to project CI
(#76672) (#76981)
The rulesets require the `CI passed` context from any source, and the Fork
CI
monitor posts it from the fork run. Adding `use project ci` did not withdraw
that success, so a pull request could merge on the fork result before
project
CI ran, as happened on #76536.
The revoke is done by the monitor and not by an event-driven workflow,
because
`pull_request_target` is about to be disabled for this repository. The fork
result therefore stays until the next monitor run that completes; the design
document records that window as a limit.
(cherry picked from commit e17cecaabbb08681dddf3fc015307dbcc656a498)
Co-authored-by: Shahar Epstein <[email protected]>
---
contributing-docs/25_maintainer_pr_triage.md | 8 +-
.../doc/ci/08_fork_ci_for_external_contributors.md | 73 +-
.../src/airflow_breeze/utils/fork_ci_messages.py | 3 +
.../src/airflow_breeze/utils/fork_ci_reconcile.py | 363 +++++++++-
dev/breeze/tests/test_fork_ci_reconcile.py | 763 +++++++++++++++++++++
5 files changed, 1192 insertions(+), 18 deletions(-)
diff --git a/contributing-docs/25_maintainer_pr_triage.md
b/contributing-docs/25_maintainer_pr_triage.md
index 333faefdd1f..8ab303197ca 100644
--- a/contributing-docs/25_maintainer_pr_triage.md
+++ b/contributing-docs/25_maintainer_pr_triage.md
@@ -274,7 +274,13 @@ Everything else the skill does (quality criteria, pings,
suspicious-change flags
usual. The `Fork CI / Tests (AMD)` commit status shows the fork CI state and
links to the run in
the fork. To move a single pull request back to project CI, add `use project
ci`; the reconciler
releases it on its next run; removing `fork ci required` by hand is not
enough, the next run
-labels a gated pull request again. Adding the label does not start project CI
by itself, and the
+labels a gated pull request again. The run that releases the pull request sets
its `CI passed`
+status to `pending` until project CI has passed on the head commit, so the
pull request cannot
+merge on the fork result from then on. Until that run, normally within 15
minutes of the label
+and longer when GitHub delays a scheduled run, it still can: wait for the
`pending` before merging
+or enabling auto-merge, or start the monitor by hand (`gh workflow run
fork-ci-monitor.yml -R
+apache/airflow`). After project CI has passed the status turns `success` on
the next monitor run,
+within about 15 minutes. Adding the label does not start project CI by itself,
and the
reconciler deletes the gated `Tests (AMD)` runs, so there is usually nothing
to re-run: close and
reopen the pull request (`gh pr close <n> -R apache/airflow && gh pr reopen
<n> -R apache/airflow`),
or ask the author to push. Pull requests that change CI-defining files are not
drafted
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 16e3981b9fc..0e1ed2cb926 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
@@ -238,7 +238,9 @@ Consequences for the other components:
reconciler removes `fork ci required`, undrafts it if the bot drafted it,
edits the state
comment to say project CI is in use, and posts a `success` status with the
description
"Project CI in use (label `use project ci`)". Project CI then runs on the
next push, or when
- a maintainer closes and reopens the pull request.
+ a maintainer closes and reopens the pull request. From that run on, and
until project CI has
+ passed on the head commit, the pull request cannot merge: its `CI passed`
status is `pending`
+ (see "The `CI passed` check").
- Both override labels on one pull request resolve to project CI and are
reported to Slack as
a conflict.
@@ -933,12 +935,12 @@ suspicious-change flags) is unaffected.
### The `CI passed` check
-The "Require CI on main" ruleset requires a `CI passed` check from GitHub
Actions before a pull
-request can merge or auto-merge. Project CI's `CI passed` job reports it, but
a gated pull
+The "Require CI on main" ruleset requires a `CI passed` check before a pull
request can merge or
+auto-merge; the required check names no integration, so a check run or a
commit status of that
+name from any source satisfies it. Project CI's `CI passed` job reports it,
but a gated pull
request never runs project CI, so the reconciler reports it instead: when the
state of the head
commit is `green`, it posts a `CI passed` commit status with state `success`,
linking the fork
-run. A commit status written with the monitor's `GITHUB_TOKEN` comes from
GitHub Actions, which
-is the source the ruleset accepts.
+run.
- Only `success` is posted. Without the status a gated pull request cannot
merge, which is what a
pending or failing one would say, and it never competes with project CI's
own check.
@@ -946,9 +948,59 @@ is the source the ruleset accepts.
it is not evidence. Once a maintainer adds `use project ci`, project CI
reports the check.
- When the head commit leaves `green`, for example a re-run in the fork that
fails, a `success`
posted earlier is overwritten with `pending` so the pull request cannot
merge on it.
-- A pull request released with `use project ci` keeps any `success` the
reconciler posted for
- its current head commit. A maintainer who wants the project CI result before
merging waits
- for project CI before enabling auto-merge, or pushes a new commit.
+- A pull request carrying `use project ci` cannot merge on the fork result.
GitHub requires both to
+ pass when a check run and a commit status have the same name and that name
is required, so the
+ `CI passed` status and project CI's `CI passed` check run must both succeed.
The run that
+ releases the pull request (see "Override labels") overwrites a fork
`success` on its head commit
+ with `pending` ("Waiting for project CI (use project ci)"). It reads the
labels from the pull
+ request node rather than from the label search, which lags behind a new
label by minutes; a
+ `success` that project CI earned is kept. A labelled pull request that gets
a new head commit can already carry a fork `success`
+ posted for another gated pull request on that commit, while project CI's
check run does not exist
+ until it runs; the pass over labelled pull requests below overwrites that
one.
+- Every monitor run also searches the open pull requests carrying the label,
as they have left the
+ state machine, and keeps the status in line with project CI. It only writes
when the head commit
+ already has a `CI passed` status: the project check run alone decides
otherwise, and a `pending`
+ the monitor created would block the pull request after project CI passed.
The project CI result is
+ the newest `pull_request` run of a project CI workflow file on the head
commit whose job named
+ exactly `CI passed` exists; a run that has no such job and reports `CI
passed (run cancelled)` or
+ `CI passed (project CI not run)` is ignored and does not hide an older run's
result (a re-run
+ keeps its check suite, so one that has both is judged by its `CI passed`
job). While that
+ job has not succeeded, a `success` is overwritten with `pending`; once it
has, the monitor posts
+ `success` ("Project CI passed for this commit"). Adding the label does not
start project CI (see
+ "Override labels"): the pull request stays blocked until a maintainer starts
it and it passes,
+ and then until the next monitor run posts the `success`, which can take
about 15 minutes.
+ Removing the label returns the pull request to the normal flow above:
removing it updates the
+ pull request, so discovery (open pull requests updated within the last 24
hours) labels it
+ `fork ci required` again and the next run reports the fork result, replacing
the waiting
+ `pending` once the fork run is green.
+ A pull request that is not gated after the label is removed (for example the
gate is off) is not
+ labelled again, so discovery posts the project CI `success` for it instead,
when its head commit's
+ `CI passed` status is one the reconciler wrote and is not `success`, and
project CI has passed.
+ Discovery only sees pull requests updated within the last 24 hours: one
whose project CI passes
+ later than that without any other update stays `pending` until it is updated.
+- A commit status belongs to the commit, so the monitor never posts a fork `CI
passed` `success`
+ on a head commit that a pull request carrying `use project ci` has, even for
another pull
+ request on the same commit; the project CI pass owns those. Revocations to
`pending` always go
+ through. When the list of those commits cannot be read, it posts no fork
`success` in that run. The list comes from a search that lags behind a new
label, so
+ before posting a fork `success` the monitor also reads the pull request
itself, and the open
+ pull requests of the same head branch (the listing that finds fork pull
requests), and skips the
+ post when one now carries the label or the head commit is new. A duplicate
pull request from
+ another branch at the same commit is not found this way.
+- The protection is eventual, not atomic. Nothing reacts to the label when it
is added
+ (`pull_request_target` is not used, see "Goals and non-goals"), so a fork
`success` posted
+ before the label stays until the next monitor run that completes: normally
within one cadence
+ (15 minutes), longer when GitHub delays or drops a scheduled run or a run
fails, and one more
+ cycle when the label arrives after a run has taken its snapshot of the pull
request. The pull
+ request can merge on the fork result in that window. A maintainer who adds
the label to a pull
+ request whose fork CI is green waits for the `pending` before merging or
enabling auto-merge,
+ or starts the monitor by hand (`workflow_dispatch`). A strict merge-time
guarantee needs a
+ required check bound to a source, which is out of scope here. Revoking a
`success` is never
+ limited by the per-run budget of commit statuses; posting the project CI
`success` is.
+- Known limitation: the ruleset accepts `CI passed` from any source, so a pull
request that changes
+ workflows can make its own project CI run produce a `CI passed` check run.
This holds for every
+ pull request that runs project CI; reviewers must check changes to CI files
before adding
+ `use project ci`, and again on every push of a labelled pull request, since
the label survives
+ pushes.
- In a gated run of `ci-amd.yml` every job after the gate is skipped, so
project CI's `CI passed`
job must not report success there; it reports under another name when
`build-info` is
skipped, and the reconciler's status is the only `CI passed` the pull
request has.
@@ -1245,8 +1297,9 @@ as a failed check rather than a crash.
## Known limitations
- Drafting, the setup comment and the commit status arrive within one cadence
of the event
- they reflect, not instantly. The cadence can be lowered to 5 minutes without
changing the
- budget analysis, since unchanged pull requests cost no mutations.
+ they reflect, not instantly. The cadence can be lowered, but the per-run
write budgets are
+ sized for four runs an hour against the `GITHUB_TOKEN` limits shared by
every workflow of the
+ repository, so they must be scaled down with it.
- The workflows API lists workflows from the fork's default branch. A fork
whose `main`
predates `ci-amd.yml` reports it missing; the setup comment covers this with
`gh repo sync`.
diff --git a/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py
b/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py
index 10cca162083..521b4714ef8 100644
--- a/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py
+++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py
@@ -45,6 +45,9 @@ JOB_NAME_MAX_LENGTH = 100
CI_PASSED_CONTEXT = "CI passed"
CI_PASSED_DESCRIPTION = "Fork CI passed for this commit"
CI_PASSED_REVOKED_DESCRIPTION = "Fork CI result no longer trusted for this
commit"
+# Replaces a fork `success` on a pull request carrying `use project ci` until
project CI has passed.
+CI_PASSED_WAITING_PROJECT_CI_DESCRIPTION = "Waiting for project CI (use
project ci)"
+CI_PASSED_PROJECT_DESCRIPTION = "Project CI passed for this commit"
# Also required by the merge rulesets, so GitHub lists it as expected and
waiting on every pull
# request from the moment it is pushed. The reconciler reports it, linking the
run: for a gated pull
# request once the fork has a run for the head commit, for any other once
project CI has one.
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 fc316385adf..151f33c0e82 100644
--- a/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
+++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
@@ -60,7 +60,9 @@ from airflow_breeze.utils.fork_ci_github import GitHubClient,
GitHubError
from airflow_breeze.utils.fork_ci_messages import (
CI_PASSED_CONTEXT,
CI_PASSED_DESCRIPTION,
+ CI_PASSED_PROJECT_DESCRIPTION,
CI_PASSED_REVOKED_DESCRIPTION,
+ CI_PASSED_WAITING_PROJECT_CI_DESCRIPTION,
CI_RUN_STARTED_CONTEXT,
CI_RUN_STARTED_DESCRIPTION,
CI_RUN_STARTED_PROJECT_DESCRIPTION,
@@ -122,6 +124,7 @@ GATED_RUN_JOBS = (
GATED_RUN_JOB_NAMES = frozenset[str]().union(*GATED_RUN_JOBS)
CHECK_RUN_NEEDED_JOB_NAME = "Check if the run is needed"
PROJECT_CI_NOT_RUN_JOB_NAME = "CI passed (project CI not run)"
+CI_PASSED_CANCELLED_JOB_NAME = "CI passed (run cancelled)"
BOT_LOGINS = frozenset({"github-actions", "github-actions[bot]"})
DISCOVERY_WINDOW = timedelta(hours=24)
PASS_TWO_PAGE_SIZE = 25
@@ -176,6 +179,9 @@ MAX_JOB_STATUSES_PER_RUN = 40
# At most this many, so job statuses keep the rest; the first runs after the
check became required
# catch up on the open pull requests a batch at a time, most recently updated
first.
MAX_CI_RUN_STARTED_PER_RUN = 20
+# `CI passed` successes for pull requests carrying `use project ci`, written
once per new project CI
+# pass, so far fewer than this per run. Revoking a success is never limited.
+MAX_PROJECT_CI_PASSED_PER_RUN = 5
# The GitHub Actions app, whose check suites are the workflow runs of a commit.
GITHUB_ACTIONS_APP_ID = 15368
# A single large fork run can need more writes than one run's budget, which
the next run catches up
@@ -286,6 +292,7 @@ class PullRequestSnapshot:
current_status: tuple[str, str, str] | None = None
status_created_at: datetime | None = None
ci_passed_state: str | None = None
+ ci_passed_description: str | None = None
ci_run_started_state: str | None = None
# GateEvent.ci_run_at of a pilot's pull request created before they
joined; set from its
# `fork ci required` label by `mark_older_pilot_pull_request`.
@@ -591,7 +598,18 @@ def classify(
plan.draft = False
since = marker.since if marker and marker.state == state.value else now
_set_marker_comment(plan, pr, comment(Marker(state.value, pr.head_sha,
since)))
- _set_status(plan, pr, state.value,
f"https://github.com/{repo}/pull/{pr.number}/checks")
+ checks_url = f"https://github.com/{repo}/pull/{pr.number}/checks"
+ _set_status(plan, pr, state.value, checks_url)
+ # The pull request must not merge on the fork result once a maintainer
moves it to project
+ # CI. Revoked here, in the run that sees the label, rather than only by
+ # ``report_project_ci_passed``, whose search lags behind a new label
by minutes. A success
+ # that project CI earned (a retried release after that pass posted it)
is kept.
+ if (
+ state == ForkState.RELEASED
+ and pr.ci_passed_state == "success"
+ and pr.ci_passed_description != CI_PASSED_PROJECT_DESCRIPTION
+ ):
+ plan.ci_passed = ("pending", checks_url,
CI_PASSED_WAITING_PROJECT_CI_DESCRIPTION)
return plan
if LABEL_USE_PROJECT_CI in labels:
@@ -827,6 +845,10 @@ query($q: String!, $cursor: String) {
author { login }
headRepository { nameWithOwner }
labels(first: 50) { nodes { name } }
+ commits(last: 1) { nodes { commit {
+ oid
+ status { context(name: "%(context)s") { state description } }
+ } } }
} }
}
}
@@ -857,7 +879,7 @@ query($q: String!, $cursor: String) {
oid
status {
context(name: "%(context)s") { state targetUrl description createdAt
}
- ciPassed: context(name: "%(ci_passed)s") { state }
+ ciPassed: context(name: "%(ci_passed)s") { state description }
ciRunStarted: context(name: "%(ci_run_started)s") { state }
}
} } }
@@ -930,6 +952,7 @@ def parse_pull_request(node: dict[str, Any]) ->
PullRequestSnapshot:
current_status = None
status_created_at = None
ci_passed_state = None
+ ci_passed_description = None
ci_run_started_state = None
commits = (node.get("commits") or {}).get("nodes") or []
head_status_unread = bool(commits) and commits[0]["commit"].get("oid") !=
node["headRefOid"]
@@ -937,6 +960,7 @@ def parse_pull_request(node: dict[str, Any]) ->
PullRequestSnapshot:
status = commits[0]["commit"].get("status") or {}
if status.get("ciPassed"):
ci_passed_state = status["ciPassed"]["state"].lower()
+ ci_passed_description = status["ciPassed"].get("description")
if status.get("ciRunStarted"):
ci_run_started_state = status["ciRunStarted"]["state"].lower()
context = status.get("context")
@@ -978,6 +1002,7 @@ def parse_pull_request(node: dict[str, Any]) ->
PullRequestSnapshot:
current_status=current_status,
status_created_at=status_created_at,
ci_passed_state=ci_passed_state,
+ ci_passed_description=ci_passed_description,
ci_run_started_state=ci_run_started_state,
marker=marker,
marker_comment_id=marker_id,
@@ -1102,6 +1127,220 @@ def report_project_ci_runs_started(
return reported
+PROJECT_CI_PASSED_QUERY = """
+query($q: String!, $cursor: String) {
+ search(type: ISSUE, first: 50, after: $cursor, query: $q) {
+ pageInfo { hasNextPage endCursor }
+ nodes { ... on PullRequest {
+ number headRefOid
+ commits(last: 1) { nodes { commit {
+ oid
+ status { context(name: "%(context)s") { state description } }
+ checkSuites(first: 100, filterBy: {appId: %(app_id)d}) { nodes {
+ createdAt status conclusion
+ workflowRun { url event workflow { resourcePath } }
+ checkRuns(first: 10, filterBy: {checkName: "%(context)s"}) { nodes {
name conclusion } }
+ cancelled: checkRuns(first: 1, filterBy: {checkName:
"%(cancelled)s"}) { totalCount }
+ notRun: checkRuns(first: 1, filterBy: {checkName: "%(not_run)s"}) {
totalCount }
+ } }
+ } } }
+ } }
+ }
+}
+"""
+
+
+PROJECT_CI_HEADS_QUERY = """
+query($q: String!, $cursor: String) {
+ search(type: ISSUE, first: 100, after: $cursor, query: $q) {
+ pageInfo { hasNextPage endCursor }
+ nodes { ... on PullRequest { headRefOid } }
+ }
+}
+"""
+
+
+def find_project_ci_passed_url(node: dict[str, Any]) -> str | None:
+ """
+ The latest project CI pull_request run's URL when its `CI passed` job
succeeded on the head commit.
+
+ The job reports under another name when the run was cancelled or skipped;
such a run does not
+ count, nor does it hide an older run's result. A re-run keeps its check
suite, so a suite that
+ also has the exact `CI passed` check run is judged by that one, its latest
attempt.
+ None also when the last commit listed is not the head;
``read_project_ci_passed_url`` reads that.
+ """
+ if not is_head_listed(node):
+ return None
+ suites = [
+ suite
+ for suite in (node["commits"]["nodes"][0]["commit"].get("checkSuites")
or {}).get("nodes") or []
+ if (run := suite.get("workflowRun") or {}).get("event") ==
"pull_request"
+ and _workflow_file((run.get("workflow") or {}).get("resourcePath")) in
PROJECT_CI_WORKFLOW_FILES
+ ]
+ suites = [
+ suite
+ for suite in suites
+ if suite["checkRuns"]["nodes"]
+ or not (
+ suite["cancelled"]["totalCount"]
+ or suite["notRun"]["totalCount"]
+ or (suite["status"], suite["conclusion"]) == ("COMPLETED",
"CANCELLED")
+ )
+ ]
+ latest = max(suites, key=lambda suite: suite["createdAt"], default=None)
+ if latest and any(
+ check["name"] == CI_PASSED_CONTEXT and check["conclusion"] == "SUCCESS"
+ for check in latest["checkRuns"]["nodes"]
+ ):
+ return latest["workflowRun"]["url"]
+ return None
+
+
+def read_project_ci_passed_url(client: GitHubClient, repo: str, head_sha: str)
-> str | None:
+ """``find_project_ci_passed_url`` for a head commit the search did not
list, read from REST."""
+ runs = [
+ run
+ for run in client.rest_get(
+
f"repos/{repo}/actions/runs?event=pull_request&head_sha={head_sha}&per_page=100"
+ ).get("workflow_runs", [])
+ if _workflow_file(run.get("path")) in PROJECT_CI_WORKFLOW_FILES
+ ]
+ if not runs:
+ return None
+
+ def get_check_runs(name: str) -> list[dict[str, Any]]:
+ return client.rest_get(
+
f"repos/{repo}/commits/{head_sha}/check-runs?check_name={quote(name)}&filter=latest&per_page=100"
+ ).get("check_runs", [])
+
+ passed_checks = get_check_runs(CI_PASSED_CONTEXT)
+ skipped_suites = {
+ check["check_suite"]["id"]
+ for name in (CI_PASSED_CANCELLED_JOB_NAME, PROJECT_CI_NOT_RUN_JOB_NAME)
+ for check in get_check_runs(name)
+ } - {check["check_suite"]["id"] for check in passed_checks}
+ passed_suites = {check["check_suite"]["id"] for check in passed_checks}
+ # Cancelled before any job started, the run has no check run at all.
+ skipped_suites |= {
+ run["check_suite_id"]
+ for run in runs
+ if (run["status"], run["conclusion"]) == ("completed", "cancelled")
+ and run["check_suite_id"] not in passed_suites
+ }
+ run = next((run for run in runs if run["check_suite_id"] not in
skipped_suites), None)
+ if run and any(
+ check["name"] == CI_PASSED_CONTEXT
+ and check["conclusion"] == "success"
+ and check["check_suite"]["id"] == run["check_suite_id"]
+ for check in passed_checks
+ ):
+ return run["html_url"]
+ return None
+
+
+def is_unchanged_without_project_ci(client: GitHubClient, repo: str, pr:
PullRequestSnapshot) -> bool:
+ """
+ Whether the pull request, read live, still has the head commit and no `use
project ci`.
+
+ The search that lists labelled pull requests lags behind a new label by
minutes, and a snapshot
+ may predate it, so a fork `success` is only posted after this read. The
open pull requests of the
+ same head branch are read too, since they share the commit status. Listing
by branch is what
+ finds fork pull requests; a duplicate pull request from another branch at
the same commit is not.
+ """
+ live = client.rest_get(f"repos/{repo}/pulls/{pr.number}")
+ if live["head"]["sha"] != pr.head_sha:
+ return False
+ owner = (pr.head_repo or "").split("/", 1)[0]
+ same_branch = client.rest_get(
+
f"repos/{repo}/pulls?state=open&per_page=100&head={quote(f'{owner}:{pr.head_ref}',
safe=':')}"
+ )
+ return all(
+ LABEL_USE_PROJECT_CI not in {label["name"] for label in
pull_request["labels"]}
+ for pull_request in [live, *same_branch]
+ )
+
+
+def find_project_ci_label_heads(client: GitHubClient, repo: str) -> set[str]:
+ """The head commits of the open pull requests carrying `use project ci`."""
+ return {
+ node["headRefOid"]
+ for node in _search(
+ client,
+ PROJECT_CI_HEADS_QUERY,
+ f'repo:{repo} is:pr is:open label:"{LABEL_USE_PROJECT_CI}"',
+ )
+ }
+
+
+def report_project_ci_passed(
+ client: GitHubClient,
+ *,
+ repo: str,
+ attention: list[tuple[int, str]],
+ budget: int = MAX_PROJECT_CI_PASSED_PER_RUN,
+) -> list[int]:
+ """
+ Keep the `CI passed` status of pull requests with `use project ci` in line
with project CI.
+
+ Released pull requests leave the reconciler's pass, but a `success` it
posted from the fork run
+ would still let them merge before project CI has passed. Return the
numbers it wrote for.
+
+ :param budget: the most `success` statuses to write; revoking one is never
limited.
+ :param attention: collects the pull requests whose status could not be
read or written; the
+ others are still handled.
+ """
+ written: list[int] = []
+ successes = 0
+ for node in _search(
+ client,
+ PROJECT_CI_PASSED_QUERY
+ % {
+ "context": CI_PASSED_CONTEXT,
+ "cancelled": CI_PASSED_CANCELLED_JOB_NAME,
+ "not_run": PROJECT_CI_NOT_RUN_JOB_NAME,
+ "app_id": GITHUB_ACTIONS_APP_ID,
+ },
+ f'repo:{repo} is:pr is:open label:"{LABEL_USE_PROJECT_CI}"
sort:updated-desc',
+ ):
+ try:
+ if is_head_listed(node):
+ status = (node["commits"]["nodes"][0]["commit"].get("status")
or {}).get("context") or {}
+ state, description = (status.get("state") or "").lower(),
status.get("description")
+ passed_url = find_project_ci_passed_url(node)
+ else:
+ rest_status = read_ci_passed_status(client, repo,
node["headRefOid"])
+ state, description = rest_status.get("state"),
rest_status.get("description")
+ passed_url = read_project_ci_passed_url(client, repo,
node["headRefOid"])
+ # Both the status and the project check run must pass when they
share a name, so a
+ # status the monitor never posted is not created here.
+ if not state:
+ continue
+ if passed_url:
+ if (state, description) == ("success",
CI_PASSED_PROJECT_DESCRIPTION):
+ continue
+ if successes >= budget:
+ console_print(
+ f"[info]`{CI_PASSED_CONTEXT}` of more pull requests
left for the next run[/]"
+ )
+ continue
+ change = ("success", passed_url, CI_PASSED_PROJECT_DESCRIPTION)
+ elif state == "success":
+ change = (
+ "pending",
+ f"https://github.com/{repo}/pull/{node['number']}/checks",
+ CI_PASSED_WAITING_PROJECT_CI_DESCRIPTION,
+ )
+ else:
+ continue
+ client.set_status(node["number"], node["headRefOid"], *change,
context=CI_PASSED_CONTEXT)
+ written.append(node["number"])
+ successes += change[0] == "success"
+ except Exception as e:
+ console_print(f"[error]#{node['number']}: `{CI_PASSED_CONTEXT}`
not updated: {e}[/]")
+ attention.append((node["number"], f"`{CI_PASSED_CONTEXT}` not
updated: {type(e).__name__}"))
+ return written
+
+
def _search(client: GitHubClient, query: str, search_query: str) ->
Iterable[dict[str, Any]]:
cursor = None
while True:
@@ -1229,11 +1468,14 @@ def discover(
repo: str,
now: datetime,
unknown: list[tuple[int, str]] | None = None,
+ ci_passed_written: list[int] | None = None,
) -> list[int]:
"""
Label newly gated pull requests with `fork ci required` and return their
numbers.
:param unknown: collects the pull requests whose base branch configuration
could not be read.
+ :param ci_passed_written: collects the pull requests that were not gated
and got their `CI passed`
+ `success` from project CI, see ``report_orphaned_ci_passed``.
"""
since = (now - DISCOVERY_WINDOW).strftime("%Y-%m-%dT%H:%M:%SZ")
queries = (
@@ -1241,8 +1483,19 @@ def discover(
f'repo:{repo} is:pr is:open label:"{LABEL_USE_FORK_CI}"
-label:"{LABEL_FORK_CI_REQUIRED}"',
)
labelled: list[int] = []
+
+ def recover_orphaned_status(node: dict[str, Any]) -> None:
+ if ci_passed_written is None or LABEL_USE_PROJECT_CI in _labels(node):
+ return
+ try:
+ report_orphaned_ci_passed(client, repo, node, ci_passed_written)
+ except Exception as e:
+ console_print(f"[error]#{node['number']}: `{CI_PASSED_CONTEXT}`
not updated: {e}[/]")
+ if unknown is not None:
+ unknown.append((node["number"], f"`{CI_PASSED_CONTEXT}` not
updated: {type(e).__name__}"))
+
for search_query in queries:
- for node in _search(client, DISCOVERY_QUERY, search_query):
+ for node in _search(client, DISCOVERY_QUERY % {"context":
CI_PASSED_CONTEXT}, search_query):
if node["number"] in labelled:
continue
try:
@@ -1252,6 +1505,7 @@ def discover(
unknown.append((node["number"], f"{type(e).__name__}:
{e}"))
continue
if config is None:
+ recover_orphaned_status(node)
continue
author = _login(node.get("author"))
created_at = parse_github_datetime(node["createdAt"])
@@ -1278,9 +1532,58 @@ def discover(
if decide(event, config).reason in GATED_REASONS:
client.add_labels(node["number"], [LABEL_FORK_CI_REQUIRED])
labelled.append(node["number"])
+ else:
+ recover_orphaned_status(node)
return labelled
+def read_ci_passed_status(client: GitHubClient, repo: str, head_sha: str) ->
dict[str, Any]:
+ """The ``CI passed`` commit status of a head commit that the search did
not list, from REST."""
+ statuses = client.rest_get(
+ f"repos/{repo}/commits/{head_sha}/status?per_page=100", paginate=True,
items_key="statuses"
+ )
+ return next((status for status in statuses if status["context"] ==
CI_PASSED_CONTEXT), {})
+
+
+def report_orphaned_ci_passed(
+ client: GitHubClient, repo: str, node: dict[str, Any], written: list[int]
+) -> None:
+ """
+ Post the project CI `success` for a pull request that is not gated but has
a stale `CI passed` status.
+
+ A pull request that left the gated flow, for example by losing `use
project ci` or `use fork ci`
+ while the gate is off, keeps the `pending` or fork status the reconciler
wrote, and a status and
+ a check run of one name must both pass. Only a status the reconciler wrote
counts, and only
+ discovery's 24 hour window sees the pull request.
+ """
+ commit = ((node.get("commits") or {}).get("nodes") or
[{}])[0].get("commit") or {}
+ if commit.get("oid") == node["headRefOid"]:
+ status = (commit.get("status") or {}).get("context") or {}
+ else:
+ status = read_ci_passed_status(client, repo, node["headRefOid"])
+ if (
+ (status.get("state") or "").lower() in ("", "success")
+ or status.get("description")
+ not in (
+ CI_PASSED_DESCRIPTION,
+ CI_PASSED_WAITING_PROJECT_CI_DESCRIPTION,
+ CI_PASSED_REVOKED_DESCRIPTION,
+ )
+ or len(written) >= MAX_PROJECT_CI_PASSED_PER_RUN
+ ):
+ return
+ if passed_url := read_project_ci_passed_url(client, repo,
node["headRefOid"]):
+ client.set_status(
+ node["number"],
+ node["headRefOid"],
+ "success",
+ passed_url,
+ CI_PASSED_PROJECT_DESCRIPTION,
+ context=CI_PASSED_CONTEXT,
+ )
+ written.append(node["number"])
+
+
def fetch_fork_observations(
client: GitHubClient, prs: list[PullRequestSnapshot]
) -> dict[int, ForkObservation]:
@@ -1965,6 +2268,7 @@ def read_head_status(client: GitHubClient, repo: str, pr:
PullRequestSnapshot) -
),
status_created_at=parse_github_datetime(status.get("created_at") or
"") if status else None,
ci_passed_state=ci_passed["state"] if ci_passed else None,
+ ci_passed_description=ci_passed.get("description") if ci_passed else
None,
ci_run_started_state=ci_run_started["state"] if ci_run_started else
None,
head_status_unread=False,
)
@@ -2237,7 +2541,10 @@ def run_reconcile(
draft_changes: list[DraftChange] = []
configs = BaseGateConfigs(client, repo=repo,
default_branch=default_branch, default_config=config)
- labelled = discover(client, configs, repo=repo, now=now, unknown=unknown)
+ ci_passed_written: list[int] = []
+ labelled = discover(
+ client, configs, repo=repo, now=now, unknown=unknown,
ci_passed_written=ci_passed_written
+ )
prs = [
parse_pull_request(node)
@@ -2281,8 +2588,26 @@ def run_reconcile(
ci_run_started_note = (
f"reporting `{CI_RUN_STARTED_CONTEXT}` for project CI failed:
{type(e).__name__}"
)
+ # Commit statuses belong to a commit, so a fork `success` posted for any
pull request on one of
+ # these would also let a pull request with `use project ci` merge. None
when they could not be
+ # listed: no fork `success` is then posted at all.
+ ci_passed_notes: list[str] = []
+ project_ci_heads: set[str] | None
+ try:
+ project_ci_heads = find_project_ci_label_heads(client, repo)
+ ci_passed_reserved = min(MAX_PROJECT_CI_PASSED_PER_RUN,
len(project_ci_heads))
+ except Exception as e:
+ console_print(f"[error]Listing the pull requests with
`{LABEL_USE_PROJECT_CI}` failed: {e}[/]")
+ project_ci_heads = None
+ ci_passed_reserved = MAX_PROJECT_CI_PASSED_PER_RUN
+ ci_passed_notes.append(
+ f"listing the pull requests with `{LABEL_USE_PROJECT_CI}` failed:
{type(e).__name__}"
+ )
job_reporter = JobStatusReporter(
- client, repo=repo, budget=max(MAX_JOB_STATUSES_PER_RUN -
ci_run_started_writes, 0), now=now
+ client,
+ repo=repo,
+ budget=max(MAX_JOB_STATUSES_PER_RUN - ci_run_started_writes -
ci_passed_reserved, 0),
+ now=now,
)
previous_job_status_failure_runs =
read_job_status_failure_runs(reported_file)
@@ -2333,6 +2658,18 @@ def run_reconcile(
head_shared_across_bases=pr.head_sha in shared_heads,
find_gate_change=functools.partial(gate_change_finder.find,
pr),
)
+ # A revocation to `pending` always goes through: it is idempotent
and must not depend on
+ # `report_project_ci_passed` succeeding later in this run.
+ if (
+ plan.ci_passed
+ and plan.ci_passed[0] == "success"
+ and (
+ pr.head_sha in (project_ci_heads or ())
+ or project_ci_heads is None
+ or not is_unchanged_without_project_ci(client, repo, pr)
+ )
+ ):
+ plan.ci_passed = None
draft_change = apply_plan(client, pr, plan)
if draft_change is not None:
draft_changes.append(draft_change)
@@ -2350,11 +2687,23 @@ def run_reconcile(
console_print(f"[error]#{listed.number}: {e}[/]")
unknown.append((listed.number, f"{type(e).__name__}: {e}"))
+ # After the loop, so that it reads the labels and statuses as they are
last.
+ try:
+ report_project_ci_passed(
+ client,
+ repo=repo,
+ attention=attention,
+ budget=MAX_PROJECT_CI_PASSED_PER_RUN - len(ci_passed_written),
+ )
+ except Exception as e:
+ console_print(f"[error]Reporting `{CI_PASSED_CONTEXT}` for
`{LABEL_USE_PROJECT_CI}` failed: {e}[/]")
+ ci_passed_notes.append(
+ f"reporting `{CI_PASSED_CONTEXT}` for `{LABEL_USE_PROJECT_CI}`
failed: {type(e).__name__}"
+ )
attention.extend(bypass_finder.ambiguous)
attention.extend(job_reporter.delayed)
notes = [bypass_finder.incomplete] if bypass_finder.incomplete else []
- if ci_run_started_note:
- notes.append(ci_run_started_note)
+ notes.extend(note for note in (ci_run_started_note, *ci_passed_notes) if
note)
previous_deferred_runs = read_run_count(reported_file, "deferred_runs")
deferred_runs = (previous_deferred_runs or 0) + 1 if job_reporter.deferred
else 0
if job_reporter.deferred:
diff --git a/dev/breeze/tests/test_fork_ci_reconcile.py
b/dev/breeze/tests/test_fork_ci_reconcile.py
index a26a3cd21fc..c7b50800756 100644
--- a/dev/breeze/tests/test_fork_ci_reconcile.py
+++ b/dev/breeze/tests/test_fork_ci_reconcile.py
@@ -21,6 +21,7 @@ import dataclasses
import json
from datetime import UTC, datetime, timedelta, timezone
from unittest import mock
+from urllib.parse import quote
import pytest
from click.testing import CliRunner
@@ -30,6 +31,9 @@ from airflow_breeze.utils.fork_ci_gate import GateConfig
from airflow_breeze.utils.fork_ci_github import GitHubClient, GitHubError
from airflow_breeze.utils.fork_ci_messages import (
CI_PASSED_CONTEXT,
+ CI_PASSED_DESCRIPTION,
+ CI_PASSED_PROJECT_DESCRIPTION,
+ CI_PASSED_WAITING_PROJECT_CI_DESCRIPTION,
CI_RUN_STARTED_CONTEXT,
CI_RUN_STARTED_DESCRIPTION,
CI_RUN_STARTED_PROJECT_DESCRIPTION,
@@ -66,6 +70,8 @@ from airflow_breeze.utils.fork_ci_reconcile import (
fetch_workflow_state,
find_ci_run_since,
find_heads_shared_across_bases,
+ find_project_ci_label_heads,
+ find_project_ci_passed_url,
find_project_ci_run_url,
find_state_comment,
is_cancelled_gate_run,
@@ -80,7 +86,9 @@ from airflow_breeze.utils.fork_ci_reconcile import (
read_ci_run_at,
read_head_status,
read_job_status_failure_runs,
+ read_project_ci_passed_url,
read_project_ci_run_url,
+ report_project_ci_passed,
report_project_ci_runs_started,
run_reconcile,
)
@@ -93,6 +101,7 @@ JOINED = CREATED + timedelta(days=2)
CONFIG = GateConfig(enabled=True, enabled_since=datetime(2026, 10, 1,
tzinfo=UTC), close_after_days=7)
RUN_URL = "https://github.com/someone/airflow/actions/runs/1"
PROJECT_RUN_URL = "https://github.com/apache/airflow/actions/runs/2"
+PROJECT_PASSED_URL = "https://github.com/apache/airflow/actions/runs/5"
GREEN = ForkObservation(suite_status="COMPLETED", suite_conclusion="SUCCESS",
run_url=RUN_URL)
@@ -681,6 +690,39 @@ def test_release_with_use_project_ci():
find_bypass.assert_not_called()
[email protected](
+ ("ci_passed_state", "ci_passed_description", "revoked"),
+ [
+ pytest.param("success", CI_PASSED_DESCRIPTION, True,
id="fork-success-is-revoked"),
+ pytest.param("success", None, True,
id="success-without-description-is-revoked"),
+ pytest.param("success", CI_PASSED_PROJECT_DESCRIPTION, False,
id="project-success-is-kept"),
+ pytest.param("pending", CI_PASSED_WAITING_PROJECT_CI_DESCRIPTION,
False, id="pending-is-kept"),
+ pytest.param(None, None, False, id="no-status-is-not-created"),
+ ],
+)
+def test_release_with_use_project_ci_revokes_a_fork_success(ci_passed_state,
ci_passed_description, revoked):
+ pr = _pr(
+ labels=("fork ci required", "use project ci"),
+ ci_passed_state=ci_passed_state,
+ ci_passed_description=ci_passed_description,
+ )
+
+ plan, _, _ = _classify(pr, GREEN)
+
+ expected = (
+ "pending",
+ "https://github.com/apache/airflow/pull/42/checks",
+ CI_PASSED_WAITING_PROJECT_CI_DESCRIPTION,
+ )
+ assert plan.ci_passed == (expected if revoked else None)
+
+
+def test_base_branch_without_gate_keeps_the_ci_passed_status():
+ plan, _, _ = _classify(_pr(base_ref="v2-10-test",
ci_passed_state="success"), GREEN, config=None)
+
+ assert plan.ci_passed is None
+
+
def test_base_branch_without_gate_releases():
plan, find_bypass, _ = _classify(_pr(base_ref="v2-10-test"), GREEN,
config=None)
@@ -971,6 +1013,24 @@ def _finder(
return BypassFinder(client, repo=REPO, now=NOW,
base_triggers=BASE_TRIGGERS, is_gated_base=is_gated_base)
[email protected](autouse=True)
+def mock_project_ci_heads():
+ with mock.patch(
+ "airflow_breeze.utils.fork_ci_reconcile.find_project_ci_label_heads",
autospec=True
+ ) as mock_heads:
+ mock_heads.return_value = set()
+ yield mock_heads
+
+
[email protected](autouse=True)
+def mock_report_project_ci_passed():
+ with mock.patch(
+ "airflow_breeze.utils.fork_ci_reconcile.report_project_ci_passed",
autospec=True
+ ) as mock_report:
+ mock_report.return_value = []
+ yield mock_report
+
+
@pytest.fixture
def client():
github = mock.create_autospec(GitHubClient, instance=True)
@@ -1504,6 +1564,36 @@ def test_read_head_status_reads_ci_run_started(client):
assert read_head_status(client, REPO, pr).ci_run_started_state == "success"
+def test_parse_pull_request_reads_the_ci_passed_description():
+ node = _pr_node(
+ 1,
+ commits={
+ "nodes": [
+ {
+ "commit": {
+ "oid": "sha1",
+ "status": {
+ "context": None,
+ "ciPassed": {"state": "SUCCESS", "description":
CI_PASSED_PROJECT_DESCRIPTION},
+ },
+ }
+ }
+ ]
+ },
+ )
+
+ assert parse_pull_request(node).ci_passed_description ==
CI_PASSED_PROJECT_DESCRIPTION
+
+
+def test_read_head_status_reads_the_ci_passed_description(client):
+ client.rest_get.return_value = [
+ {"context": CI_PASSED_CONTEXT, "state": "success", "description":
CI_PASSED_PROJECT_DESCRIPTION}
+ ]
+ pr = dataclasses.replace(parse_pull_request(_pr_node(1)),
head_status_unread=True)
+
+ assert read_head_status(client, REPO, pr).ci_passed_description ==
CI_PASSED_PROJECT_DESCRIPTION
+
+
def _branch_run(created_at: str, head_repo: str = "someone/airflow") -> dict:
return {"created_at": created_at, "head_repository": {"full_name":
head_repo}}
@@ -1611,6 +1701,7 @@ def _run_json(run_id, workflow_file="ci-amd.yml", *,
path=None, conclusion=None)
return {
"html_url": f"https://github.com/apache/airflow/actions/runs/{run_id}",
"path": path or f".github/workflows/{workflow_file}",
+ "status": "completed" if conclusion else "in_progress",
"conclusion": conclusion,
}
@@ -1805,6 +1896,135 @@ def
test_discover_labels_an_older_pilot_pull_request_after_a_run_since_joining(c
client.add_labels.assert_called_once_with(2, ["fork ci required"])
+def _ci_passed_commit(state, description, oid="sha1"):
+ return {
+ "nodes": [
+ {"commit": {"oid": oid, "status": {"context": {"state": state,
"description": description}}}}
+ ]
+ }
+
+
[email protected](
+ ("enabled", "state", "description", "passed_url", "expected_written"),
+ [
+ pytest.param(
+ False,
+ "PENDING",
+ CI_PASSED_WAITING_PROJECT_CI_DESCRIPTION,
+ PROJECT_PASSED_URL,
+ [1],
+ id="ungated-waiting-project-ci-passed",
+ ),
+ pytest.param(
+ False, "PENDING", CI_PASSED_DESCRIPTION, PROJECT_PASSED_URL, [1],
id="ungated-fork-status"
+ ),
+ pytest.param(
+ False, "PENDING", CI_PASSED_WAITING_PROJECT_CI_DESCRIPTION, None,
[], id="ungated-not-passed"
+ ),
+ pytest.param(False, "SUCCESS", CI_PASSED_DESCRIPTION,
PROJECT_PASSED_URL, [], id="already-success"),
+ pytest.param(False, "PENDING", "someone else", PROJECT_PASSED_URL, [],
id="foreign-status"),
+ pytest.param(
+ True,
+ "PENDING",
+ CI_PASSED_WAITING_PROJECT_CI_DESCRIPTION,
+ PROJECT_PASSED_URL,
+ [],
+ id="gated-is-left-to-the-fork-flow",
+ ),
+ ],
+)
[email protected]("airflow_breeze.utils.fork_ci_reconcile.read_project_ci_passed_url",
autospec=True)
+def
test_discover_posts_project_ci_success_for_an_ungated_pull_request_with_a_stale_status(
+ mock_read, client, enabled, state, description, passed_url,
expected_written
+):
+ config = GateConfig(enabled=enabled, enabled_since=datetime(2026, 10, 1,
tzinfo=UTC))
+ node = {**_pr_node(1), "labels": {"nodes": []}, "commits":
_ci_passed_commit(state, description)}
+ node["headRefOid"] = "sha1"
+ mock_read.return_value = passed_url
+ client.graphql.side_effect = [_search_result(node), _search_result()]
+ configs = BaseGateConfigs(client, repo=REPO, default_branch="main",
default_config=config)
+ written: list[int] = []
+
+ discover(client, configs, repo=REPO, now=NOW, ci_passed_written=written)
+
+ assert written == expected_written
+ if expected_written:
+ client.set_status.assert_called_once_with(
+ 1, "sha1", "success", PROJECT_PASSED_URL,
CI_PASSED_PROJECT_DESCRIPTION, context=CI_PASSED_CONTEXT
+ )
+ else:
+ client.set_status.assert_not_called()
+
+
[email protected]("airflow_breeze.utils.fork_ci_reconcile.read_project_ci_passed_url",
autospec=True)
+def
test_discover_recovers_a_status_on_a_base_branch_without_the_gate(mock_read,
client):
+ configs = mock.create_autospec(BaseGateConfigs, instance=True)
+ configs.get.return_value = None
+ node = {
+ **_pr_node(1),
+ "headRefOid": "sha1",
+ "labels": {"nodes": []},
+ "commits": _ci_passed_commit("PENDING",
CI_PASSED_WAITING_PROJECT_CI_DESCRIPTION),
+ }
+ mock_read.return_value = PROJECT_PASSED_URL
+ client.graphql.side_effect = [_search_result(node), _search_result()]
+ written: list[int] = []
+
+ discover(client, configs, repo=REPO, now=NOW, ci_passed_written=written)
+
+ assert written == [1]
+ client.add_labels.assert_not_called()
+
+
[email protected]("airflow_breeze.utils.fork_ci_reconcile.read_project_ci_passed_url",
autospec=True)
+def
test_discover_reads_the_status_of_a_head_commit_the_search_did_not_list(mock_read,
client):
+ config = GateConfig(enabled=False)
+ node = {
+ **_pr_node(1),
+ "headRefOid": "sha1",
+ "labels": {"nodes": []},
+ "commits": _ci_passed_commit("SUCCESS", CI_PASSED_DESCRIPTION,
oid="older"),
+ }
+ mock_read.return_value = PROJECT_PASSED_URL
+ client.graphql.side_effect = [_search_result(node), _search_result()]
+ client.rest_get.return_value = [
+ {
+ "context": CI_PASSED_CONTEXT,
+ "state": "pending",
+ "description": CI_PASSED_WAITING_PROJECT_CI_DESCRIPTION,
+ }
+ ]
+ configs = BaseGateConfigs(client, repo=REPO, default_branch="main",
default_config=config)
+ written: list[int] = []
+
+ discover(client, configs, repo=REPO, now=NOW, ci_passed_written=written)
+
+ assert written == [1]
+ mock_read.assert_called_once_with(client, REPO, "sha1")
+
+
+def
test_discover_keeps_going_when_the_project_ci_lookup_of_one_pull_request_fails(client):
+ config = GateConfig(enabled=False)
+ nodes = [
+ {
+ **_pr_node(n),
+ "headRefOid": f"sha{n}",
+ "labels": {"nodes": []},
+ "commits": _ci_passed_commit("PENDING",
CI_PASSED_WAITING_PROJECT_CI_DESCRIPTION, f"sha{n}"),
+ }
+ for n in (1, 2)
+ ]
+ client.graphql.side_effect = [_search_result(*nodes), _search_result()]
+ client.rest_get.side_effect = [GitHubError("HTTP 502"), {"workflow_runs":
[]}]
+ configs = BaseGateConfigs(client, repo=REPO, default_branch="main",
default_config=config)
+ unknown: list[tuple[int, str]] = []
+
+ discover(client, configs, repo=REPO, now=NOW, unknown=unknown,
ci_passed_written=[])
+
+ assert unknown == [(1, "`CI passed` not updated: GitHubError")]
+ assert client.rest_get.call_count == 2
+
+
def _contents(files: dict[tuple[str, str], str]):
def rest_get(path, paginate=False, items_key=None):
name, ref = path.removeprefix(f"repos/{REPO}/contents/").split("?ref=")
@@ -3450,3 +3670,546 @@ def
test_run_reconcile_reports_delayed_job_statuses_as_attention_items(
assert _reconcile_with_reporter(client, tmp_path) is True
assert "job statuses of fork run R are still not all posted" in (tmp_path
/ "s.json").read_text()
+
+
+def _passed_node(number=1, *, state=None, description=None, suites=(),
oid=None):
+ node = _project_node(number, status=state, suites=suites, oid=oid)
+ if state:
+
node["commits"]["nodes"][0]["commit"]["status"]["context"]["description"] =
description
+ return node
+
+
+def _passed_suite(
+ run_id,
+ *,
+ conclusion="SUCCESS",
+ created="2026-11-10T10:00:00Z",
+ cancelled=0,
+ not_run=0,
+ running=False,
+ suite_cancelled=False,
+ **kwargs,
+):
+ suite = _suite(run_id, created=created, **kwargs)
+ suite["status"] = "IN_PROGRESS" if running else "COMPLETED"
+ suite["conclusion"] = "CANCELLED" if suite_cancelled else None
+ suite["checkRuns"] = {"nodes": [{"name": "CI passed", "conclusion":
conclusion}] if conclusion else []}
+ suite["cancelled"] = {"totalCount": cancelled}
+ suite["notRun"] = {"totalCount": not_run}
+ return suite
+
+
+WAITING = (
+ "pending",
+ "https://github.com/apache/airflow/pull/1/checks",
+ CI_PASSED_WAITING_PROJECT_CI_DESCRIPTION,
+)
+PASSED = ("success", PROJECT_PASSED_URL, CI_PASSED_PROJECT_DESCRIPTION)
+
+
[email protected](
+ ("node", "expected"),
+ [
+ pytest.param(
+ _passed_node(state="SUCCESS", description=CI_PASSED_DESCRIPTION),
+ WAITING,
+ id="fork-success-revoked",
+ ),
+ pytest.param(_passed_node(), None, id="no-status-left-alone"),
+ pytest.param(
+ _passed_node(suites=[_passed_suite(5)]), None,
id="no-status-is-not-created-after-project-ci"
+ ),
+ pytest.param(
+ _passed_node(
+ state="SUCCESS", description=CI_PASSED_DESCRIPTION,
suites=[_passed_suite(5, cancelled=1)]
+ ),
+ PASSED,
+ id="rerun-passed-next-to-the-marker-of-its-first-attempt",
+ ),
+ pytest.param(
+ _passed_node(
+ state="SUCCESS",
+ description=CI_PASSED_DESCRIPTION,
+ suites=[
+ _passed_suite(4, created="2026-11-10T09:00:00Z"),
+ _passed_suite(6, conclusion=None, running=True),
+ ],
+ ),
+ WAITING,
+ id="newest-run-still-running-is-not-passed",
+ ),
+ pytest.param(
+ _passed_node(
+ state="SUCCESS",
+ description=CI_PASSED_DESCRIPTION,
+ suites=[
+ _passed_suite(5, created="2026-11-10T09:00:00Z"),
+ _passed_suite(6, conclusion=None, suite_cancelled=True),
+ ],
+ ),
+ ("success", "https://github.com/apache/airflow/actions/runs/5",
CI_PASSED_PROJECT_DESCRIPTION),
+ id="newer-empty-cancelled-run-does-not-hide-an-older-success",
+ ),
+ pytest.param(
+ _passed_node(
+ state="SUCCESS",
+ description=CI_PASSED_DESCRIPTION,
+ suites=[
+ _passed_suite(5, created="2026-11-10T09:00:00Z"),
+ _passed_suite(6, cancelled=1, conclusion=None),
+ ],
+ ),
+ ("success", "https://github.com/apache/airflow/actions/runs/5",
CI_PASSED_PROJECT_DESCRIPTION),
+ id="newer-cancelled-run-does-not-hide-an-older-success",
+ ),
+ pytest.param(_passed_node(state="PENDING", description="x"), None,
id="pending-left-alone"),
+ pytest.param(
+ _passed_node(
+ state="SUCCESS", description=CI_PASSED_DESCRIPTION,
suites=[_passed_suite(5, conclusion=None)]
+ ),
+ WAITING,
+ id="project-ci-still-running",
+ ),
+ pytest.param(
+ _passed_node(
+ state="SUCCESS",
+ description=CI_PASSED_DESCRIPTION,
+ suites=[_passed_suite(5, cancelled=1, conclusion=None)],
+ ),
+ WAITING,
+ id="cancelled-run",
+ ),
+ pytest.param(
+ _passed_node(
+ state="SUCCESS",
+ description=CI_PASSED_DESCRIPTION,
+ suites=[_passed_suite(5, not_run=1, conclusion=None)],
+ ),
+ WAITING,
+ id="project-ci-not-run",
+ ),
+ pytest.param(
+ _passed_node(
+ state="SUCCESS",
+ description=CI_PASSED_DESCRIPTION,
+ suites=[_passed_suite(5, event="push"), _passed_suite(6,
path=".github/workflows/other.yml")],
+ ),
+ WAITING,
+ id="not-a-ci-amd-pull-request-run",
+ ),
+ pytest.param(
+ _passed_node(
+ state="SUCCESS",
+ description=CI_PASSED_DESCRIPTION,
+ suites=[
+ _passed_suite(4, created="2026-11-10T09:00:00Z"),
+ _passed_suite(5, conclusion="FAILURE"),
+ ],
+ ),
+ WAITING,
+ id="latest-run-failed",
+ ),
+ pytest.param(
+ _passed_node(state="SUCCESS", description=CI_PASSED_DESCRIPTION,
suites=[_passed_suite(5)]),
+ PASSED,
+ id="project-ci-passed",
+ ),
+ pytest.param(
+ _passed_node(state="PENDING", description="x",
suites=[_passed_suite(5)]),
+ PASSED,
+ id="project-ci-passed-after-pending",
+ ),
+ pytest.param(
+ _passed_node(
+ state="SUCCESS", description=CI_PASSED_PROJECT_DESCRIPTION,
suites=[_passed_suite(5)]
+ ),
+ None,
+ id="project-ci-success-already-posted",
+ ),
+ pytest.param(
+ _passed_node(
+ state="SUCCESS",
+ description=CI_PASSED_PROJECT_DESCRIPTION,
+ suites=[_passed_suite(5, conclusion="FAILURE")],
+ ),
+ WAITING,
+ id="project-ci-rerun-failed",
+ ),
+ ],
+)
+def test_report_project_ci_passed(client, node, expected):
+ client.graphql.return_value = _search_result(node)
+
+ assert report_project_ci_passed(client, repo=REPO, attention=[]) == ([1]
if expected else [])
+
+ if expected is None:
+ client.set_status.assert_not_called()
+ else:
+ client.set_status.assert_called_once_with(1, "sha1", *expected,
context=CI_PASSED_CONTEXT)
+
+
+def test_report_project_ci_passed_limits_successes_but_not_revocations(client):
+ client.graphql.return_value = _search_result(
+ *(_passed_node(n, state="PENDING", description="x",
suites=[_passed_suite(5)]) for n in (1, 2, 3)),
+ _passed_node(4, state="SUCCESS", description=CI_PASSED_DESCRIPTION),
+ )
+
+ assert report_project_ci_passed(client, repo=REPO, attention=[], budget=2)
== [1, 2, 4]
+ assert 'label:"use project ci"' in client.graphql.call_args.args[1]["q"]
+
+
+def test_report_project_ci_passed_keeps_going_after_an_error(client):
+ client.graphql.return_value = _search_result(
+ *(_passed_node(n, state="SUCCESS", description=CI_PASSED_DESCRIPTION)
for n in (1, 2))
+ )
+ client.set_status.side_effect = [GitHubError("gh api failed: HTTP 502"),
None]
+ attention: list[tuple[int, str]] = []
+
+ assert report_project_ci_passed(client, repo=REPO, attention=attention) ==
[2]
+ assert attention == [(1, "`CI passed` not updated: GitHubError")]
+
+
+def _rest_for_project_ci_passed(runs, checks):
+ """``rest_get`` of a commit with these workflow runs and (name,
conclusion, check suite id) checks."""
+
+ def rest_get(path, **_):
+ if "/actions/runs" in path:
+ assert "per_page=100" in path
+ return {"workflow_runs": runs}
+ assert "per_page=100" in path
+ return {
+ "check_runs": [
+ {"name": name, "conclusion": conclusion, "check_suite": {"id":
suite_id}}
+ for name, conclusion, suite_id in checks
+ if f"check_name={quote(name)}&" in path
+ ]
+ }
+
+ return rest_get
+
+
[email protected](
+ ("checks", "expected"),
+ [
+ pytest.param(
+ [("CI passed", "success", 8)],
"https://github.com/apache/airflow/actions/runs/6", id="passed"
+ ),
+ pytest.param([("CI passed", "failure", 8)], None, id="failed"),
+ pytest.param([("CI passed", "success", 9)], None,
id="check-of-another-run"),
+ pytest.param(
+ [("CI passed", "success", 8), ("CI passed (run cancelled)",
"success", 8)],
+ "https://github.com/apache/airflow/actions/runs/6",
+ id="rerun-passed-next-to-the-marker-of-its-first-attempt",
+ ),
+ pytest.param([("CI passed", "success", 7)], None,
id="newest-run-still-running-is-not-passed"),
+ pytest.param(
+ [("CI passed", "success", 7), ("CI passed (run cancelled)",
"success", 8)],
+ PROJECT_PASSED_URL,
+ id="newer-cancelled-run-does-not-hide-an-older-success",
+ ),
+ pytest.param(
+ [("CI passed", "success", 7), ("CI passed (project CI not run)",
"success", 8)],
+ PROJECT_PASSED_URL,
+ id="newer-not-run-marker-does-not-hide-an-older-success",
+ ),
+ ],
+)
+def test_read_project_ci_passed_url(client, checks, expected):
+ older = {**_run_json(5), "check_suite_id": 7, "html_url":
PROJECT_PASSED_URL}
+ newer = {**_run_json(6), "check_suite_id": 8}
+ client.rest_get.side_effect = _rest_for_project_ci_passed([newer, older],
checks)
+
+ assert read_project_ci_passed_url(client, REPO, "sha1") == expected
+
+
[email protected](
+ ("newer_status", "newer_conclusion", "expected"),
+ [
+ pytest.param("completed", "cancelled", PROJECT_PASSED_URL,
id="newer-empty-cancelled-run"),
+ pytest.param("in_progress", None, None,
id="newer-run-in-progress-without-checks"),
+ ],
+)
+def test_read_project_ci_passed_url_with_a_newer_run_without_checks(
+ client, newer_status, newer_conclusion, expected
+):
+ older = {**_run_json(5), "check_suite_id": 7, "html_url":
PROJECT_PASSED_URL}
+ newer = {**_run_json(6), "check_suite_id": 8, "status": newer_status,
"conclusion": newer_conclusion}
+ client.rest_get.side_effect = _rest_for_project_ci_passed([newer, older],
[("CI passed", "success", 7)])
+
+ assert read_project_ci_passed_url(client, REPO, "sha1") == expected
+
+
+def test_read_project_ci_passed_url_ignores_other_workflows(client):
+ client.rest_get.return_value = {"workflow_runs": [_run_json(5,
"other.yml")]}
+
+ assert read_project_ci_passed_url(client, REPO, "sha1") is None
+
+
[email protected](
+ ("passed", "expected"),
+ [pytest.param(True, PASSED, id="found-success"), pytest.param(False,
WAITING, id="none-found")],
+)
[email protected]("airflow_breeze.utils.fork_ci_reconcile.read_project_ci_passed_url",
autospec=True)
+def test_report_project_ci_passed_reads_a_head_commit_the_search_did_not_list(
+ mock_read, client, passed, expected
+):
+ mock_read.return_value = PROJECT_PASSED_URL if passed else None
+ client.graphql.return_value = _search_result(
+ _passed_node(state="SUCCESS", description=CI_PASSED_DESCRIPTION,
oid="older")
+ )
+ client.rest_get.return_value = [
+ {"context": CI_PASSED_CONTEXT, "state": "success", "description":
CI_PASSED_DESCRIPTION}
+ ]
+
+ report_project_ci_passed(client, repo=REPO, attention=[])
+
+ client.set_status.assert_called_once_with(1, "sha1", *expected,
context=CI_PASSED_CONTEXT)
+
+
+def test_find_project_ci_label_heads(client):
+ client.graphql.return_value = _search_result({"headRefOid": "a"},
{"headRefOid": "b"})
+
+ assert find_project_ci_label_heads(client, REPO) == {"a", "b"}
+ assert 'label:"use project ci"' in client.graphql.call_args.args[1]["q"]
+
+
+LIVE_PULL_REQUEST = {"head": {"sha": "shared"}, "labels": []}
+
+
+def _run_reconcile_with_green_pull_requests(
+ client,
+ tmp_path,
+ *numbers,
+ live=LIVE_PULL_REQUEST,
+ same_branch=(),
+ ci_passed=("success", RUN_URL, CI_PASSED_DESCRIPTION),
+ state=ForkState.GREEN,
+):
+ """Reconcile gated, fork-green pull requests that all have head commit
``shared``."""
+ nodes = [_pr_node(n, headRefOid="shared") for n in numbers]
+ client.graphql.side_effect = [
+ _search_result(),
+ _search_result(),
+ _search_result(*nodes),
+ {f"pr{index}": {"object": None} for index in range(len(nodes))},
+ ]
+ client.rest_get_pages.return_value = [{"total_count": 0, "workflow_runs":
[]}]
+ client.rest_get.side_effect = lambda path, **_: (
+ live
+ if "/pulls/" in path
+ else same_branch
+ if "/pulls?" in path
+ else []
+ if "/status" in path
+ else {"state": "active"}
+ )
+ plan = Plan(state=state, ci_passed=ci_passed)
+ with (
+ mock.patch("airflow_breeze.utils.fork_ci_reconcile.classify",
autospec=True, return_value=plan),
+ mock.patch("airflow_breeze.utils.fork_ci_reconcile.JobStatusReporter",
autospec=True) as reporter,
+
mock.patch("airflow_breeze.utils.fork_ci_reconcile.report_project_ci_runs_started",
autospec=True),
+ ):
+ reporter.return_value.report.return_value = None
+ reporter.return_value.deferred = []
+ reporter.return_value.delayed = []
+ run_reconcile(
+ client,
+ CONFIG,
+ now=NOW,
+ run_url="https://run",
+ output_file=tmp_path / "s.json",
+ workflows_dir=tmp_path,
+ )
+
+
[email protected](
+ ("heads", "posted"),
+ [
+ pytest.param({"shared"}, False,
id="head-of-a-pull-request-with-the-label"),
+ pytest.param(set(), True, id="no-label"),
+ pytest.param(GitHubError("HTTP 502"), False, id="heads-unknown"),
+ ],
+)
+def test_run_reconcile_posts_no_fork_success_on_a_head_with_use_project_ci(
+ mock_project_ci_heads, client, tmp_path, heads, posted
+):
+ # Two pull requests on one head commit: statuses belong to the commit, so
one with the label
+ # makes the fork success of the other one count for it.
+ if isinstance(heads, Exception):
+ mock_project_ci_heads.side_effect = heads
+ else:
+ mock_project_ci_heads.return_value = heads
+
+ _run_reconcile_with_green_pull_requests(client, tmp_path, 1, 2)
+
+ posted_calls = [
+ c for c in client.set_status.call_args_list if c.kwargs.get("context")
== CI_PASSED_CONTEXT
+ ]
+ assert bool(posted_calls) is posted
+
+
[email protected](
+ "heads",
+ [
+ pytest.param(set(), id="label-not-yet-in-the-search-index"),
+ pytest.param(GitHubError("HTTP 502"), id="heads-unknown"),
+ pytest.param({"shared"}, id="head-already-in-the-label-search"),
+ ],
+)
+def
test_run_reconcile_revokes_a_fork_success_in_the_run_that_releases_to_project_ci(
+ mock_project_ci_heads, client, tmp_path, heads
+):
+ if isinstance(heads, Exception):
+ mock_project_ci_heads.side_effect = heads
+ else:
+ mock_project_ci_heads.return_value = heads
+ revoke = ("pending", f"https://github.com/{REPO}/pull/1/checks",
CI_PASSED_WAITING_PROJECT_CI_DESCRIPTION)
+
+ _run_reconcile_with_green_pull_requests(client, tmp_path, 1,
state=ForkState.RELEASED, ci_passed=revoke)
+
+ posted_calls = [
+ c for c in client.set_status.call_args_list if c.kwargs.get("context")
== CI_PASSED_CONTEXT
+ ]
+ assert posted_calls == [mock.call(1, "shared", *revoke,
context=CI_PASSED_CONTEXT)]
+
+
[email protected](
+ "live",
+ [
+ pytest.param(
+ {"head": {"sha": "shared"}, "labels": [{"name": "use project
ci"}]}, id="labelled-since"
+ ),
+ pytest.param({"head": {"sha": "newer"}, "labels": []},
id="head-changed"),
+ ],
+)
+def
test_run_reconcile_reads_the_pull_request_live_before_posting_a_fork_success(client,
tmp_path, live):
+ _run_reconcile_with_green_pull_requests(client, tmp_path, 1, live=live)
+
+ client.set_status.assert_not_called()
+ client.rest_get.assert_any_call(f"repos/{REPO}/pulls/1")
+
+
+def
test_run_reconcile_reads_the_pull_requests_of_the_head_branch_before_posting_a_fork_success(
+ client, tmp_path
+):
+ other = {"head": {"sha": "shared"}, "labels": [{"name": "use project ci"}]}
+
+ _run_reconcile_with_green_pull_requests(client, tmp_path, 1,
same_branch=[other])
+
+ client.set_status.assert_not_called()
+ assert any(
+ "/pulls?state=open" in c.args[0] and "head=" in c.args[0] for c in
client.rest_get.call_args_list
+ )
+
+
+def test_run_reconcile_still_revokes_when_the_labelled_heads_are_unknown(
+ mock_project_ci_heads, client, tmp_path
+):
+ mock_project_ci_heads.side_effect = GitHubError("HTTP 502")
+ pending = ("pending", RUN_URL, "Fork CI result no longer trusted")
+
+ _run_reconcile_with_green_pull_requests(client, tmp_path, 1,
ci_passed=pending)
+
+ client.set_status.assert_called_once_with(1, "shared", *pending,
context=CI_PASSED_CONTEXT)
+
+
+def
test_report_project_ci_passed_revokes_a_pull_request_the_heads_search_missed(
+ mock_project_ci_heads, mock_report_project_ci_passed, client, tmp_path
+):
+ # The heads list was empty at the start of the run, as when the search
lags behind a new label.
+ mock_project_ci_heads.return_value = set()
+ mock_report_project_ci_passed.side_effect = report_project_ci_passed
+ client.graphql.side_effect = [
+ _search_result(),
+ _search_result(),
+ _search_result(),
+ _search_result(_passed_node(state="SUCCESS",
description=CI_PASSED_DESCRIPTION)),
+ ]
+ client.rest_get_pages.return_value = [{"total_count": 0, "workflow_runs":
[]}]
+
+ with
mock.patch("airflow_breeze.utils.fork_ci_reconcile.report_project_ci_runs_started",
autospec=True):
+ run_reconcile(
+ client,
+ CONFIG,
+ now=NOW,
+ run_url="https://run",
+ output_file=tmp_path / "s.json",
+ workflows_dir=tmp_path,
+ )
+
+ client.set_status.assert_called_once_with(1, "sha1", *WAITING,
context=CI_PASSED_CONTEXT)
+
+
+def test_run_reconcile_still_revokes_a_fork_success_on_a_labelled_head(
+ mock_project_ci_heads, client, tmp_path
+):
+ mock_project_ci_heads.return_value = {"shared"}
+ revoke = ("pending", RUN_URL, "Fork CI result no longer trusted")
+
+ _run_reconcile_with_green_pull_requests(client, tmp_path, 1,
ci_passed=revoke)
+
+ client.set_status.assert_called_once_with(1, "shared", *revoke,
context=CI_PASSED_CONTEXT)
+
+
[email protected]("workflow_file", ["ci-amd.yml", "ci.yml",
"ci-amd-arm.yml"])
+def
test_find_project_ci_passed_url_accepts_the_workflow_files_of_release_branches(workflow_file):
+ node = _passed_node(
+ suites=[_passed_suite(5, path=f".github/workflows/{workflow_file}")],
state="SUCCESS", description="x"
+ )
+
+ assert find_project_ci_passed_url(node) == PROJECT_PASSED_URL
+
+
+def
test_read_project_ci_passed_url_accepts_the_workflow_files_of_release_branches(client):
+ run = {**_run_json(5, "ci.yml"), "check_suite_id": 7}
+ client.rest_get.side_effect = _rest_for_project_ci_passed([run], [("CI
passed", "success", 7)])
+
+ assert read_project_ci_passed_url(client, REPO, "sha1") ==
PROJECT_PASSED_URL
+
+
+def test_run_reconcile_reports_project_ci_passed_after_the_loop(
+ mock_project_ci_heads, mock_report_project_ci_passed, client, tmp_path
+):
+ order = mock.Mock()
+ order.attach_mock(client.set_status, "set_status")
+ order.attach_mock(mock_report_project_ci_passed, "report")
+ mock_project_ci_heads.return_value = {"other"}
+
+ _run_reconcile_with_green_pull_requests(client, tmp_path, 1)
+
+ names = [c[0] for c in order.mock_calls]
+ assert names.index("report") > names.index("set_status")
+ assert mock_report_project_ci_passed.call_args.kwargs["budget"] == 5
+
+
+def
test_run_reconcile_reports_project_ci_passed_failure(mock_report_project_ci_passed,
client, tmp_path):
+ client.graphql.side_effect = [_search_result(), _search_result(),
_search_result()]
+ client.rest_get_pages.return_value = [{"total_count": 0, "workflow_runs":
[]}]
+ mock_report_project_ci_passed.side_effect = GitHubError("gh api failed:
HTTP 502")
+
+ with mock.patch(
+
"airflow_breeze.utils.fork_ci_reconcile.report_project_ci_runs_started",
autospec=True
+ ) as mock_started:
+ mock_started.return_value = []
+ has_alerts = run_reconcile(
+ client,
+ CONFIG,
+ now=NOW,
+ run_url="https://run",
+ output_file=tmp_path / "s.json",
+ workflows_dir=tmp_path,
+ )
+
+ assert has_alerts is True
+ assert (
+ "reporting `CI passed` for `use project ci` failed: GitHubError" in
(tmp_path / "s.json").read_text()
+ )
+
+
+def
test_report_project_ci_passed_counts_only_the_successes_that_were_written(client):
+ client.graphql.return_value = _search_result(
+ *(_passed_node(n, state="PENDING", description="x",
suites=[_passed_suite(5)]) for n in (1, 2, 3))
+ )
+ client.set_status.side_effect = [GitHubError("HTTP 502"), None, None]
+
+ assert report_project_ci_passed(client, repo=REPO, attention=[], budget=1)
== [2]