feat(forge): GitHub adapter — second implementation keeps the seam a contract (#2693, milestone 288 step 8)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m8s
CI & Build / Build & push image (push) Successful in 45s

ForgeAdapter is now a named base class carrying the shared plumbing
(host join, error taxonomy, contents decoding, archive, default_branch,
latest_commit); GiteaForge keeps its exact behavior and GitHubForge joins
with the real differences: api.github.com / GHE /api/v3 host mapping,
Bearer auth, a commits call for the provenance stamp (GitHub's contents
payload only carries the blob sha), and the codeload tarball redirect.

The contract grew latest_commit, and with it the cached-SHA short-circuit
in pull-time freshness: a stored provenance commit that still heads the
recorded path confirms 'current' without a content transfer — the economy
that fits pulls inside GitHub's rate limits; every surprise falls back to
the full fetch. Webhook deliveries now also accept X-Hub-Signature-256
(sha256=<hex>); the payload shape was already common. Settings card copy
covers both forges' token scopes; the kind selector already flowed from
the server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 16:18:16 -04:00
co-authored by Claude Fable 5
parent cbccb6bd5d
commit 765635bbf2
8 changed files with 482 additions and 63 deletions
+16 -3
View File
@@ -43,14 +43,24 @@ FORGE_WEBHOOK_SECRET_KEY = "forge_webhook_secret"
def signature_ok(secret: str, body: bytes, signature: str) -> bool:
"""Validate Gitea's push signature: X-Gitea-Signature is the hex HMAC-SHA256
of the raw body under the webhook secret. Constant-time compare."""
"""Validate a push signature: the hex HMAC-SHA256 of the raw body under
the webhook secret (Gitea's X-Gitea-Signature verbatim; GitHub's
X-Hub-Signature-256 minus its "sha256=" prefix). Constant-time compare."""
if not secret or not signature:
return False
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature.strip().lower())
def delivered_signature(headers) -> str:
"""The HMAC hex a delivery carries, whichever forge sent it: Gitea's
X-Gitea-Signature verbatim, or GitHub's X-Hub-Signature-256 minus its
"sha256=" scheme prefix (#2693). Empty when neither header is present."""
return headers.get("X-Gitea-Signature", "") or headers.get(
"X-Hub-Signature-256", ""
).removeprefix("sha256=")
def push_facts(payload: dict) -> tuple[str, list[str], list[str], str]:
"""(repo identity, changed paths, removed paths, head commit) from a Gitea
push payload. Tolerant: absent fields read as empty, never raise."""
@@ -76,7 +86,10 @@ async def forge_push():
return jsonify({"error": "Not found"}), 404
body = await request.get_data()
if not signature_ok(secret, body, request.headers.get("X-Gitea-Signature", "")):
# The payload shape push_facts reads (repository.clone_url,
# commits[].added/modified/removed, after) is common to both forges, so
# the signature header is the whole GitHub mapping.
if not signature_ok(secret, body, delivered_signature(request.headers)):
return jsonify({"error": "Invalid signature"}), 401
try: