Webhook Handler (ghfe)
The webhook handler (ghfe, the “GitHub front-end”) receives GitHub workflow_job events, validates them, and records job state in PostgreSQL. It is a Go HTTP service built on net/http and pgx, intentionally minimal: no GitHub API calls, no Kubernetes calls, just signature validation, label resolution, and a write to the database. Heavier work happens in the scheduler.
Source: control-plane/cmd/ghfe/
Request flow
flowchart TD
A[GitHub webhook POST /] --> B{Verify HMAC-SHA256 signature}
B -->|Invalid| C[401 Unauthorized]
B -->|Valid| D{Event type?}
D -->|Not workflow_job| E[200 Ignored]
D -->|workflow_job| H{Action?}
H -->|queued| I[Match labels → K8s pool + image]
I -->|No match| J[200 Ignored]
I -->|Match| K[INSERT job into PostgreSQL]
K --> L[NOTIFY queue_event]
L --> M[200 OK]
H -->|in_progress| N[UPDATE job status → running]
N --> M
H -->|completed| O[UPDATE job status → completed]
O --> M
Every accepted code path also writes one row into the installation_events table. See Installation event log.
Signature validation
Every incoming request is verified with HMAC-SHA256 of the raw body against the X-Hub-Signature-256 header, using a constant-time compare. The handler also requires X-GitHub-Hook-Installation-Target-Id so each event can be attributed to one of the two GitHub Apps (organization or personal).
Event dispatch
The handler dispatches on X-GitHub-Event:
| Event | What ghfe does |
|---|---|
ping | 200 OK; logs the row |
installation (created / deleted / suspend / unsuspend) | Records the row; installation.deleted and installation.suspend have no other side effect |
installation_repositories (added / removed) | Records the row |
installation_target (renamed) | Records the row |
workflow_job (queued / in_progress / completed) | DB write + installation_events row |
Unhandled X-GitHub-Event headers are recorded with outcome=unhandled_event and otherwise ignored.
Label matching
workflow_job.queued invokes matchLabelsToK8s(cfg, orgID, repoFullName, labels) in control-plane/cmd/ghfe/payload.go. The current routing rules:
| Scope | Labels (exact set) | Board | Provider | Image |
|---|---|---|---|---|
| Default | ubuntu-24.04-riscv | scaleway-em-rv1 | scaleway | ubuntu-24.04 |
| Default | ubuntu-26.04-riscv | TBD | TBD | ubuntu-26.04 |
GGML scope: ggml-org/*, riseproject-dev/llama.cpp, riseproject-dev/llama.cpp-validation | ubuntu-24.04-riscv | spacemit-k1 | cloudv10x | ubuntu-24.04 |
Images resolve to ghcr.io/riseproject-dev/riscv-runner/runner/ubuntu-<version>:prod (or :staging).
Node selectors
matchLabelsToK8s returns a node selector, not a single pool name: a set of node labels the runner pod must match.
{"riseproject.dev/board": "spacemit-k1", "riseproject.dev/provider": "mengzhuo"}
riseproject.dev/board is written by the device plugin from the detected SoC. riseproject.dev/provider identifies which vendor supplies the machine (e.g. scaleway, cloudv10x, iscas) and is applied to nodes by hand, because nothing can detect ownership from the hardware. Two providers can supply the same board, so the provider label is what pins a job to one vendor’s machines.
The selector is stored in jobs.k8s_selector and used directly as the pod’s nodeSelector. A selector without a board is rejected, since an empty label selector would match every node in the cluster.
Capacity lookups are currently narrowed to the board only, ignoring the provider. Each board model has a single provider today, and counting per board also accounts for runners started before the provider label existed: those pods carry no provider label, so a provider-scoped count would treat their nodes as idle and double-book them. This narrows to the full selector once no board-only pods remain.
Staging proxy
In production mode, webhooks for entities flagged as staging in EntityConfigs are forwarded to STAGING_URL with the original body and headers. This lets a single GitHub App installation serve both environments: prod proxies the matching repositories to staging while continuing to handle everyone else itself.
HTTP endpoints
| Route | Method | Purpose |
|---|---|---|
/ | POST | Webhook endpoint for GitHub events |
/health | GET | Health check (returns ok) |
/setup/org | GET | Post-install landing page for organization installations |
/setup/personal | GET | Post-install landing page for personal-account installations |
/trace/entity/{entity_id} | GET | Installation event log for an entity (bearer auth) |
/trace/installation/{installation_id} | GET | Resolves to entity_id then returns its event log (bearer auth) |
/trace/job/{job_id} | GET | Resolves to entity_id via jobs.entity_id then returns its event log (bearer auth) |
/trace/payload/{event_id} | GET | Full JSONB payload for one row (bearer auth) |
/setup/* is what GitHub redirects users to after they install the App. The handler reads installation_id from the query string, fetches the installation, and renders one of five outcomes: missing, wrongApp, wrongType, installedOK, upstreamError. The trace endpoints are gated by Authorization: Bearer $TRACE_API_SECRET; see Installation event log.
The service listens on port 8080. Graceful shutdown on SIGINT/SIGTERM with a 10s drain.
Persistence
Jobs and installation_events are written to PostgreSQL. The jobs table is the canonical record of demand; the workers table tracks supply (written by the scheduler, not by ghfe). Full schema and indexes: Database schema.
On workflow_job.queued, the handler:
INSERTs a row intojobswithON CONFLICT (job_id) DO NOTHING(so a redelivered webhook is a no-op). Populatesjob_nameandjob_created_atfrom the payload.- The PostgreSQL trigger emits
NOTIFY {schema}_queue_event. The schedulerLISTENs on that channel and wakes immediately rather than waiting for its 15-second tick.
On workflow_job.in_progress, the handler updates status to running and sets job_started_at via COALESCE. On workflow_job.completed, it sets status to completed, plus job_started_at, job_completed_at, and job_conclusion (all COALESCE-once). WHERE clauses enforce forward-only status transitions, and COALESCE makes the timing/conclusion writes idempotent against webhook redeliveries.
Environment
ghfe reads:
| Variable | Purpose |
|---|---|
PROD | true to use the prod schema and *-prod image tags, otherwise staging schema and *-staging tags |
PROD_URL, STAGING_URL | URL of the prod/staging ghfe; used by the staging proxy |
POSTGRES_URL | PostgreSQL connection string |
GHAPP_WEBHOOK_SECRET | Shared HMAC secret used by both apps |
GHAPP_ORG_PRIVATE_KEY, GHAPP_PERSONAL_PRIVATE_KEY | RSA private keys for the two GitHub Apps (PEM) |
TRACE_API_SECRET | Bearer token gating /trace/* |
LOGLEVEL | DEBUG / INFO / WARN / ERROR |
Related files
control-plane/cmd/ghfe/main.go: server setup and route registration.control-plane/cmd/ghfe/webhook.go: event dispatch and side-effect writes.control-plane/cmd/ghfe/payload.go:matchLabelsToK8sand payload trimming.control-plane/cmd/ghfe/signature.go: HMAC verification.control-plane/cmd/ghfe/staging_proxy.go: prod-to-staging forwarder.control-plane/cmd/ghfe/setup.goandsetup.gohtml: post-install landing pages.control-plane/cmd/ghfe/trace.go: trace endpoint implementations.control-plane/internal/db.go: PostgreSQL operations.control-plane/internal/github.go: GitHub App auth and REST client.control-plane/internal/constants.go: config, entity ID constants,EntityConfigs.