This is an automated email from the ASF dual-hosted git repository. potiuk pushed a commit to branch ci-sync-v3-4-test in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 47b6a79c887367dfcf930da7202de142e5ff49fb Author: Jarek Potiuk <[email protected]> AuthorDate: Tue Oct 27 21:04:47 2026 +0100 Tell contributors when their fork CI workflow stops running (#75814) The fork CI monitor read the fork's workflow state only until it first saw the workflow enabled. When the workflow was disabled afterwards, by the contributor or by GitHub after 60 days of fork inactivity, the pull request kept waiting for a push that could never run CI, while its comment still said the workflow was enabled. Contributors now get a new comment telling them why it stopped and how to turn it back on, starting with syncing their fork's main from apache/airflow. breeze ci audit also stops suggesting committers disable the workflow: a committer trying fork CI needs it, and following the hint is one way a pull request ends up stuck like this. Generated-by: Claude Opus 5 (cherry picked from commit 891e83dce5dc989e996d0057417bb390537bff4f) --- contributing-docs/18_contribution_workflow.rst | 10 +-- dev/breeze/doc/08_ci_tasks.rst | 4 +- .../doc/ci/08_fork_ci_for_external_contributors.md | 26 ++++---- dev/breeze/src/airflow_breeze/utils/ci_audit.py | 12 ---- .../src/airflow_breeze/utils/fork_ci_messages.py | 26 ++++++-- .../src/airflow_breeze/utils/fork_ci_reconcile.py | 29 +++++---- dev/breeze/tests/test_ci_audit.py | 4 +- dev/breeze/tests/test_fork_ci_messages.py | 6 ++ dev/breeze/tests/test_fork_ci_reconcile.py | 74 +++++++++++++++++++++- 9 files changed, 141 insertions(+), 50 deletions(-) diff --git a/contributing-docs/18_contribution_workflow.rst b/contributing-docs/18_contribution_workflow.rst index 89a73df9712..65e953ef04d 100644 --- a/contributing-docs/18_contribution_workflow.rst +++ b/contributing-docs/18_contribution_workflow.rst @@ -147,15 +147,17 @@ Set up your checkout and fork once: 2. If fork CI applies to you, enable the ``Tests (AMD)`` workflow in your fork: today that means you joined the pilot or a maintainer labelled your pull request ``use fork ci``; once fork CI is switched on for everyone, it means you are not a committer, a collaborator or a member of an - exempted organisation. GitHub disables workflows in new forks. Otherwise keep it disabled, so - your pushes do not run CI twice: + exempted organisation. GitHub disables workflows in new forks. Sync your fork's ``main`` with + ``apache/airflow`` first, so it has the current version of the workflow: .. code-block:: bash + gh repo sync <you>/airflow --branch main gh workflow enable ci-amd.yml -R <you>/airflow - If the workflow is missing, your fork's ``main`` is older than it; sync it first with - ``gh repo sync <you>/airflow --branch main``. + Keep the workflow enabled for as long as you have open pull requests. If it gets disabled, from + your fork's Actions tab or by GitHub after 60 days without activity in your fork, the fork CI + monitor comments on your pull requests with the commands to turn it back on. 3. Check everything at once. ``breeze ci audit`` prints one row per check and the command that fixes anything missing; ``--fix`` runs those commands after asking: diff --git a/dev/breeze/doc/08_ci_tasks.rst b/dev/breeze/doc/08_ci_tasks.rst index 5e1b9311f32..7147b615096 100644 --- a/dev/breeze/doc/08_ci_tasks.rst +++ b/dev/breeze/doc/08_ci_tasks.rst @@ -168,8 +168,8 @@ Pull requests from external contributors can run their CI in the contributor's f ``breeze ci audit`` checks, read-only, whether your checkout and fork are set up for that: ``gh`` installed and authenticated, the ``upstream`` remote pointing at ``apache/airflow`` and ``origin`` at your fork, whether you are a committer, collaborator, member of an exempted organisation, pilot participant or -external contributor, whether the ``Tests (AMD)`` workflow in your fork is enabled (or disabled, for those -whose pull requests run project CI), how far your fork's ``main`` is behind, and the branch you are on. +external contributor, whether the ``Tests (AMD)`` workflow in your fork is enabled (when your pull requests +use fork CI), how far your fork's ``main`` is behind, and the branch you are on. Each failing check prints the command that fixes it; ``--fix`` runs those commands after asking. These are all available flags of ``audit`` command: diff --git a/dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md b/dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md index ab33c09aec3..ad06dbc54c5 100644 --- a/dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md +++ b/dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md @@ -490,16 +490,19 @@ that matters. It is matched by file, not by the workflow's display name, which a change, and only `push` runs count because only they execute the workflow from the head commit itself; a `pull_request` run inside the fork, into the fork's own `main`, runs a merge the contributor controls. For pull requests -whose recorded state is `setup_required` (or that have no recorded state yet), one REST call -per pull request reads `GET /repos/<fork>/actions/workflows/ci-amd.yml` to distinguish -"workflows disabled" from "enabled but not yet pushed". This is bounded by the number of -pull requests in that state, not by the total. +with no fork run for the head commit, one REST call per pull request reads +`GET /repos/<fork>/actions/workflows/ci-amd.yml` to distinguish "workflows disabled" from +"enabled but not yet pushed". It is read on every run, including for pull requests already +recorded as `awaiting_push`: a workflow can be disabled after it was enabled, by the +contributor or by GitHub after 60 days without activity in the fork, and the pull request +would otherwise keep waiting for a push that never runs CI. This is bounded by the number of +pull requests without a fork run for their head commit, not by the total. ### State machine | Observed fork state | Draft | `ready for maintainer review` | Commit status | Comment | |---|---|---|---|---| -| **setup_required**: workflow state is `disabled_fork`, or the fork has no `ci-amd.yml` | draft | remove | `error` (red), link to the fork's Actions page for `ci-amd.yml` | create or update: how to enable, with the exact `gh workflow enable ci-amd.yml -R <fork>` line, and `breeze ci audit` | +| **setup_required**: workflow state is `disabled_fork`, `disabled_manually` or `disabled_inactivity`, or the fork has no `ci-amd.yml` | draft | remove | `error` (red), link to the fork's Actions page for `ci-amd.yml` | create or update: how to enable, with the exact `gh repo sync <fork> --branch main` and `gh workflow enable ci-amd.yml -R <fork>` lines, and `breeze ci audit`; on transition from any other state but `closed` (the workflow stopped running after it worked): also post a **ne [...] | **awaiting_push**: enabled, no run for the head SHA | draft | remove | `pending` (yellow), link to the fork's Actions page | on transition from setup_required: post a **new** comment asking to push again (an edit would not notify) | | **running**: run in progress | draft | remove | `pending` (yellow), link to the run | update state and run link | | **failed**: conclusion is failure, cancelled or timed out | draft | remove | `failure` (red), link to the run | update with the run link | @@ -872,7 +875,7 @@ Unknown means any of: that outlasted its retries. - A check suite for `Tests (AMD)`, in the fork or in `apache/airflow`, with a status or conclusion outside the modelled set. -- A workflow state outside `active` / `disabled_fork` / `disabled_manually` / not found. +- A workflow state outside `active` / `disabled_fork` / `disabled_manually` / `disabled_inactivity` / not found. - A mutation that returned an error (label, comment, close, status). - A head repository that no longer exists. - A state comment whose marker cannot be parsed. @@ -949,18 +952,17 @@ prints one row: status, finding, and the command that fixes it. | 2 | Remote naming | a remote named `upstream` points at `apache/airflow` and `origin` does not | `git remote rename`/`add` per `contributing-docs/10_working_with_git.rst` | | 3 | Fork exists | `origin` is a fork of `apache/airflow` owned by the authenticated user | `gh repo fork apache/airflow --remote=false` then add `origin`, unless check 2's fix already adds or repoints it | | 4 | Who you are | classifies the user in the gate's order: pilot participant (`[pilot]`, which gates whatever the role), committer (public `apache` membership, `GET /orgs/apache/public_members/<login>`, since the gate cannot see private membership), collaborator (`.asf.yaml`), exempted (`[allowlist]`), external; an external user whose `apache` membership is private is told to make it public or ask for `[allowlist]`; when the membership call fails for any reason but a 404, the role is r [...] -| 5 | Fork workflows | external or pilot participant: `ci-amd.yml` state on the fork is `active`; committer/collaborator/exempted: informational, with a hint when it is `active` | `gh workflow enable ci-amd.yml -R <fork>`, or `gh workflow disable ...` shown but not run by `--fix` | +| 5 | Fork workflows | external or pilot participant: `ci-amd.yml` state on the fork is `active`; committer/collaborator/exempted: always OK, whatever the state | `gh workflow enable ci-amd.yml -R <fork>` | | 6 | Fork `main` freshness | fork `main` is not behind `upstream/main` by more than 200 commits (`MAX_FORK_MAIN_BEHIND`) | `gh repo sync <fork> --branch main` | | 7 | Current branch | not `main`, has an upstream tracking `origin` | informational | The role is determined before checks 2 and 3, though printed as check 4. For a committer, collaborator or exempted contributor, whose pull requests run project CI, checks 2 and 3 are informational: working from an `apache/airflow` clone without a fork is fine for them, and -`--fix` only runs the fixes of failed checks. The same goes for check 5: an enabled fork -workflow runs every push twice and attaches a second status, so they are told they can disable -it, but it is not a failure, because a committer trying fork CI with the `use fork ci` label -needs it enabled. Forks of a public repository cannot be made private on GitHub, so disabling -the workflow is the only recommendation available. +`--fix` only runs the fixes of failed checks. Check 5 passes for them whether the fork workflow +is enabled or not. The audit never suggests disabling it: a committer trying fork CI with the +`use fork ci` label needs it enabled, and a workflow disabled while a pull request waits for its +fork CI leaves that pull request stuck until the workflow is enabled again. A failed API call is reported as "could not check" rather than as a finding: a failed fork lookup is not "not a fork of apache/airflow", and a failed workflow lookup is not "missing". diff --git a/dev/breeze/src/airflow_breeze/utils/ci_audit.py b/dev/breeze/src/airflow_breeze/utils/ci_audit.py index 3b27ef8eb15..94b0cf7ce9d 100644 --- a/dev/breeze/src/airflow_breeze/utils/ci_audit.py +++ b/dev/breeze/src/airflow_breeze/utils/ci_audit.py @@ -327,18 +327,6 @@ def run_audit(*, run: Runner, config: GateConfig, collaborators: frozenset[str]) enable, ) ) - elif required is False and state == "active": - # Not a failure: a committer trying fork CI with the `use fork ci` label needs it on. - results.append( - CheckResult( - 5, - "fork workflows", - CheckStatus.INFO, - f"`Tests (AMD)` is enabled in {fork}, but your pull requests run project CI, so each " - "push runs CI twice; disable it unless you use the `use fork ci` label", - (f"gh workflow disable {CI_WORKFLOW_FILE} -R {fork}",), - ) - ) elif required is None: results.append( CheckResult( diff --git a/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py b/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py index 48208ae90a1..90f6b6cba51 100644 --- a/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py +++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py @@ -116,11 +116,12 @@ def fork_actions_url(fork: str) -> str: def _setup_instructions(fork: str) -> str: return ( "To get CI for this pull request:\n\n" - "1. Enable the workflow in your fork (once per fork):\n\n" + "1. Sync your fork's `main` with apache/airflow, so that it has the current version of the " + "workflow:\n\n" + f" ```bash\n gh repo sync {fork} --branch main\n ```\n\n" + "2. Enable the workflow in your fork:\n\n" f" ```bash\n gh workflow enable ci-amd.yml -R {fork}\n ```\n\n" - " If your fork's `main` is older than the workflow, sync it first with " - f"`gh repo sync {fork} --branch main`.\n" - "2. Push your branch again. Every push then runs CI in your fork.\n\n" + "3. Push your branch again. Every push then runs CI in your fork.\n\n" "`breeze ci audit` checks your whole local and fork setup and prints the command that fixes " f"anything missing. See [the contribution workflow]({CONTRIBUTING_URL})." ) @@ -195,6 +196,23 @@ def nudge_comment(*, fork: str) -> str: ) +_DISABLED_REASONS = { + "disabled_manually": "was disabled in your fork, from its Actions tab or with `gh workflow disable`", + "disabled_inactivity": "was disabled by GitHub because your fork had no activity for 60 days", + "disabled_fork": "is disabled in your fork", + "missing": "is missing from your fork, for example because its `main` was reset to an older commit", +} + + +def workflow_disabled_comment(*, fork: str, workflow_state: str | None) -> str: + reason = _DISABLED_REASONS.get(workflow_state or "missing", "is not enabled in your fork") + return ( + f"**Fork CI stopped running.** The `Tests (AMD)` workflow {reason}, so pushes to this " + "branch no longer run CI, and this pull request stays a draft until they do.\n\n" + f"{_setup_instructions(fork)}" + FOOTER + ) + + def inactivity_close_comment(*, days: int, fork: str) -> str: return ( f"Closing this pull request because CI has not run in your fork for {days} days. {WHY_FORK_CI}\n\n" diff --git a/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py b/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py index 6b54f6c7fac..cd9edf5a44c 100644 --- a/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py +++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py @@ -73,6 +73,7 @@ from airflow_breeze.utils.fork_ci_messages import ( status_description, tamper_close_comment, ungated_comment, + workflow_disabled_comment, ) CI_WORKFLOW_FILE = "ci-amd.yml" @@ -144,7 +145,7 @@ JOB_STATUSES_DEFERRED_NOTE = ( RUNNING_STATUSES = frozenset({"QUEUED", "IN_PROGRESS", "PENDING", "WAITING", "REQUESTED"}) FAILED_CONCLUSIONS = frozenset({"FAILURE", "CANCELLED", "TIMED_OUT", "STARTUP_FAILURE", "ACTION_REQUIRED"}) -SETUP_WORKFLOW_STATES = frozenset({"disabled_fork", "disabled_manually", "missing"}) +SETUP_WORKFLOW_STATES = frozenset({"disabled_fork", "disabled_inactivity", "disabled_manually", "missing"}) class ForkState(str, Enum): @@ -284,8 +285,6 @@ def observe_fork_state(fork: ForkObservation, marker: Marker | None) -> tuple[Fo return ForkState.FAILED, None return None, f"unexpected fork check suite conclusion {fork.suite_conclusion!r}" if fork.workflow_state is None: - if marker is not None and marker.state == ForkState.AWAITING_PUSH.value: - return ForkState.AWAITING_PUSH, None return None, "no fork run and the fork workflow state was not read" if fork.workflow_state in SETUP_WORKFLOW_STATES: return ForkState.SETUP_REQUIRED, None @@ -294,10 +293,6 @@ def observe_fork_state(fork: ForkObservation, marker: Marker | None) -> tuple[Fo return None, f"unexpected fork workflow state {fork.workflow_state!r}" -def needs_workflow_state(marker: Marker | None) -> bool: - return marker is None or marker.state != ForkState.AWAITING_PUSH.value - - def has_job_started(job: dict[str, Any]) -> bool: # A job that never reached a runner -- queued, or cancelled while queued -- has no steps and a # `runner_id` of null or 0, but its `status` can be "completed" and its `started_at` set. @@ -587,6 +582,17 @@ def classify( # tracked at https://github.com/apache/airflow/issues/74908 if marker and marker.state == ForkState.SETUP_REQUIRED.value and state == ForkState.AWAITING_PUSH: plan.new_comments.append(nudge_comment(fork=pr.head_repo)) + # A new comment, because the edited state comment would not notify the author that fork CI they + # had working stopped running. Like the nudge, it is not retried when posting it fails; tracked at + # https://github.com/apache/airflow/issues/74908 + if ( + marker + and marker.state not in (ForkState.SETUP_REQUIRED.value, ForkState.CLOSED.value) + and state == ForkState.SETUP_REQUIRED + ): + plan.new_comments.append( + workflow_disabled_comment(fork=pr.head_repo, workflow_state=fork.workflow_state) + ) if state in CLOSABLE_STATES and now - since >= timedelta(days=config.close_after_days): plan.state = ForkState.CLOSED @@ -1501,12 +1507,9 @@ def run_reconcile( if pr.head_status_unread: pr = read_head_status(client, repo, pr) fork = observations.get(pr.number, ForkObservation()) - if ( - pr.head_repo - and fork.suite_status is None - and fork.error is None - and needs_workflow_state(pr.marker) - ): + # Read on every run, not only until the workflow is first seen enabled: it can be + # disabled later, and a pull request waiting for a push would then wait forever. + if pr.head_repo and fork.suite_status is None and fork.error is None: fork = ForkObservation(workflow_state=fetch_workflow_state(client, pr.head_repo)) jobs_reported = None if config.job_statuses: diff --git a/dev/breeze/tests/test_ci_audit.py b/dev/breeze/tests/test_ci_audit.py index 2dbc0d8a3a5..9697a67acd0 100644 --- a/dev/breeze/tests/test_ci_audit.py +++ b/dev/breeze/tests/test_ci_audit.py @@ -260,8 +260,8 @@ def test_fork_problems(repo, message): frozenset(), (0, "active"), Role.COMMITTER, - CheckStatus.INFO, - ("gh workflow disable ci-amd.yml -R someone/airflow",), + CheckStatus.OK, + (), id="committer-enabled-is-not-a-failure", ), pytest.param( diff --git a/dev/breeze/tests/test_fork_ci_messages.py b/dev/breeze/tests/test_fork_ci_messages.py index 407065ca4a0..0c238cff949 100644 --- a/dev/breeze/tests/test_fork_ci_messages.py +++ b/dev/breeze/tests/test_fork_ci_messages.py @@ -42,6 +42,7 @@ from airflow_breeze.utils.fork_ci_messages import ( render_marker, state_comment, tamper_close_comment, + workflow_disabled_comment, ) MARKER = Marker(state="awaiting_push", sha="abc123", since=datetime(2026, 10, 15, 10, 0, tzinfo=UTC)) @@ -114,6 +115,9 @@ def test_parse_marker_malformed(body): pytest.param(nudge_comment(fork="u/airflow"), id="nudge"), pytest.param(inactivity_close_comment(days=7, fork="u/airflow"), id="inactivity"), pytest.param(tamper_close_comment(run_url="https://x"), id="tamper"), + pytest.param( + workflow_disabled_comment(fork="u/airflow", workflow_state="disabled_manually"), id="disabled" + ), ], ) def test_every_comment_ends_with_footer(text): @@ -125,9 +129,11 @@ def test_every_comment_ends_with_footer(text): [ pytest.param(state_comment(Marker("setup_required", "a", MARKER.since), fork="u/fork", run_url=None)), pytest.param(inactivity_close_comment(days=7, fork="u/fork")), + pytest.param(workflow_disabled_comment(fork="u/fork", workflow_state="disabled_manually")), ], ) def test_setup_texts_give_the_exact_command(text): + assert "gh repo sync u/fork --branch main" in text assert "gh workflow enable ci-amd.yml -R u/fork" in text diff --git a/dev/breeze/tests/test_fork_ci_reconcile.py b/dev/breeze/tests/test_fork_ci_reconcile.py index 9f3aa61f751..38977df88b4 100644 --- a/dev/breeze/tests/test_fork_ci_reconcile.py +++ b/dev/breeze/tests/test_fork_ci_reconcile.py @@ -141,12 +141,19 @@ def _classify(pr, fork=None, *, bypass_run=None, changes_ci_files=False, config= id="enabled-no-run", ), pytest.param( - ForkObservation(), + ForkObservation(workflow_state="active"), _marker("awaiting_push"), ForkState.AWAITING_PUSH, "pending", id="still-awaiting", ), + pytest.param( + ForkObservation(workflow_state="disabled_inactivity"), + None, + ForkState.SETUP_REQUIRED, + "error", + id="workflow-disabled-for-inactivity", + ), pytest.param( ForkObservation(suite_status="IN_PROGRESS", run_url=RUN_URL), None, @@ -451,6 +458,37 @@ def test_nudge_on_transition_from_setup_required(): assert "Push this branch again" in plan.new_comments[0] [email protected]( + ("marker", "workflow_state", "expected_reason"), + [ + pytest.param( + _marker("awaiting_push"), "disabled_manually", "was disabled in your fork", id="awaiting-push" + ), + pytest.param(_marker("green"), "disabled_inactivity", "no activity for 60 days", id="green"), + pytest.param(_marker("failed"), "missing", "is missing from your fork", id="failed"), + ], +) +def test_comment_when_workflow_disabled_after_it_worked(marker, workflow_state, expected_reason): + plan, _, _ = _classify(_pr(marker=marker), ForkObservation(workflow_state=workflow_state)) + + assert plan.state == ForkState.SETUP_REQUIRED + assert len(plan.new_comments) == 1 + assert "Fork CI stopped running" in plan.new_comments[0] + assert expected_reason in plan.new_comments[0] + assert "gh workflow enable ci-amd.yml -R someone/airflow" in plan.new_comments[0] + + [email protected]( + "marker", + [pytest.param(None, id="first-run"), pytest.param(_marker("setup_required"), id="still-disabled")], +) +def test_no_disabled_comment_when_workflow_never_worked(marker): + plan, _, _ = _classify(_pr(marker=marker), ForkObservation(workflow_state="disabled_fork")) + + assert plan.state == ForkState.SETUP_REQUIRED + assert plan.new_comments == [] + + @pytest.mark.parametrize( ("marker", "should_close"), [ @@ -1501,6 +1539,40 @@ def test_run_reconcile_isolates_failures_and_reports_them(client, tmp_path): assert client.set_status.call_args.args[:3] == (2, "sha2", "error") +def test_run_reconcile_rereads_workflow_state_while_awaiting_push(client, tmp_path): + marker_comment = { + "databaseId": 5, + "body": render_marker(_marker("awaiting_push", sha="sha1")), + "author": {"login": "github-actions"}, + } + client.graphql.side_effect = [ + _search_result(), + _search_result(), + _search_result(_pr_node(1, comments={"nodes": [marker_comment]})), + {"pr0": {"object": None}}, + ] + client.rest_get_pages.return_value = [{"total_count": 0, "workflow_runs": []}] + + def rest_get(path, paginate=False, items_key=None): + if path == "repos/someone/my-airflow/actions/workflows/ci-amd.yml": + return {"state": "disabled_manually"} + raise AssertionError(path) + + client.rest_get.side_effect = rest_get + + run_reconcile( + client, + CONFIG, + now=NOW, + run_url="https://run", + output_file=tmp_path / "s.json", + workflows_dir=tmp_path, + ) + + assert client.set_status.call_args.args[:3] == (1, "sha1", "error") + assert "Fork CI stopped running" in client.create_comment.call_args.args[1] + + @pytest.mark.parametrize( ("state", "deletes"), [
