This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 1d5644d9de2 Check that the fork's GitHub Actions can run in breeze ci 
audit (#76136)
1d5644d9de2 is described below

commit 1d5644d9de225b9205bc5b5308ac273dfb941f2d
Author: Andrew Chang <[email protected]>
AuthorDate: Mon Nov 2 23:45:26 2026 +0800

    Check that the fork's GitHub Actions can run in breeze ci audit (#76136)
    
    * Check the fork's GitHub Actions settings in breeze ci audit
    
    breeze ci audit checked only that the fork's ci-amd.yml workflow is
    active. A workflow can be active while GitHub Actions is disabled for
    the whole fork: then a push starts no run, and the audit still said ok.
    Actions can also allow only the owner's actions or a selected list:
    ci-amd.yml uses actions from other repositories, so its runs fail
    before any job starts.
    
    For a contributor who needs fork CI, the audit now reads the fork's
    Actions settings and fails when Actions is disabled or limited, with
    the command that enables all actions. Only the fork's owner can read
    these settings, so the fork CI monitor cannot check them.
    
    * Report fork Actions settings whatever the CI workflow's state
    
    Check 5 used the Actions settings only when Tests (AMD) was active.
    A fork whose workflow was inactive, or whose workflow could not be read,
    was never told that Actions was disabled or limited. Enabling the
    workflow alone still left it unable to run, and the Actions problem
    showed up only on a later audit, if at all.
    
    Generated-by: Claude Opus 5
    
    * Detect a fork paused by GitHub and its idle workflows in breeze ci audit
    
    GitHub can pause GitHub Actions in a fork to reduce wasted compute.
    The fork's settings then still read as enabled and ci-amd.yml as
    active, so check 5 passed while no push started a run. Check 5 now
    dispatches ci-amd.yml to a ref that cannot exist: GitHub answers "No
    ref found" when Actions can run and "Actions has been disabled" when
    it is paused. Git does not allow ".." in a ref name, so the dispatch
    never starts a run.
    
    A fork that enables Actions also gets every cron of the repository,
    and each one starts a run there even when its jobs are skipped. The
    new check 8 lists the workflows that only a schedule or another
    workflow's run starts, and --fix disables the ones still active in
    the fork. Fork CI runs on push and does not need them.
    
    ---------
    
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 contributing-docs/18_contribution_workflow.rst     |  11 +
 dev/breeze/doc/08_ci_tasks.rst                     |   6 +-
 .../doc/ci/08_fork_ci_for_external_contributors.md |  26 ++-
 .../src/airflow_breeze/commands/ci_commands.py     |   7 +-
 dev/breeze/src/airflow_breeze/utils/ci_audit.py    | 177 +++++++++++++++-
 dev/breeze/tests/test_ci_audit.py                  | 236 ++++++++++++++++++++-
 6 files changed, 443 insertions(+), 20 deletions(-)

diff --git a/contributing-docs/18_contribution_workflow.rst 
b/contributing-docs/18_contribution_workflow.rst
index aa09bf350d2..df03322591b 100644
--- a/contributing-docs/18_contribution_workflow.rst
+++ b/contributing-docs/18_contribution_workflow.rst
@@ -155,10 +155,21 @@ Set up your checkout and fork once:
       gh repo sync <you>/airflow --branch main
       gh workflow enable ci-amd.yml -R <you>/airflow
 
+   GitHub Actions must also be enabled in your fork and allow all actions: 
``Tests (AMD)`` uses
+   actions from other repositories.
+
    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.
 
+   GitHub can also pause GitHub Actions in your fork to reduce wasted compute, 
and it sends no
+   email when it does. Your pushes then start no run, and the fork CI monitor 
cannot tell this from
+   a commit you have not pushed yet. ``breeze ci audit`` reports it. The 
banner on your fork's
+   Actions page turns GitHub Actions back on, and a push made while it was 
paused must be pushed
+   again. To make the pause less likely,
+   ``breeze ci audit --fix`` disables the workflows in your fork that only run 
on a schedule or
+   after another workflow. Fork CI does not need them.
+
 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 7b7760f3bc4..03d61fed826 100644
--- a/dev/breeze/doc/08_ci_tasks.rst
+++ b/dev/breeze/doc/08_ci_tasks.rst
@@ -168,8 +168,10 @@ 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 (when your pull requests
-use fork 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 and GitHub Actions lets it
+run, including whether GitHub has paused GitHub Actions in your fork (when 
your pull requests use fork CI),
+how far your fork's ``main`` is behind, the branch you are on, and which 
workflows in your fork only start
+runs that fork CI does not need.
 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 06b4a88741f..1f0d137b11d 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
@@ -169,7 +169,8 @@ Actions gets all of their crons too. Their jobs carry a 
job-level
 `if: github.repository == 'apache/airflow'` guard, combined with
 `github.event_name != 'schedule'` when the workflow has other triggers that 
should still run
 in a fork. A new scheduled workflow needs the same guard. The guard skips the 
jobs but not
-the run itself, so the frequent crons still leave an empty run in every 
enabled fork.
+the run itself, so the frequent crons still leave an empty run in every 
enabled fork. Check 8 of
+`breeze ci audit` offers to disable these workflows in the fork.
 
 | Rule | Decision | Reason |
 |---|---|---|
@@ -1102,9 +1103,10 @@ 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: always OK, whatever the 
state | `gh workflow enable ci-amd.yml -R <fork>` |
+| 5 | Fork workflows | external or pilot participant: `ci-amd.yml` state on 
the fork is `active`, GitHub Actions is enabled in the fork and allows all 
actions (`GET /repos/<fork>/actions/permissions`, readable by the fork's 
owner), and GitHub has not paused GitHub Actions in the fork (see below); 
committer/collaborator/exempted: always OK, whatever the state | `gh workflow 
enable ci-amd.yml -R <fork>`; `gh api -X PUT repos/<fork>/actions/permissions 
-F enabled=true -f allowed_actions=all [...]
 | 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 |
+| 8 | Idle fork workflows | external or pilot participant: no workflow in 
`.github/workflows` of the checkout that only `schedule` or `workflow_run` 
starts (`workflow_dispatch` aside) is `active` in the fork | `gh workflow 
disable <file> -R <fork>` for each |
 
 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
@@ -1114,6 +1116,26 @@ is enabled or not. The audit never suggests disabling 
it: a committer trying for
 `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 workflow can be `active` while nothing in the fork runs. GitHub Actions can 
be disabled for the
+whole fork: then a push starts no run. Or it can allow only the owner's 
actions or a selected list:
+`ci-amd.yml` uses actions from other repositories, so its runs fail before any 
job starts. Only the
+fork's owner can read these settings, which is why check 5 reads them and the 
reconciler cannot.
+
+GitHub can also pause GitHub Actions in a fork on its own, to reduce wasted 
compute. The fork's
+Actions page then shows a banner, and GitHub sends no email. The settings 
still read as enabled
+and the workflow as `active`, so the reads above do not see it. Check 5 
dispatches `ci-amd.yml`
+to the ref `breeze-ci-audit..probe` instead. Git does not allow `..` in a ref 
name, so the ref
+never exists and the dispatch never starts a run. GitHub answers "No ref 
found" when Actions
+can run, and "Actions has been disabled" when it is paused. The settings API 
cannot undo the
+pause, since it already reads as enabled; the banner on the fork's Actions 
page does. A push made
+while the fork was paused starts no run, also after that, so push again.
+
+Check 8 makes the pause less likely. Fork CI runs on push, so a workflow that 
only a schedule or
+another workflow's run starts has nothing to do in a fork, and every run it 
starts there is
+empty. The check reads the triggers from `.github/workflows` of the checkout 
and offers to disable
+those workflows where they are still `active` in the fork. Committers, 
collaborators and
+exempted contributors do not get check 8, and check 5 does not dispatch for 
them.
+
 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".
 A 404 is a finding: the fork does not exist, or the workflow is missing. An 
`upstream` remote
diff --git a/dev/breeze/src/airflow_breeze/commands/ci_commands.py 
b/dev/breeze/src/airflow_breeze/commands/ci_commands.py
index ce0d2e19a2d..d67aa8dd12b 100644
--- a/dev/breeze/src/airflow_breeze/commands/ci_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/ci_commands.py
@@ -1808,15 +1808,18 @@ def audit(fix: bool):
     import yaml
     from rich.table import Table
 
-    from airflow_breeze.utils.ci_audit import CheckStatus, run_audit, run_tool
+    from airflow_breeze.utils.ci_audit import CheckStatus, 
find_idle_fork_workflows, run_audit, run_tool
     from airflow_breeze.utils.fork_ci_gate import load_config
 
     asf_yaml = yaml.safe_load((AIRFLOW_ROOT_PATH / ".asf.yaml").read_text())
     collaborators = frozenset((asf_yaml.get("github") or 
{}).get("collaborators") or [])
     config = load_config(AIRFLOW_ROOT_PATH / ".github" / "fork-ci-gate.toml")
+    idle_workflows = find_idle_fork_workflows(AIRFLOW_ROOT_PATH / ".github" / 
"workflows")
 
     def report():
-        results = run_audit(run=run_tool, config=config, 
collaborators=collaborators)
+        results = run_audit(
+            run=run_tool, config=config, collaborators=collaborators, 
idle_workflows=idle_workflows
+        )
         table = Table(title="Fork CI setup")
         for column in ("#", "Check", "Status", "Finding", "Fix"):
             table.add_column(column)
diff --git a/dev/breeze/src/airflow_breeze/utils/ci_audit.py 
b/dev/breeze/src/airflow_breeze/utils/ci_audit.py
index 94b0cf7ce9d..bcdc115fa3c 100644
--- a/dev/breeze/src/airflow_breeze/utils/ci_audit.py
+++ b/dev/breeze/src/airflow_breeze/utils/ci_audit.py
@@ -29,8 +29,10 @@ import subprocess
 from collections.abc import Callable, Sequence
 from dataclasses import dataclass
 from enum import Enum
+from pathlib import Path
 from typing import TYPE_CHECKING
 
+import yaml
 from packaging.version import Version
 
 from airflow_breeze.global_constants import APACHE_AIRFLOW_GITHUB_REPOSITORY, 
MIN_GH_VERSION
@@ -41,6 +43,8 @@ if TYPE_CHECKING:
 
 UPSTREAM_URL = f"https://github.com/{APACHE_AIRFLOW_GITHUB_REPOSITORY}.git";
 CI_WORKFLOW_FILE = "ci-amd.yml"
+# `..` is not allowed in a git ref name, so a dispatch to this ref can never 
start a run.
+PROBE_REF = "breeze-ci-audit..probe"
 # Commits a fork's main may lag behind upstream main before a sync is 
suggested.
 MAX_FORK_MAIN_BEHIND = 200
 
@@ -138,7 +142,65 @@ def fork_ci_required(role: Role, config: GateConfig) -> 
bool | None:
     return False
 
 
-def run_audit(*, run: Runner, config: GateConfig, collaborators: 
frozenset[str]) -> list[CheckResult]:
+def get_actions_problem(permissions_json: str, fork: str) -> str | None:
+    """Why the fork's GitHub Actions settings keep `Tests (AMD)` from running, 
or None."""
+    permissions = json.loads(permissions_json)
+    if not permissions.get("enabled"):
+        return f"GitHub Actions is disabled in {fork}, so `Tests (AMD)` never 
runs"
+    if permissions.get("allowed_actions") != "all":
+        return (
+            f"{fork} limits the actions workflows may use 
(`{permissions.get('allowed_actions')}`); "
+            "`Tests (AMD)` uses actions from other repositories, so it fails 
to start"
+        )
+    return None
+
+
+def is_actions_paused_by_github(*, fork: str, run: Runner) -> bool | None:
+    """
+    Whether GitHub itself has paused GitHub Actions in ``fork``; None when the 
answer is unclear.
+
+    The fork's settings still read as enabled then. A dispatch to a ref that 
does not exist tells
+    the two apart: it fails with "Actions has been disabled" instead of "No 
ref found".
+    """
+    probe = run(
+        [
+            "gh",
+            "api",
+            f"repos/{fork}/actions/workflows/{CI_WORKFLOW_FILE}/dispatches",
+            "-X",
+            "POST",
+            "-f",
+            f"ref={PROBE_REF}",
+        ]
+    )
+    output = f"{probe.stdout}{probe.stderr}"
+    if "Actions has been disabled" in output:
+        return True
+    if "No ref found" in output:
+        return False
+    return None
+
+
+def find_idle_fork_workflows(workflows_dir: Path) -> frozenset[str]:
+    """
+    Workflow files that only a schedule or another workflow's run starts.
+
+    Fork CI runs on push, so in a fork these workflows only start runs that 
fork CI does not need.
+    """
+    idle = set()
+    for path in workflows_dir.glob("*.y*ml"):
+        workflow = yaml.safe_load(path.read_text())
+        # YAML 1.1 reads an unquoted `on` key as True.
+        triggers = workflow.get("on", workflow.get(True)) or []
+        automatic = set([triggers] if isinstance(triggers, str) else triggers) 
- {"workflow_dispatch"}
+        if automatic and automatic <= {"schedule", "workflow_run"}:
+            idle.add(path.name)
+    return frozenset(idle)
+
+
+def run_audit(
+    *, run: Runner, config: GateConfig, collaborators: frozenset[str], 
idle_workflows: frozenset[str]
+) -> list[CheckResult]:
     results: list[CheckResult] = []
 
     gh_version = run(["gh", "--version"])
@@ -305,26 +367,54 @@ def run_audit(*, run: Runner, config: GateConfig, 
collaborators: frozenset[str])
     if fork_ok and fork is not None:
         workflow = run(["gh", "api", 
f"repos/{fork}/actions/workflows/{CI_WORKFLOW_FILE}", "--jq", ".state"])
         state = _out(workflow) if _ok(workflow) else "missing" if 
_is_not_found(workflow) else None
+        # Only the fork's owner can read these settings, and the audit runs as 
the owner.
+        actions = run(["gh", "api", f"repos/{fork}/actions/permissions"]) if 
required is True else None
+        actions_problem = (
+            get_actions_problem(_out(actions), fork) if actions is not None 
and _ok(actions) else None
+        )
+        # An enabled workflow still never runs while Actions is off or 
limited, so this fix goes first.
+        allow_actions: tuple[str, ...] = (
+            (f"gh api -X PUT repos/{fork}/actions/permissions -F enabled=true 
-f allowed_actions=all",)
+            if actions_problem
+            else ()
+        )
         if state is None:
+            not_checked = f"could not check `Tests (AMD)` in {fork}: 
{(workflow.stderr or '').strip() or 'gh api failed'}"
             results.append(
                 CheckResult(
-                    5,
-                    "fork workflows",
-                    CheckStatus.INFO,
-                    f"could not check `Tests (AMD)` in {fork}: 
{(workflow.stderr or '').strip() or 'gh api failed'}",
+                    5, "fork workflows", CheckStatus.FAIL, 
f"{actions_problem}; {not_checked}", allow_actions
                 )
+                if actions_problem
+                else CheckResult(5, "fork workflows", CheckStatus.INFO, 
not_checked)
             )
         elif required is True and state != "active":
             enable: tuple[str, ...] = (f"gh workflow enable {CI_WORKFLOW_FILE} 
-R {fork}",)
             if state == "missing":
                 enable = (f"gh repo sync {fork} --branch main", *enable)
+            finding = f"`Tests (AMD)` is {state} in {fork}; enable it"
+            if actions_problem:
+                finding += f"; also, {actions_problem}"
+            results.append(
+                CheckResult(5, "fork workflows", CheckStatus.FAIL, finding, 
(*allow_actions, *enable))
+            )
+        elif actions is not None and not _ok(actions):
+            results.append(
+                CheckResult(
+                    5,
+                    "fork workflows",
+                    CheckStatus.INFO,
+                    f"could not check the GitHub Actions settings of {fork}: "
+                    f"{(actions.stderr or '').strip() or 'gh api failed'}",
+                )
+            )
+        elif actions_problem:
             results.append(
                 CheckResult(
                     5,
                     "fork workflows",
                     CheckStatus.FAIL,
-                    f"`Tests (AMD)` is {state} in {fork}; enable it",
-                    enable,
+                    actions_problem,
+                    allow_actions,
                 )
             )
         elif required is None:
@@ -342,9 +432,33 @@ def run_audit(*, run: Runner, config: GateConfig, 
collaborators: frozenset[str])
                 )
             )
         else:
-            results.append(
-                CheckResult(5, "fork workflows", CheckStatus.OK, f"`Tests 
(AMD)` is {state} in {fork}")
-            )
+            paused = is_actions_paused_by_github(fork=fork, run=run) if 
required is True else False
+            if paused:
+                results.append(
+                    CheckResult(
+                        5,
+                        "fork workflows",
+                        CheckStatus.FAIL,
+                        f"GitHub has paused GitHub Actions in {fork}, so 
`Tests (AMD)` never runs "
+                        "although the settings allow it; turn it back on from 
the banner on the fork's "
+                        "Actions page",
+                        (f"echo Open https://github.com/{fork}/actions and 
enable the workflows",),
+                    )
+                )
+            elif paused is None:
+                results.append(
+                    CheckResult(
+                        5,
+                        "fork workflows",
+                        CheckStatus.INFO,
+                        f"`Tests (AMD)` is {state} in {fork}, but could not 
check whether GitHub "
+                        "has paused GitHub Actions there",
+                    )
+                )
+            else:
+                results.append(
+                    CheckResult(5, "fork workflows", CheckStatus.OK, f"`Tests 
(AMD)` is {state} in {fork}")
+                )
 
         compare = run(
             [
@@ -375,6 +489,47 @@ def run_audit(*, run: Runner, config: GateConfig, 
collaborators: frozenset[str])
                 CheckResult(6, "fork main", CheckStatus.OK, f"{behind} commits 
behind apache/airflow")
             )
 
+    idle_result = None
+    if fork_ok and fork is not None and required is True:
+        listed = run(
+            [
+                "gh",
+                "api",
+                f"repos/{fork}/actions/workflows",
+                "--paginate",
+                "--jq",
+                '.workflows[] | select(.state == "active") | .path',
+            ]
+        )
+        if not _ok(listed):
+            idle_result = CheckResult(
+                8,
+                "idle fork workflows",
+                CheckStatus.INFO,
+                f"could not list the workflows of {fork}: {(listed.stderr or 
'').strip() or 'gh api failed'}",
+            )
+        else:
+            active = {path.rsplit("/", 1)[-1] for path in 
_out(listed).splitlines()}
+            to_disable = sorted(active & idle_workflows)
+            idle_result = (
+                CheckResult(
+                    8,
+                    "idle fork workflows",
+                    CheckStatus.FAIL,
+                    f"{fork} has workflows that start only on a schedule or 
after another workflow, so "
+                    "every run they start there is wasted; GitHub pauses 
GitHub Actions in forks that "
+                    "waste runs",
+                    tuple(f"gh workflow disable {name} -R {fork}" for name in 
to_disable),
+                )
+                if to_disable
+                else CheckResult(
+                    8,
+                    "idle fork workflows",
+                    CheckStatus.OK,
+                    f"no workflow in {fork} starts runs that fork CI does not 
need",
+                )
+            )
+
     branch = _out(run(["git", "rev-parse", "--abbrev-ref", "HEAD"]))
     tracking = run(["git", "rev-parse", "--abbrev-ref", 
"--symbolic-full-name", "@{u}"])
     if branch in ("main", "HEAD", ""):
@@ -388,4 +543,6 @@ def run_audit(*, run: Runner, config: GateConfig, 
collaborators: frozenset[str])
     else:
         finding = f"`{branch}` tracks {_out(tracking)}"
     results.append(CheckResult(7, "branch", CheckStatus.INFO, finding))
+    if idle_result is not None:
+        results.append(idle_result)
     return results
diff --git a/dev/breeze/tests/test_ci_audit.py 
b/dev/breeze/tests/test_ci_audit.py
index 9697a67acd0..e82f1053d46 100644
--- a/dev/breeze/tests/test_ci_audit.py
+++ b/dev/breeze/tests/test_ci_audit.py
@@ -25,7 +25,14 @@ import pytest
 from click.testing import CliRunner
 
 from airflow_breeze.commands.ci_commands import audit
-from airflow_breeze.utils.ci_audit import CheckResult, CheckStatus, Role, 
run_audit, run_tool
+from airflow_breeze.utils.ci_audit import (
+    CheckResult,
+    CheckStatus,
+    Role,
+    find_idle_fork_workflows,
+    run_audit,
+    run_tool,
+)
 from airflow_breeze.utils.confirm import Answer
 from airflow_breeze.utils.fork_ci_gate import GateConfig
 
@@ -40,6 +47,12 @@ Output = tuple[int, str] | tuple[int, str, str]
 
 NOT_A_MEMBER: Output = (1, "", "gh: Not Found (HTTP 404)")
 
+ACTIONS_ALLOWED: Output = (0, json.dumps({"enabled": True, "allowed_actions": 
"all"}))
+
+NO_REF: Output = (1, "", "gh: No ref found for: breeze-ci-audit..probe (HTTP 
422)")
+
+PAUSED: Output = (1, "", "gh: Actions has been disabled for this repository. 
(HTTP 422)")
+
 
 def _machine(**overrides: Output | None) -> dict[str, Output | None]:
     """Outputs of every command the audit runs, keyed by a short name."""
@@ -51,6 +64,9 @@ def _machine(**overrides: Output | None) -> dict[str, Output 
| None]:
         "membership": NOT_A_MEMBER,
         "private_membership": (1, ""),
         "workflow": (0, "active"),
+        "actions": ACTIONS_ALLOWED,
+        "dispatch": NO_REF,
+        "fork_workflows": (0, ".github/workflows/ci-amd.yml\n"),
         "behind": (0, "12"),
         "branch": (0, "my-change"),
         "tracking": (0, "origin/my-change"),
@@ -74,8 +90,14 @@ def _runner(machine):
         command = tuple(command)
         key = keys.get(command)
         if key is None and command[:2] == ("gh", "api"):
-            if "/actions/workflows/" in command[2]:
+            if command[2].endswith("/dispatches"):
+                key = "dispatch"
+            elif command[2].endswith("/actions/workflows"):
+                key = "fork_workflows"
+            elif "/actions/workflows/" in command[2]:
                 key = "workflow"
+            elif command[2].endswith("/actions/permissions"):
+                key = "actions"
             elif "/compare/" in command[2]:
                 key = "behind"
             elif command[2].startswith("repos/"):
@@ -91,10 +113,12 @@ def _runner(machine):
 ENABLED = GateConfig(enabled=True)
 
 
-def _audit(machine, config=ENABLED, collaborators=frozenset()):
+def _audit(machine, config=ENABLED, collaborators=frozenset(), 
idle_workflows=frozenset({"stale.yml"})):
     return {
         result.number: result
-        for result in run_audit(run=_runner(machine), config=config, 
collaborators=collaborators)
+        for result in run_audit(
+            run=_runner(machine), config=config, collaborators=collaborators, 
idle_workflows=idle_workflows
+        )
     }
 
 
@@ -109,6 +133,7 @@ def test_healthy_external_setup():
         CheckStatus.OK,
         CheckStatus.OK,
         CheckStatus.INFO,
+        CheckStatus.OK,
     ]
     assert results[4].finding == f"you are a {Role.EXTERNAL.value}"
 
@@ -324,6 +349,209 @@ def test_fork_workflows_by_role(config, membership, 
collaborators, workflow, rol
     assert results[5].fix == fix
 
 
+ENABLE_ALL_ACTIONS = (
+    "gh api -X PUT repos/someone/airflow/actions/permissions -F enabled=true 
-f allowed_actions=all",
+)
+
+
[email protected](
+    ("membership", "actions", "status", "finding", "fix"),
+    [
+        pytest.param(
+            NOT_A_MEMBER,
+            (0, json.dumps({"enabled": False})),
+            CheckStatus.FAIL,
+            "GitHub Actions is disabled in someone/airflow, so `Tests (AMD)` 
never runs",
+            ENABLE_ALL_ACTIONS,
+            id="actions-disabled",
+        ),
+        pytest.param(
+            NOT_A_MEMBER,
+            (0, json.dumps({"enabled": True, "allowed_actions": 
"local_only"})),
+            CheckStatus.FAIL,
+            "someone/airflow limits the actions workflows may use 
(`local_only`); "
+            "`Tests (AMD)` uses actions from other repositories, so it fails 
to start",
+            ENABLE_ALL_ACTIONS,
+            id="local-actions-only",
+        ),
+        pytest.param(
+            NOT_A_MEMBER,
+            (0, json.dumps({"enabled": True, "allowed_actions": "selected"})),
+            CheckStatus.FAIL,
+            "someone/airflow limits the actions workflows may use 
(`selected`); "
+            "`Tests (AMD)` uses actions from other repositories, so it fails 
to start",
+            ENABLE_ALL_ACTIONS,
+            id="selected-actions-only",
+        ),
+        pytest.param(
+            NOT_A_MEMBER,
+            (1, "", "HTTP 502: Bad Gateway"),
+            CheckStatus.INFO,
+            "could not check the GitHub Actions settings of someone/airflow: 
HTTP 502: Bad Gateway",
+            (),
+            id="settings-not-read",
+        ),
+        pytest.param(
+            (0, ""),
+            (0, json.dumps({"enabled": False})),
+            CheckStatus.OK,
+            "`Tests (AMD)` is active in someone/airflow",
+            (),
+            id="committer-actions-disabled",
+        ),
+    ],
+)
+def test_fork_actions_settings(membership, actions, status, finding, fix):
+    results = _audit(_machine(membership=membership, actions=actions))
+
+    assert (results[5].status, results[5].finding, results[5].fix) == (status, 
finding, fix)
+
+
[email protected](
+    ("workflow", "finding", "fix"),
+    [
+        pytest.param(
+            (0, "disabled_manually"),
+            "`Tests (AMD)` is disabled_manually in someone/airflow; enable it; 
also, "
+            "GitHub Actions is disabled in someone/airflow, so `Tests (AMD)` 
never runs",
+            (*ENABLE_ALL_ACTIONS, "gh workflow enable ci-amd.yml -R 
someone/airflow"),
+            id="workflow-disabled",
+        ),
+        pytest.param(
+            (1, "", "gh: Not Found (HTTP 404)"),
+            "`Tests (AMD)` is missing in someone/airflow; enable it; also, "
+            "GitHub Actions is disabled in someone/airflow, so `Tests (AMD)` 
never runs",
+            (
+                *ENABLE_ALL_ACTIONS,
+                "gh repo sync someone/airflow --branch main",
+                "gh workflow enable ci-amd.yml -R someone/airflow",
+            ),
+            id="workflow-missing",
+        ),
+        pytest.param(
+            (1, "", "HTTP 502: Bad Gateway"),
+            "GitHub Actions is disabled in someone/airflow, so `Tests (AMD)` 
never runs; "
+            "could not check `Tests (AMD)` in someone/airflow: HTTP 502: Bad 
Gateway",
+            ENABLE_ALL_ACTIONS,
+            id="workflow-not-read",
+        ),
+    ],
+)
+def test_actions_disabled_reported_whatever_the_workflow_state(workflow, 
finding, fix):
+    results = _audit(_machine(workflow=workflow, actions=(0, 
json.dumps({"enabled": False}))))
+
+    assert (results[5].status, results[5].finding, results[5].fix) == 
(CheckStatus.FAIL, finding, fix)
+
+
[email protected](
+    ("membership", "dispatch", "status", "finding", "fix"),
+    [
+        pytest.param(
+            NOT_A_MEMBER,
+            PAUSED,
+            CheckStatus.FAIL,
+            "GitHub has paused GitHub Actions in someone/airflow, so `Tests 
(AMD)` never runs although "
+            "the settings allow it; turn it back on from the banner on the 
fork's Actions page",
+            ("echo Open https://github.com/someone/airflow/actions and enable 
the workflows",),
+            id="paused",
+        ),
+        pytest.param(
+            NOT_A_MEMBER,
+            (1, "", "HTTP 502: Bad Gateway"),
+            CheckStatus.INFO,
+            "`Tests (AMD)` is active in someone/airflow, but could not check 
whether GitHub has paused "
+            "GitHub Actions there",
+            (),
+            id="probe-failed",
+        ),
+        pytest.param(
+            (0, ""),
+            PAUSED,
+            CheckStatus.OK,
+            "`Tests (AMD)` is active in someone/airflow",
+            (),
+            id="committer-not-probed",
+        ),
+    ],
+)
+def test_actions_paused_by_github(membership, dispatch, status, finding, fix):
+    results = _audit(_machine(membership=membership, dispatch=dispatch))
+
+    assert (results[5].status, results[5].finding, results[5].fix) == (status, 
finding, fix)
+
+
[email protected](
+    ("membership", "fork_workflows", "expected"),
+    [
+        pytest.param(
+            NOT_A_MEMBER,
+            (0, 
".github/workflows/ci-amd.yml\n.github/workflows/stale.yml\n.github/workflows/ci-arm.yml\n"),
+            (
+                CheckStatus.FAIL,
+                "someone/airflow has workflows that start only on a schedule 
or after another workflow, so "
+                "every run they start there is wasted; GitHub pauses GitHub 
Actions in forks that waste runs",
+                (
+                    "gh workflow disable ci-arm.yml -R someone/airflow",
+                    "gh workflow disable stale.yml -R someone/airflow",
+                ),
+            ),
+            id="idle-workflows-active",
+        ),
+        pytest.param(
+            NOT_A_MEMBER,
+            (0, ".github/workflows/ci-amd.yml\n"),
+            (CheckStatus.OK, "no workflow in someone/airflow starts runs that 
fork CI does not need", ()),
+            id="idle-workflows-disabled",
+        ),
+        pytest.param(
+            NOT_A_MEMBER,
+            (1, "", "HTTP 502: Bad Gateway"),
+            (
+                CheckStatus.INFO,
+                "could not list the workflows of someone/airflow: HTTP 502: 
Bad Gateway",
+                (),
+            ),
+            id="listing-failed",
+        ),
+    ],
+)
+def test_idle_fork_workflows(membership, fork_workflows, expected):
+    results = _audit(
+        _machine(membership=membership, fork_workflows=fork_workflows),
+        idle_workflows=frozenset({"stale.yml", "ci-arm.yml"}),
+    )
+
+    assert (results[8].status, results[8].finding, results[8].fix) == expected
+
+
+def test_idle_fork_workflows_not_checked_for_committers():
+    results = _audit(_machine(membership=(0, ""), fork_workflows=(0, 
".github/workflows/stale.yml\n")))
+
+    assert 8 not in results
+
+
+def test_find_idle_fork_workflows(tmp_path):
+    workflows = {
+        "cron.yml": "on:\n  schedule:\n    - cron: '0 0 * * *'\n",
+        "cron-or-manual.yml": "on:\n  schedule:\n    - cron: '0 0 * * *'\n  
workflow_dispatch:\n",
+        "after-ci.yml": "on:\n  workflow_run:\n    workflows: ['Tests 
(AMD)']\n",
+        "quoted-on.yaml": '"on": [schedule]\n',
+        "cron-and-push.yml": "on:\n  schedule:\n    - cron: '0 0 * * *'\n  
push:\n",
+        "reusable.yml": "on:\n  workflow_call:\n  schedule:\n    - cron: '0 0 
* * *'\n",
+        "manual-only.yml": "on: workflow_dispatch\n",
+        "push.yml": "on: push\n",
+    }
+    for name, text in workflows.items():
+        (tmp_path / name).write_text(f"{text}jobs: {{}}\n")
+
+    assert find_idle_fork_workflows(tmp_path) == {
+        "cron.yml",
+        "cron-or-manual.yml",
+        "after-ci.yml",
+        "quoted-on.yaml",
+    }
+
+
 @pytest.mark.parametrize(
     ("behind", "status"),
     [

Reply via email to