mirror of
https://github.com/mukul975/Anthropic-Cybersecurity-Skills.git
synced 2026-08-29 04:39:40 +03:00
ci: keep the contributor wall up to date automatically
The Thanks-to-our-contributors block was hand-maintained and had gone stale: it listed 8 people where the API reports 13, omitting valorisa (second by contribution count with 18), Daytona39264, kevglynn, OctoBored and farhan6667. tools/update-contributors.py regenerates the block between <!-- contributors:start --> and <!-- contributors:end --> from the GitHub contributors API, ordered by contribution count, bots excluded, with the contribution count in each avatar's tooltip. It has a --check mode and refuses to write an empty wall if the API returns nothing. .github/workflows/update-contributors.yml runs it on push to main, weekly, and on demand, committing only when the block actually changes. The weekly pass exists because the contributors API is cached and can lag a merge by about a day, so a push-triggered run alone would miss people. Avatars come from github.com/<login>.png rather than a third-party contributor-image service. 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 - the same objection raised against the star-history endpoint swap in #124.
This commit is contained in:
@@ -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
|
||||
@@ -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:
|
||||
|
||||
<!-- contributors:start -->
|
||||
<p align="center">
|
||||
<a href="https://github.com/mukul975" title="mukul975 — maintainer"><img src="https://github.com/mukul975.png?size=100" width="72" height="72" alt="@mukul975"></a>
|
||||
<a href="https://github.com/juliosuas" title="juliosuas"><img src="https://github.com/juliosuas.png?size=100" width="72" height="72" alt="@juliosuas"></a>
|
||||
<a href="https://github.com/andrewibrah" title="andrewibrah"><img src="https://github.com/andrewibrah.png?size=100" width="72" height="72" alt="@andrewibrah"></a>
|
||||
<a href="https://github.com/Bortlesboat" title="Bortlesboat"><img src="https://github.com/Bortlesboat.png?size=100" width="72" height="72" alt="@Bortlesboat"></a>
|
||||
<a href="https://github.com/DevRedious" title="DevRedious"><img src="https://github.com/DevRedious.png?size=100" width="72" height="72" alt="@DevRedious"></a>
|
||||
<a href="https://github.com/ioxoi" title="ioxoi"><img src="https://github.com/ioxoi.png?size=100" width="72" height="72" alt="@ioxoi"></a>
|
||||
<a href="https://github.com/shanujans" title="shanujans"><img src="https://github.com/shanujans.png?size=100" width="72" height="72" alt="@shanujans"></a>
|
||||
<a href="https://github.com/nyxst4ck" title="nyxst4ck"><img src="https://github.com/nyxst4ck.png?size=100" width="72" height="72" alt="@nyxst4ck"></a>
|
||||
<a href="https://github.com/valorisa" title="valorisa — 18 contributions"><img src="https://github.com/valorisa.png?size=100" width="72" height="72" alt="@valorisa"></a>
|
||||
<a href="https://github.com/juliosuas" title="juliosuas — 13 contributions"><img src="https://github.com/juliosuas.png?size=100" width="72" height="72" alt="@juliosuas"></a>
|
||||
<a href="https://github.com/Daytona39264" title="Daytona39264 — 3 contributions"><img src="https://github.com/Daytona39264.png?size=100" width="72" height="72" alt="@Daytona39264"></a>
|
||||
<a href="https://github.com/kevglynn" title="kevglynn — 2 contributions"><img src="https://github.com/kevglynn.png?size=100" width="72" height="72" alt="@kevglynn"></a>
|
||||
<a href="https://github.com/andrewibrah" title="andrewibrah — 2 contributions"><img src="https://github.com/andrewibrah.png?size=100" width="72" height="72" alt="@andrewibrah"></a>
|
||||
<a href="https://github.com/Bortlesboat" title="Bortlesboat — 1 contribution"><img src="https://github.com/Bortlesboat.png?size=100" width="72" height="72" alt="@Bortlesboat"></a>
|
||||
<a href="https://github.com/DevRedious" title="DevRedious — 1 contribution"><img src="https://github.com/DevRedious.png?size=100" width="72" height="72" alt="@DevRedious"></a>
|
||||
<a href="https://github.com/ioxoi" title="ioxoi — 1 contribution"><img src="https://github.com/ioxoi.png?size=100" width="72" height="72" alt="@ioxoi"></a>
|
||||
<a href="https://github.com/OctoBored" title="OctoBored — 1 contribution"><img src="https://github.com/OctoBored.png?size=100" width="72" height="72" alt="@OctoBored"></a>
|
||||
<a href="https://github.com/shanujans" title="shanujans — 1 contribution"><img src="https://github.com/shanujans.png?size=100" width="72" height="72" alt="@shanujans"></a>
|
||||
<a href="https://github.com/farhan6667" title="farhan6667 — 1 contribution"><img src="https://github.com/farhan6667.png?size=100" width="72" height="72" alt="@farhan6667"></a>
|
||||
<a href="https://github.com/nyxst4ck" title="nyxst4ck — 1 contribution"><img src="https://github.com/nyxst4ck.png?size=100" width="72" height="72" alt="@nyxst4ck"></a>
|
||||
</p>
|
||||
|
||||
<p align="center"><sub>Ordered by contribution count · see the full <a href="https://github.com/mukul975/Anthropic-Cybersecurity-Skills/graphs/contributors">contributor graph</a></sub></p>
|
||||
<p align="center"><sub>13 contributors, ordered by contribution count · see the full <a href="https://github.com/mukul975/Anthropic-Cybersecurity-Skills/graphs/contributors">contributor graph</a></sub></p>
|
||||
<!-- contributors:end -->
|
||||
|
||||
## Community
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate the contributor avatar wall in README.md from the GitHub API.
|
||||
|
||||
Writes between the markers:
|
||||
|
||||
<!-- contributors:start -->
|
||||
...generated...
|
||||
<!-- contributors:end -->
|
||||
|
||||
Avatars are served from github.com/<login>.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 = "<!-- contributors:start -->"
|
||||
END = "<!-- contributors: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 = ['<p align="center">']
|
||||
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'<a href="https://github.com/{login}" title="{title}">'
|
||||
f'<img src="https://github.com/{login}.png?size=100" '
|
||||
f'width="{AVATAR_PX}" height="{AVATAR_PX}" alt="@{login}"></a>'
|
||||
)
|
||||
lines.append("</p>")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f'<p align="center"><sub>{len(people)} contributors, ordered by contribution count · '
|
||||
f'see the full <a href="https://github.com/{REPO}/graphs/contributors">contributor graph</a>'
|
||||
"</sub></p>"
|
||||
)
|
||||
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())
|
||||
Reference in New Issue
Block a user