CMS-krønike·marts 2026·5 min læsning

CMS Kronik #05: GitHub-adapter GitHub som database

GitHub er ikke blot en git-host. Hver fil i et repository er tilgængelig, læsbar og skrivbar via REST API'et — med fuld historik, branching og adgangskontrol inkluderet gratis. Dette er GitOps CMS-modellen: indhold som kode, nul infrastruktur.

GitHub er ikke en git-host. Det er den nye måde at se det på, der gør GitHub-adapteren fornuftig. For vores formål er GitHub en database — én med et REST API, der lader dig læse og skrive filer, med fuld historik, branching og adgangskontrol inkluderet. Gratis. Dette er ikke en hack eller kreativ misbrug. Det er en førsteklasses brug af platformen, og det har et navn: GitOps.

Indhold som kode. Hvert indlæg, hver side, hvert CMS-dokument — en fil i et git-repository. Versionsstyret, diffbar, PR-reviewbar.

Indsigten

Hver fil i et GitHub-repository har en URL. Den URL er læsbar og skrivbar via GitHub Contents API'et. Du har ikke brug for en server. Du har ikke brug for en database. Du har ikke brug for nogen infrastruktur ud over et personligt adgangstoken.

Indholdet lever i git — hvilket betyder, at det arver alt, hvad git giver dig: fuld historik, per-linje blame, branching, merging, pull requests. En redaktør kan åbne en fil i GitHub-grænsefladen, foretage en ændring og indsende en pull request. En udvikler gennemgår det som kode. Det merges. Sitet genopbygges. Det er GitOps CMS-workflowet, og det er, hvad denne adapter muliggør.

Hvordan det fungerer mekanisk

GitHub Contents API'et har tre operationer, vi interesserer os for:

HENT en fil. Returnerer filens indhold kodet som base64, plus en SHA-fingeraftryk. SHA’en er ikke commit-hash’en — det er blob-hash’en, SHA’en for filobjektet i git’s objektlager.

SKRIV en fil. Opretter eller opdaterer. Her er den vigtige del: for at opdatere en eksisterende fil skal du sende SHA’en tilbage, som du modtog, da du læste den. GitHub vil afvise skrivningen, hvis SHA’en ikke matcher den aktuelle fil. Dette er optimistisk samtidig adgang — den samme mekanisme, der forhindrer to personer i at overskrive hinandens ændringer.

SLET en fil. Samme SHA-krav. Du skal bevise, at du har læst den aktuelle version, før du kan slette den.

List en mappe. HENT stien til en mappe, og GitHub returnerer et array med filobjekter — navne, typer og SHA’er for alt i den sti.

// HENT: hent en fil og dens SHA
GET /repos/{owner}/{repo}/contents/{path}?ref=main
// → { content: "base64...", sha: "abc123", ... }

// SKRIV: opret eller opdater (SHA påkrævet for opdateringer)
PUT /repos/{owner}/{repo}/contents/{path}
// body: { message, content: "base64...", branch, sha? }

// SLET: kræver SHA
DELETE /repos/{owner}/{repo}/contents/{path}
// body: { message, sha, branch }

SHA-cachen

Dette er den sværeste implementationsdetalje, og det er værd at forstå.

Hver skrivning har brug for den aktuelle SHA. Hvis vi hentede SHA’en frisk fra GitHub før hver skrivning, ville vi fordoble vores API-opkaldsantal. I stedet vedligeholder adapteren en hukommelsesbaseret Map<string, string> — en cache fra filsti til SHA.

Hvert getFile-opkald udfylder cachen. Hvert vellykkede putFile-opkald opdaterer den med SHA’en for det nyligt skrevne blob (returneret i API-responsen). Cachen varmes naturligt op, efterhånden som du bruger adapteren.

Kold start er i orden — den første læsning af en fil koster ét API-opkald. Derefter er skrivninger fri for ekstra rundture.

Hvis to processer skriver til den samme fil samtidigt, vil det andet skriveforsøg få en 409-konflikt fra GitHub — SHA’en, det havde cached, er nu forældet. Adapteren præsenterer dette som en eksplicit fejl: GitHub: SHA-konflikt ved skrivning til {path} — prøv igen. Dette er den korrekte opførsel. Det er den samme garanti, en database giver dig med optimistisk låsning.

findMany — det ærlige kompromis

findMany er, hvor vi skal være ærlige med, hvad denne adapter er og ikke er.

Implementeringen lister mappen for at få filnavnene og henter derefter hver fil individuelt. For en samling med 50 indlæg er det 51 API-opkald — ét listDir plus ét getFile pr. dokument. O(n) anmodninger.

GitHub’s rategrænse for autentificerede anmodninger er 5.000 pr. time. For en byggepipeline, der kører én gang pr. deployment, er 51 anmodninger ubetydelige. For en runtime, der betjener anmodninger live, er det et problem.

GitHub-adapteren er et byggetidsværktøj. cms build henter alt én gang. Ikke til at betjene live-trafik.

Dette er en bevidst afgrænsningsbeslutning. Adapteren er designet til GitOps-pipeline: indhold lever i git, bygningen læser det hele ved byggetid, og outputtet er et statisk site. SHA-cachen betyder, at et cms build-kørsel — som læser hvert dokument én gang og derefter genererer HTML — rører hver fil præcis én gang. Der er ingen redundant hentning.

Hvad dette muliggør

Med GitHub-adapteren på plads er det fulde GitOps-workflow tilgængeligt:

  • Rediger indhold i GitHub-grænsefladen, CMS-administratoren eller enhver editor, der kan committe til et repository.
  • Committen lander på main. En GitHub Action udløses. cms build kører. Det statiske site deployes.
  • Ingen database. Ingen server. Ingen ops. Infrastrukturomkostninger: et personligt adgangstoken.

Indholdsgennemgang via pull requests er en reel arbejdsgang. En redaktør foreslår et indlæg. En udvikler (eller en anden redaktør) gennemgår diff’en. Markdown’en gengives i PR-forhåndsvisningen. Det merges, når det er klar. Tilbageførsel er git revert. Historik er git log. Blame er git blame. Alle git-primitiver gælder for dit indhold, fordi dit indhold er git-objekter.

Én linje til at skifte

Hele pointen med adaptermønsteret er, at resten af kodebasen er upåvirket. At skifte fra filsystem til GitHub er en én-linjers ændring i 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: [ /* uændret */ ],
});

Grænsefladen StorageAdapter er kontrakten. GitHubStorageAdapter overholder den. Skema-motoren, indholdstjenesten, byggepipelinen, AI-agenterne — ingen af dem ved eller interesserer sig for, hvilken adapter, der ligger under.

Hvor det passer ind i stacken

Tre adapters. Tre forskellige problemer.

  • Filsystem — lokal udvikling. Filer på disk, øjeblikkelig feedback, ingen netværk.
  • SQLite — administratorgrænseflade-skrivninger. En kørende server, samtidig adgang, en reel database på den lokale maskine eller et Fly.io-volumen.
  • GitHub — GitOps-pipeline. Indhold som git-objekter, nul infrastruktur, fuld historik. Deploytriggeren er en commit.

Supabase kommer næste: flerbruger, realtid, cloud-hostet, fundamentet for en ordentlig SaaS CMS. Men GitHub-adapteren er den mest arkitektonisk interessante trin på stigen — den, der gør en versionskontrolplatform til et indholdslager, med optimistisk samtidig adgang og en opvarmende cache, og ikke en eneste server at vedligeholde.