Как обрабатывать вебхуки GitHub на serverless
Проверяйте x-hub-signature-256 timing-safe HMAC (или включите turnkey-режим webhookMode: 'github'), маршрутизируйте по x-github-event, дедуплицируйте по x-github-delivery, быстро отвечайте 200 и запускайте CI/CD, ботов и уведомления в async-фоновых задачах.
Вебхуки GitHub — основа почти любой автоматизации, которую вам придётся строить: запустить CI на push, проверить pull request, выкатить релиз, проставить метку на issue, синхронизировать зеркало, переиндексировать репозиторий. И при этом их обманчиво легко сделать неправильно. Payload большой, типов событий много, одна и та же доставка может прийти дважды, а GitHub повторяет доставку на упавший endpoint до трёх суток. Ошибётесь в безопасности или идемпотентности — и вы либо оставляете открытым вектор атаки поддельным payload, либо запускаете один и тот же деплой три раза.
Это практический гайд по serverless-обработке вебхуков GitHub: как проверить подпись, маршрутизировать по типу события, дедуплицировать повторные доставки, быстро ответить и увести тяжёлую работу в фоновую задачу. Каждый пример — это обычная функция-обработчик за маршрутом шлюза, и каждое утверждение соответствует тому, что платформа реально делает.
Почему serverless github webhooks сложнее, чем кажется
Вебхук — это просто HTTP POST, поэтому наивный вариант выглядит как один обработчик, который парсит тело и делает работу прямо внутри. В продакшене такой вариант ломается по четырём предсказуемым причинам.
Во-первых, на публичный URL может отправить POST кто угодно. Без проверки подписи поддельный payload pull_request или push способен управлять вашей автоматизацией. Лечится это HMAC-проверкой сырого тела — той самой, которую большинство либо пропускает «на время», либо реализует через сравнение строк за непостоянное время.
Во-вторых, GitHub повторяет доставки. Если endpoint медленный или вернул не-2xx, GitHub доставляет событие заново — до трёх суток. Если ваш обработчик успел выполнить деплой до ответа, повтор выполнит его снова. Exactly-once доставки в интернете не бывает, поэтому обработка дублей — не опция, а обязанность.
В-третьих, один endpoint принимает десятки типов событий. push, pull_request, release, issues, issue_comment, workflow_run, и у каждого свои под-действия. Впихнуть всё это в одну синхронную функцию — значит получить монолит, который тяжело тестировать и легко сломать.
В-четвёртых, настоящая работа медленная. Запуск CI, индексация репозитория, вызов LLM для ревью диффа — ничему из этого не место внутри запроса вебхука. Нужно быстро ответить и продолжить асинхронно.
Паттерн, который выдерживает все четыре пункта: проверить, дедуплицировать, маршрутизировать, быстро ответить, затем передать в фоновую задачу.
Как проверить подпись вебхука GitHub: x-hub-signature-256 и HMAC
Чтобы проверить подпись вебхука GitHub, вы считаете HMAC-SHA256 по точному сырому телу запроса на вашем webhook-секрете и сравниваете результат со значением, которое GitHub присылает в заголовке x-hub-signature-256. Заголовок выглядит как sha256=<hex>.
Два правила определяют, сработает ли эта webhook HMAC-проверка.
Проверяйте сырые байты, до парсинга. GitHub подписывает ровно тот payload, который отправил. Если вы сделаете JSON.parse и заново сериализуете, порядок ключей и пробелы сдвинутся, байты изменятся — и валидное событие не пройдёт проверку. Всегда читайте event.body как строку и подавайте в HMAC именно её, а не заново собранный объект.
Сравнивайте за постоянное время. Обычное === для подписи утекает тайминговую информацию, которой атакующий может воспользоваться, чтобы подобрать подпись байт за байтом. Используйте crypto.timingSafeEqual, который требует буферов одинаковой длины, поэтому сначала проверьте длину и выйдите заранее.
Вот проверка, вынесенная отдельно, чтобы оставаться маленькой и обозримой:
import { createHmac, timingSafeEqual } from 'node:crypto';
// Verify the GitHub webhook signature over the RAW request body.
// GitHub sends: x-hub-signature-256: sha256=<hex hmac>
export function verifyGithubSignature(rawBody, signatureHeader, secret) {
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
const received = signatureHeader ?? '';
// Buffers must be equal length before timingSafeEqual, and we compare in constant time.
if (received.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}
Webhook-секрет живёт в env-конфигурации функции и подставляется в момент вызова — не в коде и не в payload. Сохраните его один раз и ссылайтесь как process.env.GITHUB_WEBHOOK_SECRET.
Turnkey-проверка через webhookMode: ‘github’
Написать HMAC-проверку самому — нормально, но это boilerplate, который вы теперь тащите на каждом маршруте вебхука. Шлюз может сделать это за вас. Задайте на маршруте webhookMode: 'github', и шлюз проверит x-hub-signature-256 по сырому телу ещё до того, как ваша функция будет вызвана. При несовпадении он вернёт 403 { error: { code: 'BAD_SIGNATURE' } }, и обработчик вообще не увидит поддельный запрос.
// Gateway route settings for POST /webhooks/github (configured in the console/API).
// webhookMode: 'github' makes the gateway verify x-hub-signature-256 on the raw body
// BEFORE your function runs. On mismatch it returns 403 { error: { code: 'BAD_SIGNATURE' } }.
{
method: 'POST',
path: '/webhooks/github',
target: 'github-webhook', // the function to invoke
webhookMode: 'github', // turnkey HMAC-SHA256 verification
webhookSecret: '{{ GITHUB_WEBHOOK_SECRET }}',
// signatureHeader defaults to x-hub-signature-256 in github mode
}
Это включается по желанию и на каждом маршруте отдельно — пока вы не включите режим на конкретном endpoint, проверки нет. Встроенных режима два: GitHub и Stripe (Stripe вдобавок проверяет свой timestamp на устойчивость к replay). Всё остальное, включая Slack, turnkey-режимом не является — эти провайдеры вы проверяете в обработчике хелпером выше, потому что их схемы подписи отличаются.
Практическая выгода: со включённой проверкой на шлюзе обработчик выбрасывает весь HMAC-блок. Он исходит из того, что вызывающая сторона уже аутентифицирована, и занимается тем, ради чего и существует, — дедупликацией и маршрутизацией.
Единственное поведенческое отличие, которое стоит держать в голове: шлюз возвращает 403 BAD_SIGNATURE, тогда как самописная проверка может вернуть что угодно (401 или 403). GitHub не важно, какой именно не-2xx он получил, — важно лишь, что проверка не прошла. Выберите одно и придерживайтесь его.
Маршрутизация по x-github-event, дедупликация по x-github-delivery
Как только payload стал доверенным, всю маршрутизацию и дедупликацию делают два заголовка.
x-github-event сообщает тип события: push, pull_request, release, issues и так далее. Делайте switch по нему. У многих событий есть ещё и payload.action — pull_request может быть opened, synchronize, reopened, closed; release может быть published, edited, deleted. Маршрутизируйте по обоим, чтобы реагировать только на то, что нужно.
x-github-delivery — это уникальный UUID на каждую доставку. Это ваш ключ идемпотентности. Запишите его до начала работы; если уже видели — верните 200 и остановитесь. Именно так вы переживаете повторные доставки GitHub в течение трёх суток, не запуская триггеры дважды.
Обработчик github webhook связывает всё вместе — проверить (или пропустить, если шлюз уже проверил), ответить на ping, который GitHub шлёт при первой регистрации хука, дедуплицировать, маршрутизировать и поставить в очередь:
import { verifyGithubSignature } from './verify.mjs';
// github webhook handler: verify, dedupe by delivery id, route by event type, ack fast.
export async function handler(event) {
const rawBody = event.body ?? ''; // string as delivered — never JSON.parse before verifying
const signature = event.headers['x-hub-signature-256'];
// Skip this block if the gateway already verified via webhookMode: 'github'.
if (!verifyGithubSignature(rawBody, signature, process.env.GITHUB_WEBHOOK_SECRET)) {
return { statusCode: 401, body: 'invalid signature' };
}
const deliveryId = event.headers['x-github-delivery']; // unique UUID per delivery
const eventType = event.headers['x-github-event']; // push, pull_request, release, ping, ...
if (eventType === 'ping') return { statusCode: 200, body: 'pong' };
// Idempotency: record the delivery id first; skip work if we have seen it.
if (!(await db.deliveries.markSeen(deliveryId))) {
return { statusCode: 200, body: 'duplicate' };
}
const payload = JSON.parse(rawBody); // safe to parse after verification
switch (eventType) {
case 'push':
await global.durable.startNew('run-ci', undefined, {
deliveryId, repo: payload.repository.full_name, sha: payload.after, ref: payload.ref,
});
break;
case 'pull_request':
if (['opened', 'synchronize', 'reopened'].includes(payload.action)) {
await global.durable.startNew('review-pr', undefined, {
deliveryId, repo: payload.repository.full_name, pr: payload.number,
});
}
break;
case 'release':
if (payload.action === 'published') {
await global.durable.startNew('deploy-release', undefined, {
deliveryId, repo: payload.repository.full_name, tag: payload.release.tag_name,
});
}
break;
// Any other event still gets a fast 200 so GitHub stops retrying.
}
return { statusCode: 200, body: 'accepted' };
}
Обратите внимание: необработанные типы событий всё равно проваливаются в быстрый 200. Молчание — это и есть цель: вам не нужно, чтобы GitHub повторял событие, которое вы намеренно игнорируете.
Быстрый ACK, затем async-работа: CI/CD, боты и уведомления
Обработчик выше не запускает CI, не ревьюит PR и не деплоит. Он вызывает global.durable.startNew(name, undefined, payload) и возвращается. Этот вызов ставит в очередь надёжную фоновую задачу на Postgres, которая переживает перезапуски, и отвечает GitHub за миллисекунды — с запасом внутри окна доставки.
Зачем разделять? Потому что у функций есть таймаут: 5 секунд по умолчанию, 15 минут максимум. Синхронный обработчик, который ждёт CI-систему или LLM, вылетит за этот бюджет и, что хуже, будет выглядеть для GitHub как сбой — а тот повторит доставку. Сначала подтвердите приём; медленную работу делайте вне запроса.
Задача — это обычный обработчик. Он читает event.payload и доходит до конца в собственном изолированном контейнере со своим бюджетом таймаута:
// jobs/run-ci.mjs — a plain function enqueued by global.durable.startNew.
// Runs outside the webhook request in its own container (timeout up to 15 min).
export async function handler(event) {
const { repo, sha, ref } = event.payload ?? {};
// The real guarantee is idempotency: keyed on repo+sha, a re-run is a safe no-op.
const run = await db.ciRuns.find(repo, sha);
if (run?.status === 'done') return { repo, sha, skipped: true };
await db.ciRuns.upsert({ repo, sha, ref, status: 'running' });
const result = await triggerBuild({ repo, sha, ref }); // call your CI/CD system
await db.ciRuns.upsert({ repo, sha, status: 'done', result });
await postCommitStatus(repo, sha, 'success'); // bot action back to GitHub
return { repo, sha, status: 'done' };
}
Это форма для любого побочного эффекта serverless github webhook. push разветвляется на индексацию и CI. pull_request разветвляется на бота, который прогоняет линтер и оставляет комментарий-ревью. release разветвляется на деплой плюс уведомление в Slack. Каждая ветка — отдельная задача со своими логами, поэтому дежурный инженер видит ровно, какой шаг упал на какой доставке. Каждый прогон попадает в историю выполнений и хранится 30 дней.
Заметьте страховку идемпотентности внутри задачи по ключу repo + sha. Это сделано намеренно. Дедупликация по x-github-delivery на уровне обработчика — быстрый фильтр, но не гарантия: при доставке at-least-once задача всё равно может быть поставлена в очередь более одного раза. Настоящая страховочная сетка — именно идемпотентность задачи. Проектируйте побочные эффекты так, чтобы повтор был no-op: проверка-перед-записью, upsert по стабильному ключу, никогда — слепой insert.
Если шаг задачи может падать транзиентно, можно подключить ретраи с экспоненциальным backoff, а задача, исчерпавшая попытки, уходит в dead-letter с последней ошибкой вместо того, чтобы пропасть. Ретраи включаются по желанию для каждой задачи, а не автоматически на каждую постановку в очередь, — так что решайте по каждой задаче, безопасен ли повтор. А это снова возвращает к тому, чтобы держать обработчик идемпотентным.
Тестирование: повторная доставка из UI GitHub
Лучшее в вебхуках GitHub — что тестирование не требует гадания. GitHub хранит журнал каждой доставки с точным payload и заголовками, которые он отправил.
Откройте в репозитории (или организации) Settings → Webhooks → ваш хук → Recent Deliveries. Каждая запись показывает payload запроса, ответ вашего endpoint и кнопку Redeliver. Повторная доставка отправляет тот же самый payload заново — тот же x-github-delivery, та же подпись. Это самый быстрый способ прогнать обработчик на реальном событии, и ровно так вы подтверждаете, что идемпотентность работает: повторно доставьте push и убедитесь, что вторая попытка возвращает duplicate и не запускает CI заново.
Для локальной разработки перенаправьте реальные события на функцию у себя на машине через GitHub CLI:
gh webhook forward --repo owner/repo --events push --url http://localhost:PORT
Держите локально тот же контракт строки event.body, что шлюз доставляет в продакшене, чтобы проверка вела себя одинаково в обоих местах. И при первой регистрации хука ждите событие ping — обработчик отвечает на него pong и идёт дальше.
Честные ограничения и компромиссы
Паттерн надёжный, но прагматичная реализация учитывает и то, чего платформа не обещает.
Холодные старты уменьшены, но не устранены. Пулы горячих контейнеров (минимум 1, до 8 тёплых на функцию) держат наготове тёплый инстанс для стабильного трафика, но первый вызов или пробуждение после простоя — всё ещё холодный путь. Окно доставки GitHub щедрое, поэтому вебхук от этого страдает редко, но не проектируйте так, будто latency всегда тёплая.
Нет exactly-once и нет гарантированного порядка. Доставка at-least-once. Два события push могут обработаться не по порядку; доставка может прийти дважды. Единственная надёжная защита — идемпотентные обработчики по стабильным идентификаторам: x-github-delivery или repo + sha. Не стройте ничего, что предполагает порядок или единственную доставку.
Таймаут функции реален: 5 с по умолчанию, 15 минут максимум. Для работы длиннее одного шага стройте цепочку шагов в пайплайне, а не тянитесь к одной безграничной функции. Обработчик вебхука никогда не должен быть тем, кто делает долгую работу.
Turnkey на шлюзе только для GitHub и Stripe. Slack, Shopify и любое кастомное — проверяются в обработчике. Slack, в частности, подписывает v0:timestamp:body, а не по схеме GitHub, поэтому его проверка — это ваш код, а не настройка маршрута.
global.durable.startNew — это Node.js-хелпер. Эти примеры — на Node.js 22. Из функции на Python или Go вы ставите задачу в очередь, отправляя POST на trigger-URL пайплайна с тем же payload, — триггер доступен по HTTP из любого рантайма.
Хранилище секретов — это env-конфиг, а не полноценный менеджер секретов. Оно подставляет webhook-секрет при вызове и вырезает его из логов и трассировок; шифруется в покое, если на платформе настроен ключ шифрования. Встроенной ротации или версионирования нет, поэтому меняйте секрет GitHub и сохранённое значение вместе.
Пропускная способность async ограничена. Async-вызовы ограничены 120 в минуту на тенант. Активный монорепозиторий с fan-out на каждый push может подойти к этому пределу — батчите или объединяйте работу на доставку, если ждёте всплесков.
Вывод
Serverless-обработка вебхуков GitHub сводится к пяти шагам по порядку: проверить подпись по сырому телу, дедуплицировать по x-github-delivery, маршрутизировать по x-github-event, быстро ответить и выполнить настоящую работу в идемпотентной фоновой задаче.
Проверка подписи — то, что нельзя пропускать: HMAC-SHA256 по сырому телу, сравнённый за постоянное время с x-hub-signature-256. Отдайте это шлюзу через webhookMode: 'github', чтобы поддельный payload получил 403 ещё до вашего кода, а обработчик ужался до маршрутизации и дедупликации. Дальше пусть надёжная фоновая задача на Postgres несёт триггер CI, действие бота или уведомление — так повторная доставка через трое суток станет безопасным no-op, а не вторым деплоем.
Подключите сначала одно событие — push из самого активного репозитория, — убедитесь, что повторная доставка возвращает duplicate, и наращивайте маршрутизацию оттуда.