syntaxai/tdd.md · main · content / blog / sama-meets-git-cms.md

sama-meets-git-cms.md 149 lines · 6536 bytes raw · source

SAMA meets git: building a self-hosted CMS that obeys the discipline

The simplest CMS test is meta: edit the page that describes the CMS, through the CMS, and watch it commit itself to git. That's how this post landed in the repo.

The setup

tdd.md is a Bun-native site, ~40 source files following the SAMA file-naming convention (Sorted, Architecture, Modeled, Atomic). Pages live as markdown in content/<section>/<slug>.md. Until last week the only way to edit one was vim content/blog/some-post.md && git commit && ./scripts/p620/deploy-tdd-md.sh. No web editor.

We had a goal: edit pages from a browser, but never bypass git. The git history is the audit log. Anything that changes a page must become a commit a reviewer can git blame.

First build — a SQLite proposal queue

The obvious shape: GitHub OAuth for identity, a textarea, save submissions to a SQLite proposals tabel as pending. The admin reviews at /admin/proposals, downloads the .md patch, applies it on dev, commits, deploys.

This worked but felt wrong. The audit trail lived in two places: SQLite knew who submitted what, git knew what actually shipped. The manual download-patch-and-commit step was a brittle bridge between them. And the SQLite proposals tabel was a duplicate of git's job — versioning page bytes — done worse.

The realisation — Forgejo can be the canonical writer

tdd.md already runs a Forgejo mirror at git.tdd.md, used for agent kata repos. Forgejo has a contents API: PUT /api/v1/repos/{owner}/{repo}/contents/{path} creates a real commit from an HTTP call, no git binary or SSH keys required on the caller.

So: what if the editor commits directly to Forgejo?

admin POST /edit/sama/skill
  ↓
fetch PUT https://git.tdd.md/api/v1/repos/syntaxai/tdd.md/contents/content/sama/skill.md
  body: { branch: "main", content: base64(body), message, sha?: previousSha }
  Authorization: token $FORGEJO_ADMIN_TOKEN
  ↓
real commit lands in syntaxai/tdd.md@main
  ↓
Bun.write to ./content/sama/skill.md so the live page reflects it
  ↓
"applied live · commit a93f01f"

No SQLite. No proposal queue. No download-patch step. The Forgejo commit IS the audit trail.

Stripping the dead layers

Once Forgejo became the writer, the SQLite proposals tabel was load-bearing for nothing. Out it went, along with c31_proposals.ts, c21_handlers_admin.ts, six render functions in c51_render_edit.ts, five route entries in c21_app.ts, and one test file. The bundled JS size dropped 12 KB. SAMA didn't shrink — it tightened. Each remaining file does less, but more honestly.

SAMA-tension log — what the build surfaced

Building this turned out to be a pretty good stress test of the discipline. Eight tensions, written down as we hit them (cms-build-log.md in the repo). The two that warrant SAMA refinements:

1. Modeled exemption for I/O-only c14 files. c14_forgejo.ts is an HTTP wrapper. SAMA's "tests live next to source" rule wants c14_forgejo.test.ts. But unit-testing an HTTP wrapper either needs a live server (turns the suite into integration) or mock.module (tests the mock, not the wire). We split: the pure parts (commit-message format, noreply email shape) moved to c31_forgejo_commit.ts with a real sibling test; the I/O wrapper stands alone with integration coverage via Playwright. SAMA needs a sub-rule for c14: pure helpers extract to c31, the wrapper is exempt.

2. Boundary contracts as discriminated unions. A cross-process function shouldn't throw on failure — failure modes are part of the contract. commitFile() returns { ok: true, commitSha } | { ok: false, kind: "conflict" | "auth" | "network" | "not_found" | "other", status, message }. The handler does if (!outcome.ok) and the renderer has one switch per kind. This is an idiom, not a layer rule, but its absence let me reach for try/catch in the handler — wrong layer for "what does conflict mean to the user".

The other six tensions are written up in the build log. Two led to operational doctrines (additive SQLite migrations, source-of-truth diagrams), two were SAMA-internally consistent and needed nothing, and two were external-service quirks that the c14 layer correctly absorbs (Forgejo's POST for create / PUT for update; Cloudflare intercepting 5xx responses).

The deploy pipeline became git-native too

With the CMS committing to Forgejo, the old deploy script (rsync local working tree to p620) was now lying. An admin web-edit would land in Forgejo, then the next deploy would overwrite it with whatever was on dev's filesystem.

The fix is the same insight: Forgejo is the canonical source. The new deploy-tdd-md.sh does:

ssh p620 'cd ~/src/tdd.md && git fetch origin && git reset --hard origin/main'
ssh p620 'podman build ... && systemctl restart tdd-md.service'

Dev pushes to git.tdd.md. Admin web-edits commit to git.tdd.md. Deploy pulls from git.tdd.md. One source, three writers, all going through real git commits. The legacy --rsync flag stays as an emergency escape hatch for deploying uncommitted dev changes.

What SAMA proved

Across this build SAMA's four letters held up — Sorted (no import-direction violations), Architecture (clean layer composition, c14 owns Forgejo's HTTP, c31 owns the pure shaping, c21 composes, c51 renders), Modeled (sibling tests where they fit), Atomic (deletion shrunk the largest files instead of growing new ones).

The gaps SAMA had — failure-contract idiom, c14 testability — became visible specifically because we built something that pushed past single-process pure logic into a real cross-boundary mutation. That's the value: SAMA isn't trying to cover every situation, it's trying to make the gaps it doesn't cover legible.

You can verify this post is real

When syntaxai saves an edit through that editor, a real commit lands in syntaxai/tdd.md within a second. The SHA shows up on the "applied live" page. The next deploy pulls it. The cycle closes without leaving Bun, Forgejo, and four well-named source files.