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
29 changes: 28 additions & 1 deletion .github/workflows/update-android-downloads.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ concurrency:
cancel-in-progress: false

permissions:
checks: read
contents: write
pull-requests: write

Expand Down Expand Up @@ -93,7 +94,8 @@ jobs:
exit 1
fi

- name: Create or update the review PR
- name: Create or update the Android downloads PR
id: download_pr
working-directory: site
env:
GH_TOKEN: ${{ github.token }}
Expand All @@ -108,6 +110,7 @@ jobs:
git config user.name "androiddevtools-bot"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
branch="automation/android-downloads"
base_sha="$(git rev-parse HEAD)"
git switch -C "$branch"
git add android-studio.html android-emulator.html index.html
git commit -m "chore: update Android downloads"
Expand All @@ -121,4 +124,28 @@ jobs:
else
gh pr create --repo "$GITHUB_REPOSITORY" --base gh-pages --head "$branch" \
--title "chore: update Android downloads" --body-file "$body_file"
pr_number="$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$branch" --base gh-pages --state open --json number --jq '.[0].number')"
fi
if [[ ! "$pr_number" =~ ^[0-9]+$ ]]; then
echo "Could not identify the Android downloads PR" >&2
exit 1
fi
{
echo "number=$pr_number"
echo "head_sha=$(git rev-parse HEAD)"
echo "base_sha=$base_sha"
} >> "$GITHUB_OUTPUT"

- name: Merge verified Android downloads PR
if: github.ref == 'refs/heads/master' && steps.download_pr.outputs.number != ''
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ steps.download_pr.outputs.number }}
HEAD_SHA: ${{ steps.download_pr.outputs.head_sha }}
BASE_SHA: ${{ steps.download_pr.outputs.base_sha }}
run: >-
python3 automation/scripts/auto_merge_android_download_pr.py
--repo "$GITHUB_REPOSITORY"
--pr-number "$PR_NUMBER"
--head-sha "$HEAD_SHA"
--base-sha "$BASE_SHA"
135 changes: 135 additions & 0 deletions scripts/auto_merge_android_download_pr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Merge only a verified PR created by the Android download workflow."""

from __future__ import annotations

import argparse
import json
import subprocess
import sys
import time
from typing import Any


BRANCH = "automation/android-downloads"
BASE_BRANCH = "gh-pages"
BOT_LOGIN = "github-actions[bot]"
ALLOWED_FILES = {"android-studio.html", "android-emulator.html", "index.html"}
MAX_DIFF_LINES = 2000
CHECK_NAME = "Cloudflare Pages"
CHECK_APP = "cloudflare-workers-and-pages"
POLL_INTERVAL_SECONDS = 10
POLL_ATTEMPTS = 30


class MergeBlocked(RuntimeError):
"""The PR needs manual attention and must stay open."""


def _get_json(endpoint: str) -> Any:
result = subprocess.run(["gh", "api", endpoint], check=True, capture_output=True, text=True)
return json.loads(result.stdout)


def _review_decision(repo: str, number: int) -> str | None:
result = subprocess.run(
["gh", "pr", "view", str(number), "--repo", repo, "--json", "reviewDecision"],
check=True,
capture_output=True,
text=True,
)
return json.loads(result.stdout)["reviewDecision"]


def _merge(repo: str, number: int, head_sha: str) -> None:
subprocess.run(
["gh", "pr", "merge", str(number), "--repo", repo, "--merge", "--match-head-commit", head_sha],
check=True,
)


def _validate_pr(pr: dict[str, Any], files: list[dict[str, Any]], repo: str, head_sha: str) -> None:
if pr["state"] != "open" or pr["draft"]:
raise MergeBlocked("PR is not open and ready")
if pr["user"]["login"] != BOT_LOGIN:
raise MergeBlocked("PR author is not the GitHub Actions bot")
if pr["base"]["repo"]["full_name"] != repo or pr["base"]["ref"] != BASE_BRANCH:
raise MergeBlocked("PR base is not this repository's gh-pages branch")
if (
pr["head"]["repo"]["full_name"] != repo
or pr["head"]["ref"] != BRANCH
or pr["head"]["sha"] != head_sha
):
raise MergeBlocked("PR head is not the commit generated by this workflow run")
if not files or len(files) != pr["changed_files"] or len(files) > len(ALLOWED_FILES):
raise MergeBlocked("PR files are missing or incomplete")
if any(file["filename"] not in ALLOWED_FILES or file["status"] != "modified" for file in files):
raise MergeBlocked("PR files escaped the generated-page allowlist")
changed_lines = sum(file["additions"] + file["deletions"] for file in files)
if changed_lines > MAX_DIFF_LINES:
raise MergeBlocked(f"PR files exceed the {MAX_DIFF_LINES}-line limit")


def _checks_passed(checks: dict[str, Any]) -> bool:
runs = checks["check_runs"]
if checks["total_count"] != len(runs):
raise MergeBlocked("PR check list is incomplete")
if any(run["status"] == "completed" and run["conclusion"] != "success" for run in runs):
raise MergeBlocked("A PR check failed or was cancelled")
cloudflare_passed = any(
run["name"] == CHECK_NAME
and run["app"]["slug"] == CHECK_APP
and run["status"] == "completed"
and run["conclusion"] == "success"
for run in runs
)
return cloudflare_passed and all(run["status"] == "completed" for run in runs)


def _validate_base_ref(repo: str, expected_sha: str) -> None:
current = _get_json(f"repos/{repo}/git/ref/heads/{BASE_BRANCH}")
if current["object"]["sha"] != expected_sha:
raise MergeBlocked("The base branch changed since the pages were generated")


def merge_verified_pr(repo: str, number: int, head_sha: str, base_sha: str, *, attempts: int = POLL_ATTEMPTS) -> None:
pr_endpoint = f"repos/{repo}/pulls/{number}"
files_endpoint = f"{pr_endpoint}/files?per_page=100"

_validate_pr(_get_json(pr_endpoint), _get_json(files_endpoint), repo, head_sha)
_validate_base_ref(repo, base_sha)
for attempt in range(attempts):
checks = _get_json(f"repos/{repo}/commits/{head_sha}/check-runs?per_page=100&filter=latest")
if _checks_passed(checks):
break
if attempt < attempts - 1:
print(f"Waiting for {CHECK_NAME} on {head_sha} ({attempt + 1}/{attempts})", flush=True)
time.sleep(POLL_INTERVAL_SECONDS)
else:
raise MergeBlocked(f"{CHECK_NAME} did not pass within the wait period")

_validate_pr(_get_json(pr_endpoint), _get_json(files_endpoint), repo, head_sha)
_validate_base_ref(repo, base_sha)
if _review_decision(repo, number) not in (None, "APPROVED"):
raise MergeBlocked("The PR requires review before merging")
_merge(repo, number, head_sha)


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", required=True)
parser.add_argument("--pr-number", required=True, type=int)
parser.add_argument("--head-sha", required=True)
parser.add_argument("--base-sha", required=True)
args = parser.parse_args()
try:
merge_verified_pr(args.repo, args.pr_number, args.head_sha, args.base_sha)
except (MergeBlocked, subprocess.CalledProcessError) as exc:
print(f"Auto-merge stopped; PR remains open: {exc}", file=sys.stderr)
return 1
print(f"Merged Android download PR #{args.pr_number}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
132 changes: 132 additions & 0 deletions tests/test_auto_merge_android_download_pr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""Safety checks for merging generated Android download pull requests."""

from __future__ import annotations

import copy
import sys
import unittest
from pathlib import Path
from unittest.mock import patch


ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))

import auto_merge_android_download_pr as merger # noqa: E402

REPO = "inferjay/AndroidDevTools"
HEAD = "a" * 40
BASE = "b" * 40
PR = {
"state": "open",
"draft": False,
"user": {"login": "github-actions[bot]"},
"base": {"ref": "gh-pages", "sha": BASE, "repo": {"full_name": REPO}},
"head": {"ref": "automation/android-downloads", "sha": HEAD, "repo": {"full_name": REPO}},
"changed_files": 2,
}
FILES = [
{"filename": "android-emulator.html", "status": "modified", "additions": 20, "deletions": 0},
{"filename": "index.html", "status": "modified", "additions": 20, "deletions": 20},
]
SUCCESSFUL_CHECKS = {
"total_count": 1,
"check_runs": [
{
"name": "Cloudflare Pages",
"app": {"slug": "cloudflare-workers-and-pages"},
"status": "completed",
"conclusion": "success",
}
],
}


class AutoMergeAndroidDownloadPrTests(unittest.TestCase):
def setUp(self) -> None:
self.merger = merger

def run_gate(
self, *, pr=None, files=None, checks=None, final_pr=None, base_refs=None, review_decision=None, attempts=2
):
pr = copy.deepcopy(PR if pr is None else pr)
files = copy.deepcopy(FILES if files is None else files)
checks = copy.deepcopy(SUCCESSFUL_CHECKS if checks is None else checks)
final_pr = copy.deepcopy(pr if final_pr is None else final_pr)
pr_reads = iter((pr, final_pr))
check_reads = iter(checks) if isinstance(checks, list) else None
base_ref_reads = iter(base_refs) if base_refs is not None else None

def get_json(endpoint):
if endpoint.endswith("/git/ref/heads/gh-pages"):
return {"object": {"sha": next(base_ref_reads) if base_ref_reads is not None else BASE}}
if endpoint.endswith("/pulls/123"):
return next(pr_reads)
if "/pulls/123/files" in endpoint:
return files
if "/check-runs" in endpoint:
return next(check_reads) if check_reads is not None else checks
raise AssertionError(f"Unexpected endpoint: {endpoint}")

with (
patch.object(self.merger, "_get_json", side_effect=get_json),
patch.object(self.merger, "_review_decision", return_value=review_decision),
patch.object(self.merger, "_merge") as merge,
patch.object(self.merger.time, "sleep") as sleep,
):
self.merger.merge_verified_pr(REPO, 123, HEAD, BASE, attempts=attempts)
return merge, sleep

def test_merges_verified_bot_pr_after_cloudflare_success(self) -> None:
merge, sleep = self.run_gate()
merge.assert_called_once_with(REPO, 123, HEAD)
sleep.assert_not_called()

def test_rejects_pr_from_other_author(self) -> None:
pr = copy.deepcopy(PR)
pr["user"]["login"] = "another-user"
with self.assertRaisesRegex(self.merger.MergeBlocked, "author"):
self.run_gate(pr=pr)

def test_rejects_unexpected_file(self) -> None:
files = copy.deepcopy(FILES)
files[0]["filename"] = "README.md"
with self.assertRaisesRegex(self.merger.MergeBlocked, "files"):
self.run_gate(files=files)

def test_waits_for_check_on_exact_head_commit(self) -> None:
pending = copy.deepcopy(SUCCESSFUL_CHECKS)
pending["check_runs"][0]["status"] = "in_progress"
pending["check_runs"][0]["conclusion"] = None
merge, sleep = self.run_gate(checks=[pending, SUCCESSFUL_CHECKS])
sleep.assert_called_once()
merge.assert_called_once_with(REPO, 123, HEAD)

def test_failed_check_blocks_merge(self) -> None:
failed = copy.deepcopy(SUCCESSFUL_CHECKS)
failed["check_runs"][0]["conclusion"] = "failure"
with self.assertRaisesRegex(self.merger.MergeBlocked, "check"):
self.run_gate(checks=failed)

def test_missing_cloudflare_check_times_out(self) -> None:
missing = {"total_count": 0, "check_runs": []}
with self.assertRaisesRegex(self.merger.MergeBlocked, "Cloudflare Pages"):
self.run_gate(checks=missing)

def test_head_change_after_check_blocks_merge(self) -> None:
final_pr = copy.deepcopy(PR)
final_pr["head"]["sha"] = "c" * 40
with self.assertRaisesRegex(self.merger.MergeBlocked, "head"):
self.run_gate(final_pr=final_pr)

def test_base_branch_change_after_check_blocks_merge(self) -> None:
with self.assertRaisesRegex(self.merger.MergeBlocked, "base branch"):
self.run_gate(base_refs=[BASE, "c" * 40])

def test_required_review_blocks_merge(self) -> None:
with self.assertRaisesRegex(self.merger.MergeBlocked, "review"):
self.run_gate(review_decision="REVIEW_REQUIRED")


if __name__ == "__main__":
unittest.main()
Loading