diff --git a/.github/workflows/contributors.yml b/.github/workflows/contributors.yml
new file mode 100644
index 0000000..2ee84d7
--- /dev/null
+++ b/.github/workflows/contributors.yml
@@ -0,0 +1,42 @@
+name: Contributors
+
+on:
+ schedule:
+ - cron: '17 2 * * *'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ sync:
+ if: >-
+ github.repository == 'HITSZ-OpenAuto/.github' &&
+ github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ concurrency:
+ group: sync-profile-contributors
+ cancel-in-progress: false
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: main
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
+ with:
+ python-version: '3.12'
+ - run: python scripts/sync_contributors.py
+ - name: Commit changed contributor files only
+ run: |
+ git add -- profile/README.md
+ if git diff --cached --quiet; then
+ echo "Contributors are already up to date."
+ exit 0
+ fi
+ git config user.name 'github-actions[bot]'
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
+ git commit -m 'chore: sync profile contributors'
+ # A concurrent main update must fail safely, never overwrite user edits.
+ git push origin HEAD:main
diff --git a/profile/README.md b/profile/README.md
index 06ca2f4..02d6177 100644
--- a/profile/README.md
+++ b/profile/README.md
@@ -28,6 +28,139 @@
由衷感谢每一位 HITSZ OpenAuto 的参与者:
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
目前仅记录在 GitHub 组织下参与的同学,但是我们同样感谢曾经通过邮件/ OpenAuto 仓库参与的同学!
diff --git a/scripts/sync_contributors.py b/scripts/sync_contributors.py
new file mode 100644
index 0000000..9b14361
--- /dev/null
+++ b/scripts/sync_contributors.py
@@ -0,0 +1,118 @@
+#!/usr/bin/env python3
+"""Sync the profile's linked avatars without changing API order/filters."""
+
+import json
+import re
+import sys
+from html import escape
+from pathlib import Path
+from urllib.parse import quote, urlsplit
+from urllib.request import Request, urlopen
+
+ROOT = Path(__file__).resolve().parents[1]
+# Keep the original SVG's parameters, including its default pagination and bots.
+API_URL = (
+ "https://contrib.hoa.moe/api/json?org=HITSZ-OpenAuto"
+ "&exclude=.github&repo=noname7321/HITSZ-OpenAuto"
+)
+START = ""
+END = ""
+LOGIN = re.compile(r"[A-Za-z0-9][A-Za-z0-9-]{0,38}(?:\[bot\])?\Z")
+AVATAR_PATH = re.compile(r"/(?:u|in)/[0-9]+\Z")
+MAX_BYTES = 5 * 1024 * 1024
+
+
+def fetch_bytes(url):
+ request = Request(url, headers={"User-Agent": "HITSZ-OpenAuto-profile-sync"})
+ with urlopen(request, timeout=30) as response:
+ data = response.read(MAX_BYTES + 1)
+ if len(data) > MAX_BYTES:
+ raise ValueError("Response is too large")
+ return data
+
+
+def validate_contributors(data):
+ """Fail closed: never turn an error/empty/invalid response into an empty grid."""
+ if not isinstance(data, list) or not data:
+ raise ValueError("Expected a non-empty contributor list")
+ seen = set()
+ for user in data:
+ if not isinstance(user, dict):
+ raise ValueError("Invalid contributor entry")
+ login = user.get("login")
+ if not isinstance(login, str) or not LOGIN.fullmatch(login):
+ raise ValueError("Invalid contributor login")
+ if login.casefold() in seen:
+ raise ValueError(f"Duplicate contributor: {login}")
+ seen.add(login.casefold())
+ if user.get("type") not in ("User", "Bot"):
+ raise ValueError(f"Invalid contributor type: {login}")
+ url = user.get("avatar_url")
+ if not isinstance(url, str):
+ raise ValueError(f"Missing avatar URL: {login}")
+ parsed = urlsplit(url)
+ if (
+ parsed.scheme != "https"
+ or parsed.netloc != "avatars.githubusercontent.com"
+ or not AVATAR_PATH.fullmatch(parsed.path)
+ or parsed.fragment
+ ):
+ raise ValueError(f"Unexpected avatar URL: {login}")
+ # The API already deduplicates and orders contributors. Do not sort or filter.
+ return data
+
+
+def profile_url(user):
+ login = user["login"]
+ # GitHub App bots have an app page rather than a /name[bot] user page.
+ if user["type"] == "Bot" and login.endswith("[bot]"):
+ return "https://github.com/apps/" + quote(login[:-5], safe="")
+ return "https://github.com/" + quote(login, safe="")
+
+
+def render_block(users):
+ lines = [START, "", ""]
+ for index, user in enumerate(users):
+ login = user["login"]
+ src = escape(user["avatar_url"], quote=True)
+ lines.append(
+ f'
'
+ )
+ if (index + 1) % 12 == 0 and index + 1 < len(users):
+ lines.append("
")
+ return "\n".join([*lines, "
", END])
+
+
+def replace_block(readme, block):
+ if readme.count(START) != 1 or readme.count(END) != 1:
+ raise ValueError("README must have exactly one pair of contributor markers")
+ start = readme.index(START)
+ end = readme.index(END)
+ if end < start:
+ raise ValueError("Contributor markers are out of order")
+ return readme[:start] + block + readme[end + len(END):]
+
+
+def sync(root=ROOT, fetch=fetch_bytes):
+ readme_path = root / "profile/README.md"
+ # bytes preserves unrelated content/newlines exactly, even on Windows.
+ original = readme_path.read_bytes().decode("utf-8")
+ replace_block(original, START + "\n" + END) # Check markers before network I/O.
+ users = validate_contributors(json.loads(fetch(API_URL)))
+ updated = replace_block(original, render_block(users)).encode("utf-8")
+
+ changed = False
+ if readme_path.read_bytes() != updated:
+ readme_path.write_bytes(updated)
+ changed = True
+ return len(users), changed
+
+
+if __name__ == "__main__":
+ try:
+ count, changed = sync()
+ print(f"{'Updated' if changed else 'Unchanged'}: {count} contributors")
+ except Exception as error:
+ print(f"Contributor sync failed: {error}", file=sys.stderr)
+ sys.exit(1)