Skip to content

Automating my GitHub avatar sync

August 6, 2025 (1y ago)

524 views

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”.

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.

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.

Written by Sam Ainsworth.

my face
© Sam Ainsworth 2024 - 2026. All Rights Reserved.privacy