Initial git webhooks service
All checks were successful
check / check (push) Successful in 14s

This commit is contained in:
matamune 2026-05-12 21:06:22 +00:00
commit 0ba9b9f95c
Signed by: matamune
GPG key ID: 3BB8E7D3B968A324
17 changed files with 705 additions and 0 deletions

View file

@ -0,0 +1,20 @@
name: check
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
jobs:
check:
runs-on: bun
steps:
- uses: actions/checkout@v4
- name: Install
run: bun install --frozen-lockfile
- name: Typecheck
run: bun run check:types
- name: Test
run: bun test

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
node_modules
dist
coverage
*.log
.env
data/*.jsonl
data/events
data/jobs
.DS_Store

18
Dockerfile Normal file
View file

@ -0,0 +1,18 @@
FROM oven/bun:1.3.11-alpine
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
COPY tsconfig.json ./
COPY src ./src
ENV HOST=0.0.0.0
ENV PORT=3000
ENV DATA_DIR=/app/data
RUN mkdir -p /app/data
EXPOSE 3000
CMD ["bun", "src/server.ts"]

32
README.md Normal file
View file

@ -0,0 +1,32 @@
# git-webhooks
Containerized Bun service for GitHub and jojo.build webhooks.
## Endpoints
```text
GET /healthz
POST /git-webhooks/jojo
POST /git-webhooks/github
```
## Environment
```text
HOST=0.0.0.0
PORT=3000
DATA_DIR=/app/data
JOJO_WEBHOOK_SECRET=...
GITHUB_WEBHOOK_SECRET=...
```
## Development
```bash
bun install
bun run check
bun run dev
```
Accepted webhook events are appended to `DATA_DIR/events.jsonl`; queued work
items are appended to `DATA_DIR/jobs.jsonl`.

24
bun.lock Normal file
View file

@ -0,0 +1,24 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@peezy.tech/git-webhooks",
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.9.3",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="],
}
}

17
package.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "@peezy.tech/git-webhooks",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --watch src/server.ts",
"start": "bun src/server.ts",
"test": "bun test",
"check:types": "tsc --noEmit",
"check": "bun run check:types && bun test"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.9.3"
}
}

23
src/http.ts Normal file
View file

@ -0,0 +1,23 @@
export function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(body), {
...init,
headers: {
"content-type": "application/json; charset=utf-8",
...init.headers,
},
});
}
export function textResponse(body: string, init: ResponseInit = {}): Response {
return new Response(body, {
...init,
headers: {
"content-type": "text/plain; charset=utf-8",
...init.headers,
},
});
}
export function methodNotAllowed(): Response {
return jsonResponse({ error: "method_not_allowed" }, { status: 405 });
}

75
src/providers/github.ts Normal file
View file

@ -0,0 +1,75 @@
import type { GitWebhookEvent, NormalizedEventName } from "../types";
type GitHubPayload = {
action?: string;
zen?: string;
hook_id?: number;
ref?: string;
before?: string;
after?: string;
repository?: {
name?: string;
full_name?: string;
clone_url?: string;
ssh_url?: string;
default_branch?: string;
owner?: {
login?: string;
name?: string;
};
};
sender?: {
login?: string;
html_url?: string;
};
};
function normalizeEventName(providerEvent: string, payload: GitHubPayload): NormalizedEventName {
if (providerEvent === "ping" || payload.zen || payload.hook_id) return "ping";
if (providerEvent === "push") return "push";
if (providerEvent === "pull_request") return "pull_request";
if (providerEvent === "workflow_run") return "workflow_run";
if (providerEvent === "release") return "release";
return "unknown";
}
export function normalizeGithubEvent(input: {
providerEvent: string;
deliveryId: string;
receivedAt: string;
payload: GitHubPayload;
}): GitWebhookEvent {
const { payload } = input;
const repoOwner = payload.repository?.owner?.login ?? payload.repository?.owner?.name;
const repoName = payload.repository?.name;
const fullName = payload.repository?.full_name ?? (repoOwner && repoName ? `${repoOwner}/${repoName}` : undefined);
return {
provider: "github",
event: normalizeEventName(input.providerEvent, payload),
providerEvent: input.providerEvent,
deliveryId: input.deliveryId,
receivedAt: input.receivedAt,
repo: repoOwner && repoName && fullName
? {
owner: repoOwner,
name: repoName,
fullName,
cloneUrl: payload.repository?.clone_url,
sshUrl: payload.repository?.ssh_url,
defaultBranch: payload.repository?.default_branch,
}
: undefined,
sender: payload.sender?.login
? {
username: payload.sender.login,
htmlUrl: payload.sender.html_url,
}
: undefined,
ref: payload.ref,
before: payload.before,
after: payload.after,
action: payload.action,
raw: payload,
};
}

76
src/providers/jojo.ts Normal file
View file

@ -0,0 +1,76 @@
import type { GitWebhookEvent, NormalizedEventName } from "../types";
type JojoPayload = {
action?: string;
ref?: string;
before?: string;
after?: string;
repository?: {
name?: string;
full_name?: string;
clone_url?: string;
ssh_url?: string;
default_branch?: string;
owner?: {
login?: string;
username?: string;
name?: string;
};
};
sender?: {
login?: string;
username?: string;
html_url?: string;
};
};
function normalizeEventName(providerEvent: string): NormalizedEventName {
if (providerEvent === "ping") return "ping";
if (providerEvent === "push") return "push";
if (providerEvent === "pull_request") return "pull_request";
if (providerEvent === "workflow_run") return "workflow_run";
if (providerEvent === "release") return "release";
return "unknown";
}
export function normalizeJojoEvent(input: {
providerEvent: string;
deliveryId: string;
receivedAt: string;
payload: JojoPayload;
}): GitWebhookEvent {
const { payload } = input;
const repoOwner = payload.repository?.owner?.login ?? payload.repository?.owner?.username ?? payload.repository?.owner?.name;
const repoName = payload.repository?.name;
const fullName = payload.repository?.full_name ?? (repoOwner && repoName ? `${repoOwner}/${repoName}` : undefined);
const senderUsername = payload.sender?.login ?? payload.sender?.username;
return {
provider: "jojo",
event: normalizeEventName(input.providerEvent),
providerEvent: input.providerEvent,
deliveryId: input.deliveryId,
receivedAt: input.receivedAt,
repo: repoOwner && repoName && fullName
? {
owner: repoOwner,
name: repoName,
fullName,
cloneUrl: payload.repository?.clone_url,
sshUrl: payload.repository?.ssh_url,
defaultBranch: payload.repository?.default_branch,
}
: undefined,
sender: senderUsername
? {
username: senderUsername,
htmlUrl: payload.sender?.html_url,
}
: undefined,
ref: payload.ref,
before: payload.before,
after: payload.after,
action: payload.action,
raw: payload,
};
}

43
src/queue.ts Normal file
View file

@ -0,0 +1,43 @@
import { appendFile, mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import type { GitWebhookEvent, QueuedJob } from "./types";
async function appendJsonLine(path: string, value: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true });
await appendFile(path, `${JSON.stringify(value)}\n`, "utf8");
}
export class EventStore {
readonly eventsPath: string;
readonly jobsPath: string;
constructor(dataDir: string) {
this.eventsPath = join(dataDir, "events.jsonl");
this.jobsPath = join(dataDir, "jobs.jsonl");
}
async appendEvent(event: GitWebhookEvent): Promise<void> {
await appendJsonLine(this.eventsPath, event);
}
async appendJob(job: QueuedJob): Promise<void> {
await appendJsonLine(this.jobsPath, job);
}
}
export function jobForEvent(event: GitWebhookEvent): QueuedJob | null {
if (event.event !== "push" || event.ref !== "refs/heads/main" || !event.repo || !event.after) {
return null;
}
return {
id: `${event.provider}:${event.deliveryId}:main_push`,
kind: "main_push",
provider: event.provider,
repoFullName: event.repo.fullName,
ref: event.ref,
sha: event.after,
deliveryId: event.deliveryId,
createdAt: event.receivedAt,
};
}

114
src/server.ts Normal file
View file

@ -0,0 +1,114 @@
import { randomUUID } from "node:crypto";
import { jsonResponse, methodNotAllowed, textResponse } from "./http";
import { normalizeGithubEvent } from "./providers/github";
import { normalizeJojoEvent } from "./providers/jojo";
import { EventStore, jobForEvent } from "./queue";
import { verifyGithubSignature, verifyJojoSignature } from "./signatures";
import type { GitWebhookEvent } from "./types";
const maxBodyBytes = 1024 * 1024;
export type ServerConfig = {
githubSecret: string;
jojoSecret: string;
dataDir: string;
};
function getHeader(headers: Headers, name: string, fallback: string): string {
return headers.get(name) ?? fallback;
}
async function parseJsonBody(request: Request): Promise<{ body: string; payload: unknown } | Response> {
const body = await request.text();
if (body.length > maxBodyBytes) {
return jsonResponse({ error: "payload_too_large" }, { status: 413 });
}
try {
return { body, payload: JSON.parse(body) };
} catch {
return jsonResponse({ error: "invalid_json" }, { status: 400 });
}
}
async function persistAcceptedEvent(store: EventStore, event: GitWebhookEvent): Promise<Response> {
await store.appendEvent(event);
const job = jobForEvent(event);
if (job) {
await store.appendJob(job);
}
console.log(JSON.stringify({ type: "webhook.accepted", provider: event.provider, event: event.event, deliveryId: event.deliveryId, job: job?.id }));
return jsonResponse({ status: event.event === "ping" ? "ok" : "accepted", event: event.event, deliveryId: event.deliveryId }, {
status: event.event === "ping" ? 200 : 202,
});
}
async function handleGithub(request: Request, config: ServerConfig, store: EventStore): Promise<Response> {
if (request.method !== "POST") return methodNotAllowed();
const parsed = await parseJsonBody(request);
if (parsed instanceof Response) return parsed;
const verified = await verifyGithubSignature(config.githubSecret, parsed.body, request.headers.get("x-hub-signature-256"));
if (!verified) return jsonResponse({ error: "invalid_signature" }, { status: 401 });
const event = normalizeGithubEvent({
providerEvent: getHeader(request.headers, "x-github-event", "unknown"),
deliveryId: getHeader(request.headers, "x-github-delivery", randomUUID()),
receivedAt: new Date().toISOString(),
payload: parsed.payload as never,
});
return persistAcceptedEvent(store, event);
}
async function handleJojo(request: Request, config: ServerConfig, store: EventStore): Promise<Response> {
if (request.method !== "POST") return methodNotAllowed();
const parsed = await parseJsonBody(request);
if (parsed instanceof Response) return parsed;
const verified = await verifyJojoSignature(config.jojoSecret, parsed.body, request.headers);
if (!verified) return jsonResponse({ error: "invalid_signature" }, { status: 401 });
const event = normalizeJojoEvent({
providerEvent: getHeader(request.headers, "x-forgejo-event", request.headers.get("x-gitea-event") ?? "unknown"),
deliveryId: getHeader(request.headers, "x-forgejo-delivery", request.headers.get("x-gitea-delivery") ?? randomUUID()),
receivedAt: new Date().toISOString(),
payload: parsed.payload as never,
});
return persistAcceptedEvent(store, event);
}
export function createHandler(config: ServerConfig): (request: Request) => Promise<Response> | Response {
const store = new EventStore(config.dataDir);
return async (request: Request) => {
const url = new URL(request.url);
if (url.pathname === "/healthz") {
return textResponse("ok\n");
}
if (url.pathname === "/git-webhooks/github") {
return handleGithub(request, config, store);
}
if (url.pathname === "/git-webhooks/jojo") {
return handleJojo(request, config, store);
}
return jsonResponse({ error: "not_found" }, { status: 404 });
};
}
if (import.meta.main) {
const port = Number(process.env.PORT ?? "3000");
const hostname = process.env.HOST ?? "0.0.0.0";
const config: ServerConfig = {
githubSecret: process.env.GITHUB_WEBHOOK_SECRET ?? "",
jojoSecret: process.env.JOJO_WEBHOOK_SECRET ?? "",
dataDir: process.env.DATA_DIR ?? "./data",
};
Bun.serve({
hostname,
port,
fetch: createHandler(config),
});
console.log(JSON.stringify({ type: "server.started", hostname, port }));
}

62
src/signatures.ts Normal file
View file

@ -0,0 +1,62 @@
import { timingSafeEqual } from "node:crypto";
const encoder = new TextEncoder();
function timingSafeEqualHex(a: string, b: string): boolean {
const left = Buffer.from(a, "hex");
const right = Buffer.from(b, "hex");
if (left.length !== right.length) {
return false;
}
return timingSafeEqual(left, right);
}
export async function hmacSha256Hex(secret: string, body: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
return Buffer.from(signature).toString("hex");
}
export async function verifyGithubSignature(
secret: string,
body: string,
signatureHeader: string | null,
): Promise<boolean> {
if (!secret || !signatureHeader?.startsWith("sha256=")) {
return false;
}
const expected = await hmacSha256Hex(secret, body);
const actual = signatureHeader.slice("sha256=".length).toLowerCase();
return /^[0-9a-f]{64}$/.test(actual) && timingSafeEqualHex(actual, expected);
}
export async function verifyJojoSignature(
secret: string,
body: string,
headers: Headers,
): Promise<boolean> {
if (!secret) {
return false;
}
const candidates = [
headers.get("x-forgejo-signature-256"),
headers.get("x-forgejo-signature"),
headers.get("x-gitea-signature-256"),
headers.get("x-gitea-signature"),
].filter((value): value is string => Boolean(value));
const expected = await hmacSha256Hex(secret, body);
return candidates.some((candidate) => {
const actual = candidate.startsWith("sha256=")
? candidate.slice("sha256=".length).toLowerCase()
: candidate.toLowerCase();
return /^[0-9a-f]{64}$/.test(actual) && timingSafeEqualHex(actual, expected);
});
}

45
src/types.ts Normal file
View file

@ -0,0 +1,45 @@
export type Provider = "jojo" | "github";
export type NormalizedEventName =
| "ping"
| "push"
| "pull_request"
| "workflow_run"
| "release"
| "unknown";
export type GitWebhookEvent = {
provider: Provider;
event: NormalizedEventName;
providerEvent: string;
deliveryId: string;
receivedAt: string;
repo?: {
owner: string;
name: string;
fullName: string;
cloneUrl?: string;
sshUrl?: string;
defaultBranch?: string;
};
sender?: {
username: string;
htmlUrl?: string;
};
ref?: string;
before?: string;
after?: string;
action?: string;
raw: unknown;
};
export type QueuedJob = {
id: string;
kind: "main_push";
provider: Provider;
repoFullName: string;
ref: string;
sha: string;
deliveryId: string;
createdAt: string;
};

54
test/providers.test.ts Normal file
View file

@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test";
import { normalizeGithubEvent } from "../src/providers/github";
import { normalizeJojoEvent } from "../src/providers/jojo";
const repository = {
name: "git-webhooks",
full_name: "peezy-tech/git-webhooks",
clone_url: "https://example.test/peezy-tech/git-webhooks.git",
ssh_url: "git@example.test:peezy-tech/git-webhooks.git",
default_branch: "main",
owner: { login: "peezy-tech" },
};
describe("provider normalization", () => {
test("normalizes GitHub push events", () => {
const event = normalizeGithubEvent({
providerEvent: "push",
deliveryId: "delivery-1",
receivedAt: "2026-05-12T00:00:00.000Z",
payload: {
ref: "refs/heads/main",
before: "before",
after: "after",
repository,
sender: { login: "peezy", html_url: "https://github.com/peezy" },
},
});
expect(event.provider).toBe("github");
expect(event.event).toBe("push");
expect(event.repo?.fullName).toBe("peezy-tech/git-webhooks");
expect(event.sender?.username).toBe("peezy");
});
test("normalizes jojo push events", () => {
const event = normalizeJojoEvent({
providerEvent: "push",
deliveryId: "delivery-2",
receivedAt: "2026-05-12T00:00:00.000Z",
payload: {
ref: "refs/heads/main",
before: "before",
after: "after",
repository,
sender: { username: "peezy", html_url: "https://jojo.build/peezy" },
},
});
expect(event.provider).toBe("jojo");
expect(event.event).toBe("push");
expect(event.repo?.owner).toBe("peezy-tech");
expect(event.sender?.username).toBe("peezy");
});
});

60
test/server.test.ts Normal file
View file

@ -0,0 +1,60 @@
import { mkdtemp, readFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, test } from "bun:test";
import { createHandler } from "../src/server";
import { hmacSha256Hex } from "../src/signatures";
async function signedRequest(path: string, provider: "github" | "jojo", secret: string, body: unknown): Promise<Request> {
const raw = JSON.stringify(body);
const digest = await hmacSha256Hex(secret, raw);
const headers: Record<string, string> = { "content-type": "application/json" };
if (provider === "github") {
headers["x-hub-signature-256"] = `sha256=${digest}`;
headers["x-github-event"] = "push";
headers["x-github-delivery"] = "github-delivery";
} else {
headers["x-forgejo-signature-256"] = `sha256=${digest}`;
headers["x-forgejo-event"] = "push";
headers["x-forgejo-delivery"] = "jojo-delivery";
}
return new Request(`http://localhost${path}`, { method: "POST", headers, body: raw });
}
describe("server", () => {
test("healthz returns ok", async () => {
const handler = createHandler({ githubSecret: "gh", jojoSecret: "jojo", dataDir: await mkdtemp(join(tmpdir(), "git-webhooks-")) });
const response = await handler(new Request("http://localhost/healthz"));
expect(response.status).toBe(200);
expect(await response.text()).toBe("ok\n");
});
test("rejects invalid signatures", async () => {
const handler = createHandler({ githubSecret: "gh", jojoSecret: "jojo", dataDir: await mkdtemp(join(tmpdir(), "git-webhooks-")) });
const response = await handler(new Request("http://localhost/git-webhooks/github", {
method: "POST",
headers: { "x-hub-signature-256": "sha256=bad" },
body: "{}",
}));
expect(response.status).toBe(401);
});
test("accepts jojo main pushes and queues a job", async () => {
const dataDir = await mkdtemp(join(tmpdir(), "git-webhooks-"));
const handler = createHandler({ githubSecret: "gh", jojoSecret: "jojo", dataDir });
const request = await signedRequest("/git-webhooks/jojo", "jojo", "jojo", {
ref: "refs/heads/main",
after: "abc123",
repository: {
name: "git-webhooks",
full_name: "peezy-tech/git-webhooks",
owner: { username: "peezy-tech" },
},
});
const response = await handler(request);
expect(response.status).toBe(202);
expect(await readFile(join(dataDir, "events.jsonl"), "utf8")).toContain("\"provider\":\"jojo\"");
expect(await readFile(join(dataDir, "jobs.jsonl"), "utf8")).toContain("\"kind\":\"main_push\"");
});
});

21
test/signatures.test.ts Normal file
View file

@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
import { hmacSha256Hex, verifyGithubSignature, verifyJojoSignature } from "../src/signatures";
describe("webhook signatures", () => {
test("verifies GitHub sha256 signatures", async () => {
const body = JSON.stringify({ ok: true });
const digest = await hmacSha256Hex("secret", body);
expect(await verifyGithubSignature("secret", body, `sha256=${digest}`)).toBe(true);
expect(await verifyGithubSignature("wrong", body, `sha256=${digest}`)).toBe(false);
});
test("verifies jojo Forgejo/Gitea signature headers", async () => {
const body = JSON.stringify({ ok: true });
const digest = await hmacSha256Hex("secret", body);
const headers = new Headers({ "x-forgejo-signature-256": `sha256=${digest}` });
expect(await verifyJojoSignature("secret", body, headers)).toBe(true);
expect(await verifyJojoSignature("wrong", body, headers)).toBe(false);
});
});

12
tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"types": ["bun-types"],
"skipLibCheck": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}