Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/allowlist_approval_rerun.yml
Original file line number Diff line number Diff line change
@@ -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 }}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
108 changes: 108 additions & 0 deletions scripts/rerun_allowlist_approval.py
Original file line number Diff line number Diff line change
@@ -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())
73 changes: 73 additions & 0 deletions scripts/test_rerun_allowlist_approval.py
Original file line number Diff line number Diff line change
@@ -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