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
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
}
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;
}
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);
}
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'],
]);
}
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;
}
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);
}
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;
}
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.mdand 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)
Waiting for your GitHub actions take🙌
I hope so!