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
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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user