diff --git a/.env.example b/.env.example index 2d9ba995d..30ec034eb 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2ed92d989..75c2d5afa 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 4e3c13ed5..0a2137e24 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -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 diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 043a876dd..f3602f6dc 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -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 @@ -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 }}` diff --git a/docs/contracts/scheduler-wake-hints.md b/docs/contracts/scheduler-wake-hints.md index f9932b6c1..f871dbf3d 100644 --- a/docs/contracts/scheduler-wake-hints.md +++ b/docs/contracts/scheduler-wake-hints.md @@ -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 @@ -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 diff --git a/docs/contracts/story-task-run-surfaces.md b/docs/contracts/story-task-run-surfaces.md index 0dafd4250..bf3623263 100644 --- a/docs/contracts/story-task-run-surfaces.md +++ b/docs/contracts/story-task-run-surfaces.md @@ -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 diff --git a/scripts/check_service_image_imports.py b/scripts/check_service_image_imports.py index dac179c9d..3571717cb 100644 --- a/scripts/check_service_image_imports.py +++ b/scripts/check_service_image_imports.py @@ -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", diff --git a/scripts/test-unit-local.sh b/scripts/test-unit-local.sh index fcf821daf..c58325140 100755 --- a/scripts/test-unit-local.sh +++ b/scripts/test-unit-local.sh @@ -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" ) diff --git a/services/api/src/config.py b/services/api/src/config.py index 88a4f1927..f91dd0f03 100644 --- a/services/api/src/config.py +++ b/services/api/src/config.py @@ -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, @@ -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() @@ -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] = {} diff --git a/services/api/src/dependencies.py b/services/api/src/dependencies.py index 41bbd9c62..3248ceb0d 100644 --- a/services/api/src/dependencies.py +++ b/services/api/src/dependencies.py @@ -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"), } ) diff --git a/services/api/src/main.py b/services/api/src/main.py index 188290b6c..2f10e1b0d 100644 --- a/services/api/src/main.py +++ b/services/api/src/main.py @@ -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") diff --git a/services/api/src/routers/__init__.py b/services/api/src/routers/__init__.py index e805601d9..92478527e 100644 --- a/services/api/src/routers/__init__.py +++ b/services/api/src/routers/__init__.py @@ -14,6 +14,7 @@ debug, engineering_budget_policies, engineering_consumer, + github_webhooks, health, incidents, lk, @@ -47,6 +48,7 @@ "debug", "engineering_consumer", "engineering_budget_policies", + "github_webhooks", "health", "incidents", "lk", diff --git a/services/api/src/routers/github_webhooks.py b/services/api/src/routers/github_webhooks.py new file mode 100644 index 000000000..baada4bec --- /dev/null +++ b/services/api/src/routers/github_webhooks.py @@ -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) diff --git a/services/api/src/schemas/github_webhook.py b/services/api/src/schemas/github_webhook.py new file mode 100644 index 000000000..29f159a26 --- /dev/null +++ b/services/api/src/schemas/github_webhook.py @@ -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 diff --git a/services/api/tests/service/test_github_webhook_wake.py b/services/api/tests/service/test_github_webhook_wake.py new file mode 100644 index 000000000..797680424 --- /dev/null +++ b/services/api/tests/service/test_github_webhook_wake.py @@ -0,0 +1,120 @@ +"""A signed GitHub workflow completion over HTTP publishes the PR/CI hint on real Redis.""" + +from collections.abc import AsyncGenerator +import hashlib +import hmac +import json +import os + +from httpx import ASGITransport, AsyncClient +import pytest + +from shared.contracts.queues.scheduler_wake import SchedulerWakeTarget + +PATH = "/api/webhooks/github" + + +def sign(body: bytes, key: str) -> str: + return "sha256=" + hmac.new(key.encode(), body, hashlib.sha256).hexdigest() + + +def completed(action: str = "completed", status: str = "completed") -> bytes: + run = {"id": 30433642, "status": status, "conclusion": "failure", "head_branch": "story/x"} + return json.dumps({"action": action, "workflow_run": run, "repository": {"id": 1}}).encode() + + +@pytest.fixture +async def github() -> AsyncGenerator[AsyncClient, None]: + """A caller holding no API credential, as GitHub is.""" + from src.main import app + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + yield client + + +async def deliver(client: AsyncClient, body: bytes, event: str, **headers: str): + return await client.post( + PATH, + content=body, + headers={"Content-Type": "application/json", "X-GitHub-Event": event, **headers}, + ) + + +@pytest.mark.asyncio +async def test_a_signed_completion_publishes_exactly_one_pr_ci_hint(github, wake_hints): + body = completed() + signature = sign(body, os.environ["GITHUB_WEBHOOK_SECRET"]) + + response = await deliver(github, body, "workflow_run", **{"X-Hub-Signature-256": signature}) + + assert response.status_code == 204, response.text + assert response.content == b"" + assert await wake_hints.published() == [SchedulerWakeTarget.PR_CI] + + replay = await deliver(github, body, "workflow_run", **{"X-Hub-Signature-256": signature}) + + assert replay.status_code == 204, replay.text + assert await wake_hints.published() == [SchedulerWakeTarget.PR_CI] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "credentials", + [ + {}, + {"X-Hub-Signature-256": sign(completed(), "not-the-webhook-secret")}, + {"X-Hub-Signature-256": sign(completed(), os.environ["INTERNAL_API_KEY"])}, + { + "X-Internal-Key": os.environ["INTERNAL_API_KEY"], + "Authorization": "Bearer not-a-signature", + }, + ], + ids=["unsigned", "wrong-secret", "internal-key-as-secret", "api-credentials"], +) +async def test_bad_authentication_is_refused_and_publishes_nothing(github, wake_hints, credentials): + response = await deliver(github, completed(), "workflow_run", **credentials) + + assert response.status_code == 403, response.text + assert response.json() == {"detail": "Invalid webhook signature"} + assert await wake_hints.published() == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("event", "body"), + [ + ("ping", json.dumps({"zen": "Design for failure.", "hook_id": 1}).encode()), + ("workflow_run", completed(action="in_progress", status="in_progress")), + ], + ids=["ping", "ignored-action"], +) +async def test_a_signed_ping_or_ignored_action_is_acknowledged_silently( + github, wake_hints, event, body +): + signature = sign(body, os.environ["GITHUB_WEBHOOK_SECRET"]) + + response = await deliver(github, body, event, **{"X-Hub-Signature-256": signature}) + + assert response.status_code == 204, response.text + assert await wake_hints.published() == [] + + +@pytest.mark.asyncio +async def test_a_webhook_signature_opens_no_neighbouring_route(github, wake_hints): + body = json.dumps({"project_id": "00000000-0000-0000-0000-000000000001"}).encode() + signature = sign(body, os.environ["GITHUB_WEBHOOK_SECRET"]) + + for method, path in (("POST", "/api/stories/"), ("GET", "/api/stories/")): + response = await github.request( + method, + path, + content=body, + headers={ + "Content-Type": "application/json", + "X-GitHub-Event": "workflow_run", + "X-Hub-Signature-256": signature, + }, + ) + assert response.status_code == 401, (path, response.text) + + assert await wake_hints.published() == [] diff --git a/services/api/tests/unit/conftest.py b/services/api/tests/unit/conftest.py index a0bee01e7..4d5ae2659 100644 --- a/services/api/tests/unit/conftest.py +++ b/services/api/tests/unit/conftest.py @@ -13,6 +13,7 @@ "SECRETS_ENCRYPTION_KEY", "wHhIQWmPfLt60oHdxzbQhY1ZKnUon12e5_SuZ33xDxc=" ) # Valid Fernet key for tests only os.environ.setdefault("LK_JWT_SECRET", "unit-test-lk-jwt-secret") +os.environ.setdefault("GITHUB_WEBHOOK_SECRET", "unit-test-github-webhook-secret") from pathlib import Path # noqa: E402 diff --git a/services/api/tests/unit/test_compose_api_settings.py b/services/api/tests/unit/test_compose_api_settings.py index 383dcd7a7..b8e099d34 100644 --- a/services/api/tests/unit/test_compose_api_settings.py +++ b/services/api/tests/unit/test_compose_api_settings.py @@ -39,6 +39,7 @@ def test_api_compose_provides_required_worker_manager_connectivity( "WORKER_MANAGER_URL", "LK_JWT_SECRET", "INTERNAL_API_KEY", + "GITHUB_WEBHOOK_SECRET", "DEFAULT_AGENT_TYPE", ): monkeypatch.delenv(key, raising=False) diff --git a/services/api/tests/unit/test_config.py b/services/api/tests/unit/test_config.py index e6500bc36..861af90cb 100644 --- a/services/api/tests/unit/test_config.py +++ b/services/api/tests/unit/test_config.py @@ -58,3 +58,30 @@ def test_blank_internal_api_key_is_rejected(monkeypatch): with pytest.raises(ValueError, match="INTERNAL_API_KEY"): Settings(_env_file=None) + + +def test_missing_github_webhook_secret_is_rejected(monkeypatch): + """Webhook authentication has no disabled mode: a missing secret fails startup.""" + monkeypatch.delenv("GITHUB_WEBHOOK_SECRET", raising=False) + + with pytest.raises(ValueError, match="GITHUB_WEBHOOK_SECRET"): + Settings(_env_file=None) + + +@pytest.mark.parametrize("value", ["", " ", "\t\n "]) +def test_blank_github_webhook_secret_is_rejected(monkeypatch, value): + monkeypatch.setenv("GITHUB_WEBHOOK_SECRET", value) + + with pytest.raises(ValueError, match="GITHUB_WEBHOOK_SECRET"): + Settings(_env_file=None) + + +def test_github_webhook_secret_is_masked_outside_verification(monkeypatch): + value = "synthetic-webhook-signing-value" + monkeypatch.setenv("GITHUB_WEBHOOK_SECRET", value) + + settings = Settings(_env_file=None) + + assert settings.github_webhook_secret.get_secret_value() == value + for rendered in (repr(settings), str(settings), settings.model_dump_json()): + assert value not in rendered diff --git a/services/api/tests/unit/test_github_webhook.py b/services/api/tests/unit/test_github_webhook.py new file mode 100644 index 000000000..e5e222848 --- /dev/null +++ b/services/api/tests/unit/test_github_webhook.py @@ -0,0 +1,518 @@ +"""A signed GitHub workflow completion wakes the PR/CI sweep; nothing else it sends does.""" + +import asyncio +import hashlib +import hmac +import json +import os +from types import SimpleNamespace + +from fastapi import HTTPException +import pytest +from redis.exceptions import ConnectionError as RedisConnectionError +from starlette.requests import Request +from structlog.testing import capture_logs +from wake_doubles import NativeRedis + +from shared.contracts.queues.scheduler_wake import SchedulerWakeTarget +from src.config import get_settings +from src.routers.github_webhooks import MAX_DELIVERY_BYTES, receive_github_webhook + +WAKE = ("wake", SchedulerWakeTarget.PR_CI) +MALFORMED = "Malformed webhook delivery" + + +def secret() -> str: + return get_settings().github_webhook_secret.get_secret_value() + + +def sign(body: bytes, key: str | None = None) -> str: + digest = hmac.new((key or secret()).encode(), body, hashlib.sha256).hexdigest() + return f"sha256={digest}" + + +def delivery(action: str = "completed", conclusion: str | None = "success", **run) -> dict: + return { + "action": action, + "workflow_run": { + "id": 30433642, + "name": "CI", + "head_branch": "story/other-project", + "head_sha": "a" * 40, + "status": "completed", + "conclusion": conclusion, + "pull_requests": [{"number": 999, "head": {"ref": "story/other-project"}}], + "url": "https://api.github.com/repos/elsewhere/repo/actions/runs/30433642", + **run, + }, + "repository": {"full_name": "elsewhere/repo"}, + "installation": {"id": 1}, + } + + +def encode(payload) -> bytes: + return json.dumps(payload).encode() + + +def headers( + body: bytes, + *, + event: str | None = "workflow_run", + content_type: str | None = "application/json", + signatures: list[str] | None = None, +) -> list[tuple[str, str]]: + pairs = [] + if content_type is not None: + pairs.append(("content-type", content_type)) + if event is not None: + pairs.append(("x-github-event", event)) + for signature in [sign(body)] if signatures is None else signatures: + pairs.append(("x-hub-signature-256", signature)) + return pairs + + +def make_request(body: bytes, pairs: list[tuple[str, str]], chunk: int | None = None) -> Request: + size = chunk or max(len(body), 1) + parts = [body[i : i + size] for i in range(0, len(body), size)] or [b""] + messages = iter( + {"type": "http.request", "body": part, "more_body": index < len(parts) - 1} + for index, part in enumerate(parts) + ) + + async def receive(): + return next(messages) + + return Request(http_scope(pairs), receive) + + +def http_scope(pairs: list[tuple[str, str]]) -> dict: + return { + "type": "http", + "method": "POST", + "path": "/api/webhooks/github", + "headers": [(name.encode(), value.encode("latin-1")) for name, value in pairs], + } + + +@pytest.fixture +def journal() -> list: + return [] + + +@pytest.fixture +def native(journal) -> NativeRedis: + return NativeRedis(journal) + + +@pytest.fixture +def redis(native) -> SimpleNamespace: + return SimpleNamespace(redis=native) + + +async def deliver(redis, body: bytes, pairs: list[tuple[str, str]] | None = None, **request): + pairs = headers(body) if pairs is None else pairs + return await receive_github_webhook(make_request(body, pairs, **request), redis=redis) + + +async def refused(redis, body: bytes, pairs: list[tuple[str, str]] | None = None, **request): + with pytest.raises(HTTPException) as refusal: + await deliver(redis, body, pairs, **request) + return refusal.value + + +def assert_safe(logs: list, *needles: str) -> None: + rendered = repr(logs) + for needle in (secret(), *needles): + assert needle not in rendered + + +@pytest.mark.parametrize("conclusion", ["success", "failure", "cancelled", "timed_out", None]) +async def test_a_signed_completion_publishes_one_hint_for_any_conclusion( + journal, redis, conclusion +): + with capture_logs() as logs: + response = await deliver(redis, encode(delivery(conclusion=conclusion))) + + assert response.status_code == 204 + assert response.body == b"" + assert journal == [WAKE] + [published] = [log for log in logs if log["event"] == "pr_ci_wake_published"] + assert published == { + "event": "pr_ci_wake_published", + "log_level": "info", + "source": "github_workflow_run", + "receivers": 1, + } + + +async def test_the_signature_covers_the_exact_bytes_including_whitespace_and_unicode( + journal, redis +): + body = ( + '{ "action" :"completed",\n\t"workflow_run": {"id": 7, "status": "completed",' + ' "name": "Сборка ✓ \\u00e9"}, "sender": {"login": "测试"} }\r\n ' + ).encode() + reserialized = encode(json.loads(body)) + assert reserialized != body + + assert (await deliver(redis, body)).status_code == 204 + assert journal == [WAKE] + + refusal = await refused(redis, reserialized, headers(reserialized, signatures=[sign(body)])) + assert refusal.status_code == 403 + assert journal == [WAKE] + + +async def test_a_body_modified_after_signing_is_refused(journal, redis): + body = encode(delivery()) + tampered = body.replace(b"30433642", b"30433643") + + with capture_logs() as logs: + refusal = await refused(redis, tampered, headers(tampered, signatures=[sign(body)])) + + assert (refusal.status_code, refusal.detail) == (403, "Invalid webhook signature") + assert journal == [] + assert logs == [ + { + "event": "github_webhook_refused", + "log_level": "warning", + "reason": "signature_mismatch", + "status_code": 403, + } + ] + + +BODY = encode(delivery()) +DIGEST = hmac.new(b"x", BODY, hashlib.sha256).hexdigest() + + +@pytest.mark.parametrize( + ("signatures", "reason"), + [ + ([], "signature_missing"), + ([sign(BODY), sign(BODY)], "signature_ambiguous"), + ([f"sha1={DIGEST[:40]}"], "signature_malformed"), + (["sha256="], "signature_malformed"), + ([f"sha256={DIGEST[:63]}"], "signature_malformed"), + ([f"sha256={DIGEST}0"], "signature_malformed"), + ([f"sha256={'z' * 64}"], "signature_malformed"), + ([f"SHA256={DIGEST}"], "signature_malformed"), + ([f"sha256={DIGEST},sha256={DIGEST}"], "signature_malformed"), + ([f"sha256={DIGEST}"], "signature_mismatch"), + ([sign(BODY, os.environ["INTERNAL_API_KEY"])], "signature_mismatch"), + ([sign(BODY, os.environ["LK_JWT_SECRET"])], "signature_mismatch"), + ], + ids=lambda value: value if isinstance(value, str) else f"{len(value)}-headers", +) +async def test_a_missing_malformed_ambiguous_or_wrong_signature_is_refused( + journal, redis, signatures, reason +): + with capture_logs() as logs: + refusal = await refused(redis, BODY, headers(BODY, signatures=signatures)) + + assert (refusal.status_code, refusal.detail) == (403, "Invalid webhook signature") + assert journal == [] + assert [(log["event"], log["reason"]) for log in logs] == [("github_webhook_refused", reason)] + assert_safe(logs, DIGEST, "30433642", "elsewhere") + + +async def test_the_signature_is_checked_before_the_media_type_and_json(journal, redis): + body = b"not json" + + refusal = await refused( + redis, body, headers(body, content_type="text/plain", signatures=[f"sha256={DIGEST}"]) + ) + + assert refusal.status_code == 403 + assert journal == [] + + +@pytest.mark.parametrize( + "content_type", + ["application/json", "application/json; charset=utf-8", 'Application/JSON;Charset="UTF-8"'], +) +async def test_a_json_media_type_with_an_optional_utf8_charset_is_accepted( + journal, redis, content_type +): + body = encode(delivery()) + + assert (await deliver(redis, body, headers(body, content_type=content_type))).status_code == 204 + assert journal == [WAKE] + + +@pytest.mark.parametrize( + "content_type", + [ + None, + "text/plain", + "application/x-www-form-urlencoded", + "application/json; charset=latin-1", + "application/json; boundary=x", + "application/json; charset=utf-8; boundary=x", + "application/jsonp", + ], +) +async def test_a_signed_delivery_of_another_media_type_is_refused(journal, redis, content_type): + body = encode(delivery()) + + with capture_logs() as logs: + refusal = await refused(redis, body, headers(body, content_type=content_type)) + + assert (refusal.status_code, refusal.detail) == (415, "Webhook delivery must be JSON") + assert journal == [] + assert [log["reason"] for log in logs] == ["unsupported_media_type"] + + +async def test_duplicate_content_type_headers_are_refused(journal, redis): + body = encode(delivery()) + pairs = [*headers(body), ("content-type", "application/json")] + + assert (await refused(redis, body, pairs)).status_code == 415 + assert journal == [] + + +def _completed_run(**changes) -> dict: + payload = delivery() + payload["workflow_run"].update(changes) + return payload + + +@pytest.mark.parametrize( + ("event", "body", "reason"), + [ + ("workflow_run", b"{not json", "payload_not_json"), + ("workflow_run", b"", "payload_not_json"), + ("workflow_run", b'{"action": "completed", "x": "\xff"}', "payload_not_json"), + ("workflow_run", b"[" * 100_000 + b"]" * 100_000, "payload_not_json"), + ("workflow_run", b'["completed"]', "payload_not_object"), + ("ping", b'"zen"', "payload_not_object"), + ("push", b"null", "payload_not_object"), + ("workflow_run", encode({"workflow_run": delivery()["workflow_run"]}), "payload_invalid"), + ("workflow_run", encode({**delivery(), "action": 5}), "payload_invalid"), + ("workflow_run", encode({"action": "completed"}), "payload_invalid"), + ("workflow_run", encode({"action": "completed", "workflow_run": None}), "payload_invalid"), + ("workflow_run", encode({"action": "completed", "workflow_run": []}), "payload_invalid"), + ("workflow_run", encode(_completed_run(id=0)), "payload_invalid"), + ("workflow_run", encode(_completed_run(id=-3)), "payload_invalid"), + ("workflow_run", encode(_completed_run(id="30433642")), "payload_invalid"), + ("workflow_run", encode(_completed_run(id=True)), "payload_invalid"), + ("workflow_run", encode(_completed_run(id=1.0)), "payload_invalid"), + ("workflow_run", encode(_completed_run(status="in_progress")), "payload_invalid"), + ("workflow_run", encode(_completed_run(status=None)), "payload_invalid"), + ], +) +async def test_an_authenticated_malformed_delivery_is_refused_without_echo( + journal, redis, event, body, reason +): + with capture_logs() as logs: + refusal = await refused(redis, body, headers(body, event=event)) + + assert (refusal.status_code, refusal.detail) == (400, MALFORMED) + assert journal == [] + assert logs == [ + { + "event": "github_webhook_refused", + "log_level": "warning", + "reason": reason, + "status_code": 400, + } + ] + + +COMPLETED_PREFIX = '{"action": "completed", "workflow_run": {"id": 7, "status": "completed"' + + +@pytest.mark.parametrize("constant", ["NaN", "Infinity", "-Infinity"]) +@pytest.mark.parametrize( + ("event", "template"), + [ + ("workflow_run", COMPLETED_PREFIX + '}, "extra": %s}'), + ("workflow_run", COMPLETED_PREFIX + ', "metrics": {"ratio": [%s]}}}'), + ("workflow_run", '{"action": "in_progress", "extra": %s}'), + ("ping", '{"zen": "z", "extra": %s}'), + ("ping", '{"hook": {"config": {"ratio": %s}}}'), + ("push", '{"ref": "refs/heads/main", "extra": %s}'), + ], + ids=["completed-top", "completed-nested", "ignored-action", "ping-top", "ping-nested", "push"], +) +async def test_a_non_json_numeric_constant_is_refused_before_any_publication( + journal, redis, event, template, constant +): + body = (template % constant).encode() + + with capture_logs() as logs: + refusal = await refused(redis, body, headers(body, event=event)) + + assert (refusal.status_code, refusal.detail) == (400, MALFORMED) + assert journal == [] + assert logs == [ + { + "event": "github_webhook_refused", + "log_level": "warning", + "reason": "payload_not_json", + "status_code": 400, + } + ] + + +async def test_constant_names_as_strings_and_ordinary_numbers_remain_valid(journal, redis): + body = ( + COMPLETED_PREFIX + ', "conclusion": "NaN"}, "labels": ["Infinity", "-Infinity"],' + ' "numbers": [0, -1, 1.5e308, -2.5E-3, 12345678901234567890]}' + ).encode() + + assert (await deliver(redis, body)).status_code == 204 + assert journal == [WAKE] + + +@pytest.mark.parametrize("pairs", [[], [("x-github-event", "workflow_run")] * 2]) +async def test_a_missing_or_duplicate_event_header_is_refused(journal, redis, pairs): + body = encode(delivery()) + + refusal = await refused(redis, body, [*headers(body, event=None), *pairs]) + + assert (refusal.status_code, refusal.detail) == (400, MALFORMED) + assert journal == [] + + +@pytest.mark.parametrize( + ("event", "payload", "kind"), + [ + ("ping", {"zen": "Keep it logically awesome.", "hook_id": 1}, "ping"), + ("push", {"ref": "refs/heads/main"}, "unsupported_event"), + ("check_suite", {"action": "completed", "check_suite": {"id": 1}}, "unsupported_event"), + ("workflow_run", delivery(action="requested", status="queued"), "workflow_run_action"), + ( + "workflow_run", + delivery(action="in_progress", status="in_progress"), + "workflow_run_action", + ), + ("workflow_run", {"action": "requested", "workflow_run": None}, "workflow_run_action"), + ], +) +async def test_a_signed_ping_or_ignored_delivery_is_acknowledged_without_a_hint( + journal, redis, event, payload, kind +): + body = encode(payload) + + with capture_logs() as logs: + response = await deliver(redis, body, headers(body, event=event)) + + assert response.status_code == 204 + assert journal == [] + assert logs == [{"event": "github_webhook_ignored", "log_level": "info", "event_kind": kind}] + + +async def test_a_replayed_or_reordered_delivery_publishes_again(journal, redis): + completed = encode(delivery()) + earlier = encode(delivery(action="in_progress", status="in_progress")) + + for body in (completed, completed, earlier, completed): + assert (await deliver(redis, body)).status_code == 204 + + assert journal == [WAKE] * 3 + + +def padded(size: int) -> bytes: + body = encode(delivery()) + return body + b" " * (size - len(body)) + + +async def test_a_delivery_of_exactly_the_bound_is_accepted(journal, redis): + body = padded(MAX_DELIVERY_BYTES) + + assert (await deliver(redis, body, chunk=65536)).status_code == 204 + assert journal == [WAKE] + + +@pytest.mark.parametrize("declared", [None, "10", str(MAX_DELIVERY_BYTES)]) +async def test_a_stream_past_the_bound_is_refused_whatever_its_length_says( + journal, redis, declared +): + body = padded(MAX_DELIVERY_BYTES + 1) + pairs = headers(body) + if declared is not None: + pairs.append(("content-length", declared)) + + with capture_logs() as logs: + refusal = await refused(redis, body, pairs, chunk=65536) + + assert (refusal.status_code, refusal.detail) == (413, "Webhook delivery too large") + assert journal == [] + assert [log["reason"] for log in logs] == ["body_too_large"] + + +async def test_an_endless_stream_is_refused_after_reading_only_the_bound(journal, redis): + chunk = b" " * 65536 + reads = 0 + + async def receive(): + nonlocal reads + reads += 1 + return {"type": "http.request", "body": chunk, "more_body": True} + + request = Request(http_scope([("content-type", "application/json")]), receive) + with pytest.raises(HTTPException) as refusal: + await receive_github_webhook(request, redis=redis) + + assert refusal.value.status_code == 413 + assert reads == MAX_DELIVERY_BYTES // len(chunk) + 1 + assert journal == [] + + +async def test_zero_receivers_is_an_acknowledged_hint(journal, redis, native): + native.receivers = 0 + + assert (await deliver(redis, encode(delivery()))).status_code == 204 + assert journal == [WAKE] + + +@pytest.mark.parametrize( + "failure", [RedisConnectionError("redis://:secret@redis:6379"), OSError("socket closed")] +) +async def test_a_failed_publish_loses_only_the_hint(journal, redis, native, failure): + native.failure = failure + + with capture_logs() as logs: + response = await deliver(redis, encode(delivery())) + + assert response.status_code == 204 + assert journal == [] + assert logs == [ + { + "event": "pr_ci_wake_failed", + "log_level": "warning", + "source": "github_workflow_run", + "error_type": type(failure).__name__, + } + ] + + +async def test_cancellation_during_publish_propagates(journal, redis, native): + native.hold = asyncio.Event() + + request = asyncio.create_task(deliver(redis, encode(delivery()))) + await asyncio.wait_for(native.held.wait(), 1) + + request.cancel() + with pytest.raises(asyncio.CancelledError): + await request + assert journal == [] + + +async def test_cancellation_while_reading_the_body_propagates(journal, redis): + reading = asyncio.Event() + + async def receive(): + reading.set() + await asyncio.Event().wait() + + task = asyncio.create_task( + receive_github_webhook(Request(http_scope(headers(b"{}")), receive), redis=redis) + ) + await asyncio.wait_for(reading.wait(), 1) + + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert journal == [] diff --git a/services/api/tests/unit/test_global_auth_gate.py b/services/api/tests/unit/test_global_auth_gate.py index e0c28ef0f..a741532a8 100644 --- a/services/api/tests/unit/test_global_auth_gate.py +++ b/services/api/tests/unit/test_global_auth_gate.py @@ -132,10 +132,15 @@ async def test_every_route_outside_the_allowlist_refuses_an_anonymous_caller(met @pytest.mark.asyncio -async def test_the_allowlist_is_exactly_the_three_routes_it_is_allowed_to_be(): +async def test_the_allowlist_is_exactly_the_four_routes_it_is_allowed_to_be(): """Widening the allowlist is a decision, not a refactor. It shows up here.""" assert ANONYMOUS_ROUTES == frozenset( - {("GET", "/"), ("GET", "/health"), ("POST", "/api/lk/auth/token")} + { + ("GET", "/"), + ("GET", "/health"), + ("POST", "/api/lk/auth/token"), + ("POST", "/api/webhooks/github"), + } ) diff --git a/tests/compose/integration/backend-dind.yml b/tests/compose/integration/backend-dind.yml index f95af72da..beb46f94d 100644 --- a/tests/compose/integration/backend-dind.yml +++ b/tests/compose/integration/backend-dind.yml @@ -178,6 +178,7 @@ services: - REDIS_URL=redis://redis:6379/0 - SECRETS_ENCRYPTION_KEY=wHhIQWmPfLt60oHdxzbQhY1ZKnUon12e5_SuZ33xDxc= - LK_JWT_SECRET=test-lk-jwt-secret + - GITHUB_WEBHOOK_SECRET=test-github-webhook-secret - INTERNAL_API_KEY=test-internal-api-key-for-integration-tests depends_on: [db, redis] healthcheck: diff --git a/tests/compose/integration/backend.yml b/tests/compose/integration/backend.yml index d3554c1c7..83cf61b87 100644 --- a/tests/compose/integration/backend.yml +++ b/tests/compose/integration/backend.yml @@ -90,6 +90,7 @@ services: - REDIS_URL=redis://redis:6379/0 - SECRETS_ENCRYPTION_KEY=wHhIQWmPfLt60oHdxzbQhY1ZKnUon12e5_SuZ33xDxc= - LK_JWT_SECRET=test-lk-jwt-secret + - GITHUB_WEBHOOK_SECRET=test-github-webhook-secret - INTERNAL_API_KEY=test-internal-api-key-for-integration-tests depends_on: [db, redis] healthcheck: diff --git a/tests/compose/integration/frontend.yml b/tests/compose/integration/frontend.yml index 13b1c7ed0..016c7df3a 100644 --- a/tests/compose/integration/frontend.yml +++ b/tests/compose/integration/frontend.yml @@ -15,6 +15,7 @@ services: - WORKER_MANAGER_URL=http://worker-manager:8000 - REDIS_URL=redis://redis:6379/0 - LK_JWT_SECRET=test-lk-jwt-secret + - GITHUB_WEBHOOK_SECRET=test-github-webhook-secret - INTERNAL_API_KEY=test-internal-api-key-for-integration-tests depends_on: db: diff --git a/tests/compose/integration/infra.yml b/tests/compose/integration/infra.yml index c7dfca076..6dcfa464e 100644 --- a/tests/compose/integration/infra.yml +++ b/tests/compose/integration/infra.yml @@ -47,6 +47,7 @@ services: - WORKER_MANAGER_URL=http://worker-manager:8000 - REDIS_URL=redis://redis:6379/0 - LK_JWT_SECRET=test-lk-jwt-secret + - GITHUB_WEBHOOK_SECRET=test-github-webhook-secret - INTERNAL_API_KEY=test-internal-api-key-for-integration-tests depends_on: db: diff --git a/tests/compose/integration/po-tools.yml b/tests/compose/integration/po-tools.yml index 5b7e182e9..0680e799f 100644 --- a/tests/compose/integration/po-tools.yml +++ b/tests/compose/integration/po-tools.yml @@ -17,6 +17,7 @@ services: - ENVIRONMENT=test - SECRETS_ENCRYPTION_KEY=wHhIQWmPfLt60oHdxzbQhY1ZKnUon12e5_SuZ33xDxc= - LK_JWT_SECRET=test-lk-jwt-secret + - GITHUB_WEBHOOK_SECRET=test-github-webhook-secret - INTERNAL_API_KEY=test-internal-api-key-for-integration-tests - DEFAULT_AGENT_TYPE=codex depends_on: @@ -98,6 +99,7 @@ services: - ENVIRONMENT=test - SECRETS_ENCRYPTION_KEY=wHhIQWmPfLt60oHdxzbQhY1ZKnUon12e5_SuZ33xDxc= - LK_JWT_SECRET=test-lk-jwt-secret + - GITHUB_WEBHOOK_SECRET=test-github-webhook-secret - INTERNAL_API_KEY=test-internal-api-key-for-integration-tests - DEFAULT_AGENT_TYPE=factory depends_on: diff --git a/tests/compose/service/api.yml b/tests/compose/service/api.yml index e06dfb31c..b8e0e2204 100644 --- a/tests/compose/service/api.yml +++ b/tests/compose/service/api.yml @@ -18,6 +18,7 @@ services: - SYSTEM_CONFIGS_TEST_OVERLAY=/app/scripts/system_configs.service_test.yaml - SECRETS_ENCRYPTION_KEY=wHhIQWmPfLt60oHdxzbQhY1ZKnUon12e5_SuZ33xDxc= - LK_JWT_SECRET=test-lk-jwt-secret-for-service-tests + - GITHUB_WEBHOOK_SECRET=test-github-webhook-secret-for-service-tests - INTERNAL_API_KEY=test-internal-api-key-for-service-tests depends_on: db: @@ -68,6 +69,7 @@ services: - REDIS_URL=redis://redis:6379/0 - SECRETS_ENCRYPTION_KEY=wHhIQWmPfLt60oHdxzbQhY1ZKnUon12e5_SuZ33xDxc= - LK_JWT_SECRET=test-lk-jwt-secret-for-service-tests + - GITHUB_WEBHOOK_SECRET=test-github-webhook-secret-for-service-tests - INTERNAL_API_KEY=test-internal-api-key-for-service-tests depends_on: api: diff --git a/tests/compose/service/langgraph.yml b/tests/compose/service/langgraph.yml index d23a63d1d..4b0d0ed97 100644 --- a/tests/compose/service/langgraph.yml +++ b/tests/compose/service/langgraph.yml @@ -69,6 +69,7 @@ services: - SYSTEM_CONFIGS_TEST_OVERLAY=/app/scripts/system_configs.service_test.yaml - SECRETS_ENCRYPTION_KEY=wHhIQWmPfLt60oHdxzbQhY1ZKnUon12e5_SuZ33xDxc= - LK_JWT_SECRET=test-lk-jwt-secret-for-service-tests + - GITHUB_WEBHOOK_SECRET=test-github-webhook-secret-for-service-tests - INTERNAL_API_KEY=test-internal-api-key-for-service-tests depends_on: db: @@ -96,6 +97,7 @@ services: - REDIS_URL=redis://redis:6379/0 - SECRETS_ENCRYPTION_KEY=wHhIQWmPfLt60oHdxzbQhY1ZKnUon12e5_SuZ33xDxc= - LK_JWT_SECRET=test-lk-jwt-secret-for-service-tests + - GITHUB_WEBHOOK_SECRET=test-github-webhook-secret-for-service-tests - INTERNAL_API_KEY=test-internal-api-key-for-service-tests depends_on: control-api: diff --git a/tests/compose/service/scheduler.yml b/tests/compose/service/scheduler.yml index 2d29b0ea2..56df74887 100644 --- a/tests/compose/service/scheduler.yml +++ b/tests/compose/service/scheduler.yml @@ -40,6 +40,7 @@ services: - ENVIRONMENT=test - SECRETS_ENCRYPTION_KEY=GkH0NqyqjG599M0K9fVwU34b_2i1-m_i_M80-82d2X0= - LK_JWT_SECRET=test-lk-jwt-secret + - GITHUB_WEBHOOK_SECRET=test-github-webhook-secret - INTERNAL_API_KEY=test-internal-api-key-for-service-tests depends_on: db: diff --git a/tests/runner/compose.orchestrator.yml b/tests/runner/compose.orchestrator.yml index 17b3a1264..bf9b63d14 100644 --- a/tests/runner/compose.orchestrator.yml +++ b/tests/runner/compose.orchestrator.yml @@ -27,6 +27,7 @@ services: - SEED_SYSTEM_CONFIGS_ON_START=true - SECRETS_ENCRYPTION_KEY=wHhIQWmPfLt60oHdxzbQhY1ZKnUon12e5_SuZ33xDxc= - LK_JWT_SECRET=runner-proof-lk-jwt-secret + - GITHUB_WEBHOOK_SECRET=runner-proof-github-webhook-secret - INTERNAL_API_KEY=${RUNNER_INTERNAL_API_KEY:?} ports: - "127.0.0.1:${RUNNER_API_PORT:?}:8000"