Skip to content

Commit e76b853

Browse files
committed
feat(ci): replace the impact-card action with our own brand-styled renderer
matthewevans/gittensor-impact-action's generic orange/gray template didn't match this site's actual design (DM Sans + Space Grotesk, citron accent, ink surfaces). scripts/gittensor-impact-card.mjs replaces it: same data source (the public api.gittensor.io endpoints), but renders 12-week sparklines (merged PRs, contributors, lines changed) and a meter (emission share) styled with this repo's own tokens, plus the real Gittensor mark and this repo's own icon. Since the workflow no longer runs third-party code, drops the environment: gittensor-impact required-approval gate that existed specifically to gate that untrusted action — the job still runs with contents: write scoped to itself, just no longer needs a human click on every scheduled run. README now embeds a single dark SVG (dropping the light/dark <picture>) since the design is dark-only by choice, not because a light variant is missing.
1 parent e86933f commit e76b853

4 files changed

Lines changed: 202 additions & 18 deletions

File tree

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
name: Gittensor Impact
22

3+
# Renders the README contributor-impact card with scripts/gittensor-impact-card.mjs
4+
# (our own script, styled to this repo's brand — see apps/gittensory-ui/src/styles.css)
5+
# and publishes it to the gittensor-impact-assets branch, which the README embeds
6+
# directly. No third-party action: this replaced matthewevans/gittensor-impact-action
7+
# once its generic template no longer matched the site's design.
8+
39
on:
410
workflow_dispatch:
511
schedule:
@@ -17,20 +23,38 @@ jobs:
1723
runs-on: ubuntu-latest
1824
timeout-minutes: 10
1925
# Scoped here rather than at workflow level (defense-in-depth): this is the
20-
# only job that needs write access, to push SVG assets to the dedicated
21-
# gittensor-impact-assets branch via matthewevans/gittensor-impact-action.
26+
# only job that needs write access, to push the rendered SVG to the
27+
# dedicated gittensor-impact-assets branch.
2228
permissions:
2329
contents: write
24-
# matthewevans/gittensor-impact-action is a ~1.5-day-old repo; require a
25-
# human approval before it runs with contents:write, on top of the SHA pin.
26-
environment: gittensor-impact
2730
steps:
28-
- name: Generate and publish Gittensor impact card
29-
uses: matthewevans/gittensor-impact-action@397fd470899cba47367458289a374eea9cb0d76a # main, 2026-07-06 (#1 per-repo API endpoint)
31+
- name: Checkout
32+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
33+
with:
34+
persist-credentials: false
35+
36+
- name: Setup Node
37+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
3038
with:
31-
title: Gittensory
32-
# GitHub's release-asset uploads now reject re-uploads to an
33-
# immutable release (HTTP 422), which the action's default
34-
# release publish-mode hits every run. Branch mode force-pushes
35-
# the SVGs to a dedicated orphan branch instead, sidestepping it.
36-
publish-mode: branch
39+
node-version: 22
40+
41+
- name: Render impact card
42+
run: node scripts/gittensor-impact-card.mjs JSONbored/gittensory gittensor-impact-dark.svg
43+
44+
- name: Publish to gittensor-impact-assets
45+
env:
46+
GH_TOKEN: ${{ github.token }}
47+
run: |
48+
set -euo pipefail
49+
workdir="$RUNNER_TEMP/gittensor-impact-publish"
50+
rm -rf "$workdir"
51+
mkdir -p "$workdir"
52+
cp gittensor-impact-dark.svg "$workdir/"
53+
cd "$workdir"
54+
git init -b gittensor-impact-assets --quiet
55+
git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
56+
git config user.name "github-actions[bot]"
57+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
58+
git add gittensor-impact-dark.svg
59+
git commit -m "chore: refresh Gittensor impact card" --quiet
60+
git push --force origin HEAD:gittensor-impact-assets

README.md

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,7 @@ npm run ui:build
126126

127127
<p align="center">
128128
<a href="https://gittensor.io/miners/repository?name=JSONbored/gittensory">
129-
<picture>
130-
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/JSONbored/gittensory/gittensor-impact-assets/gittensor-impact-dark.svg">
131-
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/JSONbored/gittensory/gittensor-impact-assets/gittensor-impact-light.svg">
132-
<img src="https://raw.githubusercontent.com/JSONbored/gittensory/gittensor-impact-assets/gittensor-impact-light.svg" alt="Gittensor contributor impact" width="600">
133-
</picture>
129+
<img src="https://raw.githubusercontent.com/JSONbored/gittensory/gittensor-impact-assets/gittensor-impact-dark.svg" alt="Gittensor contributor impact" width="600">
134130
</a>
135131
</p>
136132

Lines changed: 1 addition & 0 deletions
Loading

scripts/gittensor-impact-card.mjs

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
#!/usr/bin/env node
2+
// Renders the "Gittensor impact" README card for this repo: a dark-themed SVG
3+
// with 12-week sparklines (merged PRs, contributors, lines changed) and a
4+
// meter (emission share), styled with this repo's own brand tokens rather
5+
// than a generic third-party template. Replaces matthewevans/gittensor-impact-action's
6+
// rendering (its per-repo data-fetch approach inspired this, but the visual
7+
// design here is our own, matching apps/gittensory-ui/src/styles.css).
8+
//
9+
// Usage: node scripts/gittensor-impact-card.mjs <owner/repo> <out-file.svg>
10+
11+
import { readFileSync, writeFileSync } from "node:fs";
12+
import { fileURLToPath } from "node:url";
13+
import path from "node:path";
14+
15+
const WEEKS = 12;
16+
const THEME = {
17+
cardBg: "#0e100d",
18+
fg: "#f3f6f3",
19+
muted: "#949a93",
20+
accent: "#d5e43f",
21+
accentTrack: "#333821",
22+
border: "#2a2c29",
23+
radius: 24,
24+
};
25+
26+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
27+
const repoRoot = path.resolve(__dirname, "..");
28+
const repoIconPath = path.join(repoRoot, "apps/gittensory-ui/public/brand/gittensory-icon-citron.svg");
29+
30+
function compact(n) {
31+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
32+
if (n >= 1000) return (n / 1000).toFixed(0) + "k";
33+
return String(n);
34+
}
35+
36+
async function fetchJson(url) {
37+
const res = await fetch(url, { headers: { "User-Agent": "gittensor-impact-card/1.0" } });
38+
if (!res.ok) throw new Error(`fetch failed: ${url} (${res.status})`);
39+
return res.json();
40+
}
41+
42+
function bucketWeekly(prs, now) {
43+
const weekMs = 7 * 24 * 60 * 60 * 1000;
44+
const bucketStart = new Date(now.getTime() - WEEKS * weekMs);
45+
const prBuckets = Array(WEEKS).fill(0);
46+
const locBuckets = Array(WEEKS).fill(0);
47+
const seenByBucket = Array.from({ length: WEEKS }, () => new Set());
48+
const contributorBuckets = Array(WEEKS).fill(0);
49+
50+
for (const pr of prs) {
51+
const t = new Date(pr.mergedAt);
52+
if (t < bucketStart || t > now) continue;
53+
const idx = Math.min(WEEKS - 1, Math.floor((t - bucketStart) / weekMs));
54+
prBuckets[idx] += 1;
55+
locBuckets[idx] += (pr.additions || 0) + (pr.deletions || 0);
56+
seenByBucket[idx].add(pr.author);
57+
}
58+
59+
const seenSoFar = new Set();
60+
for (let i = 0; i < WEEKS; i++) {
61+
for (const a of seenByBucket[i]) seenSoFar.add(a);
62+
contributorBuckets[i] = seenSoFar.size;
63+
}
64+
return { prBuckets, locBuckets, contributorBuckets };
65+
}
66+
67+
function sparkline(x, y, w, h, values, mutedColor, accentColor, cardBg) {
68+
const max = Math.max(...values, 1);
69+
const min = Math.min(...values, 0);
70+
const range = max - min || 1;
71+
const n = values.length;
72+
const stepX = w / (n - 1);
73+
const pts = values.map((v, i) => [x + i * stepX, y + h - ((v - min) / range) * h]);
74+
const svgPath = pts.map(([px, py], i) => `${i === 0 ? "M" : "L"}${px.toFixed(1)},${py.toFixed(1)}`).join(" ");
75+
const [lastX, lastY] = pts[pts.length - 1];
76+
return `
77+
<path d="${svgPath}" fill="none" stroke="${mutedColor}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
78+
<circle cx="${lastX.toFixed(1)}" cy="${lastY.toFixed(1)}" r="6" fill="${cardBg}"/>
79+
<circle cx="${lastX.toFixed(1)}" cy="${lastY.toFixed(1)}" r="4" fill="${accentColor}"/>`;
80+
}
81+
82+
function meter(x, y, w, h, value, max, accentColor, trackColor) {
83+
const fillW = Math.max(h, Math.min(value / max, 1) * w);
84+
return `
85+
<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}" fill="${trackColor}"/>
86+
<rect x="${x}" y="${y}" width="${fillW.toFixed(1)}" height="${h}" rx="${h / 2}" fill="${accentColor}"/>`;
87+
}
88+
89+
function render({ repo, impact, buckets, gtLogoB64, repoIconB64 }) {
90+
const { cardBg, fg, muted, accent, accentTrack, border, radius } = THEME;
91+
const W = 1200, H = 420;
92+
const pad = 56;
93+
const cols = 4;
94+
const colW = (W - 2 * pad) / cols;
95+
const font = "'DM Sans', ui-sans-serif, system-ui, -apple-system, sans-serif";
96+
const displayFont = "'Space Grotesk', ui-sans-serif, system-ui, -apple-system, sans-serif";
97+
const sparkW = colW - 40;
98+
const sparkH = 56;
99+
const sparkY = 150;
100+
101+
const stats = [
102+
{ type: "sparkline", series: buckets.prBuckets, value: impact.totalPRs.toLocaleString(), label: "merged PRs" },
103+
{ type: "sparkline", series: buckets.contributorBuckets, value: String(impact.totalContributors), label: "contributors" },
104+
{ type: "sparkline", series: buckets.locBuckets, value: compact(impact.totalLinesChanged), label: "lines changed" },
105+
{ type: "meter", raw: impact.emissionShare * 100, max: 100, value: `${(impact.emissionShare * 100).toFixed(1)}%`, label: "emission share" },
106+
];
107+
108+
let statsSvg = "";
109+
stats.forEach((s, i) => {
110+
const x = pad + i * colW;
111+
if (s.type === "meter") {
112+
statsSvg += meter(x, sparkY + sparkH / 2 - 7, sparkW, 14, s.raw, s.max, accent, accentTrack);
113+
} else {
114+
statsSvg += sparkline(x, sparkY, sparkW, sparkH, s.series, muted, accent, cardBg);
115+
}
116+
statsSvg += `
117+
<text x="${x}" y="${sparkY + sparkH + 78}" font-family="${font}" font-size="60" font-weight="700" fill="${fg}">${s.value}</text>
118+
<text x="${x}" y="${sparkY + sparkH + 112}" font-family="${font}" font-size="21" font-weight="500" fill="${muted}">${s.label}</text>`;
119+
});
120+
121+
const logoW = 48, logoH = 48 / (708 / 567); // gittensor.io/gt-logo.svg aspect ratio
122+
const repoIconSize = 26;
123+
const repoIconX = W - pad - repoIconSize;
124+
const repoIconY = 396 - 14 - (repoIconSize - 16);
125+
const repoTextX = repoIconX - 10;
126+
127+
return `<svg xmlns="http://www.w3.org/2000/svg" width="600" height="210" viewBox="0 0 ${W} ${H}" role="img">
128+
<rect x="1" y="1" width="${W - 2}" height="${H - 2}" rx="${radius}" fill="${cardBg}" stroke="${border}" stroke-width="1"/>
129+
<image href="data:image/svg+xml;base64,${gtLogoB64}" x="${pad}" y="${(80 - logoH / 2 - 4).toFixed(1)}" width="${logoW}" height="${logoH.toFixed(1)}"/>
130+
<text x="${pad + logoW + 14}" y="80" font-family="${displayFont}" font-size="22" font-weight="500" letter-spacing="0.08em" fill="${muted}">GITTENSOR IMPACT</text>
131+
<line x1="${pad}" y1="124" x2="${W - pad}" y2="124" stroke="${border}" stroke-width="1"/>
132+
${statsSvg}
133+
<text x="${pad}" y="396" font-family="${font}" font-size="19" font-weight="400" fill="${muted}">Updated weekly &#183; gittensor.io</text>
134+
<image href="data:image/svg+xml;base64,${repoIconB64}" x="${repoIconX}" y="${repoIconY}" width="${repoIconSize}" height="${repoIconSize}"/>
135+
<text x="${repoTextX}" y="396" font-family="${displayFont}" font-size="20" font-weight="500" letter-spacing="-0.01em" fill="${fg}" text-anchor="end">${repo}</text>
136+
</svg>`;
137+
}
138+
139+
async function main() {
140+
const [repo, outFile] = process.argv.slice(2);
141+
if (!repo || !outFile) {
142+
console.error("Usage: node scripts/gittensor-impact-card.mjs <owner/repo> <out-file.svg>");
143+
process.exit(1);
144+
}
145+
const encoded = repo.replace("/", "%2F");
146+
const [impact, prs, gtLogoSvg] = await Promise.all([
147+
fetchJson(`https://api.gittensor.io/repos/${encoded}/impact`),
148+
fetchJson(`https://api.gittensor.io/repos/${encoded}/prs`),
149+
fetch("https://gittensor.io/gt-logo.svg").then((r) => r.text()),
150+
]);
151+
const buckets = bucketWeekly(prs, new Date());
152+
const gtLogoB64 = Buffer.from(gtLogoSvg).toString("base64");
153+
const repoIconB64 = Buffer.from(readFileSync(repoIconPath)).toString("base64");
154+
155+
const svg = render({ repo, impact, buckets, gtLogoB64, repoIconB64 });
156+
writeFileSync(outFile, svg);
157+
console.log(`Wrote ${outFile} (${svg.length} bytes)`);
158+
}
159+
160+
main().catch((err) => {
161+
console.error(err);
162+
process.exit(1);
163+
});

0 commit comments

Comments
 (0)