Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ PROVISIONING_POLICY_TIME4VPS_MANAGED_SERVER_IDS=
GITHUB_APP_ID=your_app_id
GITHUB_APP_PRIVATE_KEY_PATH=/app/keys/github_app.pem
GITHUB_ORG=your_github_org
# Signs GitHub webhook deliveries; required by the API. Generate: openssl rand -hex 32
GITHUB_WEBHOOK_SECRET=replace_with_a_long_random_webhook_secret

# ===========================
# Provisioning Configuration
Expand Down
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ See the CHANGELOG rule in [AGENTS.md](../AGENTS.md).

## 2026-10-18

- `POST /api/webhooks/github` publishes a `pr_ci` wake hint for an HMAC-signed `workflow_run`
completion; the API now requires `GITHUB_WEBHOOK_SECRET`, and public routing is not active yet.
- `POST /stories/{id}/pr_review` publishes a `pr_ci` wake hint after its commit, so the PR/CI sweep
reads a new PR review at once; GitHub CI completion still waits for the interval.
- `PATCH /runs/{id}` publishes `qa_routing` and `temporary_access` wake hints after a QA Run's first
Expand Down
2 changes: 1 addition & 1 deletion docs/CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Feature-specific invariants live in focused guides. Read this index first, then
| Generated-product template/package installation and QA | [Generated product kit and QA contracts](contracts/kit-template-and-qa.md) |
| Runtime lifecycle/security invariants | [Lifecycle and security invariants](contracts/lifecycle-invariants.md) |
| Managed target reconciliation/readiness | [Managed target readiness](contracts/managed-target-readiness.md) |
| Scheduler loop wake hints over native Pub/Sub | [Scheduler wake hints](contracts/scheduler-wake-hints.md) |
| Scheduler loop wake hints over native Pub/Sub, GitHub workflow-completion webhook | [Scheduler wake hints](contracts/scheduler-wake-hints.md) |

## Canonical vocabularies

Expand Down
8 changes: 7 additions & 1 deletion docs/DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,8 @@ that names no user is unrestricted, as before. `resolve_actor` in

Without the key or an explicitly authorized LK bearer, nothing reaches a handler: every route
except `GET /`, `GET /health` and `POST /api/lk/auth/token` is closed by
`require_authenticated_caller`. `X-Internal-Key` may enter the authenticated service surface;
`require_authenticated_caller`; `POST /api/webhooks/github` is left to its handler, which requires
a GitHub signature instead. `X-Internal-Key` may enter the authenticated service surface;
an LK bearer may enter only routes that declare a second bearer-aware owner/admin/current-user
dependency. Routes without one are internal-only by default. So the key is required by internal
services such as `make seed`, the scripts under `infra/scripts/` and the admin frontend's nginx
Expand Down Expand Up @@ -381,6 +382,11 @@ either `openrouter_balance` (the balance and whether an alert was sent) or
| `GH_APP_PRIVATE_KEY` | GitHub App private key PEM (written to `/opt/secrets/github_app.pem`) |
| `GH_ORG` | GitHub organization name |
| `GHCR_TOKEN` | GitHub token with `packages:read` scope (for pulling worker images) |
| `GITHUB_WEBHOOK_SECRET` | Random secret GitHub signs webhook deliveries with; required by the API, which refuses to start without a nonblank value. No other key derives it |

The deploy workflow does not write `GITHUB_WEBHOOK_SECRET` into the server `.env` yet, so a
deploy of the API needs that wiring and the provisioned secret first. Activation of the webhook is
described in [GitHub workflow completion](contracts/scheduler-wake-hints.md#github-workflow-completion).

`GH_ORG` is the name of the *secret* only. The rename stopped at the secret: the deploy workflow
writes it into the server `.env` as `GITHUB_ORG=${{ secrets.GH_ORG }}`
Expand Down
51 changes: 46 additions & 5 deletions docs/contracts/scheduler-wake-hints.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ path, and the awakened cycle decides from the same durable Story/Task/Run state
| `lifecycle_supervision` | api Run update `PATCH /runs/{id}` on the first typed success of an ordinary story deploy | scheduler-pipeline `lifecycle_supervision` loop |
| `temporary_access` | api Run update `PATCH /runs/{id}` on the first readback proof of a temporary-access grant or revoke operation, and on a QA Run's first terminal state | scheduler-pipeline `temporary_access` loop |
| `qa_routing` | api Run update `PATCH /runs/{id}` on a story-scoped QA Run's first terminal state | scheduler-pipeline `qa_routing` loop |
| `pr_ci` | api Story transition `POST /stories/{id}/pr_review` | scheduler-pipeline `pr_ci` loop |
| `pr_ci` | api Story transition `POST /stories/{id}/pr_review`; api GitHub receiver `POST /api/webhooks/github` on a signed `workflow_run` completion | scheduler-pipeline `pr_ci` loop |

## Producers

Expand Down Expand Up @@ -184,10 +184,51 @@ path, and the awakened cycle decides from the same durable Story/Task/Run state
- A Redis or socket error on publish is logged as `pr_ci_wake_failed` with the story id and the
error type only; the committed transition and its response are unchanged. Cancellation
propagates. There is no retry.
- Every GitHub-side change still waits for the interval: CI completing with success or failure, a
PR becoming mergeable, behind or conflicted, a merge, and the published images of a merge
commit. No authenticated ingress for GitHub events exists, so this loop receives no hint from
GitHub.
- The [GitHub receiver](#github-workflow-completion) publishes one `pr_ci` hint for each signed
`workflow_run` completion it accepts. Every other GitHub-side change still waits for the
interval: a PR becoming mergeable, behind or conflicted, a merge, and the published images of a
merge commit, as does every completion that receiver never gets.

### GitHub workflow completion

- `POST /api/webhooks/github` is the one route the API's application gate leaves to its handler
besides the anonymous ones. The handler authenticates every delivery by its own signature; a
valid `X-Internal-Key` or LK bearer on the request changes nothing.
- The body is read as a stream and refused with 413 once it passes 1 MiB (1,048,576 bytes),
whatever `Content-Length` says. Exactly one `X-Hub-Signature-256` header of `sha256=` and 64 hex
digits must equal the HMAC-SHA256 of the unchanged body bytes under `GITHUB_WEBHOOK_SECRET`,
compared with `hmac.compare_digest` before any parsing; a missing, malformed, repeated or
mismatching signature is a 403 that publishes nothing.
- An authenticated delivery must have a single `Content-Type` of `application/json`, optionally
with `charset=utf-8`, or it is a 415. Its body must be a UTF-8 JSON object and it must carry one
`X-GitHub-Event` header, or it is a 400. A signed `ping` and every other event are answered 204
and publish nothing.
- A `workflow_run` delivery's `action` must be a string. For `completed`, `workflow_run` must be an
object with a positive integer `id` and `status` `completed` (`GitHubWorkflowRunCompleted` in
`services/api/src/schemas/github_webhook.py`); otherwise it is a 400. Every other member is
ignored, so GitHub may add fields. A valid completion of any conclusion publishes one `pr_ci`
hint and is answered 204; every other action is answered 204 and publishes nothing.
- The payload is never business evidence. Its PR, branch, SHA, repository, installation and
conclusion select no record and authorize no merge, deploy or fix; the route writes nothing, calls
no scheduler code and fetches nothing from GitHub. The awakened sweep reads current Story, PR,
check and image state as it does on its interval.
- There is no delivery ledger: a replayed, duplicated or reordered completion publishes again and
the loop's Event coalesces it, and a delivery that never arrives waits for the interval.
- Refusal bodies carry a fixed `detail` only. A refusal is logged as `github_webhook_refused` with a
server-owned `reason` and the status code, an ignored delivery as `github_webhook_ignored` with a
server-owned `event_kind`; no header, payload, signature or exception text is logged or echoed.
- Publication uses the same best-effort publisher with `source=github_workflow_run`: a Redis or
socket error is logged as `pr_ci_wake_failed` with the error type only and the delivery is still
answered 204, so GitHub does not redeliver a hint the interval covers. Zero receivers is a
published hint. Cancellation propagates. There is no retry.
- No GitHub delivery reaches this route yet. Activation needs, in order: a random secret generated
and provisioned as `GITHUB_WEBHOOK_SECRET` (for example `openssl rand -hex 32`, held only in the
deployment secret store); the deploy workflow writing it into the API's environment, without
which the API refuses to start; public HTTPS routing of exactly `POST /api/webhooks/github` to the
API that preserves the body and headers; the GitHub App's webhook URL and that secret, its
`workflow_run` event subscription and its Actions read permission; and evidence of a delivery
answered 204 followed by an awakened `pr_ci` sweep. To disable it, remove the route from the
public proxy or the App's subscription; the interval sweep keeps recovering every completion.

## Listener

Expand Down
1 change: 1 addition & 0 deletions docs/contracts/story-task-run-surfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ completed result.
| System config | `services/api/src/schemas/system_config.py` | corresponding API routers | API-local system-config payloads are not shared DTOs |
| Telegram binding | `shared/contracts/dto/telegram.py` | `routers/projects.py` | token binding is fail-closed and stores the verified bot identity |
| API analytics payloads | `services/api/src/schemas/analytics.py` | `routers/analytics.py` | these API-local models have no shared duplicate |
| GitHub `workflow_run` delivery | `services/api/src/schemas/github_webhook.py` | `routers/github_webhooks.py` | a minimal projection of GitHub's payload read only after its HMAC signature verifies; it selects no record and only wakes the `pr_ci` loop, see [GitHub workflow completion](scheduler-wake-hints.md#github-workflow-completion) |
| Promo-code and port allocation payloads | `services/api/src/schemas/promo_code.py`, `schemas/port_allocation.py` | promo-code and allocation routes | route ownership determines admission and visibility |

### Shared DTO foundations
Expand Down
1 change: 1 addition & 0 deletions scripts/check_service_image_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ def pyproject(self) -> Path:
"DEFAULT_AGENT_TYPE": "claude",
"GITHUB_APP_ID": "12345",
"GITHUB_APP_PRIVATE_KEY_PATH": "/dev/null",
"GITHUB_WEBHOOK_SECRET": "test-github-webhook-secret",
"HEALTH_CHECK_INTERVAL": "60",
"INTERNAL_API_KEY": "test-internal-key",
"LK_DOMAIN": "https://lk.test.example.com",
Expand Down
1 change: 1 addition & 0 deletions scripts/test-unit-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ CLEAN_ENV=(
TELEGRAM_MAX_CONCURRENT_UPDATES="8"
INTERNAL_API_KEY="test-internal-key"
LK_JWT_SECRET="test-lk-jwt-secret"
GITHUB_WEBHOOK_SECRET="test-github-webhook-secret"
DEFAULT_AGENT_TYPE="claude"
DATABASE_URL="postgresql+asyncpg://test:test@localhost:5432/test"
)
Expand Down
14 changes: 12 additions & 2 deletions services/api/src/config.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
"""API service configuration.

Requires: DATABASE_URL, REDIS_URL, WORKER_MANAGER_URL, LK_JWT_SECRET,
INTERNAL_API_KEY, DEFAULT_AGENT_TYPE
INTERNAL_API_KEY, GITHUB_WEBHOOK_SECRET, DEFAULT_AGENT_TYPE
"""

from functools import lru_cache

from pydantic import Field
from pydantic import Field, SecretStr, field_validator

from shared.config import (
BaseSettings,
Expand All @@ -33,6 +33,9 @@ class Settings(BaseSettings):
# Internal service token — sent by workers, scheduler, langgraph as X-Internal-Key
internal_api_key: str = internal_api_key_field()

# Signs GitHub webhook deliveries; a dedicated credential no other key derives.
github_webhook_secret: SecretStr = Field(alias="GITHUB_WEBHOOK_SECRET", repr=False)

# Project creation resolves this at request time, so deployments can change
# the developer-worker default without changing the PO request contract.
default_agent_type: AgentType = default_agent_type_field()
Expand All @@ -49,6 +52,13 @@ class Settings(BaseSettings):
# registry; empty shows none beyond the ones deployed products already use.
platform_services: str = Field(default="", alias="PLATFORM_SERVICES")

@field_validator("github_webhook_secret")
@classmethod
def _refuse_blank_webhook_secret(cls, value: SecretStr) -> SecretStr:
if not value.get_secret_value().strip():
raise ValueError("GITHUB_WEBHOOK_SECRET must not be empty or whitespace")
return value

def get_platform_services(self) -> dict[str, str]:
"""Parse the declared platform services into name → base URL."""
services: dict[str, str] = {}
Expand Down
4 changes: 4 additions & 0 deletions services/api/src/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,10 @@ async def get_lk_user(
# its caller has nothing to authenticate with yet. The one-time token in
# the body is the secret, and the handler verifies it against Redis.
("POST", "/api/lk/auth/token"),
# GitHub cannot hold our credentials. The handler authenticates every
# delivery by its HMAC signature over the raw body, and an internal key
# or bearer on the request never stands in for that signature.
("POST", "/api/webhooks/github"),
}
)

Expand Down
1 change: 1 addition & 0 deletions services/api/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,4 @@ async def root():
app.include_router(routers.product_briefs.router, prefix="/api")
app.include_router(routers.capability_previews.router, prefix="/api")
app.include_router(routers.temporary_access.router, prefix="/api")
app.include_router(routers.github_webhooks.router, prefix="/api")
2 changes: 2 additions & 0 deletions services/api/src/routers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
debug,
engineering_budget_policies,
engineering_consumer,
github_webhooks,
health,
incidents,
lk,
Expand Down Expand Up @@ -47,6 +48,7 @@
"debug",
"engineering_consumer",
"engineering_budget_policies",
"github_webhooks",
"health",
"incidents",
"lk",
Expand Down
133 changes: 133 additions & 0 deletions services/api/src/routers/github_webhooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""GitHub webhook receiver: an authenticated workflow completion wakes the PR/CI sweep."""

import hashlib
import hmac
import json
import re

from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import ValidationError
import structlog

from shared.contracts.queues.scheduler_wake import SchedulerWakeTarget
from shared.redis.client import RedisStreamClient

from ..config import get_settings
from ..dependencies import get_redis_client
from ..scheduler_wake import publish_wake
from ..schemas.github_webhook import GitHubWorkflowRunCompleted, GitHubWorkflowRunDelivery

logger = structlog.get_logger()

router = APIRouter(prefix="/webhooks", tags=["webhooks"])

MAX_DELIVERY_BYTES = 1024 * 1024
_SIGNATURE = re.compile(r"sha256=([0-9a-fA-F]{64})")
_MALFORMED = "Malformed webhook delivery"


def _refuse(status_code: int, reason: str, detail: str) -> HTTPException:
"""Log a refusal by its server-owned reason only; nothing the sender wrote is echoed."""
logger.warning("github_webhook_refused", reason=reason, status_code=status_code)
return HTTPException(status_code=status_code, detail=detail)


async def _bounded_body(request: Request) -> bytes:
"""The exact delivery bytes, refused once the stream passes the bound, whatever its length."""
body = bytearray()
async for chunk in request.stream():
if len(body) + len(chunk) > MAX_DELIVERY_BYTES:
raise _refuse(
status.HTTP_413_CONTENT_TOO_LARGE, "body_too_large", "Webhook delivery too large"
)
body += chunk
return bytes(body)


def _verify_signature(request: Request, body: bytes) -> None:
values = request.headers.getlist("x-hub-signature-256")
if not values:
reason = "signature_missing"
elif len(values) > 1:
reason = "signature_ambiguous"
elif (match := _SIGNATURE.fullmatch(values[0])) is None:
reason = "signature_malformed"
else:
secret = get_settings().github_webhook_secret.get_secret_value().encode()
expected = hmac.new(secret, body, hashlib.sha256).digest()
if hmac.compare_digest(expected, bytes.fromhex(match.group(1))):
return
reason = "signature_mismatch"
raise _refuse(status.HTTP_403_FORBIDDEN, reason, "Invalid webhook signature")


def _is_json_media_type(request: Request) -> bool:
values = request.headers.getlist("content-type")
if len(values) != 1:
return False
media_type, *parameters = (part.strip() for part in values[0].split(";"))
if media_type.lower() != "application/json":
return False
if not parameters:
return True
if len(parameters) > 1:
return False
name, _, value = parameters[0].partition("=")
return name.strip().lower() == "charset" and value.strip().strip('"').lower() == "utf-8"


def _refuse_non_json_constant(name: str) -> None:
# Python's decoder accepts NaN and the infinities, which RFC 8259 JSON does not.
raise ValueError("non-JSON numeric constant")


def _json_object(body: bytes) -> dict:
try:
payload = json.loads(body.decode("utf-8"), parse_constant=_refuse_non_json_constant)
except (ValueError, RecursionError):
raise _refuse(status.HTTP_400_BAD_REQUEST, "payload_not_json", _MALFORMED) from None
if not isinstance(payload, dict):
raise _refuse(status.HTTP_400_BAD_REQUEST, "payload_not_object", _MALFORMED)
return payload


def _is_completed_workflow_run(payload: dict) -> bool:
try:
if GitHubWorkflowRunDelivery.model_validate(payload).action != "completed":
return False
GitHubWorkflowRunCompleted.model_validate(payload)
except ValidationError:
raise _refuse(status.HTTP_400_BAD_REQUEST, "payload_invalid", _MALFORMED) from None
return True


@router.post("/github", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
async def receive_github_webhook(
request: Request,
redis: RedisStreamClient = Depends(get_redis_client),
) -> Response:
"""Wake the PR/CI sweep for a signed `workflow_run` completion of any conclusion.

The signature over the raw body is the only credential this route accepts. The hint is the
only effect: the payload selects, authorizes and fetches nothing.
"""
body = await _bounded_body(request)
_verify_signature(request, body)
if not _is_json_media_type(request):
raise _refuse(
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
"unsupported_media_type",
"Webhook delivery must be JSON",
)
payload = _json_object(body)
events = request.headers.getlist("x-github-event")
if len(events) != 1:
raise _refuse(status.HTTP_400_BAD_REQUEST, "event_header_invalid", _MALFORMED)
if events[0] != "workflow_run":
kind = "ping" if events[0] == "ping" else "unsupported_event"
logger.info("github_webhook_ignored", event_kind=kind)
elif _is_completed_workflow_run(payload):
await publish_wake(redis, SchedulerWakeTarget.PR_CI, source="github_workflow_run")
else:
logger.info("github_webhook_ignored", event_kind="workflow_run_action")
return Response(status_code=status.HTTP_204_NO_CONTENT)
33 changes: 33 additions & 0 deletions services/api/src/schemas/github_webhook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""The GitHub `workflow_run` delivery members the webhook receiver reads.

GitHub owns the payload; every member not declared here is ignored, so these
projections stay valid as GitHub adds fields.
"""

from typing import Literal

from pydantic import BaseModel, ConfigDict, Field, StrictStr


class GitHubWorkflowRunDelivery(BaseModel):
"""Any `workflow_run` delivery: only its action is read."""

model_config = ConfigDict(extra="ignore")

action: StrictStr


class GitHubCompletedWorkflowRun(BaseModel):
model_config = ConfigDict(extra="ignore")

id: int = Field(strict=True, gt=0)
status: Literal["completed"]


class GitHubWorkflowRunCompleted(BaseModel):
"""A `completed` delivery, for any conclusion; its run names no business record."""

model_config = ConfigDict(extra="ignore")

action: Literal["completed"]
workflow_run: GitHubCompletedWorkflowRun
Loading