← 返回博客

GitHub Actions 事务邮件 + Rovela(2026):Secrets 与基于 run_id 的幂等键

从 GitHub Actions 经 Rovela POST /emails 发送 CI 失败、Release 发布与部署成功通知。把 ROVELA_API_KEY 放进 Actions secrets;用 curl 或 Node fetch,Idempotency-Key = gha-{kind}-{github.run_id}。与 Vercel cron 摘要、AI agent Cursor 发信区分。

更新于 2026年11月4日
GitHub Actions 事务邮件 + Rovela(2026):Secrets 与基于 run_id 的幂等键

GitHub Actions 负责跑流水线 — Rovela 负责投递通知邮件

GitHub Actions 已经在你关心的每次 push、PR、release 与部署任务上运行。当 CI 失败、Release 发布或生产部署完成时,对值班/创始人更有用的往往是一封短事务邮件——而不是又一个被静音的 Slack bot。把 ROVELA_API_KEY(以及 MAIL_FROM / NOTIFY_TO)存成 GitHub Actions secrets,再在 workflow 步骤里用 curl 或 fetch 调用 POST https://api.rovela.dev/emails,并用 github.run_id 构造稳定的 Idempotency-Key。

当步骤调用发送接口时,Rovela 负责入队、DKIM 签名与投递(发送邮件 API、快速开始、鉴权)。GitHub 拥有触发器、runner 与密钥注入;你的 workflow YAML 决定何时发信以及正文有哪些字段;Rovela 只负责投递。

本指南会接好这类 workflow:

  1. 把 ROVELA_API_KEY、MAIL_FROM、NOTIFY_TO 放在仓库 / Environment secrets——不要写进 YAML,不要 echo
  2. 在 release published、CI / 部署失败(if: failure())或部署成功(if: success())时发信
  3. 用 Ubuntu runner 自带的 curl,或一小段 Node fetch 调用 Rovela
  4. 设置 Idempotency-Key: gha-{kind}-{github.run_id},避免 24 小时内重试/重投导致重复邮件(发送邮件 API)

Rovela 不会创建 GitHub workflow、不会代管 Actions secrets,也不会读取你的仓库。 GitHub 拥有这次 run;你的步骤负责把 context 映射成 HTML/text;Rovela 负责 POST /emails。

与这些文章区分:

GitHub Actions 做什么,Rovela 发什么

关注点负责方
Workflow 触发(push、release、workflow_run 等)GitHub Actions
Runner + secrets.* 注入GitHub
选择失败 / 成功 / release你的 workflow if:
用 github.* context 拼主题与 HTML/text你的步骤
POST /emails、DKIM、队列、退信Rovela
Idempotency-Key ≈ gha-{kind}-{run_id}你的步骤

Rovela 返回 200 表示已接受并入队,并不代表「已经进收件箱」(快速开始)。

前置条件

  1. 在 Rovela 控制台完成发信域名验证。未验证的 from 域名会以 400 拒绝(快速开始)。建议使用 ci.example.com 或 mail.example.com。
  2. 形如 re_… 的 API Key。CI 通知优先使用 sending_access(鉴权)。
  3. 在 Settings → Secrets and variables → Actions 配置仓库(或 Environment)密钥:ROVELA_API_KEY、MAIL_FROM、NOTIFY_TO。
  4. 在 .github/workflows/ 下添加 workflow。GitHub 托管的 ubuntu-latest runner 自带 curl 与 jq。

按 GitHub 的方式存放密钥

在 GitHub UI 创建 secret(或用 gh secret set)。只通过 env: 里的 ${{ secrets.NAME }} 引用——不要把密钥插进日志或 commit message。

# 在已有本地值的机器上执行(切勿提交这些值)
gh secret set ROVELA_API_KEY --body "$ROVELA_API_KEY"
gh secret set MAIL_FROM --body 'Acme CI <ci@mail.example.com>'
gh secret set NOTIFY_TO --body 'oncall@example.com'

若通知只应来自受保护的部署 job,优先使用 Environment secret(如 production)。轮换 Rovela 密钥:先新建 → 更新 secret → 再吊销旧密钥(鉴权)。

Idempotency-Key = github.run_id(以及何时加 attempt)

根据 github context:

  • github.run_id — 仓库内每次 workflow run 唯一;重新运行同一 run 时不变
  • github.run_attempt — 从 1 开始,每次 re-run 递增
  • github.server_url / github.repository / github.run_id — 拼出邮件里可点的 Actions run URL

Rovela 的 Idempotency-Key(最长 256 字符)会让 24 小时内相同请求的重试返回原响应,而不是再发一封(发送邮件 API)。

推荐键:

键适用场景
gha-ci-failure-{run_id}每个 workflow run 一封失败告警
gha-release-{run_id}每次发布 release 的 run 一封公告
gha-deploy-ok-{run_id}每次成功部署 run 一封「已上线」通知
gha-ci-failure-{run_id}-a{run_attempt}有意在 re-run 时再通知一次

默认只用 run_id,这样 GitHub 重试通知步骤,或有人在 24 小时内 re-run,都不会轰炸值班。只有在 re-run 必须再发一封新邮件时,才加上 run_attempt。

模式 A — CI / 部署失败通知(if: failure())

把通知步骤放在最后。if: failure() 会在前面步骤失败后执行;你不需要给失败步骤加 continue-on-error: true(那样会让 job 变绿)。

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install
        run: npm ci

      - name: Test
        run: npm test

      - name: Email on-call via Rovela
        if: failure()
        env:
          ROVELA_API_KEY: ${{ secrets.ROVELA_API_KEY }}
          MAIL_FROM: ${{ secrets.MAIL_FROM }}
          NOTIFY_TO: ${{ secrets.NOTIFY_TO }}
          RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
          REPO: ${{ github.repository }}
          WORKFLOW: ${{ github.workflow }}
          REF: ${{ github.ref_name }}
          ACTOR: ${{ github.actor }}
          SHA: ${{ github.sha }}
          RUN_ID: ${{ github.run_id }}
        run: |
          set -euo pipefail
          SHORT_SHA="${SHA:0:7}"
          SUBJECT="CI failed: ${REPO} @ ${REF} (${SHORT_SHA})"
          TEXT=$(printf '%s\n' \
            "Workflow: ${WORKFLOW}" \
            "Repo: ${REPO}" \
            "Ref: ${REF}" \
            "Actor: ${ACTOR}" \
            "SHA: ${SHORT_SHA}" \
            "Run: ${RUN_URL}")
          HTML="<p><b>CI failed</b></p><ul>
            <li>Workflow: ${WORKFLOW}</li>
            <li>Repo: ${REPO}</li>
            <li>Ref: ${REF}</li>
            <li>Actor: ${ACTOR}</li>
            <li>SHA: <code>${SHORT_SHA}</code></li>
            <li><a href=\"${RUN_URL}\">Open Actions run</a></li>
            </ul>"
          # 用 jq 安全拼 JSON — 分支名/actor 可能含引号
          payload=$(jq -n \
            --arg from "$MAIL_FROM" \
            --arg to "$NOTIFY_TO" \
            --arg subject "$SUBJECT" \
            --arg html "$HTML" \
            --arg text "$TEXT" \
            --arg run_id "$RUN_ID" \
            '{from:$from, to:$to, subject:$subject, html:$html, text:$text,
              tags:{kind:"gha-ci-failure", run_id:$run_id}}')
          curl -fsS -X POST "https://api.rovela.dev/emails" \
            -H "Authorization: Bearer ${ROVELA_API_KEY}" \
            -H "Content-Type: application/json" \
            -H "Idempotency-Key: gha-ci-failure-${RUN_ID}" \
            -d "$payload"

注意:

  • 通过 env: 传入密钥,让它们成为 runner 上的环境变量;避免把 ${{ secrets.* }} 直接拼进可能进入 debug 日志的 shell 字符串。
  • 用 jq -n 构建 JSON(GitHub 托管 Ubuntu 预装),避免 ref 中的特殊字符弄坏正文。
  • 务必带上 run URL——值班凌晨点的就是它。

模式 B — Release 发布公告

# .github/workflows/release-email.yml
name: Release email

on:
  release:
    types: [published]

jobs:
  announce:
    runs-on: ubuntu-latest
    steps:
      - name: Email release note via Rovela
        env:
          ROVELA_API_KEY: ${{ secrets.ROVELA_API_KEY }}
          MAIL_FROM: ${{ secrets.MAIL_FROM }}
          NOTIFY_TO: ${{ secrets.NOTIFY_TO }}
          TAG: ${{ github.event.release.tag_name }}
          NAME: ${{ github.event.release.name }}
          HTML_URL: ${{ github.event.release.html_url }}
          BODY: ${{ github.event.release.body }}
          REPO: ${{ github.repository }}
          RUN_ID: ${{ github.run_id }}
        run: |
          set -euo pipefail
          SUBJECT="Released ${TAG} — ${REPO}"
          # 正文保持简短;完整说明放在 GitHub Release 页面
          TEXT=$(printf '%s\n' "Release: ${NAME:-$TAG}" "Tag: ${TAG}" "URL: ${HTML_URL}")
          HTML="<p>Release <b>${TAG}</b> is published.</p>
            <p><a href=\"${HTML_URL}\">Open release</a></p>"
          payload=$(jq -n \
            --arg from "$MAIL_FROM" \
            --arg to "$NOTIFY_TO" \
            --arg subject "$SUBJECT" \
            --arg html "$HTML" \
            --arg text "$TEXT" \
            --arg tag "$TAG" \
            '{from:$from, to:$to, subject:$subject, html:$html, text:$text,
              tags:{kind:"gha-release", tag:$tag}}')
          curl -fsS -X POST "https://api.rovela.dev/emails" \
            -H "Authorization: Bearer ${ROVELA_API_KEY}" \
            -H "Content-Type: application/json" \
            -H "Idempotency-Key: gha-release-${RUN_ID}" \
            -d "$payload"

release 且 types: [published] 会在 GitHub Release 上线时触发。changelog 可能很长——邮件里链到 html_url,保持事务性即可。

模式 C — 部署成功通知

      - name: Email deploy success via Rovela
        if: success()
        env:
          ROVELA_API_KEY: ${{ secrets.ROVELA_API_KEY }}
          MAIL_FROM: ${{ secrets.MAIL_FROM }}
          NOTIFY_TO: ${{ secrets.NOTIFY_TO }}
          RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
          REPO: ${{ github.repository }}
          REF: ${{ github.ref_name }}
          SHA: ${{ github.sha }}
          RUN_ID: ${{ github.run_id }}
        run: |
          set -euo pipefail
          SHORT_SHA="${SHA:0:7}"
          SUBJECT="Deployed ${REPO} @ ${REF} (${SHORT_SHA})"
          TEXT="Deploy succeeded. ${RUN_URL}"
          HTML="<p>Deploy succeeded for <b>${REPO}</b> @ <code>${REF}</code> (<code>${SHORT_SHA}</code>).</p>
            <p><a href=\"${RUN_URL}\">Open Actions run</a></p>"
          payload=$(jq -n \
            --arg from "$MAIL_FROM" \
            --arg to "$NOTIFY_TO" \
            --arg subject "$SUBJECT" \
            --arg html "$HTML" \
            --arg text "$TEXT" \
            '{from:$from, to:$to, subject:$subject, html:$html, text:$text,
              tags:{kind:"gha-deploy-ok"}}')
          curl -fsS -X POST "https://api.rovela.dev/emails" \
            -H "Authorization: Bearer ${ROVELA_API_KEY}" \
            -H "Content-Type: application/json" \
            -H "Idempotency-Key: gha-deploy-ok-${RUN_ID}" \
            -d "$payload"

成功邮件请留给生产部署 workflow——不要给每个 PR CI 都发,否则收件箱会训练人忽略通知。若 job 也会在其他分支跑,用 if: success() && github.ref == 'refs/heads/main' 收紧。

可选:用 Node fetch 代替 curl

若 job 里已有 Node:

      - name: Email via Rovela (Node)
        if: failure()
        env:
          ROVELA_API_KEY: ${{ secrets.ROVELA_API_KEY }}
          MAIL_FROM: ${{ secrets.MAIL_FROM }}
          NOTIFY_TO: ${{ secrets.NOTIFY_TO }}
          RUN_ID: ${{ github.run_id }}
          RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
          REPO: ${{ github.repository }}
        run: |
          node <<'NODE'
          const res = await fetch('https://api.rovela.dev/emails', {
            method: 'POST',
            headers: {
              Authorization: `Bearer ${process.env.ROVELA_API_KEY}`,
              'Content-Type': 'application/json',
              'Idempotency-Key': `gha-ci-failure-${process.env.RUN_ID}`.slice(0, 256),
            },
            body: JSON.stringify({
              from: process.env.MAIL_FROM,
              to: process.env.NOTIFY_TO,
              subject: `CI failed: ${process.env.REPO}`,
              html: `<p>CI failed.</p><p><a href="${process.env.RUN_URL}">Open run</a></p>`,
              text: `CI failed. ${process.env.RUN_URL}`,
              tags: { kind: 'gha-ci-failure', run_id: process.env.RUN_ID },
            }),
          });
          const body = await res.json();
          if (!res.ok) {
            console.error(body);
            process.exit(1);
          }
          console.log('queued', body.id);
          NODE

同一套 API 与幂等规则。Actions 里优先使用 sending_access 密钥(鉴权)。

多收件人与 tags

to 可以是字符串或数组(to / cc / bcc 合计最多 50;按收件人计费)(发送邮件 API)。小值班列表可以:

# NOTIFY_TO secret 可以是单个地址;或在 jq 里传 JSON 数组:
--argjson to '["oncall@example.com","founders@example.com"]'

可选的消息 tags 便于之后在 Rovela 控制台 / webhook 里过滤(kind、run_id、repo)。

值得处理的失败模式

现象常见原因
步骤绿了但没信secret 名写错,或 if: 从未为真
Rovela 401ROVELA_API_KEY 错误或已吊销
Rovela 400 点名域名MAIL_FROM 不在已验证域名上
Rovela 429触达限流 — 退避重试,同一 Idempotency-Key
重复告警幂等键每次随机生成,或不小心加了 run_attempt
日志里密钥显示 *** 但仍 401该 Environment / fork PR 拿不到 secret
Fork PR 发不出默认不会把仓库 secrets 传给来自 fork 的 workflow — 符合预期

Fork 的 pull request 不应拿到生产发信密钥。把通知步骤放在对 main 的 push、release,或 Environment 保护的部署 job 上。

不要 echo API key。GitHub 会在日志里打码已知密钥,但那只是兜底,不是打印密钥的许可。

上线检查清单

  1. 在 Rovela 验证发信域名(按控制台完成 SPF / DKIM / DMARC / return-path)。
  2. 创建 sending_access 密钥;把 ROVELA_API_KEY、MAIL_FROM、NOTIFY_TO 设为 Actions secrets。
  3. 添加失败 / release / 部署步骤,使用 Idempotency-Key: gha-…-${{ github.run_id }}。
  4. 故意触发一次失败,或 draft→publish 一次 release;确认一个 message id 与一封收件箱投递。
  5. 24 小时内 re-run 同一 workflow——在 run_id 作用域下应返回原 id(不会第二封)。
  6. 若运维需要退信可见性,订阅 Rovela 投递 webhook(读消息需要 full_access 或 session;Actions 应继续用 sending_access)。

本文未覆盖

  • 以 Slack / Discord webhook 为主通道 — 同样可用 if: failure(),只是 URL 不同;本文是经 Rovela 发邮件
  • 按计划从生产数据库汇总摘要 — 见 Vercel Cron 摘要邮件
  • 临时 agent 工具链 — 见 AI Agent 用 HTTP 发邮件
  • 从 Actions 发营销群发 — 活动邮件留给营销工具;本路径只做事务性运维 / release 通知

小结

GitHub Actions 已经知道 CI 何时失败、Release 何时发布、部署何时成功。把 Rovela 凭据放进 Actions secrets,用 curl 或 fetch 调用 POST https://api.rovela.dev/emails,并把 Idempotency-Key 钉在 github.run_id 上(仅当 re-run 必须再发时才加 run_attempt)。GitHub 拥有 workflow;Rovela 拥有投递——你的应用里不必再挂一套邮件 worker。