从 push 到上线只差一条 Git 钩子OpenShip CI/CD 全链路时序拆解【免费下载链接】openshipSelf-hosted deployment platform项目地址: https://gitcode.com/GitHub_Trending/ope/openship推代码即上线这个承诺托管平台做了十几年自托管生态却一直被两座大山压着Kubernetes 的复杂度和商业 PaaS 的账单。OpenShip 用一条 Git Webhook 把两者都绕了过去——2026 年 7 月它曾单日暴涨 1641 星成为 GitHub 热榜增长最快的项目之一社区讨论的核心始终是同一个体验git push之后构建、路由、证书、健康检查全自动跑完像 Vercel 一样但跑在你自己的 VPS 上。热度容易复制时序很难。这篇文章不聊它多好用而是顺着一条 push 的旅程逐段拆解 OpenShip 在源码级实现的完整链路Webhook 如何验签与去重、构建引擎如何冻结配置快照、OpenResty 与 ACME 如何与部署并行编排、失败部署的日志和回滚如何被兜底。所有结论都对应仓库中的真实文件与代码路径。push 触发之后Git 事件如何驱动构建与部署引擎一条 push 首先变成 GitHub 对POST /api/webhooks/github的投递。入口在 apps/api/src/modules/webhooks/webhook.controller.ts它只做三件事按路由参数查 provider、用 provider 验签、把解析后的 JSON 交给provider.handle。值得注意的细节是它的返回语义——签名合法只证明投递来源真实不代表动作执行成功因此 handler 抛错时控制器返回 500 而不是吞掉错误保证 GitHub 侧可以重投递。验签逻辑在 apps/api/src/modules/github/github.webhook.ts 中远比表面复杂。一个owner/repo可能同时注册在多个项目上monorepo 子项目、多分支环境而 GitHub 只按 hook 发送一份签名所以collectDeliverySecrets会把所有匹配项目各自的webhookSecret解密后组成候选集只要任意一个匹配timingSafeEqual常量时间比较即通过最后才追加环境变量里的 legacy 密钥。注释里点明了一件事没有无签名路径——连自托管场景下无法解析出密钥的投递也必须被拒绝这是默认拒绝default-deny的底线。接下来是幂等层。x-github-delivery这个 header 是 GitHub 每次投递的全局唯一 IDgithub.webhook.ts 中通过repos.webhookDelivery.claimGithub()对它做持久化 claim——注意是持久化的跨进程重启、跨副本都生效。重复投递直接返回Duplicate delivery ignored而处理结果会以received/failed状态写回这条 claim 记录作为审计锚点。真正干活的是 apps/api/src/modules/github/webhook-push.ts 的handlePush。时序上它依次完成事件过滤删除分支、非refs/heads/的 ref 直接忽略项目匹配findByGitRepo找到所有指向该仓库的项目再按autoDeploy 分支匹配筛出目标。多租户安全在这里非常显眼——GitHub App 的签名是单密钥、对所有 org 相同所以签名无法区分租户代码用installation.id把广播范围绑定到投递来源的安装防止一个租户的项目被扇入到别人的 push 里触发未授权部署变更文件提取与智能路由extractChangedFiles见 packages/platform/src/engine/modules/github/webhook-changed-files.ts从 payload 的 commits 列表重建变更文件集合。这里有一组精心设计的兜底force push 时 commit 列表不可信直接forceAll改动触及根配置文件openship.json等或 monorepo 共享路径时也forceAllcommits 超过 20 条且compareCommits无法恢复完整列表时宁可全量部署也不漏部署under-deploy would ship stale code而如果routeServicesByChanges判定没有任何服务受影响则直接 skip——这条 push 连一次部署都不会创建fan-out 与审计observedAllSettled并行对每个项目触发部署结果逐一写入webhook_delivery行dispatched/skipped/failed状态齐全。失败的项目还会通过 notification 发出deployment.failed事件避免webhook 静默失败。最后一步调用triggerDeploymentpackages/platform/src/engine/modules/deployments/build.service.ts这是构建引擎的入口时序上包含几个关键闸门commit-sha 去重先把调用方传来的任意 ref 规范化为完整 SHA再查该 commit 是否 in-flight 或已上线。这关闭了 GitHub App 与 repo webhook 双投递的窗口——第二个到达的投递会被跳过并返回skipped: true而不会产生重复部署配置快照冻结buildConfigSnapshot把项目的构建配置连同卷、release 命令、部署类别source/build/workload 三轴整体冻结成DeploymentConfigSnapshot。注释反复强调一个原则旧部署被重放时必须按它当年的配置执行而不是按项目今天的状态——这是回滚语义正确的根基PreflightrunDeploymentPreflight在创建任何资源之前跑完域名、端口、凭据等检查GitHub 凭据类失败会被映射为特定的错误码让前端能弹对应的配置弹窗Rollback contextresolveRollbackContext统一解析rollbackStrategysnapshot/git与commitShaBefore锚点入队与执行createQueuedDeployment写入 deployment 行后kickoffBuild以同步 claim的方式把状态从queued原子推进到building再 fire-and-forget 启动executeBuildAndDeploy。注释里记录了一个真实的竞态如果 queued→building 是异步的仪表盘的redeployBuildSession → startBuild链会二次触发kickoffBuild导致两个执行体并行、重复开工作区、向同一 SSE 流双写日志。executeBuildAndDeploypackages/platform/src/engine/modules/deployments/build-pipeline.ts是执行引擎本体。它持有一个上限 50,000 条的日志缓冲BuildLogger统一把构建输出同时推给内存数组、SSE 流和会话存储构建完成后若产出 imageRef 才进入部署阶段否则立即onFailure——构建结束但没产出可部署产物被当作明确的失败而非成功。路由、证书、健康检查的并行时序OpenShip 的边车不是动态生成的 nginx.conf而是一份烤进镜像的 OpenResty 基座加上运行时管理的 vhost。看 apps/edge/nginx.conf 顶部注释就能明白设计这份文件是生成产物不允许手改因为 bare/remote 边缘节点通过 TypeScript 常量打同样的补丁两份配置必须同源。运行时 API 只往sites-enabled/*.conf宿主挂载里写 vhost 文件然后docker execreload。这份配置里藏着几个不那么显眼但决定并行时序是否安全的细节ACME 的零停机编排location ^~ /.well-known/acme-challenge/把 HTTP-01 挑战代理到127.0.0.1:49180的 certbot 临时独立服务器。certbot 只在签发期间占用该端口Lets Encrypt 打 80 端口 → 这里 → certbot没有 80 端口争夺、没有 webroot所以证书签发可以和部署并行推进而不互相踩踏见 packages/platform/src/engine/modules/deployments/ssl.service.ts 中基于manageDomainSsl的 renew 流程以及 ssl.operations.ts 的操作层443 默认服务器的反串流设计如果 443 上没有default_servernginx 会把第一个加载的 443 vhost 的证书后端服务给任何 SNI 不匹配的域名——这是跨域串流安全洞。配置为此专门放了一个不属于任何域名的死端证书让未路由的请求握手完成后只收到 404 页绝不 fallthrough 到别的应用注释还解释了为什么不能用ssl_certificate于 http 作用域会继承真实域名证书不可伪造的信任锚set_real_ip_from只信任 Cloudflare 公布的网段且信任头意味着只有 TCP 对端来自这些网段才读CF-Connecting-IP。XFF 从不被信任因为 Cloudflare 是追加而非覆盖 XFF从左读会让客户端自选 IP、自选限流桶回环管理面127.0.0.1:9145上挂着 SSE live-log 流pipe_stream.lua与监控/健康接口mgmt_api.lua构建日志的实时推送就靠这条内部通道。部署后的健康检查由 readiness-gate.ts 承载它把就绪拆成两个独立闸门TCP/HTTP probe对应用端口做 GET路径、端口、超时、间隔均可配置与stabilization 窗口监视重启循环windowMs内多次崩溃即判失败云托管下强制开启并默认fail。两者的失败策略独立fail会把部署翻转为失败并回滚到上一部署warn则只上报。值得注意的工程取舍是probe 在 stabilization 已失败时会被跳过——对一个已知在蹦跳的工作负载去拨端口只是给已经到手的判决多赔一个完整超时。就绪判定与路由、证书处于同一收尾段静态站点没有端口可拨就绪被重新定义为文件可服务且从边缘节点实际观察的位置探测能抓到静态部署唯一的失败模式404。而部分服务成功、部分失败的多服务部署不会伪装成成功——rollupDeploymentStatus产出的partial_failure会进入待人工决策状态keep/rejectSSE 仍报 ready但行状态与decision: pending标记会让仪表盘弹出 Action Required。一次失败部署的完整追踪日志与回滚机制失败不是边缘情况是必须一等公民设计的路径。OpenShip 的失败可观测性从构建一开始就在收集证据实时通道logCallback把每条日志同时写入内存缓冲与sessionManager.appendLogSSE 通过边缘的pipe_stream.lua把构建输出推给浏览器仪表盘的部署页不会在构建中卡死转圈持久化清洗persistLogs先把\r覆盖的终端输出折叠为最终态再做 sanitize——原生日志里会出现 NUL 或未配对的 surrogate失败的 docker exec 会把多路复用流连帧头一起吐出来而 Postgres 的 jsonb 列拒收这类 payload注释明确说被拒绝的 UPDATE 曾被误读为部署失败所以清洗后落库是唯一路径终结日志onFailuredeployment-lifecycle.ts在清理动作之前先把真实失败原因写入 SSE 与持久化会话避免后续二次诊断污染根因随后reportError上报结构化错误并总是清理工作区/镜像——用户不需要手动收拾残局状态机终态失败、成功、取消、action_required、no_changes都是不可逆终态恢复逻辑只在中间态生效。回滚是真正的重头戏。它不是一个重建上一版的粗暴操作而是先规划再执行的restore plan机制packages/platform/src/engine/modules/deployments/rollback/planRestore 先裁决restore-plan.ts只有ready或partial_failure的部署是可回滚点目标已是 active 部署则拒绝ALREADY_ACTIVE没有配置快照且无保留单元则拒绝ARTIFACT_GONE——每个拒绝都带明确的错误码而非静默失败五种恢复模式RestorePlanunit-swap原地换单元最便宜bare runtime 可恢复时优先redeploy-pinned用保留镜像逐字重放不重建、不拉取reacquire-image重新拉取冻结的 release 镜像rebuild从目标 commit 重新构建ineligible给出不可回滚原因。镜像是否还在宿主机上会逐个查询某个 tag 被回收或 release 目录被删时优雅降级为重建而不是部署中途才炸执行路径rollback-orchestrator.tsrollback(targetDeploymentId)先解析 planunit-swap路径在项目运行时锁内二次校验 plan 没有过期清理可能在预览与执行之间获胜redeploy-pinned等其他模式走restoreViaRedeploy——直接调用triggerDeployment重放冻结快照。注释专门澄清了依赖方向orchestrator 静态依赖 build.service而 build.service不导入 rollback唯一的 deploy↔rollback 边是 build-pipeline 对onDeploymentReady的动态 import所以不存在循环依赖可逆性回滚前先记录当前 active 部署的 commit 作为新的commitShaBefore让回滚操作本身可再回滚。回滚策略rollbackStrategy默认snapshot归档镜像工作区瞬时恢复可选git回滚时重新 clone 到commitShaBefore重建原 release 的保真新部署就绪后才归档前代deployment-lifecycle.ts 的onSuccess与 build-pipeline 的archivePreviousDeployment失败、取消、reconciling 的部署都不触碰线上版本。docker 的制品是镜像本身被 keep 集合保留bare runtime 则用unitRestore把上一代单元归档为可恢复单元。把整条链路放回时间轴上push 到达 → 验签与持久化去重毫秒级→ 分支匹配与租户过滤 → 变更文件智能路由可能直接 skip→ commit-sha 二次去重 → 配置快照冻结与 preflight → 同步 claim 入队 → 构建执行日志双写→ 部署与就绪闸门 → 路由/证书/健康检查收尾 → 前代归档。而任何一步失败从根因日志到恢复 plan 再到回滚可再回滚都有对应的代码路径兜底。这才是从 push 到上线只差一条 Git 钩子的真实代价与真实价值把复杂的失败治理前置到流水线的每一个闸门里用户只需要 push 一次。对独立开发者这是把 Vercel 体验搬回自己服务器的捷径对想理解自托管 PaaS 设计的人来说这份源码本身就是一份关于幂等、快照冻结与失败可恢复性的高质量教材。【免费下载链接】openshipSelf-hosted deployment platform项目地址: https://gitcode.com/GitHub_Trending/ope/openship创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考