diff --git a/.github/workflows/update-contributors.yml b/.github/workflows/update-contributors.yml new file mode 100644 index 00000000..50c8502e --- /dev/null +++ b/.github/workflows/update-contributors.yml @@ -0,0 +1,44 @@ +name: Update contributors + +on: + push: + branches: [main] + schedule: + # GitHub's contributors API is cached and lags a merge by up to ~24h, so a + # weekly pass catches anyone the push-triggered run was too early to see. + - cron: '17 4 * * 1' + workflow_dispatch: + +# Only one run at a time; a burst of merges must not race on README.md. +concurrency: + group: update-contributors + cancel-in-progress: false + +jobs: + update: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Regenerate the contributor wall + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python3 tools/update-contributors.py + + - name: Commit if it changed + run: | + git config user.name "mukul975" + git config user.email "mukuljangra5@gmail.com" + git add README.md + if git diff --staged --quiet; then + echo "No new contributors." + exit 0 + fi + git commit -m "chore: update contributor wall" + # Another workflow (update-index) may have pushed while this ran. + git pull --rebase --autostash origin main + git push diff --git a/README.md b/README.md index 65d74ae3..dbaaaf0d 100644 --- a/README.md +++ b/README.md @@ -404,18 +404,25 @@ This project follows the [Contributor Covenant](https://www.contributor-covenant This library is built by the community. Thank you to everyone who has contributed: +

@mukul975 -@juliosuas -@andrewibrah -@Bortlesboat -@DevRedious -@ioxoi -@shanujans -@nyxst4ck +@valorisa +@juliosuas +@Daytona39264 +@kevglynn +@andrewibrah +@Bortlesboat +@DevRedious +@ioxoi +@OctoBored +@shanujans +@farhan6667 +@nyxst4ck

-

Ordered by contribution count · see the full contributor graph

+

13 contributors, ordered by contribution count · see the full contributor graph

+ ## Community diff --git a/tools/update-contributors.py b/tools/update-contributors.py new file mode 100644 index 00000000..ce3debb8 --- /dev/null +++ b/tools/update-contributors.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Regenerate the contributor avatar wall in README.md from the GitHub API. + +Writes between the markers: + + + ...generated... + + +Avatars are served from github.com/.png rather than a third-party +contributor-image service. That is deliberate: a README image is fetched on +every page view, so an external host would be an uncontrolled dependency in the +most-viewed file in the repository. GitHub's own avatar endpoint has neither +problem, and GitHub proxies it through camo like any other image. + +Usage: + python tools/update-contributors.py # rewrite README.md + python tools/update-contributors.py --check # exit 1 if out of date +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.request + +REPO = os.environ.get("GITHUB_REPOSITORY", "mukul975/Anthropic-Cybersecurity-Skills") +MAINTAINER = REPO.split("/")[0] +README = "README.md" + +START = "" +END = "" + +# Accounts that are bots or automation, excluded from the wall. +EXCLUDE_SUFFIXES = ("[bot]",) +EXCLUDE_LOGINS = {"github-actions", "dependabot", "pull"} + +AVATAR_PX = 72 + + +def fetch_contributors() -> list[dict]: + """Every non-bot contributor, most contributions first.""" + people: list[dict] = [] + page = 1 + while True: + url = f"https://api.github.com/repos/{REPO}/contributors?per_page=100&page={page}" + req = urllib.request.Request(url, headers={ + "Accept": "application/vnd.github+json", + "User-Agent": "update-contributors", + }) + token = os.environ.get("GITHUB_TOKEN") + if token: + req.add_header("Authorization", f"Bearer {token}") + + with urllib.request.urlopen(req, timeout=30) as resp: + batch = json.load(resp) + if not batch: + break + people.extend(batch) + if len(batch) < 100: + break + page += 1 + + return [ + p for p in people + if p.get("type") != "Bot" + and not p.get("login", "").endswith(EXCLUDE_SUFFIXES) + and p.get("login") not in EXCLUDE_LOGINS + ] + + +def render(people: list[dict]) -> str: + lines = ['

'] + for person in people: + login = person["login"] + count = person.get("contributions", 0) + plural = "" if count == 1 else "s" + title = f"{login} — maintainer" if login == MAINTAINER else f"{login} — {count} contribution{plural}" + lines.append( + f'' + f'@{login}' + ) + lines.append("

") + lines.append("") + lines.append( + f'

{len(people)} contributors, ordered by contribution count · ' + f'see the full contributor graph' + "

" + ) + return "\n".join(lines) + + +def splice(readme: str, block: str) -> str: + if START not in readme or END not in readme: + raise SystemExit( + f"ERROR: {README} is missing the {START} / {END} markers. " + "Add them around the contributor wall." + ) + head, rest = readme.split(START, 1) + _, tail = rest.split(END, 1) + return f"{head}{START}\n{block}\n{END}{tail}" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", + help="exit 1 if README.md is out of date; do not write") + args = parser.parse_args() + + if not os.path.isfile(README): + print(f"ERROR: {README} not found. Run from the repository root.") + return 1 + + people = fetch_contributors() + if not people: + print("ERROR: the API returned no contributors; refusing to blank the section.") + return 1 + + current = open(README, encoding="utf-8").read() + updated = splice(current, render(people)) + + if updated == current: + print(f"OK: contributor wall is current ({len(people)} contributors)") + return 0 + + if args.check: + print(f"ERROR: contributor wall is out of date ({len(people)} contributors). " + "Run: python tools/update-contributors.py") + return 1 + + with open(README, "w", encoding="utf-8", newline="") as handle: + handle.write(updated) + print(f"Updated contributor wall: {len(people)} contributors") + return 0 + + +if __name__ == "__main__": + sys.exit(main())