GitHub 指南

如何在本地测试 GitHub Webhook

学习如何用 HookNexus 在本地测试 GitHub Webhook:捕获 ping、push、pull request 与 release 事件,查看真实载荷,并将同一次投递路由到 localhost。

要在本地测试 GitHub Webhook,将仓库 Webhook 指向 HookNexus 等公网检查端点,用初始 ping 事件确认连通,再把后续每次投递转发到本机服务器。先捕获、再转发的两阶段做法,能在编写处理代码之前就把网络与配置问题与应用 bug 分开。

多数开发者会遇到同一堵墙:GitHub Webhook 要求公网可达 URL,而 Express 或 Fastify 跑在 localhost:3000。你可以打穿防火墙或开云主机,但两者都不自带请求检查、载荷重放或签名验证。专用 Webhook 调试工具能补上这块,把令人抓狂的搭建过程变成约五分钟的工作流。

本指南覆盖从创建公网端点到编写可验证 X-Hub-Signature-256 头并按事件类型路由的 Express 处理代码。

为什么 GitHub Webhook 本地测试需要专门流程

GitHub Webhook 与普通 REST 调用不同,有三点让没有额外工具时在 localhost 上难以调试。

公网 URL 要求。 在 GitHub 界面保存 Webhook 后,GitHub 会立即向你填写的 URL 发送 ping。若 URL 是 http://localhost:3000/webhooks,投递会立刻失败,因为 GitHub 服务器无法访问你的机器。你需要一个公网可路由的端点来接收请求,并可选择将流量隧道回本地进程。

先有 ping 事件。 在任何 pushpull_request 投递之前,GitHub 会发 ping 确认端点存活。若跳过这一步直接触发真实事件,可能意识不到端点本身配置有误——从而在处理程序上浪费时间,而问题其实在更上游。

不同事件类型载荷形状不同。 push 载荷含 commits 数组与 refpull_requestaction 下包裹整个 PR 对象;release 含资源下载 URL。若处理程序只假设一种形状,换事件就会静默出错。在写逻辑前检查各事件类型的原始载荷,能避免整类 bug。

开始之前需要准备

在打开 GitHub Webhook 设置前,请确认:

  • 仓库或组织管理员权限 —— 仅管理员可创建或编辑 Webhook。
  • Webhook 调试端点 —— 登录 HookNexus 在控制台创建,会得到形如 https://hook.hooknexus.com/h/abc123 的公网 URL。
  • HookNexus CLI(可选,但推荐用于转发 localhost)—— 按 CLI 安装指南 安装。
  • 若跟随下方 Express 示例,本机需 Node.js 18+

分步:在本地测试 GitHub Webhook

第一步 —— 创建公网 Webhook 端点

登录 HookNexus,点击 Create Endpoint,复制生成的 URL。GitHub 将向该地址发送每次投递。所有入站请求会实时记录,无需写代码即可查看头、正文与状态。

第二步 —— 在 GitHub 中配置 Webhook

  1. 打开 GitHub 上的仓库,进入 Settings > Webhooks > Add webhook
  2. 将 HookNexus 端点 URL 粘贴到 Payload URL
  3. Content type 设为 application/json
  4. 填写 Secret —— 随机字符串,应用侧也用它校验签名。请妥善保存,第七步会用到。
  5. Which events would you like to trigger this webhook? 中选 Let me select individual events,勾选所需事件:pushpull_requestrelease 等,并保留 ping(创建时总会发送)。
  6. 点击 Add webhook

GitHub 会立即发送 ping。回到 HookNexus 控制台确认已收到。

第三步 —— 用 ping 事件验证

ping 事件载荷示例:

{
  "zen": "Speak like a human.",
  "hook_id": 401234567,
  "hook": {
    "type": "Repository",
    "id": 401234567,
    "name": "web",
    "active": true,
    "events": ["push", "pull_request", "release"],
    "config": {
      "content_type": "json",
      "insecure_ssl": "0",
      "url": "https://hook.hooknexus.com/h/abc123"
    }
  },
  "repository": {
    "id": 123456789,
    "full_name": "your-org/your-repo"
  }
}

在 HookNexus 请求详情中确认三点:

  1. 状态码 200 —— 端点接受了投递。
  2. X-GitHub-Event: ping —— 事件类型正确。
  3. X-Hub-Signature-256 —— GitHub 用你的密钥对载荷做了签名。

若看不到 ping,说明 URL 错误或端点未激活。请先修复再继续触发真实事件;连接不通时触发真实事件没有意义。完整排查见 GitHub Webhook 未投递

第四步 —— 触发真实事件

ping 确认后,触发真实事件。较简单的方式:

  • Push —— 向任意分支提交并推送小改动。
  • Pull request —— 打开、编辑或关闭 PR。
  • Release —— 创建草稿或发布 Release。

每种操作都会在数秒内让 GitHub 向你的端点发送 POST。刷新 HookNexus 控制台查看新投递。

第五步 —— 检查头与载荷

每次 GitHub Webhook 投递都包含这些关键头:

Header作用
X-GitHub-Event事件类型:pushpull_requestrelease
X-Hub-Signature-256使用 Webhook 密钥对正文计算的 HMAC-SHA256 签名
X-GitHub-Delivery本次投递的唯一 UUID,可用于去重
X-GitHub-Hook-IDWebhook 配置 ID

在 HookNexus 中点击投递查看完整 JSON 正文。对 push 事件,重要字段包括:

  • ref —— 推送到的分支(如 refs/heads/main)。
  • commits —— 提交对象数组,含 idmessageauthormodified 等。
  • pusher —— 推送者。
  • repository —— 完整仓库元数据。

pull_request 事件,关注 actionopenedclosedsynchronizereopened)及嵌套的 pull_request 对象。

事先理解这些结构,日后处理时调试时间更短。

第六步 —— 转发到 localhost

确认载荷无误后,用 HookNexus CLI 将实时投递转发到本地开发机:

# Install the CLI globally
npm install -g hooknexus

# Log in
hooknexus login

# Forward traffic from your endpoint to localhost:3000
hooknexus forward <endpoint-id> --to http://localhost:3000/webhooks/github

之后命中 HookNexus 端点的每次新投递都会实时转发到 localhost:3000/webhooks/github。本地服务收到的头与正文与 GitHub 发出的一致——含签名头,因此验证逻辑与生产相同。高级选项见 转发指南

第七步 —— 在应用中处理 Webhook

下面是可验证 GitHub 签名并按事件类型路由的 Express 处理示例;该代码跑在 localhost,接收转发来的请求。

import express from "express";
import crypto from "node:crypto";

const app = express();

app.post(
  "/webhooks/github",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.headers["x-hub-signature-256"];
    const event = req.headers["x-github-event"];
    const deliveryId = req.headers["x-github-delivery"];

    if (!signature || !event) {
      return res.status(400).json({ error: "Missing required headers" });
    }

    // Verify HMAC-SHA256 signature
    const secret = process.env.GITHUB_WEBHOOK_SECRET;
    const expected =
      "sha256=" +
      crypto.createHmac("sha256", secret).update(req.body).digest("hex");

    if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
      console.error(`Signature mismatch for delivery ${deliveryId}`);
      return res.status(401).json({ error: "Invalid signature" });
    }

    const payload = JSON.parse(req.body.toString());

    // Route by event type
    switch (event) {
      case "ping":
        console.log(`Ping received: ${payload.zen}`);
        break;
      case "push":
        console.log(
          `Push to ${payload.ref}: ${payload.commits.length} commit(s)`
        );
        handlePush(payload);
        break;
      case "pull_request":
        console.log(
          `PR #${payload.pull_request.number} ${payload.action}`
        );
        handlePullRequest(payload);
        break;
      case "release":
        console.log(
          `Release ${payload.release.tag_name} ${payload.action}`
        );
        handleRelease(payload);
        break;
      default:
        console.log(`Unhandled event: ${event}`);
    }

    res.status(200).json({ received: true });
  }
);

function handlePush(payload) {
  const branch = payload.ref.replace("refs/heads/", "");
  for (const commit of payload.commits) {
    console.log(`  [${branch}] ${commit.id.slice(0, 7)}: ${commit.message}`);
  }
}

function handlePullRequest(payload) {
  const pr = payload.pull_request;
  console.log(`  Title: ${pr.title}`);
  console.log(`  Base: ${pr.base.ref} <- Head: ${pr.head.ref}`);
}

function handleRelease(payload) {
  console.log(`  Tag: ${payload.release.tag_name}`);
  console.log(`  Assets: ${payload.release.assets.length}`);
}

app.listen(3000, () => console.log("Listening on port 3000"));

关于该处理程序有两点:

  1. express.raw() 必不可少。签名基于原始请求体字节计算。若先用 express.json(),解析再序列化后的正文无法与签名匹配。
  2. crypto.timingSafeEqual() 用于防止对签名比较的时序攻击。不要用 === 比较 HMAC。

签名验证模式与边界情况见 校验 GitHub Webhook 签名

理解 GitHub Webhook 载荷结构

事件类型与载荷

GitHub 支持 30 多种 Webhook 事件。调试中最常见包括:

事件触发时机关键载荷字段
pingWebhook 创建或重新启用zenhook_idhook
push代码推送到任意分支refcommitspushercompare
pull_requestPR 打开、关闭、合并、同步等actionpull_requestnumber
releaseRelease 发布、编辑、删除actionreleaserelease.tag_name
issuesIssue 打开、加标签、关闭等actionissuelabel
workflow_runGitHub Actions 工作流完成actionworkflow_runconclusion

每种事件的顶层结构完全不同。处理程序必须先读 X-GitHub-Event,再访问对应字段。

X-GitHub-Event

该头是 GitHub Webhook 投递中最重要的元数据,告诉应用应期待哪种 schema。常见错误是先解析正文再试图从字段推断事件——这很脆弱,因为不同事件可能共享字段名(例如 pull_requestissues 都有 action)。

应始终根据头值分发,再校验该事件类型的载荷形状。

仓库级与组织级 Webhook

GitHub 支持两级 Webhook:

  • 仓库 Webhook 仅对该仓库内事件触发,在 Repo Settings > Webhooks 配置。
  • 组织 Webhook 对组织内所有仓库事件触发,在 Org Settings > Webhooks 配置。

组织 Webhook 的载荷中会多一个 organization 对象,适合审计日志、集中 CI 触发或合规自动化。本地开发时仓库级 Webhook 通常更简单,噪音更少。

常见问题与修复

ping 成功但真实事件不到

通常表示 Webhook 只订阅了 ping。回到 Settings > Webhooks 编辑 Webhook,确认已勾选正确事件。GitHub 在 Webhook 设置页提供 Recent Deliveries,可查看是否尝试投递及端点返回的状态码。

另一原因:分支保护或必需检查可能阻止或延迟 push,导致 push 事件未触发。确认触发动作在 GitHub 上确实已完成。

签名验证失败

X-Hub-Signature-256 不匹配最常见原因:

  1. 密钥错误 —— 应用中的值与 GitHub Webhook 设置里填写的 Secret 不一致,请重新复制粘贴。
  2. 验证前已解析正文 —— 若 express.json() 等中间件先于处理程序运行,正文字节会变化。应使用 express.raw({ type: "application/json" }) 收到未改动的 Buffer。
  3. 编码不一致 —— 确保双方使用 UTF-8。若框架将正文按 Latin-1 解码,HMAC 会对不上。

下面是一个可单独用于调试的签名验证函数:

import crypto from "node:crypto";

function verifyGitHubSignature(secret, payload, signatureHeader) {
  if (!signatureHeader) return false;

  const expected =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(payload).digest("hex");

  try {
    return crypto.timingSafeEqual(
      Buffer.from(signatureHeader),
      Buffer.from(expected)
    );
  } catch {
    return false;
  }
}

// Usage in a test or debugging script
const rawBody = Buffer.from('{"zen":"Speak like a human."}');
const secret = "whsec_test123";
const sig =
  "sha256=" +
  crypto.createHmac("sha256", secret).update(rawBody).digest("hex");

console.log(verifyGitHubSignature(secret, rawBody, sig)); // true
console.log(verifyGitHubSignature("wrong", rawBody, sig)); // false

完整 walkthrough 见 校验 GitHub Webhook 签名

事件到了但处理程序没反应

若 HookNexus 能看到投递且本地也收到,但没有预期行为:

  1. 检查事件类型。 switch 可能没有对应 case。在 default 中记录未处理事件。
  2. 检查 actionpull_request,GitHub 会为 openedclosedsynchronizereopenedlabeled 等多次投递。若代码只处理 opened,其他动作会被静默忽略。
  3. 检查响应码。 若处理抛错返回 500,GitHub 会标记失败并可能在数小时内重试最多三次。查看服务器日志中的堆栈。

快速检查清单

每次为本地测试新建 GitHub Webhook 时可对照:

  • 已创建 HookNexus 端点并复制公网 URL
  • 在 GitHub 仓库/组织设置中添加 Webhook,Content type 为 application/json
  • 已设置 Webhook Secret 并写入本地 .env
  • 已确认 ping 到达且返回 200
  • 已触发真实事件(push、PR 或 release)并检查载荷
  • 已确认 X-Hub-Signature-256 与密钥一致
  • 已安装 CLI 并开始转发到 localhost
  • 已验证处理程序正确处理事件并返回 200

常见问题

修改本地服务代码后需要重启 CLI 吗?

不需要。CLI 与 HookNexus 端点保持长连接,每次投递独立转发。本地服务重启(如 nodemon)后,下一条入站请求会等新服务就绪。仅当更换端点 ID 或本地目标端口时才需重启 CLI。

组织级 Webhook 能用同样方式测试吗?

可以。流程相同——把 HookNexus URL 填到组织 Webhook 设置即可。区别是组织 Webhook 会对组织内所有仓库触发,流量更多。可在 HookNexus 控制台或处理代码中按 payload.repository.full_name 过滤。

如何在不再次在 GitHub 触发的情况下重放某次投递?

HookNexus 控制台 打开该投递,点击 Replay,相同的请求(头、正文、签名)会再次发往端点。适合调试间歇性故障,或难以重复的触发(合并 PR、发布 Release)。重放保留原始 X-GitHub-Delivery UUID,也可测试去重逻辑。

X-Hub-SignatureX-Hub-Signature-256 有何区别?

X-Hub-Signature 使用 HMAC-SHA1,X-Hub-Signature-256 使用 HMAC-SHA256。GitHub 会同时发送两者,但 SHA-1 已视为偏弱。应始终校验 X-Hub-Signature-256。若只看到 SHA-1 头,可能是 Webhook 创建于 SHA-256 头推出之前——可删除后重建。

下一步

你现已具备可用的 GitHub Webhook 本地测试流程。可继续深入: