CI/CD 跑完了结果要在后台刷半天?GitHub Actions 这次把通知直接弹到桌面上了

你的 CI/CD 流水线跑完了,你在哪一步知道结果的?

大多数人的答案是:打开邮箱。或者打开 Slack 等通知。或者——什么通知都没有,跑完自己去平台上看。

这几个方案都有问题。邮箱有延迟,而且容易被当作垃圾邮件。Slack 消息太多,CI 成功的消息很快就被顶上去了。自己去平台上看,最不靠谱,等你发现失败的时候,可能已经过了两个小时。

现在,GitHub Actions 的工作流状态可以直接弹到你桌面上了。跑完一条流水线,Mac/Windows 的通知中心直接弹出来告诉你成功还是失败。不用打开任何页面,不用刷新邮箱,不用盯着 Slack。

实现这个效果,需要 GitHub Webhook + SSE 中转 + Notification API 三层串联。GitHub 负责触发,SSE 负责实时推送,Notification 负责在操作系统层面展示。

第一步:让 GitHub 在工作流跑完时通知你的服务。GitHub Actions 原生支持 Webhook,工作流完成时会向你指定的 URL 发送一个 POST 请求。

// GitHub Webhook 接收端(Express 示例)
app.post('/webhook/github-actions', (req, res) => {
  const payload = req.body;
  const event = req.headers['x-github-event'];

  // 只处理 workflow_run 事件
  if (event !== 'workflow_run') return res.json({ ok: true });

  const { action, workflow_run } = payload;
  if (action !== 'completed') return res.json({ ok: true });

  const status = workflow_run.conclusion; // success / failure / cancelled
  const repo = workflow_run.repository.full_name;
  const workflow = workflow_run.name;
  const branch = workflow_run.head_branch;

  // 立即通过 SSE 推送给所有在线的浏览器客户端
  broadcast({
    type: 'workflow_complete',
    status,
    repo,
    workflow,
    branch,
    url: workflow_run.html_url,
    time: new Date().toISOString()
  });

  res.json({ ok: true });
});

这一步的关键是 broadcast 函数——它把 GitHub 发来的事件转发给所有当前在线的 SSE 客户端。不需要数据库,不需要队列,GitHub 已经帮你做了重试,你只需要实时地把消息送出去。

第二步:SSE 中转层,把 GitHub Webhook 的消息实时推给浏览器。

// SSE 端点
app.get('/stream/ci', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  // 把这个响应对象存入连接池
  const clientId = addClient(res);

  req.on('close', () => removeClient(clientId));

  // 每 30 秒发送一个心跳,防止连接被中间件关闭
  const heartbeat = setInterval(() => {
    res.write(': heartbeatnn');
  }, 30000);

  req.on('close', () => clearInterval(heartbeat));
});

这里有两个细节要注意。第一个是心跳:很多反向代理(Nginx、Cloudflare)会在 60 秒没有流量时主动关闭连接,所以每 30 秒发一个空的心搏帧 : heartbeatnn 保持连接活跃。第二个是 Cache-Control: no-cache:如果这个头没设置,有些 CDN 会缓存 SSE 响应,导致消息延迟。

第三步:浏览器接收推送并弹出系统通知。

// 先请求通知权限
async function requestNotifPermission() {
  if (!('Notification' in window)) return false;
  if (Notification.permission === 'granted') return true;
  if (Notification.permission !== 'denied') {
    const perm = await Notification.requestPermission();
    return perm === 'granted';
  }
  return false;
}

// 建立 SSE 连接
const canNotify = await requestNotifPermission();
const eventSource = new EventSource('/stream/ci');

// 注册 Service Worker,让页面关闭后通知依然能弹出来
if ('serviceWorker' in navigator) {
  const reg = await navigator.serviceWorker.register('/sw.js');
  await navigator.serviceWorker.ready;
}

eventSource.addEventListener('workflow_complete', async (e) => {
  const data = JSON.parse(e.data);
  const { status, repo, workflow, branch, url } = data;

  const icon = status === 'success' ? '/icons/success.png' : '/icons/failure.png';
  const title = status === 'success' ? '✅ CI 通过' : '❌ CI 失败';
  const body = `${workflow} · ${branch} · ${repo}`;

  if (navigator.serviceWorker.controller) {
    // 通过 Service Worker 弹出系统通知,页面关了也能弹
    navigator.serviceWorker.controller.postMessage({
      title, body, icon, url, tag: `ci-${workflow}-${branch}`
    });
  } else if (canNotify) {
    // 降级:直接用 Notification API
    new Notification(title, { body, icon, tag: `ci-${workflow}-${branch}` });
  }
});

重点在这里:通过 Service Worker 发通知,和直接在页面里 new Notification() 是两个不同的东西。前者运行在浏览器后台,页面关闭后依然能触发通知。后者只在页面打开时有效。Service Worker 注册一次,之后就常驻后台,不需要页面一直开着。

最后,Service Worker 里的处理逻辑:

// sw.js
self.addEventListener('message', (event) => {
  const { title, body, icon, url, tag } = event.data;
  self.registration.showNotification(title, {
    body,
    icon,
    tag,
    requireInteraction: true, // 点击才关闭,不自动消失
    data: { url }
  });
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(clients.openWindow(event.notification.data.url));
});

requireInteraction: true 是这个方案里最重要的一个参数。默认情况下,浏览器会在几秒后自动关闭通知。对于 CI 失败这种需要立即处理的事件,应该让通知保持直到用户主动点击。

整个方案的延迟是多少?GitHub Webhook 到你的服务通常是 1-2 秒,SSE 推送是即时的,Notification 弹出来通常在事件触发后 3 秒以内。对比轮询方案常见的 30-60 秒轮询周期,实时性好了不止一个量级。

这个方案有一个隐性成本你要知道:浏览器对每个域名的 Service Worker 通知数量有限制,Chrome 是大约 3 条 / 分钟,超出后通知会被静默丢弃。如果你的 CI 频率非常高(比如每次 PR commit 都触发),需要对通知做合并,相同 workflow + branch 的失败只保留最新一条,用 tag 机制控制合并。

// 高频场景下的通知合并逻辑
if (status === 'failure') {
  // 用固定 tag,同一个 workflow + branch 的失败合并
  tag = `ci-failure-${workflow}-${branch}`;
} else {
  // 成功事件不需要合并,每次都弹
  tag = `ci-success-${Date.now()}`;
}

GitHub 的通知已经够用了,为什么还要这个方案?因为 GitHub 通知是邮件,你不一定在看邮件。这个方案让你在开发时不用主动去看任何地方,CI 的结果自己找上门来。对于需要快速响应的团队,这个差异挺大的。

评论区

0 条评论

登录后可评论。

阿监·前端工程化 882 阅读