Visitar URL original
Merge pull request #1140 from tt-a1i/codex/publish-contributors-wall · tt-a1i/archify@d605933 · GitHub
Skip to content

Commit d605933

Browse files
authored
Merge pull request #1140 from tt-a1i/codex/publish-contributors-wall
docs: publish contributors wall in README
2 parents 81c6be8 + 05720ab commit d605933

9 files changed

Lines changed: 660 additions & 0 deletions

File tree

‎.github/workflows/contributors.yml‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: Contributors
2+
3+
on:
4+
schedule:
5+
# Weekly, after the Monday star-history refresh, so the two publishers do not overlap.
6+
- cron: '15 5 * * 1'
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: write
11+
12+
concurrency:
13+
group: contributors-svg
14+
cancel-in-progress: true
15+
16+
jobs:
17+
update:
18+
runs-on: ubuntu-latest
19+
timeout-minutes: 15
20+
steps:
21+
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
22+
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
23+
with:
24+
node-version: 22
25+
- name: Render contributors wall
26+
env:
27+
GH_TOKEN: ${{ github.token }}
28+
run: node scripts/generate-contributors-svg.mjs --out "$RUNNER_TEMP/contributors.svg"
29+
- name: Publish wall with linear history
30+
run: bash scripts/publish-contributors-svg.sh contributors "$RUNNER_TEMP/contributors.svg"

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,12 @@ Scan the QR code below with Alipay, or save it and open it in Alipay to scan.
518518

519519
Using Archify, sharing it, reporting bugs, and contributing improvements are also ways to help.
520520

521+
## Contributors
522+
523+
Thanks to everyone who helped build Archify. [Go follow them.](https://github.com/tt-a1i/archify/graphs/contributors) ❤️
524+
525+
<p align="center"><a href="https://github.com/tt-a1i/archify/graphs/contributors"><img alt="Archify contributors" src="https://raw.githubusercontent.com/tt-a1i/archify/contributors/contributors.svg" width="1000" /></a></p>
526+
521527
## Star History
522528

523529
<p align="center"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/tt-a1i/archify/star-history/assets/star-history-dark.svg" /><img alt="Star History" src="https://raw.githubusercontent.com/tt-a1i/archify/star-history/assets/star-history-light.svg" /></picture></p>

‎README_EN.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,12 @@ Scan the QR code below with Alipay, or save it and open it in Alipay to scan.
518518

519519
Using Archify, sharing it, reporting bugs, and contributing improvements are also ways to help.
520520

521+
## Contributors
522+
523+
Thanks to everyone who helped build Archify. [Go follow them.](https://github.com/tt-a1i/archify/graphs/contributors) ❤️
524+
525+
<p align="center"><a href="https://github.com/tt-a1i/archify/graphs/contributors"><img alt="Archify contributors" src="https://raw.githubusercontent.com/tt-a1i/archify/contributors/contributors.svg" width="1000" /></a></p>
526+
521527
## Star History
522528

523529
<p align="center"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/tt-a1i/archify/star-history/assets/star-history-dark.svg" /><img alt="Star History" src="https://raw.githubusercontent.com/tt-a1i/archify/star-history/assets/star-history-light.svg" /></picture></p>

‎README_JA.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,12 @@ Alipay で下の QR コードをスキャンするか、画像を保存して Al
486486

487487
利用、共有、不具合の報告、改善への貢献も、プロジェクトの支えになります。
488488

489+
## コントリビューター
490+
491+
Archify の開発を支えてくれたすべての人に感謝します。[フォローしてみてください。](https://github.com/tt-a1i/archify/graphs/contributors) ❤️
492+
493+
<p align="center"><a href="https://github.com/tt-a1i/archify/graphs/contributors"><img alt="Archify のコントリビューター" src="https://raw.githubusercontent.com/tt-a1i/archify/contributors/contributors.svg" width="1000" /></a></p>
494+
489495
## Star History
490496

491497
<p align="center"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/tt-a1i/archify/star-history/assets/star-history-dark.svg" /><img alt="Star History" src="https://raw.githubusercontent.com/tt-a1i/archify/star-history/assets/star-history-light.svg" /></picture></p>

‎README_KO.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,12 @@ node bin/archify.mjs validate workflow examples/agent-tool-call.workflow.json --
227227

228228
Issue, Pull Request, 실제 사용 사례 다이어그램을 환영합니다. [기여 가이드](CONTRIBUTING.md)에서 시작하세요. 실패는 재현 가능한 버그 양식으로 제보하고, 검증된 다이어그램은 [커뮤니티 Showcase 양식](https://github.com/tt-a1i/archify/issues/new?template=showcase.yml)으로 제출할 수 있습니다.
229229

230+
## 기여자
231+
232+
Archify를 함께 만들어 준 모든 분께 감사합니다. [팔로우해 보세요.](https://github.com/tt-a1i/archify/graphs/contributors) ❤️
233+
234+
<p align="center"><a href="https://github.com/tt-a1i/archify/graphs/contributors"><img alt="Archify 기여자" src="https://raw.githubusercontent.com/tt-a1i/archify/contributors/contributors.svg" width="1000" /></a></p>
235+
230236
## Star History
231237

232238
<p align="center"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/tt-a1i/archify/star-history/assets/star-history-dark.svg" /><img alt="Star History" src="https://raw.githubusercontent.com/tt-a1i/archify/star-history/assets/star-history-light.svg" /></picture></p>

‎README_ZH.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,12 @@ node bin/archify.mjs deliver workflow examples/agent-tool-call.workflow.json /tm
524524

525525
使用、分享、反馈问题和贡献改进,也都是对项目的支持。
526526

527+
## 贡献者
528+
529+
感谢每一位参与构建 Archify 的人。[欢迎关注他们。](https://github.com/tt-a1i/archify/graphs/contributors) ❤️
530+
531+
<p align="center"><a href="https://github.com/tt-a1i/archify/graphs/contributors"><img alt="Archify 贡献者" src="https://raw.githubusercontent.com/tt-a1i/archify/contributors/contributors.svg" width="1000" /></a></p>
532+
527533
## Star History
528534

529535
<p align="center"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/tt-a1i/archify/star-history/assets/star-history-dark.svg" /><img alt="Star History" src="https://raw.githubusercontent.com/tt-a1i/archify/star-history/assets/star-history-light.svg" /></picture></p>
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
#!/usr/bin/env node
2+
// Builds a README contributors wall from the GitHub contributors API.
3+
// Avatar bytes are inlined as base64 so GitHub's image proxy does not have to
4+
// fetch nested images. The scheduled workflow publishes the file on a data
5+
// branch; this script does not commit.
6+
7+
import fs from 'node:fs';
8+
import { pathToFileURL } from 'node:url';
9+
10+
const WALL_WIDTH = 1000;
11+
const COLUMNS = 8;
12+
const AVATAR = 72;
13+
const CELL_WIDTH = WALL_WIDTH / COLUMNS;
14+
const AVATAR_X = (CELL_WIDTH - AVATAR) / 2;
15+
const AVATAR_Y = 8;
16+
const NAME_LIMIT = 18;
17+
const ROW_HEIGHT = AVATAR_Y + AVATAR + 36;
18+
const MAX_AVATAR_BYTES = 1_500_000;
19+
20+
// Exact logins, plus any account GitHub marks as a Bot or names with [bot].
21+
// "claude" is the unscoped Claude app login, not a substring of human names.
22+
const BOT_LOGINS = new Set([
23+
'allcontributors',
24+
'claude',
25+
'codecov',
26+
'copilot',
27+
'dependabot',
28+
'dependabot-preview',
29+
'github-actions',
30+
'imgbot',
31+
'pre-commit-ci',
32+
'renovate',
33+
'snyk-bot',
34+
'web-flow',
35+
]);
36+
37+
const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
38+
39+
export function isBotContributor(user) {
40+
if (!user || typeof user.login !== 'string') return true;
41+
if (user.type === 'Bot') return true;
42+
const login = user.login.trim();
43+
if (/\[bot\]$/i.test(login)) return true;
44+
return BOT_LOGINS.has(login.toLowerCase());
45+
}
46+
47+
export function displayLogin(login) {
48+
if (login.length <= NAME_LIMIT) return login;
49+
return `${login.slice(0, NAME_LIMIT - 1)}…`;
50+
}
51+
52+
export function avatarMediaType(bytes, headerType = '') {
53+
if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image/png';
54+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg';
55+
const gif = bytes.length >= 6 ? bytes.subarray(0, 6).toString('ascii') : '';
56+
if (gif === 'GIF87a' || gif === 'GIF89a') return 'image/gif';
57+
if (bytes.length >= 12 && bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp';
58+
const normalized = String(headerType).split(';')[0].trim().toLowerCase();
59+
if (['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(normalized)) return normalized;
60+
throw new Error('Avatar is not a PNG, JPEG, GIF, or WebP image');
61+
}
62+
63+
function xmlEscape(value) {
64+
return value.replace(/[&<>"']/g, (char) => ({
65+
'&': '&amp;',
66+
'<': '&lt;',
67+
'>': '&gt;',
68+
'"': '&quot;',
69+
"'": '&apos;',
70+
}[char]));
71+
}
72+
73+
function repoParts(repo) {
74+
const match = /^([^/\s]+)\/([^/\s]+)$/.exec(repo || '');
75+
if (!match) throw new Error('Repository must look like owner/name');
76+
return { owner: match[1], name: match[2] };
77+
}
78+
79+
function githubHeaders(token, accept = 'application/vnd.github+json') {
80+
const headers = {
81+
Accept: accept,
82+
'User-Agent': 'archify-contributors-svg',
83+
'X-GitHub-Api-Version': '2022-11-28',
84+
};
85+
if (token) headers.Authorization = `Bearer ${token}`;
86+
return headers;
87+
}
88+
89+
function nextPage(linkHeader) {
90+
if (!linkHeader) return '';
91+
for (const part of linkHeader.split(',')) {
92+
const match = /<([^>]+)>;\s*rel="next"/.exec(part);
93+
if (!match) continue;
94+
const url = new URL(match[1]);
95+
if (url.protocol !== 'https:' || url.hostname !== 'api.github.com') {
96+
throw new Error('Refusing contributors pagination outside api.github.com');
97+
}
98+
return url.href;
99+
}
100+
return '';
101+
}
102+
103+
export async function fetchContributors({ repo = 'tt-a1i/archify', token = '', fetchImpl = fetch } = {}) {
104+
const { owner, name } = repoParts(repo);
105+
let url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/contributors?per_page=100`;
106+
const people = [];
107+
const seen = new Set();
108+
while (url) {
109+
const response = await fetchImpl(url, {
110+
headers: githubHeaders(token),
111+
signal: AbortSignal.timeout(20_000),
112+
});
113+
if (!response.ok) throw new Error(`GitHub contributors request failed (${response.status})`);
114+
const page = await response.json();
115+
if (!Array.isArray(page)) throw new Error('GitHub contributors response was not a list');
116+
for (const user of page) {
117+
if (isBotContributor(user) || seen.has(user.login)) continue;
118+
if (!LOGIN.test(user.login)) continue;
119+
seen.add(user.login);
120+
people.push({
121+
login: user.login,
122+
contributions: Number(user.contributions) || 0,
123+
avatarUrl: user.avatar_url,
124+
});
125+
}
126+
url = nextPage(response.headers.get('link'));
127+
}
128+
people.sort((a, b) => b.contributions - a.contributions || a.login.localeCompare(b.login));
129+
if (!people.length) throw new Error('No human contributors to render');
130+
return people;
131+
}
132+
133+
function avatarRequestUrl(avatarUrl, login) {
134+
let url;
135+
try {
136+
url = new URL(avatarUrl);
137+
} catch {
138+
throw new Error(`Contributor ${login} has no avatar URL`);
139+
}
140+
if (url.protocol !== 'https:' || !['avatars.githubusercontent.com', 'github.com'].includes(url.hostname)) {
141+
throw new Error(`Refusing avatar host for ${login}`);
142+
}
143+
url.searchParams.set('s', '144');
144+
return url;
145+
}
146+
147+
export async function fetchAvatarDataUri(person, { fetchImpl = fetch } = {}) {
148+
let requested = avatarRequestUrl(person.avatarUrl, person.login);
149+
let response;
150+
for (let redirects = 0; ; redirects += 1) {
151+
// Avatars are public. Keep the API token on the API request path only.
152+
response = await fetchImpl(requested, {
153+
headers: githubHeaders('', 'application/octet-stream'),
154+
redirect: 'manual',
155+
signal: AbortSignal.timeout(20_000),
156+
});
157+
if (![301, 302, 303, 307, 308].includes(response.status)) break;
158+
await response.body?.cancel();
159+
const location = response.headers.get('location');
160+
if (!location) throw new Error(`Avatar redirect has no location for ${person.login}`);
161+
if (redirects >= 5) throw new Error(`Too many avatar redirects for ${person.login}`);
162+
// Validate each hop before fetching it, including HTTPS and allowed hosts.
163+
requested = avatarRequestUrl(new URL(location, requested).href, person.login);
164+
}
165+
if (!response.ok) throw new Error(`Avatar download failed for ${person.login} (${response.status})`);
166+
avatarRequestUrl(response.url || requested, person.login);
167+
const bytes = Buffer.from(await response.arrayBuffer());
168+
if (bytes.length === 0 || bytes.length > MAX_AVATAR_BYTES) {
169+
throw new Error(`Avatar for ${person.login} is empty or larger than ${MAX_AVATAR_BYTES} bytes`);
170+
}
171+
const mediaType = avatarMediaType(bytes, response.headers.get('content-type') || '');
172+
return `data:${mediaType};base64,${bytes.toString('base64')}`;
173+
}
174+
175+
export function renderContributorsSvg(contributors) {
176+
if (!Array.isArray(contributors) || contributors.length === 0) {
177+
throw new Error('No human contributors to render');
178+
}
179+
const rows = Math.ceil(contributors.length / COLUMNS);
180+
const cells = contributors.map((person, index) => {
181+
if (!person || !LOGIN.test(person.login || '')) throw new Error('Contributor login is not a GitHub login');
182+
if (!/^data:image\/(?:png|jpeg|gif|webp);base64,[A-Za-z0-9+/=]+$/.test(person.dataUri || '')) {
183+
throw new Error(`Contributor ${person.login} is missing an inline avatar`);
184+
}
185+
const column = index % COLUMNS;
186+
const row = Math.floor(index / COLUMNS);
187+
const label = displayLogin(person.login);
188+
const centerX = AVATAR_X + AVATAR / 2;
189+
const centerY = AVATAR_Y + AVATAR / 2;
190+
return ` <g transform="translate(${column * CELL_WIDTH} ${row * ROW_HEIGHT})">
191+
<title>${xmlEscape(person.login)}</title>
192+
<defs><clipPath id="archify-contributor-${index}"><circle cx="${centerX}" cy="${centerY}" r="${AVATAR / 2}"/></clipPath></defs>
193+
<image href="${person.dataUri}" x="${AVATAR_X}" y="${AVATAR_Y}" width="${AVATAR}" height="${AVATAR}" clip-path="url(#archify-contributor-${index})" preserveAspectRatio="xMidYMid slice"/>
194+
<circle class="ring" cx="${centerX}" cy="${centerY}" r="${AVATAR / 2}" fill="none" stroke-width="1"/>
195+
<text class="name" x="${centerX}" y="${AVATAR_Y + AVATAR + 22}" text-anchor="middle">${xmlEscape(label)}</text>
196+
</g>`;
197+
});
198+
return `<?xml version="1.0" encoding="UTF-8"?>
199+
<svg xmlns="http://www.w3.org/2000/svg" width="${WALL_WIDTH}" height="${rows * ROW_HEIGHT}" viewBox="0 0 ${WALL_WIDTH} ${rows * ROW_HEIGHT}" role="img">
200+
<title>Archify contributors</title>
201+
<style><![CDATA[
202+
.name { fill: #24292f; stroke: #ffffff; stroke-width: 3px; paint-order: stroke fill; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; font-size: 12px; }
203+
.ring { stroke: #d0d7de; }
204+
@media (prefers-color-scheme: dark) {
205+
.name { fill: #f0f6fc; stroke: #010409; }
206+
.ring { stroke: #30363d; }
207+
}
208+
]]></style>
209+
${cells.join('\n')}
210+
</svg>
211+
`;
212+
}
213+
214+
export async function buildContributorsSvg(options = {}) {
215+
const people = await fetchContributors(options);
216+
const rendered = [];
217+
for (const person of people) {
218+
rendered.push({
219+
login: person.login,
220+
dataUri: await fetchAvatarDataUri(person, options),
221+
});
222+
}
223+
return renderContributorsSvg(rendered);
224+
}
225+
226+
function parseArgs(argv) {
227+
const options = { repo: 'tt-a1i/archify', out: '' };
228+
for (let index = 0; index < argv.length; index += 1) {
229+
const arg = argv[index];
230+
if (arg === '--repo') options.repo = argv[++index];
231+
else if (arg === '--out') options.out = argv[++index];
232+
else if (arg === '--help' || arg === '-h') options.help = true;
233+
else throw new Error(`Unknown argument: ${arg}`);
234+
if (options.repo === undefined || options.out === undefined) throw new Error(`Missing value for ${arg}`);
235+
}
236+
return options;
237+
}
238+
239+
async function main(argv, env = process.env) {
240+
const options = parseArgs(argv);
241+
if (options.help) {
242+
console.log('Usage: node scripts/generate-contributors-svg.mjs [--repo owner/name] [--out file]');
243+
return;
244+
}
245+
const svg = await buildContributorsSvg({
246+
repo: options.repo,
247+
token: env.GH_TOKEN || env.GITHUB_TOKEN || '',
248+
});
249+
if (options.out) fs.writeFileSync(options.out, svg);
250+
else process.stdout.write(svg);
251+
}
252+
253+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
254+
main(process.argv.slice(2)).catch((error) => {
255+
console.error(error.message);
256+
process.exit(1);
257+
});
258+
}

0 commit comments

Comments
 (0)