From e76b853f54fdd323aca2cbeebe64ef0e4b702042 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:19:47 -0700 Subject: [PATCH] feat(ci): replace the impact-card action with our own brand-styled renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ) since the design is dark-only by choice, not because a light variant is missing. --- .github/workflows/gittensor-impact.yml | 50 ++++-- README.md | 6 +- .../public/brand/gittensory-icon-citron.svg | 1 + scripts/gittensor-impact-card.mjs | 163 ++++++++++++++++++ 4 files changed, 202 insertions(+), 18 deletions(-) create mode 100644 apps/gittensory-ui/public/brand/gittensory-icon-citron.svg create mode 100644 scripts/gittensor-impact-card.mjs 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

- - - - Gittensor contributor impact - + Gittensor contributor impact

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 ` + + +GITTENSOR IMPACT + +${statsSvg} +Updated weekly · gittensor.io + +${repo} +`; +} + +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); +});