diff --git a/.github/workflows/allowlist_approval_rerun.yml b/.github/workflows/allowlist_approval_rerun.yml new file mode 100644 index 000000000..4ce7e2664 --- /dev/null +++ b/.github/workflows/allowlist_approval_rerun.yml @@ -0,0 +1,60 @@ +# +# 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. +# +# When a review makes the `approval` check pass, re-run the PR's earlier +# failed `approval` run from the `pull_request` event. The ruleset keeps +# blocking the merge on that stale failure otherwise, even though the +# review-triggered run is green. +# +# A review on a fork PR only gets a read-only token, so the re-run cannot +# happen in allowlist_approval.yml itself. `workflow_run` runs this file +# from the default branch with the token below; it reads nothing from the +# PR except the head commit SHA, which the script validates. +name: Allowlist Maintainer Approval Re-run + +on: + # Runs default-branch code, reads only the head SHA, and can only re-run + # the approval workflow's own failed run. + workflow_run: # zizmor: ignore[dangerous-triggers] + workflows: ["Allowlist Maintainer Approval"] + types: [completed] + +permissions: + contents: read + +jobs: + rerun: + if: >- + github.event.workflow_run.event == 'pull_request_review' && + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-slim + timeout-minutes: 5 + permissions: + contents: read + actions: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Re-run the stale approval check + run: python3 scripts/rerun_allowlist_approval.py --repo "${REPO}" --head-sha "${HEAD_SHA}" + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} diff --git a/README.md b/README.md index 6e952b587..8aa61972e 100644 --- a/README.md +++ b/README.md @@ -363,6 +363,7 @@ The workflows below run on PRs that touch the allow list, so the verification an - **`check_action_tags` job in `check_action_tags.yml`** — triggers when `actions.yml`, `approved_patterns.yml`, the generated Dependabot composite action, the update workflow, or gateway verification code changes. It verifies that configured action SHAs exist and, when a `tag` is recorded, that the SHA is reachable from that Git tag or branch. - **`approval` job in `allowlist_approval.yml`** — runs on every PR and on every review submitted, edited or dismissed. If the PR touches `actions.yml`, `approved_patterns.yml` or the generated Dependabot composite action, it fails until at least one maintainer (a reviewer with write access to this repository) has approved; only each reviewer's latest approve / request-changes / dismissal counts. Other PRs pass immediately. The check is required on `main` by the `Required checks` ruleset in `.asf.yaml`, so an allow-list PR cannot be merged unapproved. People with write access can deliberately merge past it ("bypass rules" on the PR), but cannot push past it; repository admins bypass it outright, which is how `update.yml`'s sync commit, pushed as the admin `asfgit`, still lands. The logic lives in `scripts/check_allowlist_approval.py`, which the job runs from the PR's base commit so a PR cannot rewrite its own gate. +- **`rerun` job in `allowlist_approval_rerun.yml`** — when a review makes the `approval` check pass, re-runs the PR's earlier failed `approval` run from the `pull_request` event. Without it the ruleset keeps blocking the merge on that stale failure, although the review-triggered run is green. It is a `workflow_run` workflow because a review on a fork PR only gets a read-only token; it runs from the default branch, takes nothing from the PR but the head commit SHA, and only re-runs the latest `pull_request` run of the check, through `scripts/rerun_allowlist_approval.py`. These workflows use regular `pull_request` (and `pull_request_review`) triggers with read-only permissions and no PR comments — pass/fail is surfaced through the status check. They do not auto-approve or merge; a human reviewer must still approve. diff --git a/scripts/rerun_allowlist_approval.py b/scripts/rerun_allowlist_approval.py new file mode 100644 index 000000000..61bca93dd --- /dev/null +++ b/scripts/rerun_allowlist_approval.py @@ -0,0 +1,108 @@ +#!/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. +# +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// +# +"""Re-run a PR's failed ``approval`` check once a maintainer has approved it. + +The ``approval`` job runs on ``pull_request`` and again on each review. The +review-triggered run passes once a maintainer approves, but the run from the +``pull_request`` event keeps its earlier failure, and the ``Required checks`` +ruleset keeps blocking the merge on it. Re-running that failed run makes it +evaluate the PR again, now with the approval in place. + +Only the latest ``pull_request`` run for the head commit is considered, and +only if it finished with a failure: a passing, running or queued run already +reflects the current state, or soon will. + +The decision logic is a pure function for unit testing; ``main`` lists the +runs and requests the re-run through ``gh api``. + +Usage: + GH_TOKEN=... python3 scripts/rerun_allowlist_approval.py --repo OWNER/REPO --head-sha SHA +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from collections.abc import Iterable + +WORKFLOW_FILE = "allowlist_approval.yml" + +# Conclusions a re-run can turn green. ``cancelled`` covers a run cut short +# by the workflow's own concurrency group. +RERUNNABLE = frozenset({"failure", "cancelled", "timed_out"}) + +SHA_RE = re.compile(r"^[0-9a-f]{40}$") + + +def run_to_rerun(runs: Iterable[dict]) -> int | None: + """Id of the latest ``pull_request`` run if it needs a re-run, else None. + + ``runs`` are workflow runs for one head commit, in any order. + """ + pr_runs = [r for r in runs if r["event"] == "pull_request"] + if not pr_runs: + return None + latest = max(pr_runs, key=lambda r: (r["created_at"], r["id"])) + if latest["status"] == "completed" and latest["conclusion"] in RERUNNABLE: + return latest["id"] + return None + + +def _gh(*args: str) -> str: + return subprocess.run( + ["gh", "api", *args], check=True, capture_output=True, text=True + ).stdout + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--repo", required=True, help="OWNER/REPO") + parser.add_argument("--head-sha", required=True, help="PR head commit") + args = parser.parse_args(argv) + if not SHA_RE.match(args.head_sha): + print(f"::error::Not a commit SHA: {args.head_sha!r}") + return 1 + + out = _gh( + f"repos/{args.repo}/actions/workflows/{WORKFLOW_FILE}/runs" + f"?head_sha={args.head_sha}&per_page=100", + "--jq", + ".workflow_runs[] | {id, event, status, conclusion, created_at} | @json", + ) + runs = [json.loads(line) for line in out.splitlines() if line] + run_id = run_to_rerun(runs) + if run_id is None: + print("No failed pull_request run of the approval check to re-run.") + return 0 + _gh("-X", "POST", f"repos/{args.repo}/actions/runs/{run_id}/rerun-failed-jobs") + print(f"Re-running the approval check from run {run_id}.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_rerun_allowlist_approval.py b/scripts/test_rerun_allowlist_approval.py new file mode 100644 index 000000000..d47c1ccae --- /dev/null +++ b/scripts/test_rerun_allowlist_approval.py @@ -0,0 +1,73 @@ +# +# 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. +# +import importlib.util +import sys +from pathlib import Path + +_spec = importlib.util.spec_from_file_location( + "rerun_allowlist_approval", Path(__file__).with_name("rerun_allowlist_approval.py") +) +raa = importlib.util.module_from_spec(_spec) +assert _spec and _spec.loader +sys.modules["rerun_allowlist_approval"] = raa +_spec.loader.exec_module(raa) + + +def _run(run_id, event, conclusion, created_at, status="completed"): + return { + "id": run_id, + "event": event, + "status": status, + "conclusion": conclusion, + "created_at": created_at, + } + + +def test_failed_pull_request_run_is_rerun(): + runs = [ + _run(1, "pull_request", "failure", "2026-10-21T19:22:00Z"), + _run(2, "pull_request_review", "success", "2026-10-22T15:00:00Z"), + ] + assert raa.run_to_rerun(runs) == 1 + + +def test_only_the_latest_pull_request_run_counts(): + runs = [ + _run(1, "pull_request", "failure", "2026-10-21T19:22:00Z"), + _run(3, "pull_request", "success", "2026-10-22T16:00:00Z"), + ] + assert raa.run_to_rerun(runs) is None + + +def test_running_pull_request_run_is_left_alone(): + runs = [_run(1, "pull_request", None, "2026-10-21T19:22:00Z", status="in_progress")] + assert raa.run_to_rerun(runs) is None + + +def test_cancelled_pull_request_run_is_rerun(): + assert raa.run_to_rerun([_run(4, "pull_request", "cancelled", "2026-10-21T19:22:00Z")]) == 4 + + +def test_review_runs_alone_need_nothing(): + assert raa.run_to_rerun([_run(2, "pull_request_review", "failure", "2026-10-22T15:00:00Z")]) is None + + +def test_main_rejects_a_non_sha(capsys): + assert raa.main(["--repo", "apache/infrastructure-actions", "--head-sha", "main; rm -rf /"]) == 1 + assert "Not a commit SHA" in capsys.readouterr().out