name: Update README Traction Chart
on:
schedule:
- cron: "0 14 * * 1" # Monday 8 AM Central
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
generate-chart:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
- name: Install dependencies
run: |
mkdir -p /tmp/sharp-deps
cd /tmp/sharp-deps
npm init -y > /dev/null
npm install sharp
- name: Generate chart
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_PATH: /tmp/sharp-deps/node_modules
run: |
node << 'SCRIPT'
const sharp = require('sharp');
const fs = require('fs');
const PACKAGE_NAME = '@assistant-ui/react';
const GITHUB_REPO = 'assistant-ui/assistant-ui';
const OUTPUT_PATH = '.github/assets/traction.png';
const COLORS = { primary: '#ffffff', text: '#ffffff', textMuted: '#888888', grid: '#333333' };
const ASSISTANT_UI_LOGO = ``;
const GITHUB_LOGO = ``;
const NPM_LOGO = ``;
function getStartDate() {
const d = new Date();
d.setMonth(d.getMonth() - 18);
return d.toISOString().split('T')[0];
}
const START_DATE = getStartDate();
async function fetchNpmStats(packageName) {
const end = new Date().toISOString().split('T')[0];
const url = `https://npm-stat.com/api/download-counts?package=${encodeURIComponent(packageName)}&from=${START_DATE}&until=${end}`;
const response = await fetch(url);
if (!response.ok) throw new Error(`npm stats fetch failed: ${response.status}`);
const data = await response.json();
return data[packageName] || {};
}
async function fetchCurrentStars(repo) {
const [owner, name] = repo.split('/');
const headers = { 'Accept': 'application/vnd.github.v3+json' };
if (process.env.GITHUB_TOKEN) {
headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`;
}
const response = await fetch(`https://api.github.com/repos/${owner}/${name}`, { headers });
if (!response.ok) throw new Error(`GitHub repo fetch failed: ${response.status}`);
const data = await response.json();
return data.stargazers_count;
}
async function fetchStarHistory(repo, currentStars) {
const [owner, name] = repo.split('/');
const stars = [];
let page = 1;
const headers = { 'Accept': 'application/vnd.github.star+json' };
if (process.env.GITHUB_TOKEN) {
headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`;
}
// Paginate through all stars (no hard limit) with delays to avoid rate limits
while (true) {
if (page > 1) await new Promise(r => setTimeout(r, 100));
let response = await fetch(
`https://api.github.com/repos/${owner}/${name}/stargazers?per_page=100&page=${page}`,
{ headers }
);
if (response.status === 403 || response.status === 429) {
console.warn(`Rate limited on page ${page}, waiting 3 minutes before retry...`);
await new Promise(r => setTimeout(r, 180000));
response = await fetch(
`https://api.github.com/repos/${owner}/${name}/stargazers?per_page=100&page=${page}`,
{ headers }
);
}
if (!response.ok) {
console.warn(`Stopped at page ${page}: HTTP ${response.status} (fetched ${stars.length} stars)`);
break;
}
const data = await response.json();
if (data.length === 0) break;
stars.push(...data.map(s => s.starred_at));
console.log(`Fetched page ${page}, ${stars.length} stars total`);
if (data.length < 100) break;
page++;
}
const startDate = new Date(START_DATE);
const starsBeforeStart = stars.filter(d => new Date(d) < startDate).length;
const weeks = {};
for (const date of stars) {
const d = new Date(date);
if (d < startDate) continue;
const weekStart = getWeekStart(d.toISOString().split('T')[0]);
weeks[weekStart] = (weeks[weekStart] || 0) + 1;
}
const result = [];
const now = new Date();
const currentWeekStart = getWeekStart(now.toISOString().split('T')[0]);
let d = new Date(START_DATE);
const dayOfWeek = d.getDay();
const daysToMonday = dayOfWeek === 0 ? 1 : (dayOfWeek === 1 ? 0 : 8 - dayOfWeek);
d.setDate(d.getDate() + daysToMonday);
let cumulative = starsBeforeStart;
while (d <= now) {
const key = d.toISOString().split('T')[0];
if (key >= currentWeekStart) break;
cumulative += (weeks[key] || 0);
result.push({ date: key, value: cumulative });
d.setDate(d.getDate() + 7);
}
result.push({ date: now.toISOString().split('T')[0], value: currentStars });
return result;
}
function getWeekStart(date) {
const d = new Date(date);
const day = d.getDay();
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
d.setDate(diff);
return d.toISOString().split('T')[0];
}
function aggregateByWeek(dailyData) {
const sorted = Object.entries(dailyData)
.filter(([, count]) => count > 0)
.sort(([a], [b]) => a.localeCompare(b));
if (sorted.length < 7) return sorted.map(([date, value]) => ({ date, value }));
const result = [];
for (let i = 3; i < sorted.length - 4; i += 3) {
let sum = 0;
for (let j = i - 3; j <= i + 3; j++) {
sum += sorted[j][1];
}
result.push({ date: sorted[i][0], value: sum });
}
return result;
}
function smoothData(data) {
if (data.length < 3) return data;
const avg = [];
for (let i = 0; i < data.length; i++) {
const s = Math.max(0, i - 2), e = Math.min(data.length, i + 3);
const w = data.slice(s, e).map(d => d.value);
avg.push({ date: data[i].date, value: w.reduce((a, b) => a + b, 0) / w.length });
}
const out = [avg[0]];
let h = avg[0].value;
for (let i = 1; i < avg.length; i++) {
const p = out[i - 1].value, c = avg[i].value;
h = c > h ? c : h * 0.99;
const v = c > p ? p * 0.3 + c * 0.7 : c < p * 0.8 ? p * 0.85 + h * 0.1 + c * 0.05 : p * 0.95 + c * 0.05;
out.push({ date: avg[i].date, value: Math.round(v) });
}
return out;
}
function getMonthlyRate(dailyData) {
const entries = Object.entries(dailyData).sort(([a], [b]) => a.localeCompare(b));
const recent = entries.slice(-60);
if (recent.length < 30) return Math.round(recent.reduce((s, [, v]) => s + v, 0) / recent.length * 30);
let max = 0;
for (let i = 0; i <= recent.length - 30; i++) {
const sum = recent.slice(i, i + 30).reduce((s, [, v]) => s + v, 0);
if (sum > max) max = sum;
}
return max;
}
function formatNumber(num) {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
return num.toString();
}
function smoothPath(pts) {
if (pts.length < 2) return '';
if (pts.length === 2) return `M ${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)} L ${pts[1].x.toFixed(1)} ${pts[1].y.toFixed(1)}`;
let path = `M ${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[Math.max(0, i - 1)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(pts.length - 1, i + 2)];
const cp1x = p1.x + (p2.x - p0.x) / 6;
const cp1y = p1.y + (p2.y - p0.y) / 6;
const cp2x = p2.x - (p3.x - p1.x) / 6;
const cp2y = p2.y - (p3.y - p1.y) / 6;
path += ` C ${cp1x.toFixed(1)} ${cp1y.toFixed(1)}, ${cp2x.toFixed(1)} ${cp2y.toFixed(1)}, ${p2.x.toFixed(1)} ${p2.y.toFixed(1)}`;
}
return path;
}
function generateDualSVG(starsData, downloadsData, monthlyDownloads, currentStars, totalDownloads) {
const width = 800;
const height = 380;
const margin = 20;
const gap = 24;
const cardWidth = (width - margin * 2 - gap) / 2;
const cardHeight = height - margin * 2;
const chartPadding = { top: 50, right: 20, bottom: 35, left: 45 };
const innerW = cardWidth - chartPadding.left - chartPadding.right;
const innerH = cardHeight - chartPadding.top - chartPadding.bottom;
function chartCard(data, cardX, cardY, packageName, metricName, subtitle, gradId, clipId, logoSvg, subtitle2 = null, yAxisLabel = null) {
if (!data.length) return '';
const maxVal = Math.max(...data.map(d => d.value));
const chartLeft = cardX + chartPadding.left;
const chartRight = cardX + cardWidth - chartPadding.right;
const chartTop = cardY + chartPadding.top;
const chartBottom = cardY + chartPadding.top + innerH;
const niceStep = (max) => {
const rough = max / 6;
const magnitude = Math.pow(10, Math.floor(Math.log10(rough)));
const normalized = rough / magnitude;
let nice;
if (normalized <= 1) nice = 1;
else if (normalized <= 2) nice = 2;
else if (normalized <= 5) nice = 5;
else nice = 10;
return nice * magnitude;
};
const step = niceStep(maxVal);
const niceMax = Math.ceil(maxVal / step) * step;
const yVals = [];
for (let v = 0; v <= niceMax; v += step) {
yVals.push(v);
}
const pts = data.map((d, i) => ({
x: chartLeft + (data.length > 1 ? (i / (data.length - 1)) * innerW : innerW / 2),
y: chartTop + innerH - (d.value / niceMax) * innerH
}));
const line = smoothPath(pts);
const areaPath = line + ` L ${pts[pts.length - 1].x.toFixed(1)} ${chartBottom} L ${chartLeft} ${chartBottom} Z`;
const xLabels = [];
const numLabels = 8;
const labelStep = Math.max(1, Math.floor(data.length / (numLabels - 1)));
let lastYear = null;
for (let labelNum = 0; labelNum < numLabels - 1; labelNum++) {
const i = Math.min(labelNum * labelStep, data.length - 2);
const x = pts[i].x;
const d = new Date(data[i].date);
const year = d.getFullYear();
const month = d.toLocaleDateString('en-US', { month: 'short' });
let label;
if (year !== lastYear && lastYear !== null) {
label = String(year);
} else {
label = month;
}
lastYear = year;
xLabels.push({ x, label });
}
xLabels.push({ x: pts[data.length - 1].x, label: 'Today' });
return `
${ASSISTANT_UI_LOGO}
${metricName}
${subtitle}
${subtitle2 ? `${subtitle2}` : ''}
${logoSvg}
${packageName}
${yVals.slice(1).map(v => {
const y = chartTop + innerH - (v / niceMax) * innerH;
return ``;
}).join('')}
${yVals.map(v => {
const y = chartTop + innerH - (v / niceMax) * innerH;
return `${formatNumber(v)}`;
}).join('')}
${yAxisLabel ? `${yAxisLabel}` : ''}
${xLabels.map(({ x, label }) => {
return `${label}`;
}).join('')}
`;
}
const cardY = margin;
const card1X = margin;
const card2X = margin + cardWidth + gap;
return ``;
}
async function main() {
const [dailyData, stars] = await Promise.all([fetchNpmStats(PACKAGE_NAME), fetchCurrentStars(GITHUB_REPO)]);
const monthlyDownloads = getMonthlyRate(dailyData);
const totalDownloads = Object.values(dailyData).reduce((sum, v) => sum + v, 0);
const downloadsData = smoothData(aggregateByWeek(dailyData)).slice(0, -1);
const currentStars = stars || 0;
const starsData = currentStars ? await fetchStarHistory(GITHUB_REPO, currentStars) : [];
const svg = generateDualSVG(starsData, downloadsData, monthlyDownloads, currentStars, totalDownloads);
const png = await sharp(Buffer.from(svg), { density: 300 }).png().toBuffer();
fs.mkdirSync('.github/assets', { recursive: true });
fs.writeFileSync(OUTPUT_PATH, png);
console.log('Done');
}
main().catch(err => { console.error(err.message); process.exit(1); });
SCRIPT
- name: Commit and push changes
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add .github/assets/traction.png
if git diff --staged --quiet; then
echo "No changes to commit"
exit 0
fi
git checkout -B update-traction-chart
git commit -m "Update traction chart [skip ci]"
git push -f origin update-traction-chart
- name: Create or update Pull Request
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PREVIEW_URL="https://raw.githubusercontent.com/${{ github.repository }}/update-traction-chart/.github/assets/traction.png"
BODY="Automated weekly update of README traction chart.
"
existing_pr=$(gh pr list --head update-traction-chart --json number --jq '.[0].number' || echo "")
if [ -n "$existing_pr" ]; then
gh pr edit "$existing_pr" --body "$BODY"
echo "Updated PR #$existing_pr"
else
gh pr create \
--title "Update README traction chart" \
--body "$BODY" \
--head update-traction-chart \
--base main
fi