Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
386e858
ci: automate Android download archive updates
inferjay Sep 19, 2026
70bcdab
Merge pull request #84 from inferjay/automation-master
inferjay Sep 20, 2026
93da8fa
ci: update GitHub Actions runtime pins
inferjay Sep 20, 2026
ce03bbb
Merge pull request #86 from inferjay/fix/update-actions-runtime
inferjay Sep 20, 2026
5fc5cc9
fix: tolerate repository rollout lag
inferjay Sep 25, 2026
2b1c355
Merge pull request #87 from inferjay/fix/emulator-repository-rollover
inferjay Sep 25, 2026
0618dee
fix: warn on stale Emulator repository rollout mismatch
inferjay Oct 1, 2026
d0c3f79
Merge pull request #90 from inferjay/fix/emulator-repository-stalenes…
inferjay Oct 2, 2026
0e5fa9c
fix: handle archived beta superseded by matched stable
inferjay Oct 3, 2026
369625d
Merge pull request #91 from inferjay/fix/emulator-beta-superseded-by-…
inferjay Oct 3, 2026
96be5e6
ci: add narrow catch-up slots for delayed download schedules
inferjay Oct 7, 2026
9bf7593
Merge pull request #94 from inferjay/fix/download-schedule-catch-up
inferjay Oct 7, 2026
7905790
fix: prevent Emulator archive dates from masking version rollback (#97)
inferjay Oct 8, 2026
67fdb4b
ci: auto-merge verified Android download PRs
inferjay Oct 8, 2026
2f453b1
Merge pull request #98 from inferjay/ci/auto-merge-android-downloads
inferjay Oct 9, 2026
4002c83
fix: accept empty review decision in auto-merge
inferjay Oct 9, 2026
042b8b9
Merge pull request #101 from inferjay/codex/fix-auto-merge-review-dec…
inferjay Oct 9, 2026
9b86f30
docs: remove obsolete links related to issues #67 and #73
github-actions[bot] Oct 9, 2026
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
151 changes: 151 additions & 0 deletions .github/workflows/update-android-downloads.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
name: Update Android downloads

on:
workflow_dispatch:
schedule:
# Best-effort checks at 08:20, 10:20 and 12:20 Asia/Shanghai (UTC+8).
# Keep 00:20 UTC; add two narrow catch-up opportunities after persistent
# 4-6 hour schedule delays. Each attempt uses the same guarded update/PR path.
# Do not skip later attempts after success: upstream may change meanwhile.
# GitHub may delay or drop ALL slots; this is not a freshness deadline.
# If a deadline is required, an external timer can call the existing
# workflow_dispatch on master, then monitor completion and retry failures.
# That bypasses schedule delivery, but runner availability is still best-effort.
- cron: "20 0,2,4 * * *"

concurrency:
group: update-android-downloads
cancel-in-progress: false

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

jobs:
update:
runs-on: ubuntu-24.04
steps:
- name: Check out automation source
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
ref: master
path: automation
fetch-depth: 1

- name: Check out published site
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
ref: gh-pages
path: site
fetch-depth: 0

- name: Run offline regression tests
working-directory: automation
run: python3 -m unittest discover -s tests -v

- name: Run the guarded update
run: >-
python3 automation/scripts/update_android_downloads.py
--site-dir site
--mode write
--report-json update-report.json

- name: Report cross-validation (including no-change runs)
run: |
python3 - <<'PY'
import json
import os
from pathlib import Path

report = json.loads(Path("update-report.json").read_text())
with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary:
for check in report["repository_cross_validation"]:
if "warning" in check:
summary.write(f"**WARNING:** {check['warning']}\n\n")
summary.write("```json\n" + json.dumps(report, ensure_ascii=False, indent=2) + "\n```\n")
PY

- name: Fail if the update escaped its allowlist
working-directory: site
shell: bash
run: |
set -euo pipefail

mapfile -t changed_files < <(git diff --name-only)
if [ "${#changed_files[@]}" -eq 0 ]; then
echo "No Android download changes detected."
exit 0
fi

for file in "${changed_files[@]}"; do
case "$file" in
android-studio.html|android-emulator.html|index.html) ;;
*)
echo "Unexpected changed file: $file" >&2
exit 1
;;
esac
done

changed_lines="$(git diff --numstat -- android-studio.html android-emulator.html index.html | awk '{ additions += $1; deletions += $2 } END { print additions + deletions + 0 }')"
if [ "$changed_lines" -gt 2000 ]; then
echo "Refusing an unusually large generated diff: $changed_lines lines" >&2
exit 1
fi

- name: Create or update the Android downloads PR
id: download_pr
working-directory: site
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
if git diff --quiet -- android-studio.html android-emulator.html index.html; then
echo "No generated page changes; no PR needed."
exit 0
fi

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"
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
git push --force origin "HEAD:${branch}"

body_file="../update-report.json"
pr_number="$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$branch" --base gh-pages --state open --json number --jq '.[0].number')"
if [ -n "$pr_number" ]; then
gh pr edit "$pr_number" --title "chore: update Android downloads" --body-file "$body_file"
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"
13 changes: 0 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@
* [Android资源分析工具](#android资源分析工具)
* [Android Layout Parser工具](#android-layout-parser工具)
* [Android Content Provider代码生成工具](#android-content-provider代码生成工具)
* [Android Fragment Code Generator代码生成工具](#android-fragment-code-generator代码生成工具)
* [代码生成工具](#代码生成工具)
* [Android Native开发工具](#android-native开发工具)
* [Android测试工具](#android测试工具)
Expand All @@ -71,7 +70,6 @@
* [Git](#git-1)
* [Guides](#guides)
* [Google Java编程风格指南中文版](#google-java编程风格指南中文版)
* [Android Developers 国内镜像站](#android-developers-国内镜像站)
* [Android Api中文版](#android-api中文版)
* [Android Proguard混淆配置指南](#android-proguard混淆配置指南)
* [Gradle插件使用指南中文版](#gradle插件使用指南中文版)
Expand Down Expand Up @@ -949,13 +947,6 @@ ADT Bundle包含了Eclipse、ADT插件和SDK Tools,是已经集成好的IDE,

[ContentProviderCodeGenerator]:https://github.com/BoD/android-contentprovider-generator

#### Android Fragment Code Generator代码生成工具
| 名称 | 简介 | 下载地址 | 使用教程 |
|:-------------------------------|:----------------|:----------------------------------:|:---------------:|
| Android Fragment Code Generator | | [下载][FregmentCodeGenerator] | |

[FregmentCodeGenerator]:http://techisfun.github.io/pages/android-fragment-generator/

#### 代码生成工具
| 名称 | 简介 | 下载地址 | 使用教程 |
|:-----------------|:-----------------|:--------------------------:|:---------------:|
Expand Down Expand Up @@ -1230,10 +1221,6 @@ ADT Bundle包含了Eclipse、ADT插件和SDK Tools,是已经集成好的IDE,

地址1:[https://github.com/codeset/google-java-styleguide](https://github.com/codeset/google-java-styleguide)

#### Android Developers 国内镜像站

地址: [Android Developers 国内镜像站](http://androiddoc.qiniudn.com/)

#### Android Api中文版
地址:[http://www.embeddedlinux.org.cn/androidapi/](http://www.embeddedlinux.org.cn/androidapi/)

Expand Down
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())
Loading