This is an automated email from the ASF dual-hosted git repository.
400Ping pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/mahout.git
The following commit(s) were added to refs/heads/main by this push:
new 4d454cf3d [CI] Auto-merge gate for CI failures and PMC bypass ruleset
for main (#1517)
4d454cf3d is described below
commit 4d454cf3d369126406b53fb425107640a4b6a05b
Author: Jie-Kai Chang <[email protected]>
AuthorDate: Wed Oct 14 02:42:00 2026 +0800
[CI] Auto-merge gate for CI failures and PMC bypass ruleset for main (#1517)
Signed-off-by: 400Ping <[email protected]>
---
.asf.yaml | 86 ++++++
.github/pmc-members.txt | 3 +
.github/scripts/automerge_gate.py | 337 ++++++++++++++++++++++
.github/workflows/automerge-gate.yml | 85 ++++++
docs/community/pr-policy-and-review-guidelines.md | 30 +-
5 files changed, 540 insertions(+), 1 deletion(-)
diff --git a/.asf.yaml b/.asf.yaml
index 1bfb5ec67..9736d574e 100644
--- a/.asf.yaml
+++ b/.asf.yaml
@@ -65,11 +65,97 @@ github:
# GitHub's own required_approving_review_count (as used by Airflow)
cannot
# exempt PMC members, so the rule is enforced through this required
status
# check produced by .github/workflows/pr-approval-check.yml.
+ # "Auto-merge gate" (.github/workflows/automerge-gate.yml) keeps
+ # auto-merge (allow_auto_merge above) from merging a PR whose CI failed:
+ # GitHub auto-merge only waits for *required* checks, and the CI jobs
+ # themselves are deliberately not required so that a committer can still
+ # merge manually after a failure. The gate waits for all pull_request
+ # workflows, disables auto-merge on failure, and then passes.
+ #
+ # MIGRATION: the same rules are defined as the "main" ruleset below,
+ # which additionally lets PMC members bypass them. GitHub applies the
+ # most restrictive of classic protection and rulesets, so the bypass
+ # only works once this classic block is removed in a follow-up change
+ # (after confirming that the ruleset was created).
required_status_checks:
strict: false
checks:
- context: "Committer approval"
app_id: 15368 # GitHub Actions
+ - context: "Auto-merge gate"
+ app_id: 15368 # GitHub Actions
+
+ # Branch rules for main, as a ruleset (raw Rulesets API payload) because
+ # only rulesets support bypass actors: PMC members can merge a pull request
+ # from the GitHub UI ("bypass rules") without waiting for the required
+ # checks, e.g. for urgent fixes or when the ASF runner queue is stuck.
+ # bypass_mode "pull_request" means they still have to open a pull request
+ # (no direct pushes) and every bypass is recorded in the audit log.
+ # The actor ids are the GitHub user ids of .github/pmc-members.txt; update
+ # both when the PMC roster changes (`gh api users/<login> -q .id`).
+ rulesets:
+ - name: "main"
+ target: branch
+ enforcement: active
+ conditions:
+ ref_name:
+ include:
+ - "~DEFAULT_BRANCH"
+ exclude: []
+ bypass_actors:
+ - actor_id: 692523 # andrewmusselman
+ actor_type: User
+ bypass_mode: pull_request
+ - actor_id: 7681565 # andrewpalumbo
+ actor_type: User
+ bypass_mode: pull_request
+ - actor_id: 105915352 # guan404ming
+ actor_type: User
+ bypass_mode: pull_request
+ - actor_id: 70953 # MaineC
+ actor_type: User
+ bypass_mode: pull_request
+ - actor_id: 43886578 # 400Ping
+ actor_type: User
+ bypass_mode: pull_request
+ - actor_id: 101171023 # rich7420
+ actor_type: User
+ bypass_mode: pull_request
+ - actor_id: 409707 # sscdotopen
+ actor_type: User
+ bypass_mode: pull_request
+ - actor_id: 135417 # magsol
+ actor_type: User
+ bypass_mode: pull_request
+ - actor_id: 176382 # sslavic
+ actor_type: User
+ bypass_mode: pull_request
+ - actor_id: 5852441 # rawkintrevo
+ actor_type: User
+ bypass_mode: pull_request
+ rules:
+ # Only bypass actors can delete or force-push main.
+ - type: deletion
+ - type: non_fast_forward
+ - type: required_linear_history
+ # Changes must come through a pull request. No review count here:
+ # committer approval is the status check below (with PMC exemption).
+ - type: pull_request
+ parameters:
+ required_approving_review_count: 0
+ dismiss_stale_reviews_on_push: false
+ require_code_owner_review: false
+ require_last_push_approval: false
+ required_review_thread_resolution: false
+ - type: required_status_checks
+ parameters:
+ strict_required_status_checks_policy: false
+ required_status_checks:
+ - context: "Committer approval"
+ integration_id: 15368 # GitHub Actions
+ - context: "Auto-merge gate"
+ integration_id: 15368 # GitHub Actions
+
environments:
pypi:
required_reviewers:
diff --git a/.github/pmc-members.txt b/.github/pmc-members.txt
index 671dfbcca..99e464f90 100644
--- a/.github/pmc-members.txt
+++ b/.github/pmc-members.txt
@@ -28,6 +28,9 @@
# their ASF id (https://gitbox.apache.org/boxer/) can merge; the others are
listed
# for completeness. Keep one username per line, case-insensitive.
#
+# The same people are bypass actors (by numeric GitHub user id) of the "main"
+# ruleset in .asf.yaml; update both when the roster changes.
+#
# GitHub username # ASF id
andrewmusselman # akm
andrewpalumbo # apalumbo
diff --git a/.github/scripts/automerge_gate.py
b/.github/scripts/automerge_gate.py
new file mode 100644
index 000000000..9c4a8887e
--- /dev/null
+++ b/.github/scripts/automerge_gate.py
@@ -0,0 +1,337 @@
+#
+# 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.
+#
+"""Keep GitHub auto-merge from merging a pull request whose CI failed.
+
+This backs the ``Auto-merge gate`` required status check on ``main`` (see
+``.github/workflows/automerge-gate.yml`` and ``.asf.yaml``).
+
+Background: GitHub auto-merge only waits for *required* status checks. Making
+every CI job required would also block committers from merging manually when
+they decide a failure is acceptable (e.g. a flaky external check), which is
+not what the project wants. So instead a single required check waits for the
+``pull_request`` workflows of the head commit and then:
+
+* while any of them is still running -> exit 1 ("waiting"); the check is
+ re-evaluated every time one of them completes (``workflow_run`` trigger);
+* if every one of them passed (or was skipped) -> exit 0; auto-merge, if
+ enabled, merges the PR;
+* if any of them failed -> **disable auto-merge** on the PR (if it was
+ enabled) and leave a comment, then exit 0 so that a committer can still
+ merge manually. The failed CI job stays visible on the PR.
+
+Urgent changes do not go through this script at all: PMC members are bypass
+actors of the ``main`` ruleset in ``.asf.yaml`` and can merge a pull request
+from the GitHub UI without waiting for any required check.
+
+Only the Python standard library is used so the script runs in CI and locally:
+
+ GITHUB_TOKEN=$(gh auth token) DRY_RUN=1 python
.github/scripts/automerge_gate.py \\
+ --repo apache/mahout --sha <head sha>
+
+``DRY_RUN=1`` performs no mutation (auto-merge is not disabled, no comment).
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+from typing import Any
+
+API_URL = os.environ.get("GITHUB_API_URL", "https://api.github.com")
+GRAPHQL_URL = os.environ.get("GITHUB_GRAPHQL_URL",
"https://api.github.com/graphql")
+# Only workflows triggered by these events are CI for the pull request. In
+# particular this excludes "PR Approval Check" (pull_request_target), which is
+# its own required status check, and this gate itself (workflow_run).
+PR_EVENTS = {"pull_request"}
+# Conclusions that do not count as a CI failure.
+OK_CONCLUSIONS = {"success", "skipped", "neutral"}
+# A run that is waiting for a maintainer to approve it (fork PRs) is pending,
+# not failed.
+PENDING_CONCLUSIONS = {"action_required"}
+
+
+class GitHubError(RuntimeError):
+ """Raised when the GitHub API returns an unexpected response."""
+
+
+def _request(token: str, url: str, data: dict[str, Any] | None = None) -> Any:
+ body = json.dumps(data).encode("utf-8") if data is not None else None
+ request = urllib.request.Request(
+ url,
+ data=body,
+ method="POST" if body is not None else "GET",
+ headers={
+ "Accept": "application/vnd.github+json",
+ "Authorization": f"Bearer {token}",
+ "X-GitHub-Api-Version": "2022-11-28",
+ "User-Agent": "apache-mahout-automerge-gate",
+ **({"Content-Type": "application/json"} if body is not None else
{}),
+ },
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=30) as response:
+ return json.load(response)
+ except urllib.error.HTTPError as exc:
+ text = exc.read().decode("utf-8", errors="replace")
+ raise GitHubError(f"{request.method} {url} -> HTTP {exc.code}:
{text}") from exc
+
+
+def _api(
+ token: str, path: str, params: dict[str, str] | None = None, data: Any =
None
+) -> Any:
+ url = f"{API_URL}{path}"
+ if params:
+ url = f"{url}?{urllib.parse.urlencode(params)}"
+ return _request(token, url, data)
+
+
+def _graphql(token: str, query: str, variables: dict[str, Any]) -> dict[str,
Any]:
+ result = _request(token, GRAPHQL_URL, {"query": query, "variables":
variables})
+ if result.get("errors"):
+ raise GitHubError(f"GraphQL: {json.dumps(result['errors'])}")
+ return result["data"]
+
+
+def workflow_runs_for_sha(token: str, repo: str, sha: str) -> list[dict[str,
Any]]:
+ runs: list[dict[str, Any]] = []
+ page = 1
+ while True:
+ chunk = _api(
+ token,
+ f"/repos/{repo}/actions/runs",
+ {"head_sha": sha, "per_page": "100", "page": str(page)},
+ )
+ runs.extend(chunk.get("workflow_runs") or [])
+ if len(chunk.get("workflow_runs") or []) < 100:
+ return runs
+ page += 1
+
+
+def latest_pr_runs(runs: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
+ """Map workflow name -> its most recent ``pull_request`` run for the
commit.
+
+ A re-run reuses the run id (``run_attempt`` increases) and the API reports
+ the latest attempt, so keeping the newest run per workflow is enough.
+ """
+ latest: dict[str, dict[str, Any]] = {}
+ for run in runs:
+ if run.get("event") not in PR_EVENTS:
+ continue
+ name = run.get("name") or str(run.get("workflow_id"))
+ if name not in latest or run["id"] > latest[name]["id"]:
+ latest[name] = run
+ return latest
+
+
+def classify(run: dict[str, Any]) -> str:
+ """Return ``"pending"``, ``"ok"`` or ``"failed"`` for a workflow run."""
+ if run.get("status") != "completed":
+ return "pending"
+ conclusion = run.get("conclusion") or ""
+ if conclusion in PENDING_CONCLUSIONS:
+ return "pending"
+ if conclusion in OK_CONCLUSIONS:
+ return "ok"
+ return "failed"
+
+
+def open_prs_with_head(token: str, repo: str, sha: str) -> list[dict[str,
Any]]:
+ prs = _api(token, f"/repos/{repo}/commits/{sha}/pulls", {"per_page":
"100"})
+ # Only act on PRs whose *current* head is this commit: a failure on an
+ # already superseded commit must not touch the PR.
+ return [
+ pr
+ for pr in prs
+ if pr.get("state") == "open" and (pr.get("head") or {}).get("sha") ==
sha
+ ]
+
+
+def auto_merge_state(token: str, repo: str, number: int) -> tuple[str, str |
None]:
+ """Return ``(node_id, enabled_by_login_or_None)`` for a pull request."""
+ owner, name = repo.split("/", 1)
+ data = _graphql(
+ token,
+ """
+ query($owner: String!, $name: String!, $number: Int!) {
+ repository(owner: $owner, name: $name) {
+ pullRequest(number: $number) {
+ id
+ autoMergeRequest { enabledBy { login } }
+ }
+ }
+ }
+ """,
+ {"owner": owner, "name": name, "number": number},
+ )
+ pr = data["repository"]["pullRequest"]
+ request = pr.get("autoMergeRequest")
+ enabled_by = ((request or {}).get("enabledBy") or {}).get("login")
+ return pr["id"], (enabled_by or "(unknown)") if request else None
+
+
+def disable_auto_merge(token: str, node_id: str) -> None:
+ _graphql(
+ token,
+ """
+ mutation($id: ID!) {
+ disablePullRequestAutoMerge(input: {pullRequestId: $id}) {
+ pullRequest { number }
+ }
+ }
+ """,
+ {"id": node_id},
+ )
+
+
+def comment(token: str, repo: str, number: int, body: str) -> None:
+ _api(token, f"/repos/{repo}/issues/{number}/comments", data={"body": body})
+
+
+def failure_comment(sha: str, failed: list[dict[str, Any]], enabled_by: str)
-> str:
+ lines = [
+ f"**Auto-merge disabled** (it was enabled by @{enabled_by}): "
+ f"CI failed for {sha[:7]}.",
+ "",
+ ]
+ lines.extend(
+ f"- {run['name']}: {run.get('conclusion')} ({run.get('html_url')})"
+ for run in failed
+ )
+ lines.extend(
+ [
+ "",
+ "Push a fix and enable auto-merge again, or a committer can merge "
+ "manually if the failure is acceptable. Auto-merge is never "
+ "re-enabled automatically.",
+ "",
+ "_Posted by the `Auto-merge gate` workflow "
+ "(`.github/workflows/automerge-gate.yml`)._",
+ ]
+ )
+ return "\n".join(lines)
+
+
+def gate(token: str, repo: str, sha: str, dry_run: bool) -> tuple[bool,
list[str]]:
+ """Evaluate the gate. Returns ``(ok, log_lines)``."""
+ lines: list[str] = []
+ runs = latest_pr_runs(workflow_runs_for_sha(token, repo, sha))
+ if not runs:
+ # Cannot happen when triggered by workflow_run, but be explicit.
+ lines.append(f"No pull_request workflow runs found for {sha[:7]}.")
+ return False, lines
+
+ pending: list[dict[str, Any]] = []
+ failed: list[dict[str, Any]] = []
+ for name in sorted(runs):
+ run = runs[name]
+ state = classify(run)
+ lines.append(
+ f"{name}: {run.get('status')}/{run.get('conclusion') or '-'} ->
{state}"
+ )
+ if state == "pending":
+ pending.append(run)
+ elif state == "failed":
+ failed.append(run)
+
+ if pending:
+ names = ", ".join(run["name"] for run in pending)
+ lines.append(f"Waiting for: {names}. Re-evaluated when they complete.")
+ return False, lines
+
+ if not failed:
+ lines.append("All pull_request workflows passed or were skipped.")
+ return True, lines
+
+ names = ", ".join(run["name"] for run in failed)
+ lines.append(f"CI failed: {names}.")
+ for pr in open_prs_with_head(token, repo, sha):
+ number = int(pr["number"])
+ node_id, enabled_by = auto_merge_state(token, repo, number)
+ if enabled_by is None:
+ lines.append(f"PR #{number}: auto-merge not enabled; nothing to
do.")
+ continue
+ if dry_run:
+ lines.append(
+ f"PR #{number}: auto-merge enabled by @{enabled_by}; "
+ "DRY_RUN set, would disable it and comment."
+ )
+ continue
+ disable_auto_merge(token, node_id)
+ comment(token, repo, number, failure_comment(sha, failed, enabled_by))
+ lines.append(
+ f"PR #{number}: auto-merge (enabled by @{enabled_by}) disabled; "
+ "comment posted."
+ )
+ lines.append(
+ "Passing the gate so that a committer can still merge manually; "
+ "the failed job remains visible on the PR."
+ )
+ return True, lines
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter
+ )
+ parser.add_argument(
+ "--repo",
+ default=os.environ.get("GITHUB_REPOSITORY"),
+ help="owner/name (default: $GITHUB_REPOSITORY)",
+ )
+ parser.add_argument(
+ "--sha",
+ default=os.environ.get("HEAD_SHA"),
+ help="pull request head commit (default: $HEAD_SHA)",
+ )
+ args = parser.parse_args(argv)
+
+ token = os.environ.get("GITHUB_TOKEN")
+ if not token or not args.repo or not args.sha:
+ parser.error(
+ "GITHUB_TOKEN, --repo/$GITHUB_REPOSITORY and --sha/$HEAD_SHA are
required"
+ )
+ dry_run = os.environ.get("DRY_RUN", "") not in {"", "0", "false"}
+
+ try:
+ ok, lines = gate(token, args.repo, args.sha, dry_run)
+ except GitHubError as exc:
+ print(f"::error::{exc}")
+ return 1
+
+ verdict = (
+ "PASS" if ok else "WAITING: not all pull_request workflows have
completed."
+ )
+ lines.append(verdict)
+ print("\n".join(lines))
+
+ summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
+ if summary_path:
+ with open(summary_path, "a", encoding="utf-8") as summary:
+ summary.write("### Auto-merge gate\n\n")
+ summary.writelines(f"- {line}\n" for line in lines)
+ if not ok:
+ print(f"::notice::{verdict}")
+ return 0 if ok else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.github/workflows/automerge-gate.yml
b/.github/workflows/automerge-gate.yml
new file mode 100644
index 000000000..9b8ea0d2e
--- /dev/null
+++ b/.github/workflows/automerge-gate.yml
@@ -0,0 +1,85 @@
+#
+# 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.
+#
+# Keeps GitHub auto-merge from merging a PR whose CI failed, without blocking
+# a committer from merging it manually
(docs/community/pr-policy-and-review-guidelines.md).
+#
+# GitHub auto-merge only waits for *required* status checks, and a required
+# check blocks manual merges just the same. So the only required CI-related
+# check is the "Auto-merge gate" job below: it is re-run every time one of
+# the pull_request workflows finishes, stays red ("waiting") until all of
+# them have completed, and then
+# * passes when everything succeeded (auto-merge proceeds), or
+# * disables auto-merge on the PR, comments, and *still passes* when
+# something failed, so the committer can merge manually.
+# Urgent PRs do not need this gate: PMC members are bypass actors of the main
+# ruleset (.asf.yaml) and can merge from the GitHub UI without waiting for it.
+# See .github/scripts/automerge_gate.py for the details.
+#
+name: Auto-merge Gate
+
+on:
+ workflow_run:
+ # Every workflow that runs on pull_request. A workflow missing from this
+ # list still counts for the gate (it lists all pull_request runs of the
+ # commit) but will not re-trigger the gate when it finishes, so the gate
+ # could stay in "waiting" until another workflow completes.
+ workflows:
+ - Pre-commit
+ - Python Testing
+ - CI for Jupyter Notebooks
+ - Wheel Build
+ - Website Build Check
+ - Links
+ types: [completed]
+
+permissions:
+ actions: read
+ # Both are needed by GITHUB_TOKEN to toggle auto-merge; pull-requests also
+ # covers the comment.
+ contents: write
+ pull-requests: write
+
+# Serialise gate runs per commit (no cancellation): the newest run always
+# finishes last, so the status reflects the latest state of CI.
+concurrency:
+ group: automerge-gate-${{ github.event.workflow_run.head_sha }}
+ cancel-in-progress: false
+
+jobs:
+ gate:
+ # This job name is the status check context referenced in .asf.yaml.
+ name: Auto-merge gate
+ # Only CI of pull requests matters (not pushes to main etc.).
+ if: github.event.workflow_run.event == 'pull_request'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ steps:
+ # workflow_run always runs the default-branch workflow; take the script
+ # from there as well. No code from the PR head is executed.
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
+ sparse-checkout: |
+ .github/scripts/automerge_gate.py
+ sparse-checkout-cone-mode: false
+
+ - name: Evaluate CI of the pull request head
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
+ run: python3 .github/scripts/automerge_gate.py
diff --git a/docs/community/pr-policy-and-review-guidelines.md
b/docs/community/pr-policy-and-review-guidelines.md
index 7c4e5f5c8..17260c9ad 100644
--- a/docs/community/pr-policy-and-review-guidelines.md
+++ b/docs/community/pr-policy-and-review-guidelines.md
@@ -73,10 +73,38 @@ For non-trivial code contributions, contributors are
expected to have an ASF ICL
When deciding whether to merge, committers should verify all of the following:
-- Required CI checks are green.
+- Required CI checks are green (enforced automatically, see below).
- At least one committer approves (enforced automatically, see below).
- All blocking comments are resolved.
+#### Auto-merge and CI
+
+GitHub auto-merge only waits for *required* status checks, and the CI jobs are
+deliberately **not** required on `main`: a required check would also stop a
+committer from merging manually when a failure is judged acceptable. Instead
+the `Auto-merge gate` status check
([`automerge-gate.yml`](https://github.com/apache/mahout/blob/main/.github/workflows/automerge-gate.yml))
+is required. It is re-evaluated every time a CI workflow of the PR finishes:
+
+- While any CI workflow is still running, the gate is red ("waiting"), so
+ auto-merge cannot fire early.
+- When every CI workflow passed (or was skipped), the gate passes and
+ auto-merge, if enabled, merges the PR.
+- When a CI workflow failed, the gate **disables auto-merge** on the PR, leaves
+ a comment, and then passes. The PR is not merged automatically, but a
+ committer can still merge it manually. Auto-merge has to be enabled again by
+ hand after a fix.
+- **Urgent changes (PMC only):** PMC members are bypass actors of the `main`
+ branch ruleset in
[`.asf.yaml`](https://github.com/apache/mahout/blob/main/.asf.yaml).
+ On any pull request they can choose "Merge without waiting for requirements
+ to be met (bypass rules)" and merge immediately, without waiting for CI or
+ for the status checks above (for example when the ASF runner queue is
+ stuck). The change must still come as a pull request, and every bypass is
+ recorded in the repository's audit log. Use it deliberately.
+
+"Required CI checks are green" above therefore remains a committer
+responsibility: merging over a red CI job is possible and must be a deliberate
+decision.
+
#### Enforced Committer Approval
The `Committer approval` status check
([`pr-approval-check.yml`](https://github.com/apache/mahout/blob/main/.github/workflows/pr-approval-check.yml))
is required on `main` and re-runs whenever a review is submitted, edited, or
dismissed: