August 11, 2026
Teaching my portfolio to update itself: GitHub webhooks meet Gemini
The problem
The /work page on this site is a list of case studies, one .mdx file per project, each with a title, a summary, and a write-up. That's fine for the handful of projects I want to feature in depth, but it means every new repo I push to GitHub sits there unlisted until I remember to come back and write it up by hand.
I wanted that to happen automatically: create a public repo, get a case-study entry on the site within a minute or two, no manual step in between. Delete the repo, and the entry disappears with it.
Why not just poll the GitHub API?
The obvious lazy option is a scheduled job (a daily GitHub Action) that lists my repos and diffs them against the .mdx files that already exist. It requires no webhook, no GitHub App, no public endpoint — just a cron job. I considered it seriously.
I ended up going with a real-time webhook instead, for one reason: GitHub Apps are free, and this site already runs on Vercel, which means I already have a public endpoint (this very Next.js app) capable of receiving one. The marginal cost of "real-time" over "once a day" was a single API route, not a new piece of infrastructure — so there was no real tradeoff to make.
How it works
The trigger: a GitHub App, not a personal-account webhook
Personal GitHub accounts don't support an account-wide webhook the way organizations do — there's no setting that says "notify me whenever this user creates a repo." A GitHub App, on the other hand, can be installed on a personal account with access to "all repositories," and it automatically gains access to (and receives events for) every repo created afterwards. Subscribing that App to the repository event covers both created and deleted actions, so one App and one webhook URL handle both directions of syncing.
The App only needs the Metadata: read-only permission — nothing else. It doesn't need write access to my repos to know they exist; the write access happens later, and against a completely different repository (this blog), using a separate personal access token.
The webhook handler
Simplified, the handler is a single POST function with two branches — one per action the repository event can carry:
export async function POST(req: NextRequest) {
const rawBody = await req.text();
if (!verifySignature(rawBody, req.headers.get("x-hub-signature-256"), secret)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const payload = JSON.parse(rawBody);
const repo = payload.repository;
if (payload.action === "deleted") {
await deleteProjectFile(slugify(repo.name));
return NextResponse.json({ deleted: repo.name });
}
if (payload.action !== "created" || repo?.private) {
return NextResponse.json({ ignored: true });
}
const slug = slugify(repo.name);
if (await projectFileSha(slug)) {
return NextResponse.json({ exists: slug }); // already handled, see below
}
const readme = await fetchReadme(repo.full_name);
const { summary, body } = await generateSummaryAndBody({ ...repo, readme });
await commitProjectFile(slug, buildMdx(repo, summary, body));
return NextResponse.json({ created: slug });
}
Everything else — verifySignature, fetchReadme, generateSummaryAndBody, commitProjectFile, deleteProjectFile — is a small, single-purpose function behind that. None of it needs a framework: crypto and fetch from the Node standard library cover the whole thing.
Verifying the webhook is real
Anyone who finds the endpoint URL can send it a POST request, so the handler's first job is proving the request actually came from GitHub. GitHub signs every delivery with an HMAC-SHA256 digest of the raw request body, using the webhook secret configured on the App, and sends it as the X-Hub-Signature-256 header:
const expected = `sha256=${crypto.createHmac("sha256", secret).update(rawBody).digest("hex")}`;
return a.length === b.length && crypto.timingSafeEqual(a, b);
Comparing with crypto.timingSafeEqual instead of === matters here: a regular string comparison can leak information through response timing, since it exits as soon as it finds a mismatched byte. timingSafeEqual avoids that by keeping the comparison time independent of where — or whether — the strings diverge.
Only public repos get published
The webhook fires for private repos too — the GitHub App has access to all of them once installed. But this is a public website, so the handler explicitly checks repository.private and ignores anything private before doing any work. No token used anywhere in this flow has access to private repo contents; the only thing read from a new repo is its public README, over the unauthenticated GitHub API.
Webhooks are at-least-once, not exactly-once
GitHub doesn't guarantee a webhook is delivered exactly once — a slow response, a network blip, or a manual redelivery from the "Recent Deliveries" tab can all send the same created event to the handler twice. Left unhandled, that would mean two commits fighting over the same file, or the second attempt failing outright (GitHub's Contents API requires the current file's sha to overwrite an existing file, and the second delivery wouldn't have it).
The fix is to make creation idempotent rather than to prevent redelivery, which isn't something the handler controls anyway: before generating anything, it checks whether src/app/work/projects/{slug}.mdx already exists. If it does, the request is treated as a no-op — no wasted README fetch, no wasted Gemini call, no duplicate commit.
Failure modes
The happy path touches four external services in sequence — GitHub's REST API, Gemini, GitHub's Contents API, and then Vercel's own build pipeline — so it's worth being explicit about what happens when one of them doesn't cooperate:
- Gemini errors or times out. The handler throws, the function returns a 500, and no commit happens. Nothing partial gets published — either the whole entry lands, or none of it does.
- The repo has no README.
fetchReadmetreats a 404 as an empty string rather than an error, so the entry still gets created, just from a thinner prompt (name, description, language only). - A failed delivery isn't automatically retried. GitHub does not keep redelivering a webhook that returned a non-2xx status — a failed run just means no entry, until someone notices and hits "Redeliver" from the App's dashboard. For a personal portfolio that's an acceptable manual fallback; a production system built on this pattern would want to page someone or queue a retry itself.
- The unauthenticated README fetch is rate-limited to 60 requests/hour per IP by GitHub. At the volume of "a repo every so often," that ceiling is never in practical reach.
- Vercel's own function timeout is the outer bound on the whole chain — comfortably long enough for a README fetch, one Gemini call, and one commit, which together finish in well under a second in practice.
Debugging tools that made this tractable
Two dashboards did all the diagnostic work:
- The GitHub App's "Advanced → Recent Deliveries" tab shows every webhook attempt, its headers, its payload, and lets you replay any of them on demand — invaluable for testing without repeatedly creating and deleting real repos.
- Vercel's function logs show each invocation's duration and any outgoing HTTP calls it made. A 500 with a 60ms duration and no outgoing calls means the handler failed before reaching any external API; a few hundred milliseconds with calls to
generativelanguage.googleapis.commeans it got much further, and the error came from there.
Between the two, every failure in this post was diagnosed from a log line and a stack trace, without adding a single line of custom logging.
The pattern generalizes well beyond a portfolio
Strip away the "publish a case study" part and what's left is a reusable shape: GitHub event → webhook → read some context → ask an LLM to produce something → commit it back. That shape shows up in plenty of places once you're looking for it:
- Auto-documentation on push. A webhook on
push(instead ofrepository) can diff the changed files, ask an LLM to update the relevant section of aREADME.mdor an internal wiki page, and commit the result — keeping docs from silently drifting away from the code they describe. - Changelog and release notes. On a new tag or release, summarize the commits or merged PRs since the last one into human-readable release notes, instead of a bare commit list.
- PR description drafting. On
pull_request: opened, generate a first-pass description from the diff, so the author edits instead of writing from a blank box. - Dependency update summaries. When a bot like Dependabot opens a PR, fetch the changelog of the bumped package and post a plain-English summary of what actually changed as a comment.
- Onboarding docs that track reality. Regenerate an "architecture overview" doc whenever the set of services or top-level directories changes, so new hires aren't reading a diagram from two reorgs ago.
The pieces are the same every time: an event worth reacting to, read-only access to just enough context to describe it, and a commit as the delivery mechanism — which means the output lands wherever the rest of the team already looks (the repo itself), with no new dashboard or tool to check.
What I learned
- Webhooks are cheap once you already have an HTTP endpoint. The entire "real-time" upgrade over a daily cron job was one API route — the cost people associate with webhooks is really the cost of standing up somewhere for them to land, and this site already had that.
- The hard part isn't calling the LLM — it's making the event pipeline reliable. Signature verification, filtering to public repos, and idempotency are all more lines of code than the actual Gemini call. That's not a coincidence: correctness under retries and untrusted input is where webhook handlers actually earn their keep.
- LLMs are far more reliable when the input is already structured and bounded. The prompt here isn't "write me a portfolio project" — it's "here is a repo name, a description, a language, and a README; produce a summary and a few sections from exactly that." Giving the model a fixed, small set of real inputs instead of an open-ended request is most of why the output is usable without editing.
What's still manual
Creating a private repo, or renaming/transferring one, doesn't currently produce or update an entry — only created (public) and deleted are handled. And the generated write-ups are exactly as good as the repo's README; a repo with no README gets a thin case study built from just its name, description, and language. Both are acceptable trade-offs for now: the goal was to remove the "I forgot to write this up" failure mode, not to replace judgment about which projects deserve a longer, hand-written story.
More posts
What actually happens between tapping a card and the terminal saying "Approved"
The two-to-three second round trip of a card payment, traced hop by hop: terminal, acquirer, card network, issuer, and back — plus why authorization and settlement are two completely different processes.
Branching strategy: main, pre, and dev
A practical branching model for teams that need a real pre-production gate: main, pre, and short-lived feature branches, with naming conventions and a two-step PR flow.
Migrating a REST service safely: reusing E2E tests to verify external side effects
How to migrate a REST service to a new stack without a regression, by writing the end-to-end test suite once and reusing it both to lock in behavior during the migration and to verify the external services it talks to afterward.
Running old and new in parallel: coexistence during a partial migration
A migration is rarely a single cutover. Here's how to let a partially migrated system and the legacy one it's replacing serve traffic at the same time, safely, until the cutover is actually done.