diff --git a/patchwork/forge/github/__init__.py b/patchwork/forge/github/__init__.py
new file mode 100644
index 000000000000..ded7402cd5f3
--- /dev/null
+++ b/patchwork/forge/github/__init__.py
@@ -0,0 +1,83 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2026 Robin Jarry <robin@jarry.cc>
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+"""
+GitHub forge backend.
+"""
+
+import hashlib
+import hmac
+import json
+import logging
+
+from patchwork.forge import ForgeBackend
+from patchwork.forge import register_backend
+from patchwork.forge.github.to_ml import handle_pull_request
+from patchwork.forge.github.webhook import parse_pull_request
+
+logger = logging.getLogger(__name__)
+
+
+class GitHubBackend(ForgeBackend):
+ def verify_webhook_signature(self, body, headers, secret):
+ if not secret:
+ return True
+ signature = headers.get('X-Hub-Signature-256', '')
+ prefix = 'sha256='
+ if not signature.startswith(prefix):
+ return False
+ try:
+ sig = bytes.fromhex(signature[len(prefix) :])
+ except ValueError:
+ return False
+ mac = hmac.new(secret.encode(), body, hashlib.sha256)
+ return hmac.compare_digest(sig, mac.digest())
+
+ def parse_webhook_event(self, body, headers):
+ event_type = headers.get('X-GitHub-Event', '')
+ payload = json.loads(body)
+
+ parsers = {
+ 'pull_request': parse_pull_request,
+ }
+
+ parser = parsers.get(event_type)
+ if parser is None:
+ return None
+ return parser(payload)
+
+ def repo_url(self, forge_config):
+ return f'https://github.com/{forge_config.repo}.git'
+
+ def pr_ref(self, forge_config, pr_number):
+ return f'https://github.com/{forge_config.repo}/pull/{pr_number}'
+
+ def pr_refspec(self, pr_number):
+ return f'pull/{pr_number}/head'
+
+ def meta_key_pr(self):
+ return 'github_pr'
+
+ def series_metadata(self, forge_config, event):
+ return {
+ 'github_pr': self.pr_ref(forge_config, event.pr_number),
+ 'github_branch': event.pr_head_branch,
+ }
+
+ def git_credentials(self, forge_config):
+ auth = self.get_auth(forge_config)
+ token = auth.get('token', '')
+ return f'https://x-access-token:{token}@github.com\n'
+
+ def process_webhook_event(self, forge_config, event):
+ handlers = {
+ 'pull_request': handle_pull_request,
+ }
+ handler = handlers.get(event.type)
+ if handler:
+ handler(self, forge_config, event)
+
+
+register_backend('github', GitHubBackend())
diff --git a/patchwork/forge/github/to_ml.py b/patchwork/forge/github/to_ml.py
new file mode 100644
index 000000000000..fac07b180a51
--- /dev/null
+++ b/patchwork/forge/github/to_ml.py
@@ -0,0 +1,54 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2026 Robin Jarry <robin@jarry.cc>
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+from patchwork.forge.git import GitMirror
+from patchwork.forge.util import ingest_emails
+from patchwork.forge.util import next_version
+from patchwork.forge.util import sanitize_pr_body
+from patchwork.forge.util import send_emails
+
+
+def handle_pull_request(gh, forge_config, event):
+ if not forge_config.sync_forge_to_ml:
+ return
+
+ mirror = GitMirror(gh, forge_config)
+ mirror.ensure_mirror()
+ mirror.fetch()
+
+ version = 1
+ in_reply_to = ''
+ range_diff_base = ''
+ if event.pr_action == 'synchronize':
+ version, reply_msgid, range_diff_base = next_version(
+ gh, forge_config, event
+ )
+ if forge_config.thread_respins:
+ in_reply_to = reply_msgid
+
+ pr_url = gh.pr_ref(forge_config, event.pr_number)
+ cover_body = sanitize_pr_body(event.pr_body)
+ if cover_body:
+ cover_body += f'\n\nPull request: {pr_url}'
+ else:
+ cover_body = f'Pull request: {pr_url}'
+
+ with mirror.worktree(event.pr_head):
+ mirror.add_commit_notes(
+ event.pr_base,
+ lambda sha: f'{pr_url}/commits/{sha}',
+ )
+ mbox = mirror.format_patches(
+ event.pr_base,
+ event.author,
+ version=version,
+ cover_title=event.pr_title,
+ cover_body=cover_body,
+ range_diff_base=range_diff_base,
+ in_reply_to=in_reply_to,
+ )
+
+ ingest_emails(mbox, gh, forge_config, event)
+ send_emails(mbox, forge_config)
diff --git a/patchwork/forge/github/webhook.py b/patchwork/forge/github/webhook.py
new file mode 100644
index 000000000000..3945a6ff1ef4
--- /dev/null
+++ b/patchwork/forge/github/webhook.py
@@ -0,0 +1,43 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2026 Robin Jarry <robin@jarry.cc>
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+from patchwork.forge import ForgeEvent
+from patchwork.forge import ForgeUser
+
+
+def parse_pull_request(payload):
+ action = payload.get('action', '')
+ if action not in ('opened', 'synchronize'):
+ return None
+ pr = payload.get('pull_request', {})
+ pr_body = pr.get('body') or ''
+ return ForgeEvent(
+ type='pull_request',
+ repo_key=get_repo_key(payload),
+ pr_number=pr.get('number', 0),
+ author=parse_user(pr.get('user')),
+ pr_title=pr.get('title', ''),
+ pr_body=pr_body,
+ pr_head=f'pull/{pr.get("number", 0)}/head',
+ pr_base=pr.get('base', {}).get('sha', ''),
+ pr_head_branch=pr.get('head', {}).get('ref', ''),
+ pr_action=action,
+ pr_before=payload.get('before', ''),
+ )
+
+
+def get_repo_key(payload):
+ repo = payload.get('repository', {})
+ return repo.get('full_name', '').lower()
+
+
+def parse_user(user):
+ if not user:
+ return ForgeUser()
+ return ForgeUser(
+ login=user.get('login', ''),
+ name=user.get('name', ''),
+ email=user.get('email', ''),
+ )