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.
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 naive string comparison exits as soon as it finds a mismatched byte, which leaks (via response timing) how many leading bytes were correct — enough for an attacker to forge a valid signature one byte at a time. timingSafeEqual always takes the same time regardless of where 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.
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'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.