Update (2025-12-06): I packaged this pipeline as an npm module,
avatar-fetcher.
Updating my GitHub avatar was never the slow part. GitHub's built-in cropping handles that in seconds. The time sink was keeping my website avatar in sync with it: converting to WebP, resizing for the circle mask, and pushing the updates to the repo. So I made GitHub the source of truth and let the build pipeline do the rest.
Architecture at a glance
Diagram 1 as text
16 steps, 1 of them a decision. Starts at “GitHub API”.
GitHub API (a starting point)
- Go to Download Avatar
Download Avatar
- Go to Sharp Available?
- Go to Build Failure, when Network Error (drawn as a dashed line)
- Reached from GitHub API
Decision: Sharp Available?
- Go to Sharp Processing, when Yes
- Go to Copy Original, when No
- Reached from Download Avatar
Sharp Processing
- Go to Generate WebP
- Go to Generate JPG
- Go to Build Failure, when Sharp Error (drawn as a dashed line)
- Reached from Sharp Available?, when Yes
Copy Original
- Go to Generate JPG
- Reached from Sharp Available?, when No
Generate WebP
- Go to Content Hash
- Reached from Sharp Processing
Generate JPG
- Go to Content Hash
- Reached from Sharp Processing
- Reached from Copy Original
Content Hash
- Go to Versioned Files avatar-{hash}.webp avatar-{hash}.jpg
- Reached from Generate WebP
- Reached from Generate JPG
Versioned Files avatar-{hash}.webp avatar-{hash}.jpg
- Go to Update Version File lib/version.js
- Reached from Content Hash
Update Version File lib/version.js
- Go to Next.js Build
- Reached from Versioned Files avatar-{hash}.webp avatar-{hash}.jpg
- Reached from Generate Fallback Hash
Next.js Build
- Go to Preload Resources
- Go to Picture Elements
- Reached from Update Version File lib/version.js
Preload Resources
- Nothing leads out of this step.
- Reached from Next.js Build
Picture Elements
- Nothing leads out of this step.
- Reached from Next.js Build
Build Failure
- Go to Use Existing Files
- Reached from Download Avatar, when Network Error (drawn as a dashed line)
- Reached from Sharp Processing, when Sharp Error (drawn as a dashed line)
Use Existing Files
- Go to Generate Fallback Hash
- Reached from Build Failure
Generate Fallback Hash
- Go to Update Version File lib/version.js
- Reached from Use Existing Files
How it works
- Source of truth: I update my avatar through GitHub's UI, which already has a good circular crop.
- Trigger: a push to main kicks off a Vercel build.
- Fetch: a small Node.js script downloads the current GitHub avatar over HTTPS.
- Optimise: another Node.js script uses Sharp to resize, convert to WebP, and strip excess metadata.
- Cache-aware deploy: the build computes a content hash. If the image bytes didn't change, the filename stays the same and caches keep hitting. If they did change, the filename changes with them, so Cloudflare, Vercel, and browsers all serve the new file right away.
- Publish: the optimised asset is written to the site's assets folder and deployed.
Credit: the content-hashing approach came from Claude Sonnet 3.7 and has been rock-solid.
The code: GitHub API integration
Here's the core function that fetches the avatar from GitHub's API:
async function fetchGitHubAvatar() {
try {
console.log(`📡 Fetching GitHub user data for: ${GITHUB_USERNAME}`);
// Fetch user data from GitHub API
const userData = await new Promise((resolve, reject) => {
const options = {
hostname: "api.github.com",
path: `/users/${GITHUB_USERNAME}`,
headers: {
"User-Agent": "ainsworth.dev-avatar-fetcher",
},
};
https
.get(options, (res) => {
if (res.statusCode !== 200) {
reject(new Error(`GitHub API returned ${res.statusCode}`));
return;
}
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Error("Invalid JSON response"));
}
});
})
.on("error", reject);
});
if (!userData.avatar_url) {
throw new Error("No avatar_url in response");
}
const avatarUrl = `${userData.avatar_url}&s=${AVATAR_SIZE}`;
console.log(`📥 Downloading from: ${avatarUrl}`);
// Download to temp file
await downloadImage(avatarUrl, TEMP_PATH);
// ... rest of processing
} catch (error) {
console.log("ℹ️ Avatar fetch failed, using existing files");
console.log(` Reason: ${error.message}`);
}
}
The useful bit is GitHub's public user API (/users/{username}), which needs no authentication and returns the current avatar_url. Appending &s={size} to that URL asks for the exact dimensions we need.
For more details, see the GitHub REST API documentation.
Why Node, not Bash?
This pipeline started life in shell scripts and moved to Node fairly quickly. HTTP fetching with retries and sane error handling is simpler there, Sharp does consistent image processing without any external system dependencies, and the behaviour stays predictable across local dev, CI, and Vercel.
Lessons learned
- Pick one source of truth. Don't crop and convert in two places when GitHub's avatar UI can own that job.
- Hash the bytes, not the idea. A human saying "this looks the same" isn't good enough, and content hashing makes the cache behaviour deterministic.
- Automate the boring parts. Resizing and converting images is script work, and it frees up the time you'd rather spend on design and writing.
Related reading
- AI coding tools: when they started earning their keep, on how I decide what work to hand to automation.
- Seasonal avatar borders, another small automation that keeps the site feeling alive.
Most of the developer ergonomics wins I get come from deleting micro-chores I'd stopped noticing. GitHub holds the master copy, Vercel and Node do the rest, and I never open an image editor for it again.