CMS Chronicle #05: GitHub Adapter GitHub as a Database
GitHub is not just a git host. Every file in a repo is addressable, readable, and writable via REST API — with full history, branching, and access control built in for free. This is the GitOps CMS model: content as code, zero infrastructure.
GitHub is not a git host. That's the reframe that makes the GitHub adapter make sense. For our purposes, GitHub is a database — one with a REST API that lets you read and write files, with full history, branching, and access control built in. For free. This is not a hack or a creative misuse. It is a first-class use of the platform, and it has a name: GitOps.
“Content as code. Every post, every page, every CMS document — a file in a git repository. Versioned, diffable, PR-reviewable.”
The insight
Every file in a GitHub repository has a URL. That URL is readable and writable via the GitHub Contents API. You don't need a server. You don't need a database. You don't need any infrastructure beyond a personal access token.
The content lives in git — which means it inherits everything git gives you: full history, per-line blame, branching, merging, pull requests. An editor can open a file in the GitHub UI, make a change, and submit a pull request. A developer reviews it like code. It merges. The site rebuilds. That's the GitOps CMS workflow, and it's what this adapter enables.
How it works mechanically
The GitHub Contents API has three operations we care about:
GET a file. Returns the file's content encoded as base64, plus a SHA fingerprint. The SHA is not the commit hash — it's the blob hash, the SHA of the file object in git's object store.
PUT a file. Creates or updates. Here's the important part: to update an existing file, you must send back the SHA you received when you read it. GitHub will reject the write if the SHA doesn't match the current file. This is optimistic concurrency — the same mechanism that prevents two people from overwriting each other's changes.
DELETE a file. Same SHA requirement. You must prove you've read the current version before you can delete it.
List a directory. GET the path of a directory and GitHub returns an array of file objects — names, types, and SHAs for everything in that path.
// GET: fetch a file and its SHA
GET /repos/{owner}/{repo}/contents/{path}?ref=main
// → { content: "base64...", sha: "abc123", ... }
// PUT: create or update (sha required for updates)
PUT /repos/{owner}/{repo}/contents/{path}
// body: { message, content: "base64...", branch, sha? }
// DELETE: requires sha
DELETE /repos/{owner}/{repo}/contents/{path}
// body: { message, sha, branch }
The SHA cache
This is the trickiest implementation detail, and it's worth understanding.
Every write needs the current SHA. If we fetched the SHA fresh from GitHub before every write, we'd double our API call count. Instead, the adapter maintains an in-memory Map<string, string> — a cache from file path to SHA.
Every getFile call populates the cache. Every successful putFile updates it with the SHA of the newly written blob (returned in the API response). The cache warms naturally as you use the adapter.
“Cold start is fine — the first read of any file costs one API call. After that, writes are free of extra round-trips.”
If two processes write the same file concurrently, the second write will get a 409 conflict from GitHub — the SHA it cached is now stale. The adapter surfaces this as an explicit error: GitHub: SHA conflict writing {path} — please retry. This is the correct behaviour. It's the same guarantee a database gives you with optimistic locking.
findMany — the honest trade-off
findMany is where we have to be honest about what this adapter is and isn't.
The implementation lists the directory to get the file names, then fetches each file individually. For a collection with 50 posts, that's 51 API calls — one listDir plus one getFile per document. O(n) requests.
GitHub's rate limit for authenticated requests is 5,000 per hour. For a build pipeline that runs once per deploy, 51 requests is negligible. For a runtime that serves requests live, it's a problem.
“The GitHub adapter is a build-time tool. cms build fetches everything once. Not for serving live traffic.”
This is a deliberate scope decision. The adapter is designed for the GitOps pipeline: content lives in git, the build reads it all at build time, and the output is a static site. The SHA cache means that a cms build run — which reads every document once and then generates HTML — touches each file exactly once. There's no redundant fetching.
What this enables
With the GitHub adapter in place, the full GitOps workflow is available:
- Edit content in the GitHub UI, the CMS admin, or any editor that can commit to a repo.
- The commit lands on
main. A GitHub Action triggers.cms buildruns. The static site deploys. - No database. No server. No ops. Infrastructure cost: a personal access token.
Content review via pull requests is a real workflow. An editor proposes a post. A developer (or another editor) reviews the diff. The markdown renders in the PR preview. It merges when it's ready. Rollback is git revert. History is git log. Blame is git blame. All the git primitives apply to your content, because your content is git objects.
One line to switch
The entire point of the adapter pattern is that the rest of the codebase is unaffected. Switching from filesystem to GitHub is a one-line change in cms.config.ts:
import { GitHubStorageAdapter } from '@webhouse/cms/storage/github';
export default defineCmsConfig({
storage: new GitHubStorageAdapter({
owner: 'webhouse',
repo: 'webhouse-site',
branch: 'main',
contentDir: 'content',
token: process.env.GITHUB_TOKEN!,
}),
collections: [ /* unchanged */ ],
});
The StorageAdapter interface is the contract. GitHubStorageAdapter honours it. The schema engine, the content service, the build pipeline, the AI agents — none of them know or care which adapter is underneath.
Where it fits in the stack
Three adapters. Three different problems.
- Filesystem — local development. Files on disk, instant feedback, no network.
- SQLite — admin UI writes. A running server, concurrent access, a real database on the local machine or a Fly.io volume.
- GitHub — GitOps pipeline. Content as git objects, zero infra, full history. The deploy trigger is a commit.
Supabase is next: multi-user, real-time, cloud-hosted, the foundation for a proper SaaS CMS. But the GitHub adapter is the most architecturally interesting rung on the ladder — the one that turns a version control platform into a content store, with optimistic concurrency and a warming cache, and not a single server to maintain.