The SSN Coding Club portal has no database. Posts, roles, portal routes and images live in one Git repository, writers publish through pull requests they never have to open, and a single GitHub Actions workflow decides what gets validated and which of ten Vercel projects gets redeployed. This post takes the system apart one requirement at a time: how Git stands in for a database, how a draft becomes a pull request, how the server enforces who may approve what, and how one workflow ships only the apps that changed.
Here is the single most important request, the life of one article. Step through it before reading on; the rest of the post is the detail behind each step.
Understand the problem#
The club has eight technical verticals, a few hundred members who turn over every year, and no budget for a hosted CMS or someone to run a database. It wants a blog and a portal per vertical, writing that is reviewed by the right people, and a site that does not need an operator. The repo is blackscythe123/CC-Website-2026, a pnpm monorepo of Next.js apps, and the main portal is live at cc-main-portal.vercel.app.
Functional requirements#
- A member writes an article in a rich editor, with images, without losing work if the tab closes.
- Submitting creates a reviewable unit, and nothing is published until it is approved.
- Reviewers see a queue, read the article and approve or request changes. Who may approve depends on who wrote it.
- Approved articles appear on the right portals, and only the apps that need a rebuild are rebuilt.
- An admin can change roles, and the change takes effect on the next decision.
Non-functional requirements#
- Zero database. Nothing to host, back up or pay for. Git is the source of truth.
- Auditable. Every article records who wrote it and who approved it, in history.
- Fails closed. If the server cannot prove a request is allowed, it refuses.
- Cheap to deploy. One push must not create ten Vercel deployments.
- Survivable. Roles are data, not code, so a new committee changes a file, not a deploy.
Requirements: write | submit | review | publish | roles Constraint: no database, Git is the source of truth Actors: writer, reviewer, admin Nothing designed yet.
The set-up#
Core entities#
- Post: a Markdown file with frontmatter, in
content/verticals/<slug>/posts/orcontent/club/posts/. - Draft: a branch named
draft/<vertical>/<slug>-<n>plus the pull request that carries it. - Image: a file under
apps/main-portal/public/uploads/, committed to the draft branch. - Role: an entry in
content/club/roles.json: superadmin, vertical head, deputy or member. - Review: a pull request review pinned to a commit, summarised later in the squash commit's trailers.
- Portal: an entry in
content/club/portals.jsonthat maps an app to a vertical.
content/club/portals.json and to a folder under content/verticals/.The interface#
The Studio is a Next.js app and its API is a handful of route handlers. There is no REST resource for a post, because a post is not a row; the routes are verbs over GitHub.
GET /api/auth, /api/callback GitHub OAuth login
GET /api/auth/me who am I, with roles from main
POST /api/posts/draft submit: branch + commit + pull request
GET /api/posts/drafts/remote list my cloud drafts (drafts/<login>/*)
POST /api/posts/drafts/remote save a cloud draft, no PR
GET /api/posts/reviews the review queue, filtered by role
POST /api/posts/reviews approve or request changes (pullNumber)
GET /api/posts/manage list published posts
DELETE /api/posts/manage remove a published post
GET /api/roles, POST /api/roles read and write roles.json
GET/POST/PATCH /api/roles/requests role applicationsThe data model#
The model is three JSON or Markdown schemas and one GitHub object. A post's frontmatter is validated by zod in CI; roles.json and portals.json are read by the apps at request or build time.
scripts/validate-content.mjs is the migration check: a post that does not match it fails validation before a person reviews it. The pull request is the one record that GitHub keeps for us.apps/ and ten of them deploy on their own: the main portal, the Studio and eight vertical portals. The other two are the vertical template and a shared directory.Build it one requirement at a time#
Requirement 1: write without losing work#
A Medium-style canvas with a split-screen preview needs to survive a closed tab and a power cut, and it cannot use a server table to do it. The answer is two layers. While typing, the draft is autosaved to the browser's IndexedDB, in a database called CodingClubCMS, under a key that includes the lower-cased GitHub login so two people sharing a laptop do not read each other's draft. When the writer wants the draft on another machine, Sync posts it to a route that pushes a branch named drafts/<login>/<slug>, with no pull request. Pending edits are flushed when the page is hidden or unloaded. Images stay in the browser, as base64 behind a blob: placeholder, until Submit.
- Each keystroke updates the editor state; a two-second debounced autosave writes it to IndexedDB under
active_draft:<login>. - Sync sends the markdown, metadata and pending images to the
drafts/remoteroute with the writer's session token. - The route creates or updates the personal branch off
main. It is not a pull request, so no one is asked to review it.
Requirement 2: submit as a pull request#
Submit must create something reviewable and keep main clean. The posts/draft route verifies the session, checks that the user has push permission on the repository, checks the role and vertical, and then calls createDraftAndPullRequest. That function reads the head of main, creates draft/<vertical>/<slug>-<n>, commits each image and then the post to that branch, and opens a pull request into main with an overview and a governance checklist as its body.
- The Submit drawer collects the vertical, summary and tags and posts the draft to the route.
- The route cuts the draft branch from the current head of
mainand commits images then the post, authored as the writer. - It opens the pull request
feat(<vertical>): <title>intomain. - The workflow's first job runs on the pull request and reports validation and build status.
draft/<vertical>/<slug> branch and opens the pull request for the writer: here it is #28.Requirement 3: approval enforced by the server#
Reviewers work in the Studio's Reviews page, which lists open pull requests and shows who still has to approve. The browser never decides anything. The posts/reviews route takes a pull request number and an action; it derives the rest, as the next section shows. When the quorum is met it records a review pinned to the head commit, squash-merges that commit and deletes the draft branch.
- The Reviews page lists the queue; the writer sees their own pull requests, a head or deputy sees their verticals, a superadmin sees all.
- On approve, the route reads
roles.jsonfrommainwith no caching, so a removed reviewer loses authority at once. - It fetches the pull request, files and commits from GitHub, checks the quorum and merges the reviewed commit.
- The merge becomes one squash commit on
mainwith two trailers, and the draft branch is removed.
main requires one approving review; the stricter quorum is enforced by the Studio.Requirement 4: publish only what changed#
A push to main starts the workflow. Vercel's own Git integration is turned off in every app's vercel.json, so the workflow is the only thing that deploys. It validates, plans and then deploys a matrix of changed apps.
- The merge commit lands on
mainand triggerscontent-ci.yml. - Job 1 re-validates content and commit policy and smoke-builds every app.
- Job 2 finds each app's last good deploy and prints the list of apps to ship as JSON.
- Job 3 builds and uploads each listed app in parallel with the Vercel CLI.
- The portals serve the new build.
Deep dives#
Deep dive 1: Git as the database#
| A database gives | Here it comes from |
|---|---|
| Schema | zod schemas in scripts/validate-content.mjs, run in CI |
| History and audit | Git log, squash trailers, PR reviews |
| Access control | Built: roles.json plus server checks. CODEOWNERS is generated from it |
| Isolation of in-progress work | Branches |
| Transactions | Almost nothing. One commit is atomic; two commits are not |
| Queries | Directory layout and the build step. There is no ad-hoc query |
| Backups | Every clone is one |
Commit each file with the Contents API
It is the easiest call: one request per file, each its own commit. For a role change it writes roles.json and then CODEOWNERS, and a failure between the two leaves the file that grants permissions disagreeing with the file that reviews them. It works until the second call fails.
Cache the roles file and invalidate it on write
Reading the file through raw.githubusercontent.com is cheap, but that CDN lags main by several minutes, so a removed deputy could still approve. A short in-process cache fixes cost but is per instance, so another server instance keeps the old copy.
Live read with a five-second cache, atomic write through the Git Data API
Reads go through the Contents API with cache: "no-store", held for at most five seconds; the review route forces a fresh read. Writes build a tree holding both files, create one commit and fast-forward main. If that fails, the code falls back to two sequential writes, which is the one non-atomic path.
roles.json and the regenerated CODEOWNERS, so they cannot drift. The fast-forward-only ref update is the only concurrency control Git offers here.Deep dive 2: the draft branch and pull request publish flow#
Commit with status: draft on main
Every portal build would have to filter on a field, one missed filter publishes a half-written article, and every draft save is a deploy. History fills with in-progress noise.
A pending folder merged by hand
It keeps drafts off the published path, but there is no review object, no per-file checks, and a person with commit rights moves files. It reinvents the pull request with less safety.
One branch per submission, one PR, squash on approval
The branch isolates the article and its images, the PR is the review object, CI runs against it, and the squash merge turns many small commits into one with the trailers. The cost is branches that need deleting, which the merge handler does for any branch starting with draft/.
The branch name is the draft's identity: draft/<vertical>/<slug>-<n>, where n is the last five digits of the millisecond timestamp. The example from the real submission shown earlier is draft/full-stack/your-first-pull-request-a-beginner-s-guide-to-git-30709, which became pull request #28. When a reviewer asks for changes, the writer revises and resubmits with the same pull request number and branch; the route verifies that the branch really belongs to that pull request and that the caller is its author or a superadmin, then adds a commit and posts a comment so reviewers know there is something new.
Deep dive 3: role and approval rules enforced server-side#
The quorum depends on who wrote the article:
| Author is a | Merge needs |
|---|---|
| Member | 2 deputy approvals, or 1 vertical head, or 1 superadmin |
| Deputy | 1 vertical head or 1 superadmin |
| Vertical head | 1 superadmin |
| Superadmin | another superadmin |
Trust the client's claims
A request that carries {pullNumber, vertical, authorRole} is simple, and one forged field approves the wrong thing. Nothing about the browser can be trusted for authorisation.
Let GitHub enforce it with branch protection and CODEOWNERS
This is the native tool and the repo uses it as a second layer. Branch protection on main requires one approving review, and CODEOWNERS requests the vertical's reviewers automatically. But it cannot express "two deputies, or one head" and it does not know the author's role, and code-owner review is not switched on as a requirement. It is a floor, not the rule.
Server re-derives everything from GitHub, then decides
The route accepts a pull request number and nothing else it relies on. The author, files, commits and head commit come from GitHub. The roles come from roles.json on main. Then a pure function maps reviewer role and author role to merge, count deputies or a refusal. Refusals happen before any review is written.
Three details carry most of the weight. First, the vertical is read from the file path of the article inside the pull request, not from the title or the branch name; the queue page guesses it from the title for display, but the approval path does not. Second, a pull request may contain exactly one article file in the vertical under review plus uploads and nothing else, checked by two regular expressions, so a crafted pull request cannot sneak a change to roles.json through an approval meant for an article. Third, self-review is refused by exclusion: the pull request opener, the frontmatter author and every commit author on the branch are all excluded, so adding a commit to someone else's pull request disqualifies you from approving it.
roles.json. Each vertical has one head, its deputies and its members, keyed by GitHub username. Role applications arrive as GitHub issues with the role-application label.Deep dive 4: the single workflow, validate, commit policy, then deploy what changed#
There is one workflow file, .github/workflows/content-ci.yml, named "CI - Content Schema & Monorepo Build". It runs on pushes and pull requests to main, and on manual dispatch with a deploy_all switch that ignores the diff. A concurrency group per workflow and ref cancels superseded runs everywhere except on main. It has three jobs.
Job 1, validate-and-test, runs on every pull request and push. It checks out with full history, sets up Node 24, runs scripts/verify-commit.mjs, installs with pnpm, runs pnpm run test:content and then pnpm run build as a smoke build. The commit check is a policy script that the repo also installs as a local commit-msg hook: a single-line subject under 25 words, only Co-authored-by trailers with real human emails, no AI assistant names, with merge, revert and fixup commits exempt. The CMS's own Authored-by and Approved-by trailers are the only place a users.noreply.github.com address is accepted, and a regular expression pins their exact shape. The content check applies the zod schema, checks that slugs are kebab-case and unique within a vertical, validates portal ids against portals.json, and rejects blob: and data: image URLs.
Job 2, detect-changed-apps, runs only on main. The goal is written in the YAML itself:
# CD: Vercel Git auto-deploys are disabled (apps/*/vercel.json). Production
# deploys happen here, only for apps whose files changed since that app's
# last successful deploy, so one push no longer creates 10 Vercel deployments.find-deploy-bases.mjs reads the last 60 runs of this workflow on main through the Actions API and, for each deployable app, records the head commit of its most recent successful "Deploy <app>" job. detect-changed-apps.mjs then diffs each app against its own base, using rules in scripts/lib/affected-apps.mjs:
| What changed | What deploys |
|---|---|
Docs, scripts, READMEs, .github/ | Nothing |
package.json, lockfile, workspace file, root tsconfig, or anything under packages/ | Every app |
Anything under content/ | The main portal ("posts are the database") |
| A vertical's own directory or its vertical's content | That vertical portal |
| The Studio's own directory | The Studio only; it reads roles live, so content changes never redeploy it |
| An app with no usable base, or a failed history read | Deploy anyway |
Let Vercel build on every push
It is zero configuration, and one push creates a deployment in each of ten projects, most of them identical to the last. It was the first setup, and the reason for the rewrite.
Diff every app against one base per run or push
This is what the first Actions version did, and I replaced it. If one app's deploy fails, the next run diffs from the failed commit, finds nothing new for that app and skips it, so the app stays stale until someone touches it again. If the base is the last fully successful run, one failure makes every live app redeploy.
Diff each app against its own last successful deploy
A failed deploy leaves that app's base where it was, so it is retried by the next run. A partial failure does not force already-live apps to redeploy. When the planner cannot prove an app is unchanged, because the base is missing or the history read failed, it deploys, so the failure mode is a redundant deploy rather than a missed one.
cp-portal, across five example commits. The real base is the head commit of that app's latest successful deploy job.Job 3, deploy, is a matrix over the JSON list from job 2, with fail-fast: false so one broken app does not stop the others, and a per-app concurrency group that never cancels an in-flight deploy:
strategy:
fail-fast: false
matrix:
app: ${{ fromJSON(needs.detect-changed-apps.outputs.apps) }}
concurrency:
group: vercel-deploy-${{ matrix.app }}
cancel-in-progress: falseEach leg installs the Vercel CLI, links the project cc-<app>, pulls production settings, runs vercel build --prod on the Actions runner and uploads the result with vercel deploy --prebuilt --prod. Building in Actions and uploading the prebuilt output makes the CI run the single place where a deploy happens and fails. The token is a repository secret, VERCEL_TOKEN.
Deep dive 5: image handling#
Commit images straight to main on paste
An unreviewed file becomes public, and a writer who abandons the article leaves an orphan.
Upload to an external object store
It would work, but it adds a service, a credential and a second place to look. It breaks the zero-database rule in spirit, and the image would not travel with the pull request.
Hold in the browser, commit to the draft branch, validate twice
The bytes stay in IndexedDB and in a blob: URL for the preview. On Submit, the Studio validates each image, commits it to the draft branch only, and rewrites the placeholder to a permanent /uploads/... path. If that rewrite is skipped for any reason, CI refuses the pull request.
Accepted images are renamed to <timestamp>-<sanitised-base>.<ext> with everything outside a-z0-9_- replaced, which closes off path tricks, and committed with a chore(media): upload ... message. The merge handler later accepts only files matching apps/main-portal/public/uploads/[A-Za-z0-9._-]+ as uploads in a pull request, so an image path cannot be used to smuggle a file into another directory.
Deep dive 6: concurrency and conflicts#
Most of the conflicts are designed away by layout. Each submission adds one new file at its own path, and uploaded images have unique timestamped names, so two pull requests rarely touch the same file and merge cleanly in any order. When two writers pick the same slug in the same vertical, the second pull request adds a file that already exists on main and GitHub refuses to merge it; the failure message returns to the reviewer. The remaining cases are about ordering.
Count any approvals on the pull request
An author can collect two deputy approvals on a harmless version, push a different one and merge. Approval would attach to a pull request, not to the content that was read.
Dismiss stale reviews with branch protection
GitHub can do this, but it is a repository setting, and in this repository dismiss_stale_reviews is off. Relying on a setting that someone can toggle is the wrong place for a security rule.
Pin each approval to a commit and merge that commit
The review is created with commit_id set to the head sha, the deputy count only counts approvals whose commit equals the current head, and the merge sends the same sha. If the author pushes, old approvals stop counting and GitHub rejects a merge of anything but the reviewed commit.
The workflow side follows the same logic. The top-level concurrency group cancels superseded runs on a pull request but never on main, so a merge is not abandoned by a later push. Each deploy leg has its own group, vercel-deploy-<app>, with cancellation off, so two runs cannot deploy the same app at once and an in-flight deploy is never killed halfway. GitHub keeps one running and one pending job per group and replaces an older pending job with a newer one. That is acceptable here because every run checks out its own commit and diffs against the app's last good deploy, so the newest pending run covers what a replaced one would have shipped.
The final design#
A single request, then a single rebuild:
- The writer submits. The draft route checks session, repo permission, role and vertical.
- Each image is validated by type, size and magic bytes, and committed to the draft branch.
- The post and its images sit on
draft/<vertical>/<slug>-<n>and a pull request is opened; job 1 runs on it. - A reviewer approves. The review route reads live roles, the pull request and its commits from GitHub.
- The file check confirms one article in the right vertical, and the authority check returns merge, count deputies or a refusal.
- The squash merge of the reviewed sha writes the trailers and deletes the draft branch.
- The push to
mainruns the workflow, which diffs each app against its own last good deploy. - One Vercel deploy leg runs per changed app. The rest are skipped.
What I would change#
- Make validation a required check. Branch protection requires one approving review and no status checks, and does not require code-owner review. The quorum lives in the Studio, so a collaborator who merges on github.com can bypass it. I would require
validate-and-testand code-owner review, and turn on stale-review dismissal. - Freeze the install. The workflow installs with
--frozen-lockfile=false, so CI can resolve a different tree than the lockfile. A frozen install makes the build reproducible. - Tell the writer about a rejected image. Fail the submit with a message instead of skipping the file.
- Detect stale edits. Compare file hashes on revision, and serialise role writes so the fallback cannot overwrite an edit.
- Check every commit, not just the head. The commit-policy step inspects the latest commit message; on a branch with several commits only the last one is read.
- Staff the other seven verticals. Only full-stack has a head, deputies and members in
roles.json, and the per-vertical subdomains are still to do. The seed posthow-this-cms-works.mdalso still describes the old Decap flow.
Test yourself#
A member's article is approved by one deputy. What happens?
- It merges, because a deputy is a reviewer.
- The approval is recorded and counted as 1 of 2; the pull request stays open until a second deputy, a head or a superadmin approves.
- It merges after 24 hours unless someone objects.
- The Studio asks the author to approve it too.
Why does the review route read roles.json through the Contents API with no caching rather than from the raw CDN?
- The CDN does not serve JSON.
- The Contents API is faster.
- The raw CDN can lag main by minutes, so a removed reviewer could still approve.
- It saves a GitHub token.
The author pushes a revision after a deputy approved. What happens to that approval?
- It stops counting, because the count only includes approvals on the current head commit.
- It counts, because the deputy is still a deputy.
- It is deleted by GitHub.
- It counts for half.
A push changes only content/verticals/full-stack/posts/new.md. Which apps deploy?
- All ten.
- Only the Studio.
- Only the full-stack portal.
- The main portal and the full-stack portal.
content/, and a vertical portal deploys on its own vertical's content. The Studio reads roles live and is not affected.Why does each app have its own deploy base instead of one base per push?
- To make the workflow run faster.
- So an app whose deploy failed is retried, and a partially failed run does not force live apps to redeploy.
- Because Vercel requires it.
- To avoid the Actions API.
The image check fails and the Studio skips the image. What stops a blob: URL reaching a reader?
- Nothing; the portal rewrites it.
- The reviewer must notice it.
- The content validator in the workflow rejects
blob:anddata:URLs in cover images and body text. - Vercel blocks the deploy.