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]
