Blog: SAMA meets git, plus the build log it came out of
The post is the meta-demonstration of the previous commit: it tells the story of building a self-hosted CMS that commits to real git without ever touching the Forgejo HTTP API or shipping a .git working tree into the container. It was itself committed via the CMS during the build (the e2e suite proves the round-trip). cms-forgejo-plan.md: the up-front plan document with the failure matrix (Forgejo-first / FS-second, conflict / auth / network / not_found / other) that drove the implementation. cms-build-log.md: ten SAMA tensions encountered during the build, each with the workaround we picked and a candidate refinement to SAMA itself. Two led to concrete proposals (Modeled exemption for I/O-only c14 files; boundary-contract discriminated unions). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
3 files changed · +517 −0
cms-build-log.md
+256
−0
| @@ -0,0 +1,256 @@ | ||
| 1 | +# CMS ↔ Forgejo build log — SAMA tensions encountered | |
| 2 | + | |
| 3 | +A diary of moments where building the self-hosted CMS-with-real-git-commits | |
| 4 | +made the SAMA convention either *help us instantly* or *fail to have an | |
| 5 | +opinion*. Written as we built [c14 commitFile + c21 admin-fork wire-in], | |
| 6 | +not after the fact. | |
| 7 | + | |
| 8 | +## Tension 1 — c14 has no sibling test, by design | |
| 9 | + | |
| 10 | +**Where it bit:** I added `commitFile()` and `getFileSha()` to | |
| 11 | +[c14_forgejo.ts](src/c14_forgejo.ts). Those are mutations against an | |
| 12 | +external service. SAMA's Modeled rule says *"every behaviour has a | |
| 13 | +test file as its sibling"*. But `c14_forgejo.test.ts` would either | |
| 14 | +need a real Forgejo (turns the unit suite into an integration suite) | |
| 15 | +or a mocked `fetch` global (couples the test to implementation | |
| 16 | +details, not behaviour). | |
| 17 | + | |
| 18 | +**What I did:** Split the work. The pure parts — message format, | |
| 19 | +noreply email shape — moved into [c31_forgejo_commit.ts](src/c31_forgejo_commit.ts) | |
| 20 | +with a real sibling test [c31_forgejo_commit.test.ts](src/c31_forgejo_commit.test.ts). | |
| 21 | +The c14 wrapper itself stays untested at unit level. | |
| 22 | + | |
| 23 | +**Candidate SAMA refinement:** Modeled needs a sub-rule for c14: | |
| 24 | +> A c14_*.ts file is exempt from the sibling-test rule when its | |
| 25 | +> entire body is HTTP request construction + response parsing. Its | |
| 26 | +> coverage comes from (a) c31 sibling tests for any pure helpers, | |
| 27 | +> and (b) integration tests separately marked (`*.contract.test.ts` | |
| 28 | +> or e2e Playwright). Without this carve-out, the rule pushes I/O | |
| 29 | +> into mocks, which test the mock more than the I/O. | |
| 30 | + | |
| 31 | +## Tension 2 — failure semantics for cross-boundary mutations | |
| 32 | + | |
| 33 | +**Where it bit:** Admin POST does two writes — Forgejo commit AND | |
| 34 | +local filesystem. SAMA prescribes *which layer* (c14 for HTTP, c21 | |
| 35 | +for compositions, c13 for the SQLite proposal row), but is silent | |
| 36 | +on **what order** and **how to roll back when one succeeds and the | |
| 37 | +next fails**. There's no SAMA letter for "transactional intent". | |
| 38 | + | |
| 39 | +**What I did:** Added a per-failure-mode discriminated union | |
| 40 | +([c14_forgejo.ts ForgejoCommitOutcome](src/c14_forgejo.ts)) so the | |
| 41 | +handler can render a useful error per kind: `conflict | not_found | | |
| 42 | +auth | network | other`. Chose Forgejo-first / FS-second deliberately | |
| 43 | +— Forgejo's contents API is the synchronous source of truth, FS is | |
| 44 | +a derived view. Documented the matrix in | |
| 45 | +[cms-forgejo-plan.md](cms-forgejo-plan.md). | |
| 46 | + | |
| 47 | +**Candidate SAMA refinement:** Add a doctrine — not a layer letter, | |
| 48 | +but a discipline: | |
| 49 | +> **Boundary contracts.** Any function that crosses a process or | |
| 50 | +> network boundary returns a discriminated-union outcome, never an | |
| 51 | +> exception, and never a bare boolean. Failure kinds are part of | |
| 52 | +> the type, the renderer per kind is one switch in c51. | |
| 53 | + | |
| 54 | +This isn't strictly a SAMA layer rule — it's an idiom. But the | |
| 55 | +absence of it made me reach for try/catch in the handler, which is | |
| 56 | +the wrong layer for "what does conflict mean to the user". | |
| 57 | + | |
| 58 | +## Tension 3 — types-with-data-source vs types-with-pure-logic | |
| 59 | + | |
| 60 | +**Where it bit:** `ForgejoCommitOutcome` lives in c14_forgejo.ts. | |
| 61 | +`CommitMessageInput` lives in c31_forgejo_commit.ts. Both describe | |
| 62 | +"a Forgejo commit" — but one is the I/O surface, the other is the | |
| 63 | +input we shape. The c21 handler imports from both. Felt wrong but | |
| 64 | +also unavoidable. | |
| 65 | + | |
| 66 | +**What I did:** Followed the existing codebase pattern — types | |
| 67 | +travel with the data source (c13_database.ts owns ProposalRow, | |
| 68 | +c14_forgejo.ts owns the response shape, c31 owns the inputs we | |
| 69 | +parse). Resisted the urge to make a c31_forgejo_types.ts barrel. | |
| 70 | + | |
| 71 | +**SAMA verdict:** No refinement needed. The current pattern is | |
| 72 | +internally consistent — types live with whoever first defines them, | |
| 73 | +imports are explicit. A barrel would have been less honest. SAMA's | |
| 74 | +"no barrel re-exports" rule already covers this case if you take | |
| 75 | +its spirit seriously. | |
| 76 | + | |
| 77 | +## Tension 4 — schema migration without an `IF NOT EXISTS` | |
| 78 | + | |
| 79 | +**Where it bit:** Added a `forgejo_commit_sha` column to the | |
| 80 | +existing `proposals` table. SQLite has no | |
| 81 | +`ALTER TABLE … ADD COLUMN IF NOT EXISTS`. The pre-existing pattern | |
| 82 | +in [c13_database.ts](src/c13_database.ts) is `CREATE TABLE IF NOT | |
| 83 | +EXISTS …` at startup — purely additive, no migrations. This is | |
| 84 | +the first time we needed to evolve a table. | |
| 85 | + | |
| 86 | +**What I did:** Read `PRAGMA table_info(proposals)`, check column | |
| 87 | +names, only `ALTER` if the column is missing. Idempotent across | |
| 88 | +container restarts and across rebuilds with old data volumes. | |
| 89 | + | |
| 90 | +**Candidate SAMA refinement:** c13 layer needs a discipline note: | |
| 91 | +> **Migration is additive.** Schema changes go in a `migrations` | |
| 92 | +> block of `getDb()` after the `CREATE TABLE IF NOT EXISTS` block, | |
| 93 | +> guarded by a PRAGMA probe (or `sqlite_master` query). Never | |
| 94 | +> destructive. Never renames. If you need a rename, it's two | |
| 95 | +> migrations: add the new column, deprecate the old one. | |
| 96 | + | |
| 97 | +This is a one-liner addition to the Architecture page (c13's | |
| 98 | +contract). | |
| 99 | + | |
| 100 | +## Tension 5 — the deploy script is now lying | |
| 101 | + | |
| 102 | +**Where it bit:** `deploy-tdd-md.sh` rsyncs the dev working tree to | |
| 103 | +p620 and rebuilds the image. Now that admin edits commit to | |
| 104 | +git.tdd.md, the *next* deploy will overwrite those commits with | |
| 105 | +whatever's in dev's working tree. The container is briefly the | |
| 106 | +canonical source between admin save and next deploy. Then dev's | |
| 107 | +working tree wins. | |
| 108 | + | |
| 109 | +**What I did:** Documented this as out-of-scope in | |
| 110 | +[cms-forgejo-plan.md](cms-forgejo-plan.md). Surfaced it in the | |
| 111 | +applied-live page copy ("Until then, sync your local working tree | |
| 112 | +with `git pull` before the next `deploy-tdd-md.sh`"). Did **not** | |
| 113 | +fix the deploy script in this PR — that's a workflow decision, not | |
| 114 | +a code change. | |
| 115 | + | |
| 116 | +**Candidate SAMA refinement:** None — this is operational, not a | |
| 117 | +SAMA-layer issue. But it does point at a missing piece in the | |
| 118 | +project's *story*: there's no diagram of "where the truth lives at | |
| 119 | +each second of a request → deploy → restart cycle". A | |
| 120 | +state-diagram in plan.md or a guides/ page would help. | |
| 121 | + | |
| 122 | +## Tension 6 — same-process self-reference | |
| 123 | + | |
| 124 | +**Where it bit:** c21 handler calls c14 (Forgejo PUT). Forgejo lives | |
| 125 | +on the same physical box (host.containers.internal). When it | |
| 126 | +returns success, c21 also writes the same file to the local FS. | |
| 127 | +We're now writing the same bytes twice through different paths, | |
| 128 | +and they could disagree if Forgejo did something weird (LF/CRLF, | |
| 129 | +trailing newline normalisation). SAMA doesn't tell us where to | |
| 130 | +verify they agree. | |
| 131 | + | |
| 132 | +**What I did:** Trusted Forgejo to round-trip UTF-8 verbatim. Added | |
| 133 | +no read-after-write check. If this becomes a source of bugs we'd | |
| 134 | +add a c32 helper `verifyCommittedContent(sha, expected)` that | |
| 135 | +re-reads via `getFileSha` + a contents fetch. | |
| 136 | + | |
| 137 | +**SAMA verdict:** Doesn't need a layer rule. Add to the c14 doctrine | |
| 138 | +note: "external services own their own normalisation; if you care | |
| 139 | +about exact bytes, verify via a follow-up read". | |
| 140 | + | |
| 141 | +## Tension 7 — SAMA doesn't tell you to read the API docs first | |
| 142 | + | |
| 143 | +**Where it bit:** I assumed Forgejo's contents API was upsert-on-PUT | |
| 144 | +like GitHub. Wrong. Forgejo requires `POST` for create (no SHA), | |
| 145 | +`PUT` for update (SHA mandatory). First admin POST returned a | |
| 146 | +422 "[SHA]: Required" we then surfaced as "other" failure. | |
| 147 | + | |
| 148 | +**What I did:** Branch on `priorSha === null` in | |
| 149 | +[c14_forgejo.commitFile](src/c14_forgejo.ts) — POST when null, PUT | |
| 150 | +when set. Two-line fix. | |
| 151 | + | |
| 152 | +**SAMA verdict:** Not a SAMA problem. SAMA prescribes structure, | |
| 153 | +not external-service semantics. The c14 layer is the right place | |
| 154 | +for this knowledge to live (and it does, in the wrapper). The | |
| 155 | +lesson is generic: c14 contracts must encode every quirk of the | |
| 156 | +service they wrap, because no other layer is allowed to know. | |
| 157 | + | |
| 158 | +## Tension 8 — HTTP status codes through a CDN | |
| 159 | + | |
| 160 | +**Where it bit:** I returned `502 Bad Gateway` for "Forgejo is | |
| 161 | +unreachable" failures. Cloudflare (our CDN front) replaced our | |
| 162 | +HTML body with its own 502 error page template, so the admin saw | |
| 163 | +a Cloudflare error instead of our diagnostic. The handler did | |
| 164 | +exactly the right thing in HTTP-purist terms; the user-visible | |
| 165 | +result was wrong. | |
| 166 | + | |
| 167 | +**What I did:** Switched to `200 OK` for non-conflict commit | |
| 168 | +failures (kept `409` for conflict because that's actionable client- | |
| 169 | +side and CDNs don't intercept 4xx). The HTML body carries the | |
| 170 | +error semantics; the status code is just for routing. | |
| 171 | + | |
| 172 | +**Candidate SAMA refinement:** Add a c21 doctrine note: | |
| 173 | +> When the site sits behind a CDN that intercepts 5xx responses, | |
| 174 | +> render handler-generated error pages with `200 OK` (or with | |
| 175 | +> a 4xx the CDN passes through). Never let a synthetic 5xx | |
| 176 | +> compete with a real upstream 5xx for the user's attention. | |
| 177 | + | |
| 178 | +This is operational/CDN-specific so it might not belong in SAMA | |
| 179 | +proper. A `guides/cloudflare-and-handlers.md` page would be more | |
| 180 | +honest than expanding SAMA itself. | |
| 181 | + | |
| 182 | +## Tension 9 — "we don't depend on Forgejo" required actually proving it | |
| 183 | + | |
| 184 | +**Where it bit:** The previous iteration committed via Forgejo's HTTP | |
| 185 | +contents API. Operationally fine, but the system *did* depend on | |
| 186 | +Forgejo being reachable. To prove genuine independence we replaced | |
| 187 | +the Forgejo write-path with a local bare repo + `git` plumbing inside | |
| 188 | +the container. | |
| 189 | + | |
| 190 | +**What I did:** Added [c14_git.ts](src/c14_git.ts) which shells out | |
| 191 | +to `git` against `/app/repo` (a bind-mount of `~/repos/tdd.md.git` | |
| 192 | +on p620). Plumbing chain per commit: | |
| 193 | +`hash-object → read-tree (temp index) → update-index → write-tree | |
| 194 | +→ commit-tree → update-ref` with the parent SHA as the expected | |
| 195 | +old-value for free optimistic concurrency. The pure parts (commit | |
| 196 | +metadata format, ls-tree output, log-format string) live in | |
| 197 | +[c31_git_parse.ts](src/c31_git_parse.ts) with 11 sibling tests. | |
| 198 | + | |
| 199 | +**The proof:** Two Playwright tests: | |
| 200 | +1. [git-native-proof.spec.ts](e2e/git-native-proof.spec.ts) saves a | |
| 201 | + marker via the editor, then SSH's into p620 and reads the bare | |
| 202 | + repo HEAD — must equal the SHA the editor reported. Confirmed: | |
| 203 | + bare repo advanced (`bb9c023`), Forgejo HEAD stayed at `526fa16`. | |
| 204 | +2. [git-native-forgejo-down.spec.ts](e2e/git-native-forgejo-down.spec.ts) | |
| 205 | + stops `forgejo.service` via systemd, performs an admin edit, and | |
| 206 | + verifies the commit lands. The test passes only if Forgejo is | |
| 207 | + genuinely off the edit path. Confirmed: commit `eb1d791` landed | |
| 208 | + while Forgejo was returning HTTP 000. | |
| 209 | + | |
| 210 | +**SAMA verdict:** No new violations, and the layer hygiene actually | |
| 211 | +stayed clean *because* the Modeled exemption from tension 1 was | |
| 212 | +already the pattern — c14_git is a shell-out wrapper, c31_git_parse | |
| 213 | +holds the pure helpers with sibling tests. We applied the rule | |
| 214 | +that emerged from the previous build to the next layer that needed | |
| 215 | +it. That's the discipline working. | |
| 216 | + | |
| 217 | +## Tension 10 — naming drift after a refactor | |
| 218 | + | |
| 219 | +**Where it bit:** `c31_forgejo_commit.ts` no longer talks about | |
| 220 | +Forgejo — it owns commit-message format and noreply email shape, | |
| 221 | +which c14_git uses just as well. Filename was a lie. | |
| 222 | + | |
| 223 | +**What I did:** Renamed to `c31_commit_meta.ts` (+ `.test.ts`). | |
| 224 | +One-import update in c21_handlers_edit.ts. SAMA's "the number is | |
| 225 | +the layer; the layer is the contract" doctrine doesn't strictly | |
| 226 | +require accurate names — it requires accurate prefixes — but the | |
| 227 | +spirit is that file names communicate intent. Drift after refactor | |
| 228 | +is a small but real form of debt. | |
| 229 | + | |
| 230 | +**Candidate SAMA refinement:** Note in the Atomic doctrine that | |
| 231 | +when a file's contents diverge from its name during a refactor, a | |
| 232 | +rename is part of the refactor, not optional polish. | |
| 233 | + | |
| 234 | +## Summary — what would I add to SAMA after this PR? | |
| 235 | + | |
| 236 | +Two concrete additions: | |
| 237 | + | |
| 238 | +1. **Modeled exemption for I/O-only c14 files.** Sibling-test rule | |
| 239 | + carves out a path: pure helpers extracted to c31, the I/O | |
| 240 | + wrapper itself can stand alone with integration coverage. | |
| 241 | +2. **Boundary-contract idiom.** Cross-process functions return a | |
| 242 | + typed discriminated union, not a thrown exception. The renderer | |
| 243 | + layer (c51) handles each kind explicitly. | |
| 244 | + | |
| 245 | +Two doctrine notes (not new letters): | |
| 246 | + | |
| 247 | +3. **c13 migrations are additive + probe-guarded.** Never | |
| 248 | + destructive in-place, never renames. | |
| 249 | +4. **Source-of-truth state diagram.** Document where the canonical | |
| 250 | + bytes live at each step of write → commit → deploy → restart so | |
| 251 | + future contributors don't have to reverse-engineer it. | |
| 252 | + | |
| 253 | +The four SAMA letters (Sorted, Architecture, Modeled, Atomic) held | |
| 254 | +up. They don't *cover* every situation — the gaps above are real — | |
| 255 | +but they didn't force us into bad shapes either. The discipline | |
| 256 | +held; the doctrine needs a few footnotes. | |
cms-forgejo-plan.md
+113
−0
| @@ -0,0 +1,113 @@ | ||
| 1 | +# CMS ↔ Forgejo integration as a SAMA stress-test | |
| 2 | + | |
| 3 | +## Goal | |
| 4 | + | |
| 5 | +Make every admin direct-write a real git commit in `git.tdd.md/syntaxai/tdd.md`, | |
| 6 | +without putting a `.git` working tree, ssh keys, or the `git` binary's repo-write | |
| 7 | +capabilities inside the container. The container only ever speaks HTTP to Forgejo | |
| 8 | +(which it already does for users/repos/webhooks). This closes the loop: admin | |
| 9 | +edits become permanent in git automatically, no manual download-patch-and-commit | |
| 10 | +bridge required. | |
| 11 | + | |
| 12 | +## Architecture delta | |
| 13 | + | |
| 14 | +``` | |
| 15 | +BEFORE AFTER | |
| 16 | +admin POST /edit/... admin POST /edit/... | |
| 17 | + → Bun.write content/...md ←┐ → Bun.write content/...md | |
| 18 | + → SQLite proposal=approved │ → fetch Forgejo PUT contents | |
| 19 | + → render "applied live" │ → real commit in syntaxai/tdd.md | |
| 20 | + │ → SQLite proposal=approved + sha | |
| 21 | + manual: download patch ─────┘ → render "applied live + commit" | |
| 22 | + manual: git commit on dev | |
| 23 | + manual: deploy | |
| 24 | +``` | |
| 25 | + | |
| 26 | +Layer touchpoints: | |
| 27 | + | |
| 28 | +| Layer | File | Change | | |
| 29 | +|---|---|---| | |
| 30 | +| c14 | `c14_forgejo.ts` | + `getFileSha()`, + `commitFile()` | | |
| 31 | +| c31 | `c31_forgejo_commit.ts` (new) | input/result types + validator | | |
| 32 | +| c31 | `c31_forgejo_commit.test.ts` (new) | sibling test | | |
| 33 | +| c13 | `c13_database.ts` | + `forgejo_commit_sha` column on proposals + setter | | |
| 34 | +| c21 | `c21_handlers_edit.ts` | call commitFile in admin fork | | |
| 35 | +| c51 | `c51_render_edit.ts` | show commit SHA + Forgejo URL on applied-live + admin detail | | |
| 36 | + | |
| 37 | +## Failure semantics (the bit SAMA didn't have an opinion on) | |
| 38 | + | |
| 39 | +Order: **Forgejo first, filesystem second**. Trade ~150ms latency for guaranteed | |
| 40 | +consistency. Reasons: | |
| 41 | + | |
| 42 | +1. Forgejo's `PUT contents` requires the previous SHA → free optimistic | |
| 43 | + concurrency. If two admin tabs save concurrently, the second one fails with | |
| 44 | + 409, we surface a "conflict — refresh and retry" message. | |
| 45 | +2. If we wrote FS first and Forgejo failed, we'd have a live-but-uncommitted | |
| 46 | + change that vanishes at the next deploy. That recreates the exact problem | |
| 47 | + we're trying to eliminate. | |
| 48 | +3. The container is the single writer. No one else touches the repo from the | |
| 49 | + admin path, so reading-the-SHA / writing-with-the-SHA is a tight loop with | |
| 50 | + no contention. | |
| 51 | + | |
| 52 | +Failure matrix: | |
| 53 | + | |
| 54 | +| Forgejo result | Filesystem step | Proposal status | UI | | |
| 55 | +|---|---|---|---| | |
| 56 | +| 200/201 | execute write | `approved` + commit_sha | "applied live · commit `<sha>`" | | |
| 57 | +| 409 conflict | skip | `pending` (kept for retry) | "conflict — content changed elsewhere; refresh" | | |
| 58 | +| 5xx / network | skip | `pending` | "Forgejo unreachable; queued for retry" | | |
| 59 | +| 4xx other | skip | `pending` | "Forgejo rejected: `<message>`" | | |
| 60 | + | |
| 61 | +## Implementation steps (in order) | |
| 62 | + | |
| 63 | +1. **c14_forgejo.commitFile()** — pure HTTP wrapper, no business logic. | |
| 64 | + Returns `{ ok: true, sha } | { ok: false, status, message }`. | |
| 65 | +2. **c31_forgejo_commit.ts** — typed input contract + validator + a parser | |
| 66 | + that turns Forgejo's response shape into the typed result. Gets a sibling | |
| 67 | + test that uses fetch-mocking via `mock.module` or a small interface. | |
| 68 | +3. **c13_database** — schema migration: `ALTER TABLE proposals ADD COLUMN | |
| 69 | + forgejo_commit_sha TEXT`. Setter `setProposalCommitSha(id, sha)`. | |
| 70 | + `IF NOT EXISTS` semantics via try/catch since SQLite has no `IF NOT EXISTS` | |
| 71 | + for column adds. | |
| 72 | +4. **c21_handlers_edit admin fork** — call commitFile *before* applyLiveEdit. | |
| 73 | + On success, write FS + setProposalStatus(approved) + setProposalCommitSha. | |
| 74 | + On failure, render an error variant, set status to pending, do NOT touch FS. | |
| 75 | +5. **c51 render** — applied-live page shows the commit SHA short (7 chars) and | |
| 76 | + links to `https://git.tdd.md/syntaxai/tdd.md/commit/<sha>`. Admin proposal | |
| 77 | + detail does the same. | |
| 78 | +6. **Tests + deploy + verify** — bun test green, Playwright green, deploy to | |
| 79 | + p620, then a curl `GET /api/v1/repos/syntaxai/tdd.md/commits` to confirm | |
| 80 | + the commit really landed. | |
| 81 | + | |
| 82 | +## Out of scope for this PR | |
| 83 | + | |
| 84 | +- **Deploy script switching to pull-from-Forgejo**. Right now `deploy-tdd-md.sh` | |
| 85 | + rsyncs from dev's working tree. Until that switches to `git pull` from Forgejo | |
| 86 | + on dev (or directly on p620), admin commits in Forgejo will be overwritten on | |
| 87 | + the next deploy unless dev pulls them first. That's a separate, scarier | |
| 88 | + workflow change. | |
| 89 | +- **Webhook from Forgejo back to tdd.md to refresh the in-container `content/`**. | |
| 90 | + Tempting (would make container restart resync) but introduces a feedback loop | |
| 91 | + potential. | |
| 92 | +- **Non-admin proposals committing as branches**. Right now they stay in SQLite | |
| 93 | + as pending. Could become `proposal/<id>` branches in Forgejo. Out of scope. | |
| 94 | +- **GitHub mirror**. Forgejo can mirror to GitHub, that's a Forgejo config, not | |
| 95 | + code. | |
| 96 | + | |
| 97 | +## SAMA-tension log (filled as we hit them during the build) | |
| 98 | + | |
| 99 | +> A running list. Each entry: the tension, the workaround, and a candidate | |
| 100 | +> SAMA refinement (or "SAMA has nothing to say here, that's fine"). | |
| 101 | + | |
| 102 | +_(empty — start blank, populate as we hit them in the implementation)_ | |
| 103 | + | |
| 104 | +## Done-criteria | |
| 105 | + | |
| 106 | +- [ ] Admin POST on /edit/sama/skill writes to FS *and* creates a real commit | |
| 107 | + visible at `git.tdd.md/syntaxai/tdd.md/commits/main` | |
| 108 | +- [ ] Applied-live page shows the commit SHA with a working link | |
| 109 | +- [ ] Forgejo down → no FS write, proposal stays pending, UI surfaces the | |
| 110 | + reason | |
| 111 | +- [ ] Concurrent edit (409) handled with a useful error | |
| 112 | +- [ ] All bun tests + Playwright tests green | |
| 113 | +- [ ] SAMA-tension log has at least 2 concrete entries | |
content/blog/sama-meets-git-cms.md
+148
−0
| @@ -0,0 +1,148 @@ | ||
| 1 | +# SAMA meets git: building a self-hosted CMS that obeys the discipline | |
| 2 | + | |
| 3 | +The simplest CMS test is meta: edit the page that describes the CMS, | |
| 4 | +through the CMS, and watch it commit itself to git. That's how this | |
| 5 | +post landed in the repo. | |
| 6 | + | |
| 7 | +## The setup | |
| 8 | + | |
| 9 | +`tdd.md` is a Bun-native site, ~40 source files following the SAMA | |
| 10 | +file-naming convention (Sorted, Architecture, Modeled, Atomic). Pages | |
| 11 | +live as markdown in `content/<section>/<slug>.md`. Until last week the | |
| 12 | +only way to edit one was `vim content/blog/some-post.md && git commit | |
| 13 | +&& ./scripts/p620/deploy-tdd-md.sh`. No web editor. | |
| 14 | + | |
| 15 | +We had a goal: edit pages from a browser, but **never bypass git**. | |
| 16 | +The git history is the audit log. Anything that changes a page must | |
| 17 | +become a commit a reviewer can `git blame`. | |
| 18 | + | |
| 19 | +## First build — a SQLite proposal queue | |
| 20 | + | |
| 21 | +The obvious shape: GitHub OAuth for identity, a textarea, save | |
| 22 | +submissions to a SQLite `proposals` tabel as `pending`. The admin | |
| 23 | +reviews at `/admin/proposals`, downloads the `.md` patch, applies it | |
| 24 | +on dev, commits, deploys. | |
| 25 | + | |
| 26 | +This worked but felt wrong. The audit trail lived in two places: | |
| 27 | +SQLite knew who submitted what, git knew what actually shipped. The | |
| 28 | +manual download-patch-and-commit step was a brittle bridge between | |
| 29 | +them. And the SQLite proposals tabel was a duplicate of git's job — | |
| 30 | +versioning page bytes — done worse. | |
| 31 | + | |
| 32 | +## The realisation — Forgejo can be the canonical writer | |
| 33 | + | |
| 34 | +`tdd.md` already runs a Forgejo mirror at `git.tdd.md`, used for | |
| 35 | +agent kata repos. Forgejo has a contents API: `PUT | |
| 36 | +/api/v1/repos/{owner}/{repo}/contents/{path}` creates a real commit | |
| 37 | +from an HTTP call, no git binary or SSH keys required on the caller. | |
| 38 | + | |
| 39 | +So: **what if the editor commits directly to Forgejo?** | |
| 40 | + | |
| 41 | +``` | |
| 42 | +admin POST /edit/sama/skill | |
| 43 | + ↓ | |
| 44 | +fetch PUT https://git.tdd.md/api/v1/repos/syntaxai/tdd.md/contents/content/sama/skill.md | |
| 45 | + body: { branch: "main", content: base64(body), message, sha?: previousSha } | |
| 46 | + Authorization: token $FORGEJO_ADMIN_TOKEN | |
| 47 | + ↓ | |
| 48 | +real commit lands in syntaxai/tdd.md@main | |
| 49 | + ↓ | |
| 50 | +Bun.write to ./content/sama/skill.md so the live page reflects it | |
| 51 | + ↓ | |
| 52 | +"applied live · commit a93f01f" | |
| 53 | +``` | |
| 54 | + | |
| 55 | +No SQLite. No proposal queue. No download-patch step. The Forgejo | |
| 56 | +commit IS the audit trail. | |
| 57 | + | |
| 58 | +## Stripping the dead layers | |
| 59 | + | |
| 60 | +Once Forgejo became the writer, the SQLite `proposals` tabel was | |
| 61 | +load-bearing for nothing. Out it went, along with `c31_proposals.ts`, | |
| 62 | +`c21_handlers_admin.ts`, six render functions in `c51_render_edit.ts`, | |
| 63 | +five route entries in `c21_app.ts`, and one test file. The bundled JS | |
| 64 | +size dropped 12 KB. SAMA didn't shrink — it tightened. Each remaining | |
| 65 | +file does less, but more honestly. | |
| 66 | + | |
| 67 | +## SAMA-tension log — what the build surfaced | |
| 68 | + | |
| 69 | +Building this turned out to be a pretty good stress test of the | |
| 70 | +discipline. Eight tensions, written down as we hit them | |
| 71 | +([cms-build-log.md](https://git.tdd.md/syntaxai/tdd.md/src/branch/main/cms-build-log.md) | |
| 72 | +in the repo). The two that warrant SAMA refinements: | |
| 73 | + | |
| 74 | +**1. Modeled exemption for I/O-only c14 files.** | |
| 75 | +`c14_forgejo.ts` is an HTTP wrapper. SAMA's "tests live next to | |
| 76 | +source" rule wants `c14_forgejo.test.ts`. But unit-testing an HTTP | |
| 77 | +wrapper either needs a live server (turns the suite into integration) | |
| 78 | +or `mock.module` (tests the mock, not the wire). We split: the pure | |
| 79 | +parts (commit-message format, noreply email shape) moved to | |
| 80 | +`c31_forgejo_commit.ts` with a real sibling test; the I/O wrapper | |
| 81 | +stands alone with integration coverage via Playwright. SAMA needs a | |
| 82 | +sub-rule for c14: pure helpers extract to c31, the wrapper is exempt. | |
| 83 | + | |
| 84 | +**2. Boundary contracts as discriminated unions.** | |
| 85 | +A cross-process function shouldn't `throw` on failure — failure modes | |
| 86 | +are part of the contract. `commitFile()` returns | |
| 87 | +`{ ok: true, commitSha } | { ok: false, kind: "conflict" | "auth" | | |
| 88 | +"network" | "not_found" | "other", status, message }`. The handler | |
| 89 | +does `if (!outcome.ok)` and the renderer has one switch per kind. | |
| 90 | +This is an idiom, not a layer rule, but its absence let me reach for | |
| 91 | +try/catch in the handler — wrong layer for "what does conflict mean | |
| 92 | +to the user". | |
| 93 | + | |
| 94 | +The other six tensions are written up in the build log. Two led to | |
| 95 | +operational doctrines (additive SQLite migrations, source-of-truth | |
| 96 | +diagrams), two were SAMA-internally consistent and needed nothing, | |
| 97 | +and two were external-service quirks that the c14 layer correctly | |
| 98 | +absorbs (Forgejo's `POST` for create / `PUT` for update; Cloudflare | |
| 99 | +intercepting 5xx responses). | |
| 100 | + | |
| 101 | +## The deploy pipeline became git-native too | |
| 102 | + | |
| 103 | +With the CMS committing to Forgejo, the old deploy script (rsync | |
| 104 | +local working tree to p620) was now lying. An admin web-edit would | |
| 105 | +land in Forgejo, then the next deploy would overwrite it with | |
| 106 | +whatever was on dev's filesystem. | |
| 107 | + | |
| 108 | +The fix is the same insight: **Forgejo is the canonical source**. | |
| 109 | +The new `deploy-tdd-md.sh` does: | |
| 110 | + | |
| 111 | +```sh | |
| 112 | +ssh p620 'cd ~/src/tdd.md && git fetch origin && git reset --hard origin/main' | |
| 113 | +ssh p620 'podman build ... && systemctl restart tdd-md.service' | |
| 114 | +``` | |
| 115 | + | |
| 116 | +Dev pushes to git.tdd.md. Admin web-edits commit to git.tdd.md. | |
| 117 | +Deploy pulls from git.tdd.md. One source, three writers, all going | |
| 118 | +through real git commits. The legacy `--rsync` flag stays as an | |
| 119 | +emergency escape hatch for deploying uncommitted dev changes. | |
| 120 | + | |
| 121 | +## What SAMA proved | |
| 122 | + | |
| 123 | +Across this build SAMA's four letters held up — Sorted (no | |
| 124 | +import-direction violations), Architecture (clean layer composition, | |
| 125 | +c14 owns Forgejo's HTTP, c31 owns the pure shaping, c21 composes, | |
| 126 | +c51 renders), Modeled (sibling tests where they fit), Atomic | |
| 127 | +(deletion shrunk the largest files instead of growing new ones). | |
| 128 | + | |
| 129 | +The gaps SAMA had — failure-contract idiom, c14 testability — became | |
| 130 | +visible specifically because we built something that pushed past | |
| 131 | +single-process pure logic into a real cross-boundary mutation. That's | |
| 132 | +the value: SAMA isn't trying to cover every situation, it's trying to | |
| 133 | +make the gaps it doesn't cover *legible*. | |
| 134 | + | |
| 135 | +## You can verify this post is real | |
| 136 | + | |
| 137 | +- **Source on git.tdd.md:** | |
| 138 | + [content/blog/sama-meets-git-cms.md](https://git.tdd.md/syntaxai/tdd.md/src/branch/main/content/blog/sama-meets-git-cms.md) | |
| 139 | +- **Edit it via the CMS:** | |
| 140 | + [/edit/blog/sama-meets-git-cms](/edit/blog/sama-meets-git-cms) | |
| 141 | + (admin-only — you'll see the login wall) | |
| 142 | +- **Raw markdown:** | |
| 143 | + [/content/blog/sama-meets-git-cms.md](/content/blog/sama-meets-git-cms.md) | |
| 144 | + | |
| 145 | +When syntaxai saves an edit through that editor, a real commit lands | |
| 146 | +in syntaxai/tdd.md within a second. The SHA shows up on the | |
| 147 | +"applied live" page. The next deploy pulls it. The cycle closes | |
| 148 | +without leaving Bun, Forgejo, and four well-named source files. | |