diff --git a/.github/workflows/gittensor-impact.yml b/.github/workflows/gittensor-impact.yml
index 757cf9aa6..fb50cea9e 100644
--- a/.github/workflows/gittensor-impact.yml
+++ b/.github/workflows/gittensor-impact.yml
@@ -1,5 +1,11 @@
name: Gittensor Impact
+# Renders the README contributor-impact card with scripts/gittensor-impact-card.mjs
+# (our own script, styled to this repo's brand — see apps/gittensory-ui/src/styles.css)
+# and publishes it to the gittensor-impact-assets branch, which the README embeds
+# directly. No third-party action: this replaced matthewevans/gittensor-impact-action
+# once its generic template no longer matched the site's design.
+
on:
workflow_dispatch:
schedule:
@@ -17,20 +23,38 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
# Scoped here rather than at workflow level (defense-in-depth): this is the
- # only job that needs write access, to push SVG assets to the dedicated
- # gittensor-impact-assets branch via matthewevans/gittensor-impact-action.
+ # only job that needs write access, to push the rendered SVG to the
+ # dedicated gittensor-impact-assets branch.
permissions:
contents: write
- # matthewevans/gittensor-impact-action is a ~1.5-day-old repo; require a
- # human approval before it runs with contents:write, on top of the SHA pin.
- environment: gittensor-impact
steps:
- - name: Generate and publish Gittensor impact card
- uses: matthewevans/gittensor-impact-action@397fd470899cba47367458289a374eea9cb0d76a # main, 2026-07-06 (#1 per-repo API endpoint)
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
+ with:
+ persist-credentials: false
+
+ - name: Setup Node
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
- title: Gittensory
- # GitHub's release-asset uploads now reject re-uploads to an
- # immutable release (HTTP 422), which the action's default
- # release publish-mode hits every run. Branch mode force-pushes
- # the SVGs to a dedicated orphan branch instead, sidestepping it.
- publish-mode: branch
+ node-version: 22
+
+ - name: Render impact card
+ run: node scripts/gittensor-impact-card.mjs JSONbored/gittensory gittensor-impact-dark.svg
+
+ - name: Publish to gittensor-impact-assets
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ workdir="$RUNNER_TEMP/gittensor-impact-publish"
+ rm -rf "$workdir"
+ mkdir -p "$workdir"
+ cp gittensor-impact-dark.svg "$workdir/"
+ cd "$workdir"
+ git init -b gittensor-impact-assets --quiet
+ git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add gittensor-impact-dark.svg
+ git commit -m "chore: refresh Gittensor impact card" --quiet
+ git push --force origin HEAD:gittensor-impact-assets
diff --git a/README.md b/README.md
index 20f85d3f3..8351f581f 100644
--- a/README.md
+++ b/README.md
@@ -126,11 +126,7 @@ npm run ui:build
-
-
-
-
-
+
diff --git a/apps/gittensory-ui/public/brand/gittensory-icon-citron.svg b/apps/gittensory-ui/public/brand/gittensory-icon-citron.svg
new file mode 100644
index 000000000..a4ba4b5cb
--- /dev/null
+++ b/apps/gittensory-ui/public/brand/gittensory-icon-citron.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/scripts/gittensor-impact-card.mjs b/scripts/gittensor-impact-card.mjs
new file mode 100644
index 000000000..52b5cbac2
--- /dev/null
+++ b/scripts/gittensor-impact-card.mjs
@@ -0,0 +1,163 @@
+#!/usr/bin/env node
+// Renders the "Gittensor impact" README card for this repo: a dark-themed SVG
+// with 12-week sparklines (merged PRs, contributors, lines changed) and a
+// meter (emission share), styled with this repo's own brand tokens rather
+// than a generic third-party template. Replaces matthewevans/gittensor-impact-action's
+// rendering (its per-repo data-fetch approach inspired this, but the visual
+// design here is our own, matching apps/gittensory-ui/src/styles.css).
+//
+// Usage: node scripts/gittensor-impact-card.mjs
+
+import { readFileSync, writeFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import path from "node:path";
+
+const WEEKS = 12;
+const THEME = {
+ cardBg: "#0e100d",
+ fg: "#f3f6f3",
+ muted: "#949a93",
+ accent: "#d5e43f",
+ accentTrack: "#333821",
+ border: "#2a2c29",
+ radius: 24,
+};
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(__dirname, "..");
+const repoIconPath = path.join(repoRoot, "apps/gittensory-ui/public/brand/gittensory-icon-citron.svg");
+
+function compact(n) {
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
+ if (n >= 1000) return (n / 1000).toFixed(0) + "k";
+ return String(n);
+}
+
+async function fetchJson(url) {
+ const res = await fetch(url, { headers: { "User-Agent": "gittensor-impact-card/1.0" } });
+ if (!res.ok) throw new Error(`fetch failed: ${url} (${res.status})`);
+ return res.json();
+}
+
+function bucketWeekly(prs, now) {
+ const weekMs = 7 * 24 * 60 * 60 * 1000;
+ const bucketStart = new Date(now.getTime() - WEEKS * weekMs);
+ const prBuckets = Array(WEEKS).fill(0);
+ const locBuckets = Array(WEEKS).fill(0);
+ const seenByBucket = Array.from({ length: WEEKS }, () => new Set());
+ const contributorBuckets = Array(WEEKS).fill(0);
+
+ for (const pr of prs) {
+ const t = new Date(pr.mergedAt);
+ if (t < bucketStart || t > now) continue;
+ const idx = Math.min(WEEKS - 1, Math.floor((t - bucketStart) / weekMs));
+ prBuckets[idx] += 1;
+ locBuckets[idx] += (pr.additions || 0) + (pr.deletions || 0);
+ seenByBucket[idx].add(pr.author);
+ }
+
+ const seenSoFar = new Set();
+ for (let i = 0; i < WEEKS; i++) {
+ for (const a of seenByBucket[i]) seenSoFar.add(a);
+ contributorBuckets[i] = seenSoFar.size;
+ }
+ return { prBuckets, locBuckets, contributorBuckets };
+}
+
+function sparkline(x, y, w, h, values, mutedColor, accentColor, cardBg) {
+ const max = Math.max(...values, 1);
+ const min = Math.min(...values, 0);
+ const range = max - min || 1;
+ const n = values.length;
+ const stepX = w / (n - 1);
+ const pts = values.map((v, i) => [x + i * stepX, y + h - ((v - min) / range) * h]);
+ const svgPath = pts.map(([px, py], i) => `${i === 0 ? "M" : "L"}${px.toFixed(1)},${py.toFixed(1)}`).join(" ");
+ const [lastX, lastY] = pts[pts.length - 1];
+ return `
+
+
+`;
+}
+
+function meter(x, y, w, h, value, max, accentColor, trackColor) {
+ const fillW = Math.max(h, Math.min(value / max, 1) * w);
+ return `
+
+`;
+}
+
+function render({ repo, impact, buckets, gtLogoB64, repoIconB64 }) {
+ const { cardBg, fg, muted, accent, accentTrack, border, radius } = THEME;
+ const W = 1200, H = 420;
+ const pad = 56;
+ const cols = 4;
+ const colW = (W - 2 * pad) / cols;
+ const font = "'DM Sans', ui-sans-serif, system-ui, -apple-system, sans-serif";
+ const displayFont = "'Space Grotesk', ui-sans-serif, system-ui, -apple-system, sans-serif";
+ const sparkW = colW - 40;
+ const sparkH = 56;
+ const sparkY = 150;
+
+ const stats = [
+ { type: "sparkline", series: buckets.prBuckets, value: impact.totalPRs.toLocaleString(), label: "merged PRs" },
+ { type: "sparkline", series: buckets.contributorBuckets, value: String(impact.totalContributors), label: "contributors" },
+ { type: "sparkline", series: buckets.locBuckets, value: compact(impact.totalLinesChanged), label: "lines changed" },
+ { type: "meter", raw: impact.emissionShare * 100, max: 100, value: `${(impact.emissionShare * 100).toFixed(1)}%`, label: "emission share" },
+ ];
+
+ let statsSvg = "";
+ stats.forEach((s, i) => {
+ const x = pad + i * colW;
+ if (s.type === "meter") {
+ statsSvg += meter(x, sparkY + sparkH / 2 - 7, sparkW, 14, s.raw, s.max, accent, accentTrack);
+ } else {
+ statsSvg += sparkline(x, sparkY, sparkW, sparkH, s.series, muted, accent, cardBg);
+ }
+ statsSvg += `
+${s.value}
+${s.label}`;
+ });
+
+ const logoW = 48, logoH = 48 / (708 / 567); // gittensor.io/gt-logo.svg aspect ratio
+ const repoIconSize = 26;
+ const repoIconX = W - pad - repoIconSize;
+ const repoIconY = 396 - 14 - (repoIconSize - 16);
+ const repoTextX = repoIconX - 10;
+
+ return ``;
+}
+
+async function main() {
+ const [repo, outFile] = process.argv.slice(2);
+ if (!repo || !outFile) {
+ console.error("Usage: node scripts/gittensor-impact-card.mjs ");
+ process.exit(1);
+ }
+ const encoded = repo.replace("/", "%2F");
+ const [impact, prs, gtLogoSvg] = await Promise.all([
+ fetchJson(`https://api.gittensor.io/repos/${encoded}/impact`),
+ fetchJson(`https://api.gittensor.io/repos/${encoded}/prs`),
+ fetch("https://gittensor.io/gt-logo.svg").then((r) => r.text()),
+ ]);
+ const buckets = bucketWeekly(prs, new Date());
+ const gtLogoB64 = Buffer.from(gtLogoSvg).toString("base64");
+ const repoIconB64 = Buffer.from(readFileSync(repoIconPath)).toString("base64");
+
+ const svg = render({ repo, impact, buckets, gtLogoB64, repoIconB64 });
+ writeFileSync(outFile, svg);
+ console.log(`Wrote ${outFile} (${svg.length} bytes)`);
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});