GitHub 停发 push 邮件后,自动通知怎么接?用 Python 做验签、去重与可靠入队

代码已经推上去了,邮箱却一封提醒也没有。先别急着查垃圾箱、重配SMTP,或者怀疑仓库没有更新。2026年10月15日,GitHub宣布退役仓库push事件邮件通知,不再发送这类邮件。对把邮箱当作自动化入口的团队来说,真正要迁移的不是收件地址,而是事件从发生到处理的整条链路。

这个变更不代表所有GitHub通知都停止,也不代表提交、工作流或Webhook失效。本文只讨论push邮件这一个入口。我们把问题收窄:怎样接住一次代码推送,验证它确实来自持有密钥的一方,遇到补投不重复记账,并且在接口返回成功之前,把待处理记录真正保存下来。

下面提供的是可运行的Python接收核心与离线自测,不是假装已经部署到你的公网服务器。它不发邮件、不调用机器人、不访问真实仓库,也不需要购买服务。先把这部分边界做扎实,再接你已有的HTTP框架和通知渠道,比直接复制一个收到请求就发消息的脚本更容易排错。

一、先分清停掉的是哪一段

旧链路可以拆成四段:仓库产生事件,平台选择通知方式,邮件进入收件箱,下游程序读取并执行。现在变化发生在通知方式这一段,不能据此推断仓库的事件本身消失。排查时先打开仓库确认目标分支的提交,再检查你实际依赖的入口,避免把一次产品调整误诊为代码推送失败。

如果邮箱后面连着日报、部署提醒或任务分派,邮件标题往往已经变成隐藏接口。有人按主题关键字过滤,有人按发件人分流,还有人从正文提取提交号。这些约定平时看起来很省事,一旦模板变化、邮件延迟或功能退役,所有消费者都可能一起失声,却没有清晰的失败位置。

迁移前先列一张自己的依赖清单:哪个仓库、哪些分支、谁接收、提醒里必须出现什么信息、漏一次会不会影响业务。个人项目可能只需要在日常查看仓库时确认变动;只有确实依赖自动处理的场景,才值得部署接收端。不要把停掉一种通知理解为必须再建设一套复杂平台。

同样,不要为了补回一封邮件就给脚本仓库管理员权限。接收事件、查询提交、向团队发提醒是三种能力,授权应分别对应真实用途。本文的核心只验证并保存请求,不持有GitHub访问令牌。以后添加补查功能时,再根据所查询资源确定读取权限,而不是先塞进一个万能密钥。

二、邮件提醒与可靠事件不是同一个东西

提醒的目标是让人看到,事件接收的目标是让程序知道自己接住了什么。人可以根据上下文忽略两封重复邮件,程序却可能把两次相同输入变成两次部署、两条日报或两次外部调用。因此换成Webhook之后,第一件事不是追求消息漂亮,而是给每次投递建立能回查的身份。

这里至少有三个不同的标识:投递标识表示一次递送,仓库与分支标识表示事件作用范围,提交号表示某个代码位置。它们不能互相替代。只用分支名去重会吞掉后续合法推送;只用提交号去重也可能把不同分支上的合法业务混在一起。先定义你究竟在去重哪一层。

本文把接收层的唯一键设为投递标识,并额外记录原始请求体摘要。同一个标识、相同摘要再次到达,返回已接收;同一个标识却带不同内容,返回冲突。这是本地接收约定,不是对所有Webhook平台的通用保证,更不能把它宣传成跨系统的严格恰好一次执行。

如果通知消费者也会重试,它仍需要自己的业务去重键。比如一条面向某个团队的推送提醒,可以由仓库、分支、目标提交与通知渠道共同确定。接收端去重解决的是同一投递不要重复入库,消费者去重解决的是同一业务不要重复对外生效,两个问题相似,但所在位置不同。

三、验签一定要发生在改动请求体之前

接收HTTP请求时,要保留收到的原始字节。不要先把JSON转成字典,再重新序列化后计算摘要:空格、转义或键顺序变化,都可能让业务内容看起来相同,却产生不同的签名结果。本文函数直接接收bytes,先校验签名,验证通过以后才解析JSON,不把这两个步骤倒过来。

GitHub的文档给出了基于共享密钥的HMAC验证方式。实现上要把算法前缀一起比较,并使用恒定时间比较函数,避免手写逐字符比较。密钥不是请求里传来的某个字段,也不是仓库访问令牌;它应由你在Webhook配置和接收服务之间单独约定,放在受控配置中。

示例中的密钥只用于本地测试,不能复制到公网部署。真实服务启动时应从已有的秘密管理方式读取,缺失就拒绝启动,而不是自动回退到测试值。日志也不要输出签名密钥、完整请求头或整个提交内容。定位故障通常只需要投递标识、事件类型、接收结果与时间。

验签通过只说明请求体满足共享密钥的验证条件,不等于所有业务内容都值得执行。后续仍要核对事件类型、仓库范围和分支范围。尤其不要从请求里的任意字符串拼接Shell命令。接收推送通知与执行仓库提供的命令是完全不同的授权边界,不能因为签名正确就把两者合并。

四、成功响应应该意味着什么

最容易出现的接收漏洞并不是HTTP报错,而是过早返回成功。请求刚进内存,代码就响应二百,随后进程退出、机器重启或通知服务异常,平台看到的仍然是一次成功投递。你的业务却没有任何可恢复记录。这时再提高重试次数,也无法找回已经被错误确认的那次事件。

相反,把发邮件、生成摘要、请求机器人等所有工作放进接收接口,也会让响应时间受外部服务拖累。一个下游慢请求就能占住接收线程,并把平台投递问题和通知问题搅在一起。更清楚的约定是:接口负责验证和可靠保存,消费者负责读取待办并完成真正的通知。

本文选择在事务提交完成之后返回接收成功。保存失败就让调用方获得失败结果,而不是吞掉异常继续宣布完成。这里用SQLite演示单机持久化收件箱,不表示任何部署规模都适合它。多实例环境需要各实例共享可靠存储;把数据库放在容器临时目录,也不能获得你期待的重启恢复。

注意"已入队"与"已通知"必须使用不同状态。示例只写入pending,明确表示后续尚未处理。若界面把这个状态展示成消息已发送,问题只是从接收端转移到了展示端。检查一个链路是否可靠,最直接的问题就是:现在这条记录到底走到了哪一步,哪个证据可以说明它已经发生。

五、去重必须和写入放在同一事务里

先查询有没有,再另起一个连接插入,看起来也是去重,但两个并发请求可能同时查到不存在,然后一起执行。正确性不能依赖"正常情况下不会同时来"。示例在数据库里设置唯一主键,并在同一写事务内检查已有摘要与创建记录,把竞争的最终裁决交给同一个存储边界。

重复投递并不自动代表出错。比如接收端已经提交事务,但返回响应前网络断开,平台无法知道它是否成功保存;之后补投相同事件,是合理的恢复动作。此时接收端应该承认已有记录,而不是再次插入,也不应该因为重复就触发一整套故障告警,制造无用噪声。

摘要冲突则不同。同一个投递标识对应不同请求体,至少说明输入与当前去重约定不一致。示例返回冲突,保留原记录,不覆盖旧内容。这样调查者仍能看到最先接受的事件。把新内容悄悄覆盖进去,会破坏已经建立的证据链,之后很难解释消费者实际处理的是哪一个版本。

去重记录也不适合无限增长。可以根据自己的补投窗口和审计需求制定保留时间,不过清理应是独立、可检查的维护动作。不能在每次接收时随机删除一批旧记录,也不能只保留最近一个提交号。保留策略一旦缩短,就要接受更老投递再次出现时可能重新进入处理的后果。

六、事件类型、分支与空推送要分别处理

Webhook的首次连通检查不一定是一条真实推送。示例识别ping并直接返回,不把连通性测试伪装成有新提交。对于不是push的事件,它也不会进入当前收件箱。实际HTTP适配层应明确把事件头、投递头、签名头和原始请求体传进函数,不凭路径名称猜测事件类型。

推送也不一定意味着主分支有可部署的新版本。它可能属于功能分支,也可能删除分支,或者移动标签。接收层可以保留事件,具体通知或部署层再根据ref等字段决定动作。不要只在页面上选择某类事件,却在消费者里完全省掉分支判断,后续配置变化就可能扩大实际执行范围。

示例保留仓库名、引用名与目标提交号,是为了给消费者一个清楚的输入,不代表只凭三个字段就能执行部署。真实应用还应定义删除事件、强制推送及目标仓库的处理规则。对普通提醒来说,说明分支被删除可能已经足够;对发布任务来说,则应直接拒绝把删除事件当作新版本部署。

本文没有把业务分支白名单写死在接收核心里,是为了让接收与消费职责保持可辨认。部署时必须把白名单放在消费者的明确配置中,并给每次放行留下原因。灵活不等于缺少约束,而是让约束出现在真正产生副作用的位置,避免保存事件和执行事件被同一个布尔值混为一谈。

七、先测异常,再把地址接到平台

下面这段代码只使用Python标准库。它包含完整的验签、JSON校验、事务去重、待办保存及六种请求测试,运行后会创建一次性测试数据库,验证完成自动移除。它不绑定公网端口,也不会改动真实仓库;因此可以先用它证明接收核心的行为,再接现有Web应用。

第一种测试是正常输入,第二种是原样补投,第三种修改请求体但保留旧签名。第四种在相同投递标识下发送另一份正确签名的内容,第五种使用无效JSON,第六种检查ping。最后额外检查数据库只留下一个pending事件,而不是只看每个函数返回了某个看起来正确的字符串。

运行方法是把代码保存为webhook_inbox.py,再执行python3 webhook_inbox.py。本文在本轮环境执行后得到全部断言通过的结果。这个结果只覆盖代码列出的本地条件,不是GitHub真实公网投递的验收,也不代表邮件供应商、反向代理或团队机器人已经配置完成。

测试中故意不访问网络,是为了把签名和去重问题从公网配置中分离出来。等本地行为确定,再检查HTTPS证书、接收路径、请求体大小限制和代理转发。若两者同时变化,失败时很难分辨是代码错、证书错、代理丢头,还是你把平台的事件选择配成了另一种类型。

补充:上线前必须讲清的四个边界

第一是接收顺序。两次推送先后发生,不代表两个HTTP请求一定按同样顺序抵达;前一个请求慢了,后一个可能先入库。通知如果只展示仓库与提交链接,通常能容忍这种现象;如果消费者要修改某个当前版本指针,就必须根据业务来源核对顺序,不能简单把最后到达当作最新状态。

第二是密钥轮换。接收服务和平台配置可能无法在同一时刻切换,必要时可以短暂接受新旧两把受控密钥,并记录命中哪个版本,但不要在日志记录密钥值。旧密钥撤销后要及时退出接受范围。本文函数只接收单把密钥,轮换策略应由调用方显式安排,不会自动长期保留失效配置。

第三是数据最小化。示例并没有把完整提交消息、作者邮件和所有变更文件永久写入数据库,只保留当前核心需要的身份与状态。这会减少不必要的数据扩散,但也意味着日后不能仅靠这份记录恢复完整原始事件。需要审计原文的系统应另行定义受控存储、保留期限和读取权限。

第四是签名覆盖范围。这里的HMAC校验针对请求体,不应把投递标识或事件类型误当成同一个签名已经保护的业务字段。反向代理和服务入口依然需要正确的HTTPS与头部处理。相同正文换一个投递标识,也可能再次入库;因此真正的对外动作还必须使用前面说明的业务去重规则。

还有一个与速度无关的细节:数据库写满和文件权限异常都可能发生在正常验签之后。应当把这种情况视为未可靠接收,让调用方看见失败,并保存不含秘密的诊断记录。不要为了追求接口成功率而在存储出错后改写响应,那会让漂亮的成功曲线掩盖真实丢失的任务。

如果团队现阶段只需要人能看到新提交,完全可以先选已有通知渠道,暂时不自动执行部署。把事件接收、提醒和发布权限逐层分开,会让迁移更容易验收。本文的代码也刻意停在收件箱这一层:它证明一条事件已被可靠接受,但不会未经另一层授权就替你执行仓库里的任何内容。

一条明确的迁移验收记录可以这样写:某个投递标识已验签,目标仓库与分支符合当前业务,数据库只有一条待办,消费者对目标渠道产生一条通知,补投没有第二次副作用。哪一项尚未完成就保留在哪一项,不使用一个笼统的成功状态同时代替接收、执行与通知三个事实。

八、可直接运行的接收核心

python 复制代码
import hashlib, hmac, json, sqlite3, sys, tempfile
from pathlib import Path

LIMIT = 1024 * 1024

def accept(db, secret, raw, signature, event, delivery):
    if len(raw) > LIMIT:
        return 413, "too_large"
    expected = "sha256=" + hmac.new(secret, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, signature):
        return 403, "bad_signature"
    if event == "ping":
        return 200, "pong"
    if event != "push":
        return 202, "ignored_event"
    if not delivery or len(delivery) > 200:
        return 400, "bad_delivery"
    try:
        payload = json.loads(raw)
        repo = payload["repository"]["full_name"]
        ref = payload["ref"]
        after = payload["after"]
        if not all(isinstance(x, str) and x for x in (repo, ref, after)):
            raise ValueError("fields")
    except (ValueError, KeyError, TypeError):
        return 400, "bad_payload"
    digest = hashlib.sha256(raw).hexdigest()
    with sqlite3.connect(db, timeout=5) as con:
        con.execute("""CREATE TABLE IF NOT EXISTS inbox(
            delivery TEXT PRIMARY KEY, digest TEXT NOT NULL,
            repo TEXT NOT NULL, ref TEXT NOT NULL,
            after_sha TEXT NOT NULL, state TEXT NOT NULL)""")
        con.execute("BEGIN IMMEDIATE")
        old = con.execute(
            "SELECT digest FROM inbox WHERE delivery=?", (delivery,)
        ).fetchone()
        if old:
            return (202, "duplicate") if old[0] == digest else (409, "id_conflict")
        con.execute("INSERT INTO inbox VALUES(?,?,?,?,?,?)",
                    (delivery, digest, repo, ref, after, "pending"))
    return 202, "persisted"

def sign(secret, raw):
    return "sha256=" + hmac.new(secret, raw, hashlib.sha256).hexdigest()

def self_test():
    secret = b"local-test-secret-not-for-production"
    raw = json.dumps({"repository": {"full_name": "demo/project"},
                      "ref": "refs/heads/main", "after": "a" * 40}).encode()
    with tempfile.TemporaryDirectory() as tmp:
        db = str(Path(tmp) / "inbox.sqlite3")
        args = (db, secret, raw, sign(secret, raw), "push", "delivery-1")
        assert accept(*args) == (202, "persisted")
        assert accept(*args) == (202, "duplicate")
        assert accept(db, secret, raw + b" ", sign(secret, raw),
                      "push", "delivery-2") == (403, "bad_signature")
        changed = raw.replace(b"demo/project", b"demo/other")
        assert accept(db, secret, changed, sign(secret, changed),
                      "push", "delivery-1") == (409, "id_conflict")
        assert accept(db, secret, b"{", sign(secret, b"{"),
                      "push", "delivery-3") == (400, "bad_payload")
        assert accept(db, secret, raw, sign(secret, raw),
                      "ping", "ping-1") == (200, "pong")
        with sqlite3.connect(db) as con:
            assert con.execute("SELECT COUNT(*) FROM inbox").fetchone()[0] == 1
            assert con.execute("SELECT state FROM inbox").fetchone()[0] == "pending"
    print("PASS: 6 request cases; exactly 1 durable pending event")

if __name__ == "__main__":
    self_test()

九、接进HTTP框架时,还差哪些明确动作

接到现有框架后,入口先限制请求体大小,再读取原始字节;不要先读取一个没有上限的大文件,再指望函数内部的长度检查节省内存。适配层调用accept,使用返回码响应。数据库异常应返回服务失败,不要在统一异常处理里改成成功,更不要把错误正文直接回显给外部请求者。

示例里没有公网HTTP服务器,也没有为了演示而默认监听所有网卡。生产接入应放到你已有的受控服务入口,使用HTTPS并做好访问日志。代理侧如果修改请求体,签名校验自然会失败;此时应该核对转发行为,不能通过关闭验签来把连通测试变绿,否则迁移只是在交换新的隐患。

消费者可以从pending开始,成功通知后再记录完成;失败则保留原因并安排下一次处理。实际发送渠道如果支持幂等键,应使用稳定的业务标识。若不支持,就要承认存在发送成功但本地记账失败的窗口,提供人工核对办法,而不是给一个无法证明的绝对不重复承诺。

还有一个容易忽略的窗口:进程刚把记录取出来就退出。如果取出动作直接删除记录,事件可能永久消失。更合理的是使用有到期时间的处理占用,未完成则重新变为可处理。这里描述的是消费者需要实现的职责,本文代码只负责持久化收件箱,不把尚未实现的工作包装成已经完成。

十、迁移验收看记录,不看"好像有消息"

真正切换时,用受控仓库的一次合法推送检查平台投递记录,再核对接收端唯一记录,最后核对目标渠道唯一通知。随后对同一次投递做一次受控补投,确认没有第二条业务通知。这比单纯盯着手机有没有弹窗更可靠,因为弹窗可能被折叠,消息也可能只是本地调试程序发出的样本。

失败投递需要查看原因并选择是否补投,不能假设平台会替你无限自动重试。补投前先看接收端已有证据:有记录就核对处理状态,没有记录再查入口。遇到响应超时更要先验收实际副作用,不要把"客户端不知道结果"直接等同于"服务端什么也没做",然后连点几次重发。

建议保留四个最小观测量:最近一次有效投递时间、待处理数量、最老待办年龄、最近失败原因。它们能区分"根本没有事件""事件接住了但消费者堵住""已经处理但渠道不可见"。不要一开始就收集整个仓库的所有内容,尤其别把私有提交信息为了排错转发到公开日志服务。

最后再逐一停用旧邮箱消费者,避免新旧链路同时提醒。平台退役邮件入口,并不意味着你自己的轮询脚本已经停止;它可能还在读取旧邮件并重复处理。让每个消费者的最终结果可查询,确认新路径稳定后再处理旧配置。迁移的终点不是换了一个网址,而是每次事件都有明确、可核对的去向。

参考资料:GitHub于2026年10月15日发布的push邮件退役公告;GitHub官方Webhook签名验证文档与投递处理文档。新闻事实与本文接收核心设计分开理解:前者说明入口变化,后者是一份供你接入自身系统的独立工程实现,并不是GitHub发布或维护的SDK。

官方退役公告 · 签名验证 · 投递处理

相关推荐
昔我往昔1 小时前
chromadb 这个query_texts 查询的是基于分词还是基于向量查询的
python·chromadb
鸿业远图科技2 小时前
抚顺市_公交站点
python
VidDown2 小时前
上线三个月后的第一次事故:一个 Django 服务的日志、监控与故障复盘
数据库·python·django·wpf·音视频·视频编解码
打工仔折腾 AI2 小时前
Spring AI + Qwen 周报生成实战:从原始日志到人工确认卡
人工智能·后端·python·spring·elasticsearch·ai agent 实战
微订校园外卖平台2 小时前
同城外卖跑腿平台需要服务器吗?交付方式、部署架构与上线核对
服务器·架构·github·外卖系统·本地生活·微订
Zhichao_972 小时前
VSCode+AI操作Git、GitHub
git·github
维克兜率天2 小时前
【维克】统计套利建模:从因子到信号的完整流程
android·开发语言·python·算法·kotlin
全栈练习生2 小时前
RAG 准确率提升体系化方案
python·ai
GoGeekBaird2 小时前
我做了一个给 Agent 用的电商开源项目:开店、上架、改版,跟 Agent 说一声就行
后端·github·agent