DEV Community

Cover image for How I Turned GitHub into a Headless CMS
CDdev
CDdev

Posted on

How I Turned GitHub into a Headless CMS

After publishing my PHP micro-framework PointArt, I built a website for it. However, the site needed some dynamic content — especially the changelog and roadmap — to update the moment something changes on GitHub.

My first instinct was an admin panel. Then I stopped myself — the content already lives on GitHub, why enter it for the second time? It also opens a door for human error. So I made GitHub the CMS instead of an admin panel and used webhooks to keep a local database in sync automatically.


Architecture

GitHub (push or release event)
  → POST /hooks/* (HMAC-verified)
    → Parse payload
      → Upsert / delete DB rows
        → Website reads DB on page load
Enter fullscreen mode Exit fullscreen mode

Two content types, two sync strategies:

Content Source Strategy
Changelog GitHub Releases Incremental — insert/update/delete per event
Roadmap CONTRIBUTING.md Full sync — re-fetch + delete-all + re-insert on every push

The website never calls the GitHub API at request time. It reads from the local database; GitHub keeps that data fresh.


Verifying the Signature

Every GitHub webhook includes an X-Hub-Signature-256 header — HMAC-SHA256 of the raw body signed with your secret. Verify it before anything else.

function verifySignature(string $rawBody, string $secret): bool {
    $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
    $received = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
    return hash_equals($expected, $received); // timing-safe
}
Enter fullscreen mode Exit fullscreen mode

Two gotchas here: read the raw body before json_decode() (HMAC is over the exact bytes GitHub sent), and use hash_equals() not === to avoid timing attacks.

GitHub also sends a ping event when the webhook is first created. Handle it or GitHub marks the endpoint as failed:

$rawBody = file_get_contents('php://input');
$payload = json_decode($rawBody, true);

if (!verifySignature($rawBody, $secret)) {
    http_response_code(403); return;
}
if (isset($payload['zen'])) {  // ping event
    echo json_encode(['ok' => 'pong']); return;
}
Enter fullscreen mode Exit fullscreen mode

In PointArt website, this lives in a HookController with #[Router] / #[Route] attributes:

#[Router(name: 'webhook', path: '/hooks')]
class HookController {
    #[Wired] private ReleaseRepository $releaseRepository;
    #[Wired] private RoadmapRepository $roadmapRepository;

    private function verifySignature(string $rawBody): bool {
        $secret   = Env::get('WEBHOOK_SECRET');
        $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
        $received = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
        return hash_equals($expected, $received);
    }
Enter fullscreen mode Exit fullscreen mode

Syncing the Changelog (Incremental)

Release events carry an action field: published, edited, or deleted. Map them to DB operations:

$action    = $payload['action'];
$release   = $payload['release'];
$releaseId = $release['id'];

if ($action === 'deleted') {
    deleteByReleaseId($releaseId);
} elseif (in_array($action, ['published', 'edited', 'released'])) {
    deleteByReleaseId($releaseId); // delete + insert = simple upsert
    insertRelease([
        'release_id'   => $releaseId,
        'tag_name'     => $release['tag_name'],
        'name'         => $release['name'] ?? $release['tag_name'],
        'body'         => $release['body'],
        'published_at' => $release['published_at'],
        'author'       => $release['author']['login'],
    ]);
}
Enter fullscreen mode Exit fullscreen mode

Delete + insert instead of UPDATE keeps it simple and naturally idempotent (more on that below).

In PointArt this maps to a repository with #[Query] attributes — method signatures auto-generate their implementations:

#[Service]
abstract class ReleaseRepository extends Repository {
    protected string $entityClass = Release::class;

    #[Query('SELECT * FROM releases ORDER BY published_at DESC')]
    abstract public function findAllOrderedByDate(): array;

    #[Query('DELETE FROM releases WHERE release_id = ?')]
    abstract public function deleteByReleaseId(int $releaseId): void;
}
Enter fullscreen mode Exit fullscreen mode

Syncing the Roadmap (Full Sync from Markdown)

For the roadmap I use a different strategy: the push event is just a trigger. I re-fetch CONTRIBUTING.md from GitHub's raw CDN and do a full delete + re-insert.

Why not parse the diff from the payload? Because fetching the whole file is simpler and I don't need partial updates — every push gives a fresh, complete view.

$url     = 'https://raw.githubusercontent.com/yourname/repo/master/CONTRIBUTING.md';
$content = @file_get_contents($url, false, stream_context_create(['http' => ['timeout' => 10]]));

// Full sync: wipe and re-insert
$roadmapRepository->deleteAll();
foreach (parseContributing($content) as $item) {
    $entry->title       = $item['title'];
    $entry->description = $item['description'];
    $roadmapRepository->save($entry);
}
Enter fullscreen mode Exit fullscreen mode

The parser scans for ### headings inside a ## What to Contribute section:

function parseContributing(string $content): array {
    $lines = explode("\n", $content);
    $items = []; $inSection = false; $title = null; $descLines = [];

    foreach ($lines as $line) {
        $line = rtrim($line);
        if (!$inSection) { if (stripos($line, '## What to Contribute') === 0) $inSection = true; continue; }
        if (str_starts_with($line, '## ')) break;
        if (str_starts_with($line, '### ')) {
            if ($title !== null) $items[] = ['title' => $title, 'description' => trim(implode(' ', $descLines))];
            $title = trim(substr($line, 4)); $descLines = []; continue;
        }
        if ($title !== null && $line !== '' && $line !== '---') $descLines[] = $line;
    }
    if ($title !== null) $items[] = ['title' => $title, 'description' => trim(implode(' ', $descLines))];
    return $items;
}
Enter fullscreen mode Exit fullscreen mode

A Few Gotchas

Webhooks only fire on future events. When you first deploy, the DB is empty, keep in mind!

GitHub retries on non-2xx. If your handler throws a 500, GitHub retries. The delete + insert pattern makes handlers naturally idempotent, so retries are harmless.

Use raw.githubusercontent.com for files, not the API. The raw CDN has no rate limit for public repos. The GitHub API is limited to 60 unauthenticated requests/hour.


The Result

  • Publish a GitHub release → changelog updates automatically
  • Edit CONTRIBUTING.md and push → roadmap refreshes

The same pattern could extend further — maybe devlogs, ideas, or any other content that already lives in a GitHub repository. If GitHub is already your source of truth, the webhook just keeps everything else in sync.

Thanks for reading!

Top comments (2)

Collapse
 
aral_aaac_2093b6ae3fbca2a profile image
aral aaac

Waiting for your GitHub actions take🙌

Collapse
 
cn8001 profile image
CDdev

I hope so!