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 的参与者: -![Contributors](https://contrib.hoa.moe/api?org=HITSZ-OpenAuto&exclude=.github&repo=noname7321/HITSZ-OpenAuto) + + +

+@kowyo +@longlin10086 +@actions-user +@CYXDBY +@LiPu-jpg +@WDGaster703 +@OliverWu515 +@IcyDesert +@IceFerryLing +@ChihayaAnon987 +@QESingularity +@RiSeikiqwq +
+@Lu-Weiyan +@rana608 +@pspdada +@ll3me +@kajimi16 +@Zjl37 +@zhuqi000 +@paipoex +@lin3939 +@just1cez +@mircecilia +@Venokos +
+@QuaL-ctrl +@Origami404 +@NaiYoung007 +@TangLongbin +@frredomerror91-netizen +@Fun10165 +@61mo +@github-actions[bot] +@dependabot[bot] +@Co-ding-Man +@chenx-dust +@drq-48 +
+@kowyo-bot +@syhanjin +@SpeechlessPanda +@ToddZZF +@YinMo19 +@IAMXII +@capoo-fan +@AutoFriedRich +@MaxwellJay256 +@prj159 +@youyun0v0 +@345ljh +
+@Y-Sensi +@phychi +@HalfCooler +@Jiupai999 +@BX-valor +@patrick1129star +@Star1950625 +@wpj9362 +@Xl-ui +@LoneVoyage +@fffish12333 +@CaiJi-Feizao +
+@070622X +@Huaaaazx +@CCCirclekkko +@ZhuHuan521 +@Sieroy +@Duckweed-yhb +@Tilr-TAIYI +@alanlu0107 +@GoldenHeng +@oldkingOK +@HitTheStars +@Yushang-Lu +
+@CuO114 +@ShiningStar621 +@Wleaf666 +@abigzhe +@novemberinnorth +@google-labs-jules[bot] +@Costannt +@LiuShaohang2004 +@Keep0650 +@tingshi-yuli +@lielie8 +@lumen183 +
+@Kokoro2336 +@lpf123321 +@Wzy-wm +@fruly-ZERO +@jiazero +@Oliverzsa +@Anastasia-Charies +@marine-aqua +@MukioXun +@aguo2196 +@Jiayi-hit +@FangShuohan +
+@blacksmith-sh[bot] +@MingxuanGame +@specailweek +@0702hjj +@gaoyingao +@xhofe +@chenc100 +@JZWXZ9 +@dicnYue +@Stardropflower +@awaw-a +@guanfuqi +
+@LeafDot37 +@tsunemori111 +@lmh12138 +@huangjifan12 +@sylybimike2001 +@xander-2077 +@EricTonyLiu +@hoshino-lr +@Mixlining +@SkyGuoGary +@handleandwheel +@WhiteFireFox +

+ 目前仅记录在 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)