potiuk commented on code in PR #74819:
URL: https://github.com/apache/airflow/pull/74819#discussion_r4304445187


##########
dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md:
##########
@@ -0,0 +1,778 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+ -->
+
+<!-- START doctoc generated TOC please keep comment here to allow auto update 
-->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+
+- [Fork CI for external contributors 
(AIP-120)](#fork-ci-for-external-contributors-aip-120)
+  - [Goals and non-goals](#goals-and-non-goals)
+  - [Overview](#overview)
+  - [Configuration](#configuration)
+  - [Eligibility](#eligibility)
+  - [Project CI gate](#project-ci-gate)
+  - [Fork mode CI](#fork-mode-ci)
+  - [Reconciler](#reconciler)
+  - [Monitor workflow](#monitor-workflow)
+  - [`breeze ci audit`](#breeze-ci-audit)
+  - [Documentation changes](#documentation-changes)
+  - [Rollout and rollback](#rollout-and-rollback)
+  - [Security considerations](#security-considerations)
+  - [Known limitations](#known-limitations)
+  - [Testing](#testing)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+# Fork CI for external contributors (AIP-120)
+
+This document is the design of the CI changes implementing
+[AIP-120: Run CI for external contributions in contributor 
forks](https://cwiki.apache.org/confluence/spaces/AIRFLOW/pages/451974711/AIP-120+Run+CI+for+external+contributions+in+contributor+forks).
+The policy itself and its rationale live in the AIP and in the dev list 
discussions
+([first](https://lists.apache.org/thread/w74655o8y1jzrzcnygntyyovocf7zmzj),
+[second](https://lists.apache.org/thread/ky392mrrcmj312y1r276r30p584n804c), 
which agreed to
+start with an opt-in pilot).
+This document describes how the policy is mechanised.
+
+## Goals and non-goals
+
+Goals:
+
+- External contributors get CI feedback from a plain `git push` to their fork. 
No pull request
+  against their own fork, no manual workflow dispatch, no sorting through 
workflow files.
+- Project CI (`apache/airflow` runners) does not run for gated pull requests. 
The decision is
+  made by one function with one configuration file.
+- A single scheduled reconciler owns the lifecycle of gated pull requests: 
labelling, drafting,
+  undrafting, the state comment, the nudge after workflows are enabled, 
closing after
+  inactivity, and closing a pull request that tampered with the gate to get 
project CI. It is
+  idempotent, so missed or duplicated scheduled runs are harmless.
+- Maintainers and contributors see the fork CI state where they already look: 
a commit status
+  on the pull request head, red / yellow / green, linking to the run in the 
fork.
+- A contributor can verify their whole setup locally with `breeze ci audit` 
before pushing.
+- Everything ships dormant and is switched on by a one-line configuration 
change.
+- Before it is switched on for everyone, the whole flow can be piloted on real 
traffic by
+  committers on single pull requests and by volunteer external contributors on 
all their new
+  pull requests, without affecting anyone else.
+
+Non-goals:
+
+- The one-time reset of the open pull request backlog (AIP-120 §9.1). The 
design makes it a
+  configuration change, but it is decided separately.
+- Any change to how committer, collaborator or exempted-organisation pull 
requests are tested.
+- Event-driven mutation of pull requests. Nothing in this design uses 
`pull_request_target`.
+  All writes come from the scheduled reconciler.
+
+## Overview
+
+```
+                 contributor fork                          apache/airflow
+   ┌──────────────────────────────────┐      
┌──────────────────────────────────────────┐
+   │ git push feature-branch          │      │ pull_request event              
          │
+   │   └─ ci-amd.yml (push trigger)   │      │   └─ ci-amd.yml                 
          │
+   │        gate job: fork -> run     │      │        gate job: eligibility -> 
skip      │
+   │        build-info: fork mode     │      │        (build-info and 
everything after   │
+   │          changed files from      │      │         are skipped for gated 
PRs)        │
+   │          compare API vs upstream │      │                                 
          │
+   │        public runners only       │      │ fork-ci-monitor.yml (every 15 
minutes)    │
+   └──────────────────────────────────┘      │   └─ breeze ci 
fork-ci-reconcile          │
+                     ▲                        │        discover new gated PRs 
-> label    │
+                     │ GraphQL: checkSuites   │        per labelled PR: fork 
state ->     │
+                     │ REST: workflow state   │          draft/undraft, 
labels, comment,  │
+                     └────────────────────────│          commit status with 
run link,     │
+                                              │          close after 7 days or 
when the   │
+                                              │          PR tampered with the 
gate        │
+                                              │        unknown failures -> 
Slack          │
+                                              
└──────────────────────────────────────────┘
+```
+
+Four pieces, in dependency order:
+
+1. **Eligibility** — `dev/breeze/src/airflow_breeze/utils/fork_ci_gate.py`, 
one pure function
+   and one configuration file. Used by the gate job and by the reconciler.
+2. **Project CI gate** — a `gate` job at the head of `ci-amd.yml`.
+3. **Fork mode CI** — a push trigger plus fork-aware selective checks.
+4. **Reconciler** — `breeze ci fork-ci-reconcile`, run by 
`fork-ci-monitor.yml` on a schedule.
+
+Plus `breeze ci audit` for the contributor's local setup, and documentation.
+
+## Configuration
+
+One file, `.github/fork-ci-gate.toml`, parsed with the standard library 
`tomllib`:
+
+```toml
+# Fork CI gate (AIP-120). See 
dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md
+
+# Master switch. When false the gate job lets every pull request run project 
CI, except
+# pull requests labelled `use fork ci` and pull requests of [pilot] 
participants. Push
+# filtering for apache/airflow branches applies regardless.
+enabled = false
+
+# Pull requests created before this instant are never gated, whatever their 
author.
+# Moving the date back is how the backlog reset (AIP-120 §9.1) would be 
applied.
+enabled_since = 2026-01-01T00:00:00Z
+
+# Days without any fork CI activity after which a gated pull request is closed.
+close_after_days = 7
+
+# Organisations exempted under AIP-120 §2.2. Keys are display names used in 
comments,
+# values are GitHub logins. Changes go through a reviewed pull request.
+[allowlist]
+# "Example Org" = ["login-one", "login-two"]
+
+# Opt-in pilot participants. Keys are GitHub logins, values are the instant 
they joined.
+# Their pull requests created at or after that instant are gated whether or 
not `enabled`
+# is set. Contributors opt in and out with a pull request editing this table.
+[pilot]
+# "login-three" = 2026-10-20T00:00:00Z
+```
+
+The file lives in the repository rather than in an Actions variable because 
ASF projects
+cannot self-serve Actions variables, and because exemption changes should be 
reviewed like
+any other change. The gate job reads it from the pull request's *base* branch, 
never from
+the pull request's own tree, so a pull request cannot change its own 
eligibility.
+
+## Eligibility
+
+`fork_ci_gate.decide(event, config) -> Decision` is a pure function. `event` 
carries the
+fields both callers have: event name, repository, ref, head repository, author 
login,
+author association, pull request creation time and the pull request's current 
labels.
+`Decision` is `run` (a bool) plus a `reason` from a fixed set, used in logs, 
comments and by
+the reconciler.
+
+The rules, first match wins. The push rules are independent of `enabled` 
because the `'**'`
+push trigger exists whether or not the gate is on, and today no CI runs for 
pushes to
+`main` or to ad-hoc branches in `apache/airflow` (post-merge coverage is the 
cron canary).
+The label and pilot rules are also independent of `enabled`; see "Override 
labels" and
+"Contributor pilot" below.
+
+The `fork_schedule` rule exists because enabling `ci-amd.yml` in a fork also 
enables its
+cron trigger, and selective checks force the full matrix for `schedule` 
events. Without it
+every enabled fork would run the full canary matrix twice a day on the 
contributor's
+concurrency cap. A manual `workflow_dispatch` in a fork still runs.
+
+| Rule | Decision | Reason |
+|---|---|---|
+| Event is `schedule` and repository is not `apache/airflow` | skip | 
`fork_schedule` |
+| Event is `schedule` or `workflow_dispatch` | run | `scheduled` |
+| Event is `push` and repository is not `apache/airflow` | run | `fork_push` |
+| Event is `push` to `apache/airflow` and the branch matches the pre-existing 
push patterns (`v*-test`, `providers-*/v*`, `chart/v*x-test`, 
`airflow-ctl/v*-test`) | run | `canonical_branch` |
+| Event is `push` to `apache/airflow`, any other branch | skip | 
`adhoc_branch` |
+| Pull request carries `use project ci` | run | `label_project_ci` |
+| Pull request carries `use fork ci` | skip | `label_fork_ci` |
+| Author login is in `[pilot]` and the pull request was created at or after 
that login's join instant | skip | `pilot` |
+| `enabled` is false | run | `gate_disabled` |
+| Pull request head repository is `apache/airflow` | run | `head_is_upstream` |
+| Pull request created before `enabled_since` | run | `before_enabled_since` |
+| Author association is `MEMBER`, `OWNER` or `COLLABORATOR` | run | 
`trusted_author` |
+| Author login is in `[allowlist]` | run | `allowlisted` |
+| Anything else | skip | `external` |
+
+The table applies once the base branch carries the gate. When the base branch 
has neither
+`.github/fork-ci-gate.toml` nor `fork_ci_gate.py` (the pull request that 
introduces the gate,
+or a release branch cut before it), the job runs project CI with reason 
`base_without_gate`,
+as it did before the gate existed. A base branch with only one of the two 
files fails the job.
+
+"Gated" throughout this document means a pull request whose decision reason is 
`external`,
+`pilot` or `label_fork_ci`. The reconciler labels exactly those; an 
`adhoc_branch` skip is
+not a pull request and is never labelled.
+
+Logins are compared case-insensitively in `[allowlist]` and `[pilot]`, as 
GitHub does.
+
+### Override labels
+
+Two labels let a committer or collaborator (anyone with triage rights; 
external contributors
+cannot set labels) override the automatic decision for one pull request:
+
+| Label | Effect |
+|---|---|
+| `use project ci` | Project CI runs for a pull request that would otherwise 
be gated. Reasons: the change needs the full matrix, self-hosted runners or 
secrets, or a maintainer has adopted the pull request. |
+| `use fork ci` | The fork regime applies to a pull request that would 
otherwise run on project CI. Reasons: a committer testing the machinery on 
their own pull request, or a large pull request from an allowlisted 
organisation that should be verified in the fork first. |
+
+Because both rules sit before the `enabled` switch, `use fork ci` works while
+`enabled = false`. That is the committer pilot: label a handful of pull 
requests and the
+gate, the fork run, the reconciler and the commit status all operate for those 
only.
+
+Consequences for the other components:
+
+- The gate job reads the pull request's labels live from the API (one 
read-only call) rather
+  than from the event payload, so labelling a pull request and pressing 
"Re-run all jobs"
+  switches it without a new push.
+- Discovery in the reconciler also picks up open pull requests carrying `use 
fork ci`
+  without `fork ci required`, regardless of `enabled`.
+- A pull request carrying both `fork ci required` and `use project ci` is 
*released*: the
+  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
+  re-run.
+- Both override labels on one pull request resolve to project CI and are 
reported to Slack as
+  a conflict.
+
+### Contributor pilot
+
+The committer pilot exercises the machinery but not the experience it is meant 
for: an
+external contributor setting up their fork from the instructions, waiting for 
their own
+fork run and being drafted and undrafted by the reconciler. The `use fork ci` 
label does not
+work for that. It is set after the pull request is opened, so the first 
`pull_request` run
+has already used project CI, and a maintainer would have to label every pull 
request the
+volunteer opens.
+
+The `[pilot]` table covers it. A contributor opts in with a pull request that 
adds their
+login and a join instant to `[pilot]`; any committer can merge it. Once the 
entry is on the
+base branch, every pull request they open is gated from its first run, whether 
or not
+`enabled` is set, and goes through exactly the flow described in this 
document: setup
+comment, draft, fork run, commit status, undraft, close after inactivity, gate 
tampering
+check. Pull requests they opened before the join instant keep project CI.
+
+The gate reads the table from the base branch, so the entry takes effect when 
it merges,
+while the join instant is compared with pull request creation. The join 
instant must
+therefore not be earlier than the merge: pull requests opened in between ran 
project CI,
+are labelled on the next reconciler run, and one of them that changes 
`.github/workflows/`
+would be closed for gate tampering. The `check-fork-ci-gate-config` prek hook 
rejects a newly
+added `[pilot]` instant, an `enabled_since` moved later, or `enabled` switched 
on while
+`enabled_since` is still in the past; contributors use the next day at 00:00 
UTC. Moving
+`enabled_since` earlier is the backlog reset and is allowed. In CI the hook 
fetches the pull
+request's base branch to compare with, because the shallow checkout has none; 
a push in a
+contributor's fork has no base branch, so it fetches `apache/airflow`'s 
`main`. Without any
+base to compare with, the hook fails rather than comparing the change with 
itself. A merge
+after that instant still leaves the window open; the
+closure is reported to Slack as an attention item, and the maintainer reopens 
the pull
+request with `use project ci`.
+
+Leaving is a pull request that removes the line. Pull requests already labelled
+`fork ci required` stay in the state machine until a maintainer adds `use 
project ci`, as
+for any other gated pull request. A maintainer can also release a single pull 
request with
+`use project ci` at any time.
+
+Opting in only ever takes project CI away from the contributor, so the opt-in 
pull request
+needs no more review than checking that the login is the author's own. The 
table is read
+from the base branch like the rest of the file, so adding oneself in the same 
pull request
+that should be gated has no effect on that pull request.
+
+`[pilot]` is meant to be short-lived: it is emptied when `enabled = true` is 
set, because
+from then on the participants are gated as `external` anyway.
+
+`MEMBER` means a member of the `apache` GitHub organisation, which every ASF 
committer is.
+`COLLABORATOR` is the triage role granted through `.asf.yaml`. The static 
`COMMITTERS` list in
+`global_constants.py` is not consulted; it drives runner selection only and 
drifts from
+reality.
+
+The module has no imports from `airflow_breeze` so that the gate job can 
execute the file
+directly with the runner's `python3` without installing breeze. The reconciler 
imports it.
+
+## Project CI gate
+
+`ci-amd.yml` gets a first job, `gate`, on `ubuntu-slim`:
+
+1. Sparse checkout of `.github/fork-ci-gate.toml` and
+   `dev/breeze/src/airflow_breeze/utils/fork_ci_gate.py` from the base branch
+   (`github.base_ref` for pull requests, `github.ref` otherwise), 
`fetch-depth: 1`.
+2. For pull requests, one read-only API call for the current labels
+   (`gh api repos/apache/airflow/pulls/<n> --jq '.labels[].name'`), so 
override labels added
+   after the event are seen on a re-run. If that call fails, the gate uses the 
labels of the
+   event payload instead. It never falls back to running project CI: anyone 
able to exhaust
+   the shared token quota would otherwise switch the gate off for every pull 
request.
+3. `python3 dev/breeze/src/airflow_breeze/utils/fork_ci_gate.py` with the 
event payload
+   fields and the labels passed as environment variables. It writes 
`project-ci=true|false`
+   and `reason=...` to `GITHUB_OUTPUT`. A base branch with neither file skips 
the script and
+   writes `project-ci=true` with `reason=base_without_gate`.
+
+`build-info` gains `needs: [gate]` and `if: needs.gate.outputs.project-ci == 
'true'`. Every
+other job already depends on `build-info`, so a gated pull request produces a 
run in which the
+gate job succeeds in seconds and everything else is skipped. No red status is 
attached to the
+pull request; the reconciler's comment explains what to do.
+
+`ci-arm.yml` is not changed. It has no `pull_request` trigger and never runs 
in forks.
+The `check-ci-workflows-in-sync` prek hook allowlist is extended to permit the 
`gate` job and
+the `build-info` dependency to exist only in the AMD workflow.

Review Comment:
   Right, the doc was stale. It now says `ci-arm.yml` keeps its triggers, that 
its `push.branches` list is the source of the canonical branch filters, and 
that its `notify-slack` condition matches `ci-amd.yml`'s. Fixed in e9a403c678.
   
   ---
   Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting



##########
dev/breeze/src/airflow_breeze/utils/fork_ci_gate.py:
##########
@@ -0,0 +1,281 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Fork CI eligibility (AIP-120).
+
+Decides whether a CI run in ``apache/airflow`` should use project CI or leave 
the pull request
+to CI in the contributor's fork. See 
dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md.
+
+The gate job in ``ci-amd.yml`` runs this file with the runner's ``python3`` 
from a sparse
+checkout, without installing breeze, so it must only import from the standard 
library.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import subprocess
+import sys
+import tomllib
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from datetime import UTC, datetime
+from enum import Enum
+from pathlib import Path
+from typing import Any
+
+APACHE_REPO = "apache/airflow"
+
+LABEL_FORK_CI_REQUIRED = "fork ci required"
+LABEL_USE_PROJECT_CI = "use project ci"
+LABEL_USE_FORK_CI = "use fork ci"
+LABEL_READY_FOR_REVIEW = "ready for maintainer review"
+
+DEFAULT_CLOSE_AFTER_DAYS = 7
+DEFAULT_ENABLED_SINCE = datetime(2026, 1, 1, tzinfo=UTC)
+
+# The push patterns ci-amd.yml used before its push trigger became '**'. 
GitHub branch filters
+# use the same `+`, `?`, `*` and `[...]` semantics as these regular 
expressions.
+CANONICAL_PUSH_BRANCH_PATTERNS: tuple[re.Pattern[str], ...] = tuple(
+    re.compile(pattern)
+    for pattern in (
+        r"^v[0-9]+-[0-9]+-test$",
+        r"^providers-[a-z]+-?[a-z]*/v[0-9]+-[0-9]+$",
+        r"^chart/v[0-9]+-[0-9]+x-test$",
+        r"^airflow-ctl/v[0-9]+-[0-9]+-test$",
+    )
+)
+
+TRUSTED_ASSOCIATIONS = frozenset({"MEMBER", "OWNER", "COLLABORATOR"})
+
+
+class Reason(str, Enum):
+    SCHEDULED = "scheduled"
+    FORK_SCHEDULE = "fork_schedule"
+    FORK_PUSH = "fork_push"
+    CANONICAL_BRANCH = "canonical_branch"
+    ADHOC_BRANCH = "adhoc_branch"
+    LABEL_PROJECT_CI = "label_project_ci"
+    LABEL_FORK_CI = "label_fork_ci"
+    PILOT = "pilot"
+    GATE_DISABLED = "gate_disabled"
+    HEAD_IS_UPSTREAM = "head_is_upstream"
+    BEFORE_ENABLED_SINCE = "before_enabled_since"
+    TRUSTED_AUTHOR = "trusted_author"
+    ALLOWLISTED = "allowlisted"
+    EXTERNAL = "external"
+
+
+GATED_REASONS = frozenset({Reason.EXTERNAL, Reason.PILOT, 
Reason.LABEL_FORK_CI})
+TAMPER_CHECKED_REASONS = frozenset({Reason.EXTERNAL, Reason.PILOT})
+
+
+@dataclass(frozen=True)
+class GateConfig:
+    enabled: bool = False
+    enabled_since: datetime = DEFAULT_ENABLED_SINCE
+    close_after_days: int = DEFAULT_CLOSE_AFTER_DAYS
+    allowlist: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
+    pilot: Mapping[str, datetime] = field(default_factory=dict)
+
+    def is_allowlisted(self, login: str) -> bool:
+        wanted = login.casefold()
+        return any(wanted == member.casefold() for members in 
self.allowlist.values() for member in members)
+
+    def pilot_joined_at(self, login: str) -> datetime | None:
+        wanted = login.casefold()
+        for pilot_login, joined_at in self.pilot.items():
+            if pilot_login.casefold() == wanted:
+                return joined_at
+        return None
+
+
+def _require_aware_datetime(value: Any, name: str) -> datetime:
+    if not isinstance(value, datetime) or value.tzinfo is None:
+        raise ValueError(
+            f"{name} must be a TOML offset date-time such as 
2026-10-20T00:00:00Z, got {value!r}"
+        )
+    return value
+
+
+def parse_config(text: str) -> GateConfig:
+    try:
+        data = tomllib.loads(text)
+    except tomllib.TOMLDecodeError as e:
+        raise ValueError(f"Invalid fork CI gate configuration: {e}") from e
+
+    enabled = data.get("enabled", False)
+    if not isinstance(enabled, bool):
+        raise ValueError(f"enabled must be true or false, got {enabled!r}")
+    enabled_since = _require_aware_datetime(data.get("enabled_since", 
DEFAULT_ENABLED_SINCE), "enabled_since")
+    close_after_days = data.get("close_after_days", DEFAULT_CLOSE_AFTER_DAYS)
+    if isinstance(close_after_days, bool) or not isinstance(close_after_days, 
int) or close_after_days < 1:
+        raise ValueError(f"close_after_days must be a positive integer, got 
{close_after_days!r}")
+
+    allowlist: dict[str, tuple[str, ...]] = {}
+    for org, logins in data.get("allowlist", {}).items():
+        if not isinstance(logins, list) or not all(isinstance(login, str) for 
login in logins):
+            raise ValueError(f"allowlist entry {org!r} must be a list of 
GitHub logins, got {logins!r}")
+        allowlist[org] = tuple(logins)
+
+    pilot = {
+        login.casefold(): _require_aware_datetime(joined_at, f"pilot entry 
{login!r}")
+        for login, joined_at in data.get("pilot", {}).items()
+    }
+    return GateConfig(
+        enabled=enabled,
+        enabled_since=enabled_since,
+        close_after_days=close_after_days,
+        allowlist=allowlist,
+        pilot=pilot,
+    )
+
+
+def load_config(path: Path) -> GateConfig:
+    return parse_config(path.read_text())
+
+
+@dataclass(frozen=True)
+class GateEvent:
+    event_name: str
+    repository: str
+    ref: str
+    head_repository: str = ""
+    author_login: str = ""
+    author_association: str = ""
+    created_at: datetime | None = None
+    labels: tuple[str, ...] = ()
+
+
+@dataclass(frozen=True)
+class Decision:
+    run: bool
+    reason: Reason
+
+
+def _is_canonical_branch(ref: str) -> bool:
+    branch = ref.removeprefix("refs/heads/")
+    return any(pattern.match(branch) for pattern in 
CANONICAL_PUSH_BRANCH_PATTERNS)
+
+
+def decide(event: GateEvent, config: GateConfig) -> Decision:
+    """Apply the eligibility rules of the design in order; the first matching 
rule wins."""
+    if event.event_name == "schedule" and event.repository != APACHE_REPO:
+        return Decision(run=False, reason=Reason.FORK_SCHEDULE)
+    if event.event_name in ("schedule", "workflow_dispatch"):
+        return Decision(run=True, reason=Reason.SCHEDULED)
+    if event.event_name == "push":
+        if event.repository != APACHE_REPO:
+            return Decision(run=True, reason=Reason.FORK_PUSH)
+        if _is_canonical_branch(event.ref):
+            return Decision(run=True, reason=Reason.CANONICAL_BRANCH)
+        return Decision(run=False, reason=Reason.ADHOC_BRANCH)
+    if LABEL_USE_PROJECT_CI in event.labels:
+        return Decision(run=True, reason=Reason.LABEL_PROJECT_CI)
+    if LABEL_USE_FORK_CI in event.labels:

Review Comment:
   Good catch. `head_is_upstream` now comes right after `use project ci` and 
before `use fork ci` and the pilot rule, because a branch of apache/airflow has 
no fork to run CI in. The rules table and a test cover it. Fixed in e9a403c678.
   
   ---
   Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting



##########
dev/breeze/src/airflow_breeze/utils/fork_ci_gate.py:
##########
@@ -0,0 +1,281 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Fork CI eligibility (AIP-120).
+
+Decides whether a CI run in ``apache/airflow`` should use project CI or leave 
the pull request
+to CI in the contributor's fork. See 
dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md.
+
+The gate job in ``ci-amd.yml`` runs this file with the runner's ``python3`` 
from a sparse
+checkout, without installing breeze, so it must only import from the standard 
library.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import subprocess
+import sys
+import tomllib
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from datetime import UTC, datetime
+from enum import Enum
+from pathlib import Path
+from typing import Any
+
+APACHE_REPO = "apache/airflow"
+
+LABEL_FORK_CI_REQUIRED = "fork ci required"
+LABEL_USE_PROJECT_CI = "use project ci"
+LABEL_USE_FORK_CI = "use fork ci"
+LABEL_READY_FOR_REVIEW = "ready for maintainer review"
+
+DEFAULT_CLOSE_AFTER_DAYS = 7
+DEFAULT_ENABLED_SINCE = datetime(2026, 1, 1, tzinfo=UTC)
+
+# The push patterns ci-amd.yml used before its push trigger became '**'. 
GitHub branch filters
+# use the same `+`, `?`, `*` and `[...]` semantics as these regular 
expressions.
+CANONICAL_PUSH_BRANCH_PATTERNS: tuple[re.Pattern[str], ...] = tuple(
+    re.compile(pattern)
+    for pattern in (
+        r"^v[0-9]+-[0-9]+-test$",
+        r"^providers-[a-z]+-?[a-z]*/v[0-9]+-[0-9]+$",
+        r"^chart/v[0-9]+-[0-9]+x-test$",
+        r"^airflow-ctl/v[0-9]+-[0-9]+-test$",
+    )
+)
+
+TRUSTED_ASSOCIATIONS = frozenset({"MEMBER", "OWNER", "COLLABORATOR"})
+
+
+class Reason(str, Enum):
+    SCHEDULED = "scheduled"
+    FORK_SCHEDULE = "fork_schedule"
+    FORK_PUSH = "fork_push"
+    CANONICAL_BRANCH = "canonical_branch"
+    ADHOC_BRANCH = "adhoc_branch"
+    LABEL_PROJECT_CI = "label_project_ci"
+    LABEL_FORK_CI = "label_fork_ci"
+    PILOT = "pilot"
+    GATE_DISABLED = "gate_disabled"
+    HEAD_IS_UPSTREAM = "head_is_upstream"
+    BEFORE_ENABLED_SINCE = "before_enabled_since"
+    TRUSTED_AUTHOR = "trusted_author"
+    ALLOWLISTED = "allowlisted"
+    EXTERNAL = "external"
+
+
+GATED_REASONS = frozenset({Reason.EXTERNAL, Reason.PILOT, 
Reason.LABEL_FORK_CI})
+TAMPER_CHECKED_REASONS = frozenset({Reason.EXTERNAL, Reason.PILOT})
+
+
+@dataclass(frozen=True)
+class GateConfig:
+    enabled: bool = False
+    enabled_since: datetime = DEFAULT_ENABLED_SINCE
+    close_after_days: int = DEFAULT_CLOSE_AFTER_DAYS
+    allowlist: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
+    pilot: Mapping[str, datetime] = field(default_factory=dict)
+
+    def is_allowlisted(self, login: str) -> bool:
+        wanted = login.casefold()
+        return any(wanted == member.casefold() for members in 
self.allowlist.values() for member in members)
+
+    def pilot_joined_at(self, login: str) -> datetime | None:
+        wanted = login.casefold()
+        for pilot_login, joined_at in self.pilot.items():
+            if pilot_login.casefold() == wanted:
+                return joined_at
+        return None
+
+
+def _require_aware_datetime(value: Any, name: str) -> datetime:
+    if not isinstance(value, datetime) or value.tzinfo is None:
+        raise ValueError(
+            f"{name} must be a TOML offset date-time such as 
2026-10-20T00:00:00Z, got {value!r}"
+        )
+    return value
+
+
+def parse_config(text: str) -> GateConfig:
+    try:
+        data = tomllib.loads(text)
+    except tomllib.TOMLDecodeError as e:
+        raise ValueError(f"Invalid fork CI gate configuration: {e}") from e
+
+    enabled = data.get("enabled", False)
+    if not isinstance(enabled, bool):
+        raise ValueError(f"enabled must be true or false, got {enabled!r}")
+    enabled_since = _require_aware_datetime(data.get("enabled_since", 
DEFAULT_ENABLED_SINCE), "enabled_since")
+    close_after_days = data.get("close_after_days", DEFAULT_CLOSE_AFTER_DAYS)
+    if isinstance(close_after_days, bool) or not isinstance(close_after_days, 
int) or close_after_days < 1:
+        raise ValueError(f"close_after_days must be a positive integer, got 
{close_after_days!r}")
+
+    allowlist: dict[str, tuple[str, ...]] = {}
+    for org, logins in data.get("allowlist", {}).items():
+        if not isinstance(logins, list) or not all(isinstance(login, str) for 
login in logins):
+            raise ValueError(f"allowlist entry {org!r} must be a list of 
GitHub logins, got {logins!r}")
+        allowlist[org] = tuple(logins)
+
+    pilot = {
+        login.casefold(): _require_aware_datetime(joined_at, f"pilot entry 
{login!r}")
+        for login, joined_at in data.get("pilot", {}).items()
+    }
+    return GateConfig(
+        enabled=enabled,
+        enabled_since=enabled_since,
+        close_after_days=close_after_days,
+        allowlist=allowlist,
+        pilot=pilot,
+    )
+
+
+def load_config(path: Path) -> GateConfig:
+    return parse_config(path.read_text())
+
+
+@dataclass(frozen=True)
+class GateEvent:
+    event_name: str
+    repository: str
+    ref: str
+    head_repository: str = ""
+    author_login: str = ""
+    author_association: str = ""
+    created_at: datetime | None = None
+    labels: tuple[str, ...] = ()
+
+
+@dataclass(frozen=True)
+class Decision:
+    run: bool
+    reason: Reason
+
+
+def _is_canonical_branch(ref: str) -> bool:
+    branch = ref.removeprefix("refs/heads/")
+    return any(pattern.match(branch) for pattern in 
CANONICAL_PUSH_BRANCH_PATTERNS)
+
+
+def decide(event: GateEvent, config: GateConfig) -> Decision:
+    """Apply the eligibility rules of the design in order; the first matching 
rule wins."""
+    if event.event_name == "schedule" and event.repository != APACHE_REPO:
+        return Decision(run=False, reason=Reason.FORK_SCHEDULE)
+    if event.event_name in ("schedule", "workflow_dispatch"):
+        return Decision(run=True, reason=Reason.SCHEDULED)
+    if event.event_name == "push":
+        if event.repository != APACHE_REPO:
+            return Decision(run=True, reason=Reason.FORK_PUSH)
+        if _is_canonical_branch(event.ref):
+            return Decision(run=True, reason=Reason.CANONICAL_BRANCH)
+        return Decision(run=False, reason=Reason.ADHOC_BRANCH)
+    if LABEL_USE_PROJECT_CI in event.labels:
+        return Decision(run=True, reason=Reason.LABEL_PROJECT_CI)
+    if LABEL_USE_FORK_CI in event.labels:
+        return Decision(run=False, reason=Reason.LABEL_FORK_CI)
+    joined_at = config.pilot_joined_at(event.author_login)
+    if joined_at is not None and event.created_at is not None and 
event.created_at >= joined_at:
+        return Decision(run=False, reason=Reason.PILOT)
+    if not config.enabled:
+        return Decision(run=True, reason=Reason.GATE_DISABLED)
+    if event.head_repository == APACHE_REPO:
+        return Decision(run=True, reason=Reason.HEAD_IS_UPSTREAM)
+    if event.created_at is not None and event.created_at < 
config.enabled_since:
+        return Decision(run=True, reason=Reason.BEFORE_ENABLED_SINCE)
+    if event.author_association in TRUSTED_ASSOCIATIONS:
+        return Decision(run=True, reason=Reason.TRUSTED_AUTHOR)
+    if config.is_allowlisted(event.author_login):
+        return Decision(run=True, reason=Reason.ALLOWLISTED)
+    return Decision(run=False, reason=Reason.EXTERNAL)
+
+
+def parse_github_datetime(value: str) -> datetime | None:
+    if not value:
+        return None
+    return datetime.fromisoformat(value.replace("Z", "+00:00"))
+
+
+def fetch_pull_request_labels(repository: str, number: str) -> tuple[str, ...] 
| None:
+    """Read the labels live, so a label added after the event is seen on a 
re-run."""
+    result = subprocess.run(

Review Comment:
   Fixed in e9a403c678. Both `gh` calls go through one helper that treats a 
missing `gh` (`OSError`) like a failed call, so the event-label fallback runs. 
There's a test with `FileNotFoundError`.
   
   ---
   Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting



##########
dev/breeze/src/airflow_breeze/utils/fork_ci_gate.py:
##########
@@ -0,0 +1,281 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Fork CI eligibility (AIP-120).
+
+Decides whether a CI run in ``apache/airflow`` should use project CI or leave 
the pull request
+to CI in the contributor's fork. See 
dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md.
+
+The gate job in ``ci-amd.yml`` runs this file with the runner's ``python3`` 
from a sparse
+checkout, without installing breeze, so it must only import from the standard 
library.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import subprocess
+import sys
+import tomllib
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from datetime import UTC, datetime
+from enum import Enum
+from pathlib import Path
+from typing import Any
+
+APACHE_REPO = "apache/airflow"
+
+LABEL_FORK_CI_REQUIRED = "fork ci required"
+LABEL_USE_PROJECT_CI = "use project ci"
+LABEL_USE_FORK_CI = "use fork ci"
+LABEL_READY_FOR_REVIEW = "ready for maintainer review"
+
+DEFAULT_CLOSE_AFTER_DAYS = 7
+DEFAULT_ENABLED_SINCE = datetime(2026, 1, 1, tzinfo=UTC)
+
+# The push patterns ci-amd.yml used before its push trigger became '**'. 
GitHub branch filters

Review Comment:
   You're right, the two had different meanings. `fork_ci_gate.py` now keeps 
the filters exactly as `ci-arm.yml` writes them 
(`CANONICAL_PUSH_BRANCH_FILTERS`) and compiles them with GitHub's filter 
semantics: `*` is any characters except `/`, `**` is anything, `?`/`+` quantify 
the preceding character. The sync test compares the strings, and a new test 
checks the meaning, including `providers-apache-hive2/v1-2`. Fixed in 
e9a403c678.
   
   ---
   Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting



##########
dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md:
##########
@@ -0,0 +1,778 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+ -->
+
+<!-- START doctoc generated TOC please keep comment here to allow auto update 
-->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+
+- [Fork CI for external contributors 
(AIP-120)](#fork-ci-for-external-contributors-aip-120)
+  - [Goals and non-goals](#goals-and-non-goals)
+  - [Overview](#overview)
+  - [Configuration](#configuration)
+  - [Eligibility](#eligibility)
+  - [Project CI gate](#project-ci-gate)
+  - [Fork mode CI](#fork-mode-ci)
+  - [Reconciler](#reconciler)
+  - [Monitor workflow](#monitor-workflow)
+  - [`breeze ci audit`](#breeze-ci-audit)
+  - [Documentation changes](#documentation-changes)
+  - [Rollout and rollback](#rollout-and-rollback)
+  - [Security considerations](#security-considerations)
+  - [Known limitations](#known-limitations)
+  - [Testing](#testing)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+# Fork CI for external contributors (AIP-120)
+
+This document is the design of the CI changes implementing
+[AIP-120: Run CI for external contributions in contributor 
forks](https://cwiki.apache.org/confluence/spaces/AIRFLOW/pages/451974711/AIP-120+Run+CI+for+external+contributions+in+contributor+forks).
+The policy itself and its rationale live in the AIP and in the dev list 
discussions
+([first](https://lists.apache.org/thread/w74655o8y1jzrzcnygntyyovocf7zmzj),
+[second](https://lists.apache.org/thread/ky392mrrcmj312y1r276r30p584n804c), 
which agreed to
+start with an opt-in pilot).
+This document describes how the policy is mechanised.
+
+## Goals and non-goals
+
+Goals:
+
+- External contributors get CI feedback from a plain `git push` to their fork. 
No pull request
+  against their own fork, no manual workflow dispatch, no sorting through 
workflow files.
+- Project CI (`apache/airflow` runners) does not run for gated pull requests. 
The decision is
+  made by one function with one configuration file.
+- A single scheduled reconciler owns the lifecycle of gated pull requests: 
labelling, drafting,
+  undrafting, the state comment, the nudge after workflows are enabled, 
closing after
+  inactivity, and closing a pull request that tampered with the gate to get 
project CI. It is
+  idempotent, so missed or duplicated scheduled runs are harmless.
+- Maintainers and contributors see the fork CI state where they already look: 
a commit status
+  on the pull request head, red / yellow / green, linking to the run in the 
fork.
+- A contributor can verify their whole setup locally with `breeze ci audit` 
before pushing.
+- Everything ships dormant and is switched on by a one-line configuration 
change.
+- Before it is switched on for everyone, the whole flow can be piloted on real 
traffic by
+  committers on single pull requests and by volunteer external contributors on 
all their new
+  pull requests, without affecting anyone else.
+
+Non-goals:
+
+- The one-time reset of the open pull request backlog (AIP-120 §9.1). The 
design makes it a
+  configuration change, but it is decided separately.
+- Any change to how committer, collaborator or exempted-organisation pull 
requests are tested.
+- Event-driven mutation of pull requests. Nothing in this design uses 
`pull_request_target`.
+  All writes come from the scheduled reconciler.
+
+## Overview
+
+```
+                 contributor fork                          apache/airflow
+   ┌──────────────────────────────────┐      
┌──────────────────────────────────────────┐
+   │ git push feature-branch          │      │ pull_request event              
          │
+   │   └─ ci-amd.yml (push trigger)   │      │   └─ ci-amd.yml                 
          │
+   │        gate job: fork -> run     │      │        gate job: eligibility -> 
skip      │
+   │        build-info: fork mode     │      │        (build-info and 
everything after   │
+   │          changed files from      │      │         are skipped for gated 
PRs)        │
+   │          compare API vs upstream │      │                                 
          │
+   │        public runners only       │      │ fork-ci-monitor.yml (every 15 
minutes)    │
+   └──────────────────────────────────┘      │   └─ breeze ci 
fork-ci-reconcile          │
+                     ▲                        │        discover new gated PRs 
-> label    │
+                     │ GraphQL: checkSuites   │        per labelled PR: fork 
state ->     │
+                     │ REST: workflow state   │          draft/undraft, 
labels, comment,  │
+                     └────────────────────────│          commit status with 
run link,     │
+                                              │          close after 7 days or 
when the   │
+                                              │          PR tampered with the 
gate        │
+                                              │        unknown failures -> 
Slack          │
+                                              
└──────────────────────────────────────────┘
+```
+
+Four pieces, in dependency order:
+
+1. **Eligibility** — `dev/breeze/src/airflow_breeze/utils/fork_ci_gate.py`, 
one pure function
+   and one configuration file. Used by the gate job and by the reconciler.
+2. **Project CI gate** — a `gate` job at the head of `ci-amd.yml`.
+3. **Fork mode CI** — a push trigger plus fork-aware selective checks.
+4. **Reconciler** — `breeze ci fork-ci-reconcile`, run by 
`fork-ci-monitor.yml` on a schedule.
+
+Plus `breeze ci audit` for the contributor's local setup, and documentation.
+
+## Configuration
+
+One file, `.github/fork-ci-gate.toml`, parsed with the standard library 
`tomllib`:
+
+```toml
+# Fork CI gate (AIP-120). See 
dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md
+
+# Master switch. When false the gate job lets every pull request run project 
CI, except
+# pull requests labelled `use fork ci` and pull requests of [pilot] 
participants. Push
+# filtering for apache/airflow branches applies regardless.
+enabled = false
+
+# Pull requests created before this instant are never gated, whatever their 
author.
+# Moving the date back is how the backlog reset (AIP-120 §9.1) would be 
applied.
+enabled_since = 2026-01-01T00:00:00Z
+
+# Days without any fork CI activity after which a gated pull request is closed.
+close_after_days = 7
+
+# Organisations exempted under AIP-120 §2.2. Keys are display names used in 
comments,
+# values are GitHub logins. Changes go through a reviewed pull request.
+[allowlist]
+# "Example Org" = ["login-one", "login-two"]
+
+# Opt-in pilot participants. Keys are GitHub logins, values are the instant 
they joined.
+# Their pull requests created at or after that instant are gated whether or 
not `enabled`
+# is set. Contributors opt in and out with a pull request editing this table.
+[pilot]
+# "login-three" = 2026-10-20T00:00:00Z
+```
+
+The file lives in the repository rather than in an Actions variable because 
ASF projects
+cannot self-serve Actions variables, and because exemption changes should be 
reviewed like
+any other change. The gate job reads it from the pull request's *base* branch, 
never from
+the pull request's own tree, so a pull request cannot change its own 
eligibility.
+
+## Eligibility
+
+`fork_ci_gate.decide(event, config) -> Decision` is a pure function. `event` 
carries the
+fields both callers have: event name, repository, ref, head repository, author 
login,
+author association, pull request creation time and the pull request's current 
labels.
+`Decision` is `run` (a bool) plus a `reason` from a fixed set, used in logs, 
comments and by
+the reconciler.
+
+The rules, first match wins. The push rules are independent of `enabled` 
because the `'**'`
+push trigger exists whether or not the gate is on, and today no CI runs for 
pushes to
+`main` or to ad-hoc branches in `apache/airflow` (post-merge coverage is the 
cron canary).
+The label and pilot rules are also independent of `enabled`; see "Override 
labels" and
+"Contributor pilot" below.
+
+The `fork_schedule` rule exists because enabling `ci-amd.yml` in a fork also 
enables its
+cron trigger, and selective checks force the full matrix for `schedule` 
events. Without it
+every enabled fork would run the full canary matrix twice a day on the 
contributor's
+concurrency cap. A manual `workflow_dispatch` in a fork still runs.
+
+| Rule | Decision | Reason |
+|---|---|---|
+| Event is `schedule` and repository is not `apache/airflow` | skip | 
`fork_schedule` |
+| Event is `schedule` or `workflow_dispatch` | run | `scheduled` |
+| Event is `push` and repository is not `apache/airflow` | run | `fork_push` |
+| Event is `push` to `apache/airflow` and the branch matches the pre-existing 
push patterns (`v*-test`, `providers-*/v*`, `chart/v*x-test`, 
`airflow-ctl/v*-test`) | run | `canonical_branch` |
+| Event is `push` to `apache/airflow`, any other branch | skip | 
`adhoc_branch` |
+| Pull request carries `use project ci` | run | `label_project_ci` |
+| Pull request carries `use fork ci` | skip | `label_fork_ci` |
+| Author login is in `[pilot]` and the pull request was created at or after 
that login's join instant | skip | `pilot` |
+| `enabled` is false | run | `gate_disabled` |
+| Pull request head repository is `apache/airflow` | run | `head_is_upstream` |
+| Pull request created before `enabled_since` | run | `before_enabled_since` |
+| Author association is `MEMBER`, `OWNER` or `COLLABORATOR` | run | 
`trusted_author` |
+| Author login is in `[allowlist]` | run | `allowlisted` |
+| Anything else | skip | `external` |
+
+The table applies once the base branch carries the gate. When the base branch 
has neither
+`.github/fork-ci-gate.toml` nor `fork_ci_gate.py` (the pull request that 
introduces the gate,
+or a release branch cut before it), the job runs project CI with reason 
`base_without_gate`,
+as it did before the gate existed. A base branch with only one of the two 
files fails the job.
+
+"Gated" throughout this document means a pull request whose decision reason is 
`external`,
+`pilot` or `label_fork_ci`. The reconciler labels exactly those; an 
`adhoc_branch` skip is
+not a pull request and is never labelled.
+
+Logins are compared case-insensitively in `[allowlist]` and `[pilot]`, as 
GitHub does.
+
+### Override labels
+
+Two labels let a committer or collaborator (anyone with triage rights; 
external contributors
+cannot set labels) override the automatic decision for one pull request:
+
+| Label | Effect |
+|---|---|
+| `use project ci` | Project CI runs for a pull request that would otherwise 
be gated. Reasons: the change needs the full matrix, self-hosted runners or 
secrets, or a maintainer has adopted the pull request. |
+| `use fork ci` | The fork regime applies to a pull request that would 
otherwise run on project CI. Reasons: a committer testing the machinery on 
their own pull request, or a large pull request from an allowlisted 
organisation that should be verified in the fork first. |
+
+Because both rules sit before the `enabled` switch, `use fork ci` works while
+`enabled = false`. That is the committer pilot: label a handful of pull 
requests and the
+gate, the fork run, the reconciler and the commit status all operate for those 
only.
+
+Consequences for the other components:
+
+- The gate job reads the pull request's labels live from the API (one 
read-only call) rather
+  than from the event payload, so labelling a pull request and pressing 
"Re-run all jobs"
+  switches it without a new push.
+- Discovery in the reconciler also picks up open pull requests carrying `use 
fork ci`
+  without `fork ci required`, regardless of `enabled`.
+- A pull request carrying both `fork ci required` and `use project ci` is 
*released*: the
+  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
+  re-run.
+- Both override labels on one pull request resolve to project CI and are 
reported to Slack as
+  a conflict.
+
+### Contributor pilot
+
+The committer pilot exercises the machinery but not the experience it is meant 
for: an
+external contributor setting up their fork from the instructions, waiting for 
their own
+fork run and being drafted and undrafted by the reconciler. The `use fork ci` 
label does not
+work for that. It is set after the pull request is opened, so the first 
`pull_request` run
+has already used project CI, and a maintainer would have to label every pull 
request the
+volunteer opens.
+
+The `[pilot]` table covers it. A contributor opts in with a pull request that 
adds their
+login and a join instant to `[pilot]`; any committer can merge it. Once the 
entry is on the
+base branch, every pull request they open is gated from its first run, whether 
or not
+`enabled` is set, and goes through exactly the flow described in this 
document: setup
+comment, draft, fork run, commit status, undraft, close after inactivity, gate 
tampering
+check. Pull requests they opened before the join instant keep project CI.
+
+The gate reads the table from the base branch, so the entry takes effect when 
it merges,
+while the join instant is compared with pull request creation. The join 
instant must
+therefore not be earlier than the merge: pull requests opened in between ran 
project CI,
+are labelled on the next reconciler run, and one of them that changes 
`.github/workflows/`
+would be closed for gate tampering. The `check-fork-ci-gate-config` prek hook 
rejects a newly
+added `[pilot]` instant, an `enabled_since` moved later, or `enabled` switched 
on while
+`enabled_since` is still in the past; contributors use the next day at 00:00 
UTC. Moving
+`enabled_since` earlier is the backlog reset and is allowed. In CI the hook 
fetches the pull
+request's base branch to compare with, because the shallow checkout has none; 
a push in a
+contributor's fork has no base branch, so it fetches `apache/airflow`'s 
`main`. Without any
+base to compare with, the hook fails rather than comparing the change with 
itself. A merge
+after that instant still leaves the window open; the
+closure is reported to Slack as an attention item, and the maintainer reopens 
the pull
+request with `use project ci`.
+
+Leaving is a pull request that removes the line. Pull requests already labelled
+`fork ci required` stay in the state machine until a maintainer adds `use 
project ci`, as
+for any other gated pull request. A maintainer can also release a single pull 
request with
+`use project ci` at any time.
+
+Opting in only ever takes project CI away from the contributor, so the opt-in 
pull request
+needs no more review than checking that the login is the author's own. The 
table is read
+from the base branch like the rest of the file, so adding oneself in the same 
pull request
+that should be gated has no effect on that pull request.
+
+`[pilot]` is meant to be short-lived: it is emptied when `enabled = true` is 
set, because
+from then on the participants are gated as `external` anyway.
+
+`MEMBER` means a member of the `apache` GitHub organisation, which every ASF 
committer is.

Review Comment:
   Checked. All recent mergers have public membership and no recent 
`CONTRIBUTOR` author has write access, but GitHub does report private members 
as `CONTRIBUTOR`. So when the rules end at `external`, the gate now asks 
`repos/apache/airflow/collaborators/<login>/permission` and treats 
`write`/`maintain`/`admin` as `trusted_author`. If that call fails, the PR 
stays gated. The reconciler's discovery does the same before labelling. Fixed 
in e9a403c678 (gate) and 9c0975d413 (discovery).
   
   ---
   Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting



##########
dev/breeze/tests/test_fork_ci_gate.py:
##########
@@ -0,0 +1,396 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import subprocess
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+from unittest import mock
+
+import pytest
+import yaml
+
+from airflow_breeze.utils.fork_ci_gate import (
+    CANONICAL_PUSH_BRANCH_PATTERNS,
+    Decision,
+    GateConfig,
+    GateEvent,
+    Reason,
+    decide,
+    load_config,
+    main,
+    parse_config,
+)
+from airflow_breeze.utils.path_utils import AIRFLOW_ROOT_PATH
+
+SINCE = datetime(2026, 10, 1, tzinfo=UTC)
+JOINED = datetime(2026, 10, 20, tzinfo=UTC)
+AFTER = datetime(2026, 11, 1, tzinfo=UTC)
+
+
+def _config(**overrides) -> GateConfig:
+    values = {
+        "enabled": True,
+        "enabled_since": SINCE,
+        "close_after_days": 7,
+        "allowlist": {"Example Org": ("org-member",)},
+        "pilot": {"pilot-user": JOINED},
+    }
+    values.update(overrides)
+    return GateConfig(**values)
+
+
+def _pr(
+    *,
+    author: str = "someone",
+    association: str = "CONTRIBUTOR",
+    created_at: datetime = AFTER,
+    labels: tuple[str, ...] = (),
+    head_repository: str = "someone/airflow",
+) -> GateEvent:
+    return GateEvent(
+        event_name="pull_request",
+        repository="apache/airflow",
+        ref="refs/pull/1/merge",
+        head_repository=head_repository,
+        author_login=author,
+        author_association=association,
+        created_at=created_at,
+        labels=labels,
+    )
+
+
+def _push(repository: str, branch: str) -> GateEvent:
+    return GateEvent(event_name="push", repository=repository, 
ref=f"refs/heads/{branch}")
+
+
[email protected](
+    ("event", "config_overrides", "expected"),
+    [
+        pytest.param(
+            GateEvent("schedule", "someone/airflow", "refs/heads/main"),
+            {},
+            Decision(run=False, reason=Reason.FORK_SCHEDULE),
+            id="schedule-in-fork",
+        ),
+        pytest.param(
+            GateEvent("schedule", "apache/airflow", "refs/heads/main"),
+            {},
+            Decision(run=True, reason=Reason.SCHEDULED),
+            id="schedule-upstream",
+        ),
+        pytest.param(
+            GateEvent("workflow_dispatch", "someone/airflow", 
"refs/heads/main"),
+            {},
+            Decision(run=True, reason=Reason.SCHEDULED),
+            id="dispatch-in-fork",
+        ),
+        pytest.param(
+            _push("someone/airflow", "my-feature"),
+            {"enabled": False},
+            Decision(run=True, reason=Reason.FORK_PUSH),
+            id="push-in-fork",
+        ),
+        pytest.param(
+            _push("apache/airflow", "v3-1-test"),
+            {},
+            Decision(run=True, reason=Reason.CANONICAL_BRANCH),
+            id="push-v-test",
+        ),
+        pytest.param(
+            _push("apache/airflow", "providers-fab/v1-5"),
+            {},
+            Decision(run=True, reason=Reason.CANONICAL_BRANCH),
+            id="push-providers-branch",
+        ),
+        pytest.param(
+            _push("apache/airflow", "providers-cncf-kubernetes/v10-1"),
+            {},
+            Decision(run=True, reason=Reason.CANONICAL_BRANCH),
+            id="push-providers-branch-two-words",
+        ),
+        pytest.param(
+            _push("apache/airflow", "chart/v1-18x-test"),
+            {},
+            Decision(run=True, reason=Reason.CANONICAL_BRANCH),
+            id="push-chart-test",
+        ),
+        pytest.param(
+            _push("apache/airflow", "airflow-ctl/v0-1-test"),
+            {},
+            Decision(run=True, reason=Reason.CANONICAL_BRANCH),
+            id="push-airflow-ctl-test",
+        ),
+        pytest.param(
+            _push("apache/airflow", "main"),
+            {"enabled": False},
+            Decision(run=False, reason=Reason.ADHOC_BRANCH),
+            id="push-main-upstream",
+        ),
+        pytest.param(
+            _push("apache/airflow", "v3-1-stable"),
+            {},
+            Decision(run=False, reason=Reason.ADHOC_BRANCH),
+            id="push-stable-upstream",
+        ),
+        pytest.param(
+            _push("apache/airflow", "some-adhoc-branch"),
+            {},
+            Decision(run=False, reason=Reason.ADHOC_BRANCH),
+            id="push-adhoc-upstream",
+        ),
+        pytest.param(
+            _pr(labels=("use project ci",)),
+            {},
+            Decision(run=True, reason=Reason.LABEL_PROJECT_CI),
+            id="label-project-ci",
+        ),
+        pytest.param(
+            _pr(association="MEMBER", labels=("use fork ci",)),
+            {},
+            Decision(run=False, reason=Reason.LABEL_FORK_CI),
+            id="label-fork-ci-enabled",
+        ),
+        pytest.param(
+            _pr(association="MEMBER", labels=("use fork ci",)),
+            {"enabled": False},
+            Decision(run=False, reason=Reason.LABEL_FORK_CI),
+            id="label-fork-ci-disabled",
+        ),
+        pytest.param(
+            _pr(labels=("use fork ci", "use project ci")),
+            {},
+            Decision(run=True, reason=Reason.LABEL_PROJECT_CI),
+            id="both-labels-project-wins",
+        ),
+        pytest.param(
+            _pr(author="pilot-user", created_at=JOINED),
+            {"enabled": False},
+            Decision(run=False, reason=Reason.PILOT),
+            id="pilot-at-join-instant-disabled",
+        ),
+        pytest.param(
+            _pr(author="pilot-user", created_at=AFTER),
+            {},
+            Decision(run=False, reason=Reason.PILOT),
+            id="pilot-enabled",
+        ),
+        pytest.param(
+            _pr(author="Pilot-User", created_at=AFTER),
+            {"enabled": False},
+            Decision(run=False, reason=Reason.PILOT),
+            id="pilot-login-case-insensitive",
+        ),
+        pytest.param(
+            _pr(author="pilot-user", created_at=JOINED - timedelta(seconds=1)),
+            {"enabled": False},
+            Decision(run=True, reason=Reason.GATE_DISABLED),
+            id="pilot-before-join-instant",
+        ),
+        pytest.param(
+            _pr(author="pilot-user", created_at=AFTER, labels=("use project 
ci",)),
+            {},
+            Decision(run=True, reason=Reason.LABEL_PROJECT_CI),
+            id="pilot-released-by-label",
+        ),
+        pytest.param(
+            _pr(),
+            {"enabled": False},
+            Decision(run=True, reason=Reason.GATE_DISABLED),
+            id="external-gate-disabled",
+        ),
+        pytest.param(
+            _pr(head_repository="apache/airflow"),
+            {},
+            Decision(run=True, reason=Reason.HEAD_IS_UPSTREAM),
+            id="head-is-upstream",
+        ),
+        pytest.param(
+            _pr(created_at=SINCE - timedelta(seconds=1)),
+            {},
+            Decision(run=True, reason=Reason.BEFORE_ENABLED_SINCE),
+            id="before-enabled-since",
+        ),
+        pytest.param(
+            _pr(created_at=SINCE),
+            {},
+            Decision(run=False, reason=Reason.EXTERNAL),
+            id="at-enabled-since",
+        ),
+        pytest.param(
+            _pr(association="MEMBER"),
+            {},
+            Decision(run=True, reason=Reason.TRUSTED_AUTHOR),
+            id="member",
+        ),
+        pytest.param(
+            _pr(association="OWNER"),
+            {},
+            Decision(run=True, reason=Reason.TRUSTED_AUTHOR),
+            id="owner",
+        ),
+        pytest.param(
+            _pr(association="COLLABORATOR"),
+            {},
+            Decision(run=True, reason=Reason.TRUSTED_AUTHOR),
+            id="collaborator",
+        ),
+        pytest.param(
+            _pr(author="ORG-Member"),
+            {},
+            Decision(run=True, reason=Reason.ALLOWLISTED),
+            id="allowlisted-case-insensitive",
+        ),
+        pytest.param(
+            _pr(association="FIRST_TIME_CONTRIBUTOR"),
+            {},
+            Decision(run=False, reason=Reason.EXTERNAL),
+            id="first-time-contributor",
+        ),
+        pytest.param(
+            _pr(association="NONE"),
+            {},
+            Decision(run=False, reason=Reason.EXTERNAL),
+            id="no-association",
+        ),
+    ],
+)
+def test_decide(event: GateEvent, config_overrides: dict, expected: Decision):
+    assert decide(event, _config(**config_overrides)) == expected
+
+
+def test_canonical_branch_patterns_match_arm_push_trigger():
+    workflow = yaml.safe_load((AIRFLOW_ROOT_PATH / ".github" / "workflows" / 
"ci-arm.yml").read_text())
+    # PyYAML parses the bare `on:` key as boolean True.
+    arm_branches = workflow[True]["push"]["branches"]
+
+    assert [
+        pattern.pattern.removeprefix("^").removesuffix("$") for pattern in 
CANONICAL_PUSH_BRANCH_PATTERNS
+    ] == (arm_branches)
+
+
+def test_repository_config_is_dormant():

Review Comment:
   Agreed. It's now only a parse check of the shipped file, so pilot opt-ins 
and switching the gate on don't break it. Fixed in e9a403c678.
   
   ---
   Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting



##########
scripts/ci/prek/check_fork_ci_gate_config.py:
##########
@@ -0,0 +1,139 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Validate ``.github/fork-ci-gate.toml`` (AIP-120 fork CI gate).
+
+The file must parse with the same rules the gate job uses, and every 
``[pilot]`` join instant
+and ``enabled_since`` value that is new or changed relative to ``main`` must 
lie in the future.
+The gate reads the file from the base branch, so an entry only takes effect 
when it merges,
+while the instant is compared with pull request creation: an instant before 
the merge would
+gate pull requests that already ran project CI. See "Contributor pilot" in
+dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import os
+import subprocess
+import sys
+from datetime import UTC, datetime
+from pathlib import Path
+from types import ModuleType
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+CONFIG_PATH = ".github/fork-ci-gate.toml"
+UPSTREAM_URL = "https://github.com/apache/airflow.git";
+GATE_MODULE_PATH = REPO_ROOT / "dev" / "breeze" / "src" / "airflow_breeze" / 
"utils" / "fork_ci_gate.py"
+
+
+def load_gate_module() -> ModuleType:
+    # fork_ci_gate.py is standard-library only by design, so it can be loaded 
without breeze.
+    spec = importlib.util.spec_from_file_location("fork_ci_gate", 
GATE_MODULE_PATH)
+    if spec is None or spec.loader is None:
+        raise ImportError(f"Cannot load {GATE_MODULE_PATH}")
+    module = importlib.util.module_from_spec(spec)
+    sys.modules[spec.name] = module
+    spec.loader.exec_module(module)
+    return module
+
+
+def find_violations(base_text: str | None, new_text: str, *, now: datetime) -> 
list[str]:
+    gate = load_gate_module()
+    try:
+        new = gate.parse_config(new_text)
+    except ValueError as e:
+        return [str(e)]
+    try:
+        base = gate.parse_config(base_text) if base_text is not None else None
+    except ValueError:
+        base = None
+
+    violations = []
+    # Moving enabled_since later (switching the gate on) must not gate pull 
requests opened
+    # before the merge. Moving it earlier is the backlog reset and gates older 
pull requests on
+    # purpose. Without a parsable base there is nothing to compare with.
+    if base is not None and new.enabled_since > base.enabled_since and 
new.enabled_since <= now:
+        violations.append(
+            f"enabled_since moved later to {new.enabled_since.isoformat()}, 
which is not in the future. "
+            "Use an instant after the expected merge."
+        )
+    if base is not None and not base.enabled and new.enabled and 
new.enabled_since <= now:
+        violations.append(
+            f"enabled is switched on while enabled_since is 
{new.enabled_since.isoformat()}, which is not "
+            "in the future; that would gate pull requests that already ran 
project CI. Set enabled_since "
+            "to an instant after the expected merge in the same change."
+        )
+    for login, joined_at in new.pilot.items():
+        previous = base.pilot.get(login) if base is not None else None
+        if previous != joined_at and joined_at <= now:

Review Comment:
   Fixed in e9a403c678. When the base branch has no 
`.github/fork-ci-gate.toml`, there's no gate there yet, so no entry is checked 
against it. The test that rejected this case now accepts it as the backport 
case.
   
   ---
   Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to