+Every human issue or pull-request author is part of the SILO community, including open and unmerged work. Merged fixes, adopted proposals, and actionable reports receive priority, with first participation guiding the remaining order. Gold rings highlight reviewed significant contributions.
-[View the full contribution record](CONTRIBUTORS.md) for each person's proposals, fixes, and reports.
+
+
+
+
+
+
+
+[View contribution notes and actual PR status](CONTRIBUTORS.md).
+
+## Star History
+
+
+
+
+
## Background
diff --git a/README_ZH.md b/README_ZH.md
index e9fb5ff44..64c827d27 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -102,54 +102,25 @@ S3 API、`MINIO_*` 环境变量、`minio_*` 指标、`x-minio-*` 头、`/minio/*
## 贡献者
-**42 位社区贡献者**共同建设 SILO、Console、mcli、公共包与相关项目。名单包含维护者,以及所有提出 Issue 或 PR 的真人作者;按已合并 PR、其他 PR、Issue 报告排序,黄圈标记显著贡献。
+
-
+每位 issue 或 PR 的作者都是 SILO 社区的一员,包括尚未合并的工作。已合并的修复、被采纳的方案和有效报告优先展示,其余参考首次参与时间;金色圆环突出经过审核的显著贡献。
-[查看完整贡献记录](CONTRIBUTORS.md),了解每位贡献者的提案、修复与问题报告。
+
+
+
+
+
+
+
+[查看贡献记录与实际 PR 状态](CONTRIBUTORS.md)。
+
+## Star History
+
+
+
+
+
## 背景
diff --git a/buildscripts/repository-cards/.gitignore b/buildscripts/repository-cards/.gitignore
new file mode 100644
index 000000000..c18dd8d83
--- /dev/null
+++ b/buildscripts/repository-cards/.gitignore
@@ -0,0 +1 @@
+__pycache__/
diff --git a/buildscripts/repository-cards/render.py b/buildscripts/repository-cards/render.py
new file mode 100644
index 000000000..e6c2c9a5e
--- /dev/null
+++ b/buildscripts/repository-cards/render.py
@@ -0,0 +1,163 @@
+# Copyright (c) 2026 Feng Ruohang
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+"""Pure, self-contained SVG rendering for SILO's README cards."""
+from datetime import date, timedelta
+from html import escape
+import math
+import xml.etree.ElementTree as ET
+
+themes = {
+ 'light': dict(bg='#ffffff', wash='#f2f7fc', edge='#d9e3ee', ink='#16222e',
+ muted='#62758a', blue='#1d588c', copper='#b4762e', grid='#e5edf5',
+ line='#2b6ca3', ring='#dce5ef', field='#f7f9fc', label='#3d4e61'),
+ 'dark': dict(bg='#101923', wash='#152738', edge='#2b3c50', ink='#e8eef6',
+ muted='#93a3b8', blue='#7fb8e8', copper='#e0a35c', grid='#263749',
+ line='#5da2dd', ring='#3a4e63', field='#0b1119', label='#b6c2d2'),
+}
+
+def read_emblem(path):
+ emblem = ET.parse(path).getroot()
+ body = ''.join(ET.tostring(child, encoding='unicode') for child in emblem
+ if child.tag.rsplit('}', 1)[-1] in ('defs', 'g'))
+ return '\n'.join(line.rstrip() for line in body.splitlines()).strip()
+
+
+def txt(x, y, value, size=14, color=None, weight=400, anchor='start', mono=False, spacing=None):
+ family = 'Menlo,Consolas,monospace' if mono else 'Arial,Helvetica,sans-serif'
+ extra = f' letter-spacing="{spacing}"' if spacing is not None else ''
+ return (f''
+ f'{escape(str(value))}')
+
+
+def start(height, theme, title, description, emblem_body):
+ t = themes[theme]
+ return [f'',
+ ])
+ return ''.join(parts)
+
+
+def stars(theme, history, snapshot, emblem_body):
+ points = history['points']
+ star_count = points[-1]['stars']
+ t = themes[theme]
+ provenance = ('Initial history reconstructed · Daily totals since ' + history['bootstrap']['through'] + ' · UTC'
+ if history['bootstrap']['reconstructed'] else 'Observed daily star totals · UTC')
+ parts = start(558, theme, f'SILO star history — {star_count:,} stars',
+ f'GitHub repository pgsty/silo. {star_count:,} stars as of {snapshot}. ' +
+ provenance, emblem_body)
+ heading(parts, t, 'SILO / GITHUB', 'Star History', 'pgsty/silo', star_count, 'GITHUB STARS')
+ left, right, top, bottom = 76, 958, 177, 440
+ begin = date.fromisoformat(points[0]['date'])
+ end = date.fromisoformat(points[-1]['date'])
+ days = max(1, (end - begin).days)
+ maximum = max(500, math.ceil(max(p['stars'] for p in points) / 500) * 500)
+ tick_step = 10 ** max(0, int(math.log10(maximum)))
+ xy = lambda day, n: (left + (right-left)*(date.fromisoformat(day)-begin).days / days,
+ bottom-(bottom-top)*n/maximum)
+ for value in range(0, maximum+1, tick_step):
+ y = xy(points[0]['date'], value)[1]
+ parts.append(f'')
+ parts.append(txt(left-16, round(y+4, 2), f'{value / 1000:g}k' if value >= 1000 else str(value), 12, t['muted'], anchor='end'))
+ dates = sorted({begin + timedelta(days=round((end-begin).days*i/5)) for i in range(6)})
+ ticks = [(d.isoformat(), d.strftime('%b %Y') if days > 90 else d.strftime('%b %d')) for d in dates]
+ for day, label in ticks:
+ x = xy(day, 0)[0]
+ parts.append(f'')
+ anchor = 'start' if day == points[0]['date'] else 'end' if day == snapshot else 'middle'
+ parts.append(txt(round(x, 2), 466, label, 12, t['muted'], anchor=anchor))
+ coords = [xy(p['date'], p['stars']) for p in points]
+ line = 'M' + ' L'.join(f'{x:.2f} {y:.2f}' for x, y in coords)
+ area = line + f' L{coords[-1][0]:.2f} {bottom} L{left} {bottom} Z'
+ parts.extend([
+ f'',
+ f'',
+ f'',
+ ])
+ x, y = coords[-1]
+ parts.extend([
+ f'',
+ f'',
+ f'',
+ txt(40, 516, f'{begin:%b %Y} — {end:%b %Y}'.upper(), 10, t['muted'], 500, mono=True, spacing=.7),
+ txt(959, 516, f'SNAPSHOT {snapshot}', 10, t['muted'], 500, 'end', mono=True, spacing=.6),
+ txt(40, 539, provenance, 11, t['muted']),
+ '',
+ ])
+ return ''.join(parts)
diff --git a/buildscripts/repository-cards/requirements.txt b/buildscripts/repository-cards/requirements.txt
new file mode 100644
index 000000000..f62ce0c56
--- /dev/null
+++ b/buildscripts/repository-cards/requirements.txt
@@ -0,0 +1 @@
+PyYAML==6.0.3
diff --git a/buildscripts/repository-cards/test_cards.py b/buildscripts/repository-cards/test_cards.py
new file mode 100644
index 000000000..bff6e9764
--- /dev/null
+++ b/buildscripts/repository-cards/test_cards.py
@@ -0,0 +1,178 @@
+# Copyright (c) 2026 Feng Ruohang
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+"""Regression checks for historical accuracy, contributor scope and SVG safety."""
+
+import base64
+import json
+from pathlib import Path
+import tempfile
+import unittest
+from unittest.mock import patch
+from urllib.error import URLError
+import xml.etree.ElementTree as ET
+
+import render
+import update
+
+NS = {'s': 'http://www.w3.org/2000/svg'}
+PNG = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a7mgAAAAASUVORK5CYII=')
+
+
+def person(handle='Alice', group='reports', featured=False):
+ return {'handle': handle, 'group': group, 'featured': featured,
+ 'what': 'A reviewed contribution', 'firstContribution': '2026-09-01'}
+
+
+def history():
+ return {'repository': 'pgsty/silo',
+ 'bootstrap': {'through': '2026-09-15', 'reconstructed': True},
+ 'points': [{'date': '2026-09-14', 'stars': 100}, {'date': '2026-09-15', 'stars': 105}]}
+
+
+class HistoryTests(unittest.TestCase):
+ def test_new_day_preserves_old_counts_and_unstars(self):
+ before = history()
+ after = update.update_history(before, '2026-09-16', 103)
+ self.assertEqual(after['points'][:-1], before['points'])
+ self.assertEqual(after['points'][-1], {'date': '2026-09-16', 'stars': 103})
+ self.assertEqual(before, history())
+
+ def test_same_day_rerun_replaces_instead_of_appending(self):
+ first = update.update_history(history(), '2026-09-15', 107)
+ self.assertEqual(len(first['points']), 2)
+ self.assertEqual(first, update.update_history(first, '2026-09-15', 107))
+
+ def test_missing_days_are_not_invented(self):
+ result = update.update_history(history(), '2026-09-18', 106)
+ self.assertEqual([p['date'] for p in result['points']], ['2026-09-14', '2026-09-15', '2026-09-18'])
+
+ def test_rejects_wrong_repository_and_corrupt_history(self):
+ cases = []
+ wrong = history(); wrong['repository'] = 'someone/else'; cases.append(wrong)
+ duplicate = history(); duplicate['points'].append(duplicate['points'][-1]); cases.append(duplicate)
+ unordered = history(); unordered['points'].reverse(); cases.append(unordered)
+ negative = history(); negative['points'][0]['stars'] = -1; cases.append(negative)
+ future = history(); future['points'][-1]['date'] = '2026-09-20'; cases.append(future)
+ for case in cases:
+ with self.subTest(case=case), self.assertRaises(ValueError):
+ update.update_history(case, '2026-09-16', 100)
+
+ def test_first_run_has_no_fabricated_history(self):
+ result = update.update_history(None, '2026-09-16', 10)
+ self.assertFalse(result['bootstrap']['reconstructed'])
+ self.assertEqual(result['points'], [{'date': '2026-09-16', 'stars': 10}])
+
+
+class ContributorTests(unittest.TestCase):
+ def test_retries_truncated_json_before_using_it(self):
+ with patch('update.request', side_effect=[b'{"partial":', b'{"ok":true}']), patch('update.time.sleep'):
+ self.assertEqual(update.GitHub('').get('repos/pgsty/silo'), {'ok': True})
+
+ def test_paginates_past_one_full_page(self):
+ class API(update.GitHub):
+ def __init__(self): self.calls = []
+ def get(self, path):
+ self.calls.append(path)
+ return list(range(100)) if 'page=1&' in path else [100]
+ api = API()
+ self.assertEqual(len(list(api.issues('pgsty/silo'))), 101)
+ self.assertIn('state=all', api.calls[0])
+ self.assertIn('page=2&', api.calls[1])
+
+ def test_bots_deduplication_unmerged_work_and_reviewed_credit(self):
+ def issue(login, kind='issue', user_type='User'):
+ item = {'user': {'login': login, 'type': user_type, 'avatar_url': ''}, 'created_at': '2026-09-02T00:00:00Z'}
+ if kind != 'issue': item['pull_request'] = {'merged_at': None if kind == 'open' else '2026-09-03T00:00:00Z'}
+ return item
+ class API:
+ def issues(self, _repo):
+ return [issue('alice'), issue('Bob', 'open'), issue('Bob', 'merged'),
+ issue('Carol', 'open'), issue('Copilot'), issue('robot', user_type='Bot')]
+ curated = {'repositories': ['pgsty/silo', 'pgsty/mc'], 'bots': ['Copilot'],
+ 'people': [person('Alice', featured=True), person('Reporter'), person('Copilot')]}
+ result = update.collect_people(API(), curated)
+ self.assertEqual({p['handle'] for p in result}, {'Alice', 'Bob', 'Carol', 'Reporter'})
+ self.assertEqual(result[0]['handle'], 'Bob')
+ self.assertEqual(result[1]['handle'], 'Carol')
+ self.assertTrue(next(p for p in result if p['handle'] == 'Alice')['featured'])
+ self.assertEqual(next(p for p in result if p['handle'] == 'Bob')['group'], 'code')
+ self.assertFalse(next(p for p in result if p['handle'] == 'Carol')['featured'])
+
+ def test_newer_reviewed_preview_survives_until_site_catches_up(self):
+ remote = {'updated': '2026-09-16T03:00:00+00:00'}
+ cached = {'updated': '2026-09-16T04:00:00+00:00'}
+ self.assertIs(update.select_curated(remote, cached), cached)
+ newer = {'updated': '2026-09-17T03:00:00+00:00'}
+ self.assertIs(update.select_curated(newer, cached), newer)
+
+ def test_avatar_failure_reuses_raster_cache(self):
+ previous = {**person(), 'avatarDataUrl': update.raster_data_url(PNG)}
+ with patch('update.request', side_effect=URLError('unavailable')):
+ result = update.add_avatars(None, [{**person(), 'avatarUrl': 'https://avatars.githubusercontent.com/u/1'}], [previous])
+ self.assertEqual(result[0]['avatarDataUrl'], previous['avatarDataUrl'])
+ with self.assertRaises(ValueError): update.raster_data_url(b'')
+ with self.assertRaises(ValueError): update.cached_avatar({'avatarDataUrl': 'data:image/svg+xml;base64,PHN2Zy8+'})
+
+ def test_fetch_failure_leaves_published_assets_untouched(self):
+ class API:
+ def get(self, path):
+ if path == 'repos/pgsty/silo': return {'full_name': 'pgsty/silo', 'stargazers_count': 106}
+ raise URLError('roster unavailable')
+ with tempfile.TemporaryDirectory() as directory:
+ out = Path(directory)
+ original = json.dumps(history())
+ (out / 'history.json').write_text(original)
+ (out / 'contributors-light.svg').write_text('previous image')
+ with self.assertRaises(URLError): update.refresh(out, API(), Path('.'))
+ self.assertEqual((out / 'history.json').read_text(), original)
+ self.assertEqual((out / 'contributors-light.svg').read_text(), 'previous image')
+
+
+class RenderTests(unittest.TestCase):
+ def test_real_emblem_generates_clean_xml(self):
+ emblem = render.read_emblem(Path(__file__).resolve().parents[2] / '.github/silo.svg')
+ svg = render.contributors('light', [person()], '2026-09-16', emblem)
+ ET.fromstring(svg)
+ self.assertTrue(all(line == line.rstrip() for line in svg.splitlines()))
+
+ def test_all_avatars_fit_when_the_roster_grows(self):
+ people = [{**person(f'person-{i}'), 'avatarDataUrl': update.raster_data_url(PNG)} for i in range(151)]
+ for theme in ('light', 'dark'):
+ root = ET.fromstring(render.contributors(theme, people, '2026-09-16', ''))
+ images = root.findall('.//s:image', NS)
+ self.assertEqual(len(images), 151)
+ footer = float(root.attrib['height']) - 42
+ self.assertTrue(all(float(i.attrib['y']) + float(i.attrib['height']) < footer for i in images))
+ self.assertTrue(all(i.attrib['href'].startswith('data:image/png;base64,') for i in images))
+
+ def test_untrusted_text_is_escaped(self):
+ data = [{**person(), 'what': ' & contributions'}]
+ svg = render.contributors('light', data, '2026-09-16', '')
+ root = ET.fromstring(svg)
+ self.assertEqual(root.findall('.//s:script', NS), [])
+ self.assertIn('<script>', svg)
+
+ def test_single_point_and_decreasing_star_history_render(self):
+ for data in (update.update_history(None, '2026-09-16', 0), update.update_history(history(), '2026-09-16', 90)):
+ for theme in ('light', 'dark'):
+ svg = render.stars(theme, data, '2026-09-16', '')
+ ET.fromstring(svg)
+ self.assertNotIn('nan', svg.lower())
+ self.assertNotIn('inf', svg.lower())
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/buildscripts/repository-cards/update.py b/buildscripts/repository-cards/update.py
new file mode 100644
index 000000000..1e49458cc
--- /dev/null
+++ b/buildscripts/repository-cards/update.py
@@ -0,0 +1,295 @@
+#!/usr/bin/env python3
+# Copyright (c) 2026 Feng Ruohang
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+"""Refresh the generated-asset checkout; publishing is handled by the workflow."""
+
+import argparse
+import base64
+from concurrent.futures import ThreadPoolExecutor
+from datetime import date, datetime, timezone
+import json
+from http.client import IncompleteRead
+import os
+from pathlib import Path
+import re
+import sys
+import time
+from urllib.error import HTTPError, URLError
+from urllib.parse import urlparse
+from urllib.request import Request, urlopen
+import xml.etree.ElementTree as ET
+
+import yaml
+
+import render
+
+REPOSITORY = 'pgsty/silo'
+SOURCE = 'repos/pgsty/silo.pgsty.com/contents/data/home/contributors.yaml?ref=main'
+GROUPS = ('code', 'proposed', 'reports')
+HANDLE = re.compile(r'[A-Za-z0-9][A-Za-z0-9-]{0,38}\Z')
+
+
+def request(url, token='', limit=8 * 1024 * 1024):
+ headers = {'User-Agent': 'silo-repository-cards', 'Accept': 'application/vnd.github+json'}
+ if urlparse(url).netloc == 'api.github.com':
+ headers['X-GitHub-Api-Version'] = '2022-11-28'
+ if token:
+ headers['Authorization'] = f'Bearer {token}'
+ for attempt in range(3):
+ try:
+ with urlopen(Request(url, headers=headers), timeout=25) as response:
+ data = response.read(limit + 1)
+ if len(data) > limit:
+ raise ValueError('Response exceeds the size limit')
+ expected = response.headers.get('Content-Length')
+ if expected is not None and len(data) != int(expected):
+ raise URLError('Incomplete response body')
+ return data
+ except HTTPError as exc:
+ if exc.code < 500 or attempt == 2:
+ raise
+ except (URLError, TimeoutError, IncompleteRead):
+ if attempt == 2:
+ raise
+ time.sleep(attempt + 1)
+
+
+class GitHub:
+ def __init__(self, token):
+ self.token = token
+
+ def get(self, path):
+ for attempt in range(3):
+ try:
+ return json.loads(request('https://api.github.com/' + path, self.token))
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
+ if attempt == 2:
+ raise ValueError(f'Incomplete or invalid GitHub JSON: {path}') from exc
+ time.sleep(attempt + 1)
+
+ def issues(self, repository):
+ page = 1
+ while True:
+ batch = self.get(f'repos/{repository}/issues?state=all&per_page=100&page={page}&sort=created&direction=asc')
+ if not isinstance(batch, list):
+ raise ValueError(f'Invalid issues response for {repository}')
+ yield from batch
+ if len(batch) < 100:
+ return
+ page += 1
+
+
+def curated_snapshot(data, revision):
+ updated = str(data['updated'])
+ datetime.fromisoformat(updated)
+ repositories = [item['repo'] for item in data['repositories']]
+ if not repositories or any(not re.fullmatch(r'pgsty/[A-Za-z0-9_.-]+', repo) for repo in repositories):
+ raise ValueError('Invalid contributor repository scope')
+ people = []
+ for group in GROUPS:
+ for entry in data[group]:
+ if not HANDLE.fullmatch(entry['handle']):
+ raise ValueError('Invalid GitHub contributor handle')
+ people.append({
+ 'handle': entry['handle'], 'group': group,
+ 'featured': bool(entry.get('featured')), 'what': entry['what'],
+ 'firstContribution': str(entry.get('firstContribution', '9999-12-31')),
+ })
+ if not people or len({p['handle'].lower() for p in people}) != len(people):
+ raise ValueError('Empty or duplicate contributor roster')
+ return {'updated': updated, 'revision': revision, 'repositories': repositories,
+ 'bots': data.get('bots', ['Copilot', 'dependabot[bot]']), 'people': people}
+
+
+def select_curated(remote, cached):
+ # The initial, approved preview can contain reviewed credit not published by
+ # the companion site yet. Keep that newer snapshot until the site catches up.
+ if cached and datetime.fromisoformat(cached['updated']) > datetime.fromisoformat(remote['updated']):
+ return cached
+ return remote
+
+
+def collect_people(api, curated):
+ bots = {name.lower() for name in curated['bots']}
+ people = {p['handle'].lower(): dict(p) for p in curated['people']
+ if p['handle'].lower() not in bots and not p['handle'].lower().endswith('[bot]')}
+ order = {p['handle'].lower(): index for index, p in enumerate(curated['people'])}
+ for repository in curated['repositories']:
+ print(f'Reading issue and PR authors: {repository}', flush=True)
+ for issue in api.issues(repository):
+ user = issue.get('user') or {}
+ handle = user.get('login', '')
+ key = handle.lower()
+ if user.get('type') != 'User' or key in bots or key.endswith('[bot]'):
+ continue
+ if not HANDLE.fullmatch(handle):
+ raise ValueError('Invalid issue author')
+ pr = issue.get('pull_request')
+ group = 'code' if pr and pr.get('merged_at') else 'proposed' if pr else 'reports'
+ first = issue['created_at'][:10]
+ date.fromisoformat(first)
+ person = people.setdefault(key, {
+ 'handle': handle, 'group': group, 'featured': False,
+ 'what': 'Contributed an issue or pull request to SILO and related projects',
+ 'firstContribution': first,
+ })
+ person['avatarUrl'] = user.get('avatar_url', '')
+ person['firstContribution'] = min(person['firstContribution'], first)
+ if GROUPS.index(group) < GROUPS.index(person['group']):
+ person['group'] = group
+ if not people:
+ raise ValueError('No human contributors were collected')
+ return sorted(people.values(), key=lambda p: (
+ GROUPS.index(p['group']), not p['featured'],
+ order.get(p['handle'].lower(), len(order)), p['firstContribution'], p['handle'].lower()))
+
+
+def raster_data_url(data):
+ if data.startswith(b'\x89PNG\r\n\x1a\n'):
+ mime = 'image/png'
+ elif data.startswith(b'\xff\xd8\xff'):
+ mime = 'image/jpeg'
+ elif data.startswith((b'GIF87a', b'GIF89a')):
+ mime = 'image/gif'
+ elif data[:4] == b'RIFF' and data[8:12] == b'WEBP':
+ mime = 'image/webp'
+ else:
+ raise ValueError('Avatar is not a raster image')
+ return f'data:{mime};base64,' + base64.b64encode(data).decode('ascii')
+
+
+def cached_avatar(person):
+ value = person.get('avatarDataUrl', '')
+ if not value:
+ return ''
+ prefix, encoded = value.split(',', 1)
+ if prefix not in ('data:image/png;base64', 'data:image/jpeg;base64', 'data:image/gif;base64', 'data:image/webp;base64'):
+ raise ValueError('Invalid cached avatar format')
+ raw = base64.b64decode(encoded, validate=True)
+ if len(raw) > 512 * 1024 or raster_data_url(raw) != value:
+ raise ValueError('Invalid cached avatar')
+ return value
+
+
+def add_avatars(api, people, previous):
+ cached = {p['handle'].lower(): cached_avatar(p) for p in previous}
+
+ def update(person):
+ person = dict(person)
+ try:
+ url = person.pop('avatarUrl', '') or api.get('users/' + person['handle'])['avatar_url']
+ parsed = urlparse(url)
+ if parsed.scheme != 'https' or parsed.netloc != 'avatars.githubusercontent.com':
+ raise ValueError('Unexpected avatar host')
+ data = request(url + ('&' if '?' in url else '?') + 's=96', limit=512 * 1024)
+ person['avatarDataUrl'] = raster_data_url(data)
+ except (HTTPError, URLError, TimeoutError, IncompleteRead, ValueError, KeyError) as exc:
+ person.pop('avatarUrl', None)
+ person['avatarDataUrl'] = cached.get(person['handle'].lower(), '')
+ print(f'Avatar fallback for @{person["handle"]}: {type(exc).__name__}', file=sys.stderr)
+ return person
+
+ with ThreadPoolExecutor(max_workers=6) as pool:
+ return list(pool.map(update, people))
+
+
+def update_history(history, day, stars):
+ date.fromisoformat(day)
+ if type(stars) is not int or stars < 0:
+ raise ValueError('Invalid repository star count')
+ if history is None:
+ history = {'repository': REPOSITORY, 'bootstrap': {'through': day, 'reconstructed': False}, 'points': []}
+ if history['repository'] != REPOSITORY:
+ raise ValueError('Star history belongs to a different repository')
+ date.fromisoformat(history['bootstrap']['through'])
+ dates = []
+ for point in history['points']:
+ date.fromisoformat(point['date'])
+ if type(point['stars']) is not int or point['stars'] < 0:
+ raise ValueError('Invalid historical star count')
+ dates.append(point['date'])
+ if dates != sorted(set(dates)) or any(d > day for d in dates):
+ raise ValueError('History contains duplicate, unordered, or future dates')
+ # Replace today's observation, preserve previous days, and allow unstars.
+ points = [dict(p) for p in history['points'] if p['date'] != day]
+ points.append({'date': day, 'stars': stars})
+ return {**history, 'points': points}
+
+
+def read_json(path, default=None):
+ return json.loads(path.read_text()) if path.exists() else default
+
+
+def refresh(output, api, source_root):
+ day = datetime.now(timezone.utc).date().isoformat()
+ metadata = api.get('repos/' + REPOSITORY)
+ if metadata['full_name'].lower() != REPOSITORY:
+ raise ValueError('Unexpected repository metadata')
+ history = update_history(read_json(output / 'history.json'), day, metadata['stargazers_count'])
+ source = api.get(SOURCE)
+ reviewed = yaml.safe_load(base64.b64decode(source['content'], validate=False))
+ curated = select_curated(curated_snapshot(reviewed, source['sha']), read_json(output / 'curated.json'))
+ people = collect_people(api, curated)
+ previous = read_json(output / 'contributors.json', {}).get('people', [])
+ people = add_avatars(api, people, previous)
+ emblem = render.read_emblem(source_root / '.github/silo.svg')
+ payloads = {}
+ for theme in ('light', 'dark'):
+ payloads[f'contributors-{theme}.svg'] = render.contributors(theme, people, day, emblem) + '\n'
+ payloads[f'star-history-{theme}.svg'] = render.stars(theme, history, day, emblem) + '\n'
+ for svg in payloads.values():
+ ET.fromstring(svg)
+ for name, data in {
+ 'history.json': history,
+ 'curated.json': curated,
+ 'contributors.json': {'repository': REPOSITORY, 'updated': day, 'people': people},
+ }.items():
+ payloads[name] = json.dumps(data, indent=2, ensure_ascii=False) + '\n'
+ payloads['README.md'] = f'''# SILO repository cards
+
+Generated by [Repository Cards](https://github.com/pgsty/silo/actions/workflows/repository-cards.yml)
+at 00:00 UTC daily (08:00 Asia/Shanghai). GitHub may queue scheduled runs.
+
+Snapshot: {day}. {metadata['stargazers_count']:,} stars; {len(people)} community contributors.
+
+- `contributors-light.svg` / `contributors-dark.svg`: human issue and PR authors across the SILO project scope, plus reviewed acknowledgements. Bots are excluded. Gold rings follow the reviewed companion-site roster; new authors are collected automatically.
+- `star-history-light.svg` / `star-history-dark.svg`: initial history reconstructed from the then-current stargazers; later points are daily observed totals, including decreases. Missing days are not fabricated.
+- `curated.json`: a cache of reviewed contributor credit from `pgsty/silo.pgsty.com/data/home/contributors.yaml`. The approved initial preview may be newer than the published site; a newer reviewed snapshot is retained until the site catches up.
+- `contributors.json`: generated contributor data and embedded raster avatars. Failed avatar refreshes use the previous image, or an initial when no image is available.
+- `history.json`: persistent daily totals. Keep this file when regenerating images.
+
+The SVGs are self-contained. Source and instructions live on the default branch;
+this branch contains generated assets only. Do not merge it into `main`.
+'''
+ # Collect and validate everything before touching the publication checkout.
+ output.mkdir(parents=True, exist_ok=True)
+ for filename, text in payloads.items():
+ (output / filename).write_text(text)
+ print(f'{day}: {len(people)} contributors; {metadata["stargazers_count"]:,} stars; {len(history["points"])} history points')
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--output', type=Path, required=True)
+ args = parser.parse_args()
+ configured = os.environ.get('GITHUB_REPOSITORY', REPOSITORY)
+ if configured.lower() != REPOSITORY:
+ raise SystemExit('This workflow is scoped to pgsty/silo')
+ refresh(args.output, GitHub(os.environ.get('GH_TOKEN', '')), Path(__file__).resolve().parents[2])
+
+
+if __name__ == '__main__':
+ main()