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


##########
.github/workflows/ci-arm.yml:
##########
@@ -1394,10 +1397,13 @@ jobs:
       - check-run-needed
       - finalize-tests
       - tests-agent-frameworks
-    # A no-op canary (`run-needed` is 'false') does not post; a failed check 
does.
+    # A no-op canary (`run-needed` is 'false') does not post; a failed check 
does. A failing gate
+    # skips build-info and everything after it, and must still be reported. A 
fork's scheduled run
+    # is the intended skip, and forks have no Slack to report to.

Review Comment:
   Good catch. The `Check the last full run` step shared by both workflows now 
reports `run-needed=false` for a scheduled run outside apache/airflow, so a 
fork's ARM canary skips everything. The step is identical in both files, so the 
sync hook still passes. The design doc and the sync-hook docstring no longer 
say ARM "never runs in forks".
   
   ---
   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,811 @@
+<!--
+ 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 head repository is `apache/airflow` | run | `head_is_upstream` |
+| 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 created before `enabled_since` | run | `before_enabled_since` |
+| Author association is `MEMBER`, `OWNER` or `COLLABORATOR` (see below for 
private membership) | 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.
+
+`head_is_upstream` comes before the label and pilot rules because a branch of 
`apache/airflow`
+has no fork to run CI in, and its pushes are `adhoc_branch` skips: `use fork 
ci` on such a pull
+request would otherwise leave it with no CI at all.
+
+GitHub reports an author whose membership of the `apache` organisation is 
private as
+`CONTRIBUTOR`, so a committer with private membership looks external and is 
gated. Neither the
+gate nor the reconciler can look up the author's permission instead: that 
endpoint needs push
+access, which the read-only token of a fork `pull_request` run and the 
monitor's token do not
+have. A committer who is gated this way makes their `apache` membership public
+(<https://github.com/orgs/apache/people>) or is added to `[allowlist]`.
+
+"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. A base branch 
without the file,
+such as a release branch the file is backported to, has no gate yet, so its 
entries are not
+checked against it. 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, the default branch for pushes in 
`apache/airflow`,
+   `github.ref` otherwise), `fetch-depth: 1`. A push in `apache/airflow` has 
no base branch and
+   its decision depends only on the pushed ref, so it takes the gate from the 
default branch:
+   taking it from the pushed branch would make a push to a branch without the 
gate files, such as
+   a release branch cut before it, look like `base_without_gate` and run 
project CI where it never
+   ran before. A push in a fork keeps the pushed commit's gate, checked out by 
`github.sha`. The
+   fork's default branch is the contributor's to change outside the pull 
request, and so is the
+   pushed branch's later tip, which a re-run of an earlier push would check 
out by `github.ref`; a
+   gate there that skips every job would produce a green run for a commit 
nothing in the pull
+   request shows. A gate change in the pushed commit is in the pull request's 
files and makes the
+   green run `green_unverified`. While the default branch has no gate yet, 
which is the case
+   until the gate merges, a push in `apache/airflow` takes the gate from the 
pushed commit
+   instead. Only committers can push there, and otherwise every pushed branch 
would run the
+   full project CI as `base_without_gate`.
+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`.
+4. For a gated pull request, the PR title check of `ci-image-checks.yml`
+   (`scripts/ci/prek/check_no_conventional_commit_message.py`, from the same 
sparse checkout).
+   Project CI is skipped and the fork run is a `push` event without a title, 
so this is the
+   only place the title is checked.
+
+`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` keeps its triggers: it has no `pull_request` trigger and never 
runs in forks. Its
+`push.branches` list is the source of `CANONICAL_PUSH_BRANCH_FILTERS` in 
`fork_ci_gate.py`

Review Comment:
   Fixed: the doc now says the canonical filters are `ci-amd.yml`'s 
`pull_request` branch filters without `main` and the `-stable` branches, as 
`fork_ci_gate.py` and the test have it. It also says what `ci-arm.yml`'s 
triggers actually are.
   
   ---
   Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting



##########
scripts/ci/prek/check_fork_ci_gate_config.py:
##########
@@ -0,0 +1,145 @@
+#!/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]

Review Comment:
   Done, it imports `AIRFLOW_ROOT_PATH` from `common_prek_utils` now.
   
   ---
   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