← Back to blog

GitHub Actions Transactional Email with Rovela (2026): Secrets + Idempotency-Key from run_id

Send CI failure, release published, and deploy success notify mail from GitHub Actions via Rovela POST /emails. Store ROVELA_API_KEY in Actions secrets; curl or Node fetch with Idempotency-Key = gha-{kind}-{github.run_id}. Distinct from Vercel cron digests and AI agent Cursor send.

Updated November 4, 2026
GitHub Actions Transactional Email with Rovela (2026): Secrets + Idempotency-Key from run_id

GitHub Actions owns the run — Rovela delivers the notify mail

GitHub Actions already runs on every push, pull request, release, and deploy job you care about. When a CI job fails, a release is published, or production deploy finishes, the useful signal is usually a short transactional email to on-call or founders — not another Slack bot you forgot to mute. Store ROVELA_API_KEY (and MAIL_FROM / NOTIFY_TO) as GitHub Actions secrets, then curl or fetch POST https://api.rovela.dev/emails from a workflow step with a stable Idempotency-Key built from github.run_id.

Rovela queues, DKIM-signs, and delivers when the step calls the Send Email API (Send Email API, Quick Start, Authentication). GitHub owns the trigger, the runner, and secret injection. Your workflow YAML owns when to mail and which fields go in the body. Rovela only delivers.

This guide wires workflows that:

  1. Keep ROVELA_API_KEY, MAIL_FROM, and NOTIFY_TO in repository / environment secrets — never in the YAML, never in echo
  2. Send on release published, CI / deploy failure (if: failure()), or successful deploy (if: success())
  3. Call Rovela with curl (Ubuntu runners ship it) or a tiny Node fetch
  4. Set Idempotency-Key: gha-{kind}-{github.run_id} so job retries and re-delivery inside 24 hours do not double-mail (Send Email API)

Rovela does not create GitHub workflows, store Actions secrets, or read your repository. GitHub owns the run. Your step owns map context → HTML/text. Rovela owns POST /emails.

Distinct from:

What GitHub Actions does vs what Rovela sends

ConcernOwner
Workflow triggers (push, release, workflow_run, …)GitHub Actions
Runner + secrets.* injectionGitHub
Choosing failure vs success vs releaseYour workflow if:
Subject / HTML / text from github.* contextYour step
POST /emails, DKIM, queue, bouncesRovela
Idempotency-Key ≈ gha-{kind}-{run_id}Your step

A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).

Prerequisites

  1. A verified sending domain in the Rovela dashboard. Unverified from domains are refused with 400 (Quick Start). Prefer ci.example.com or mail.example.com.
  2. An API key shaped like re_…. Prefer sending_access for CI notify (Authentication).
  3. Repository (or Environment) secrets: ROVELA_API_KEY, MAIL_FROM, NOTIFY_TO under Settings → Secrets and variables → Actions.
  4. A workflow file under .github/workflows/. GitHub-hosted ubuntu-latest runners include curl and jq.

Store secrets the GitHub way

Create secrets in the GitHub UI (or via gh secret set). Reference them only through ${{ secrets.NAME }} in env: — do not interpolate secrets into logs or commit messages.

# From a machine that already has the values (never commit these)
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'

Prefer an Environment secret (production) when the notify should only fire from protected deploy jobs. Rotate Rovela keys by create → update secret → revoke old key (Authentication).

Idempotency-Key = github.run_id (and when to add attempt)

From the github context:

  • github.run_id — unique per workflow run in the repository; does not change if you re-run the workflow
  • github.run_attempt — starts at 1, increments on each re-run
  • github.server_url / github.repository / github.run_id — build the Actions run URL people click from the email

Rovela’s Idempotency-Key (up to 256 characters) makes a retry of the same send within 24 hours return the original response instead of mailing again (Send Email API).

Recommended keys:

KeyUse when
gha-ci-failure-{run_id}One failure alert per workflow run
gha-release-{run_id}One release announce per run that published
gha-deploy-ok-{run_id}One “deployed” note per successful deploy run
gha-ci-failure-{run_id}-a{run_attempt}Intentionally re-notify on a re-run

Default to run_id only so a flaky notify step that GitHub retries, or a human who re-runs within 24 hours, does not spam on-call. Add run_attempt only when a re-run must send a fresh message.

Pattern A — CI / deploy failure notify (if: failure())

Put the notify step last. if: failure() runs after an earlier step failed; you do not need continue-on-error: true on the failing step (that would green the 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 builds JSON safely — branch/actor strings can contain quotes
          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"

Notes:

  • Pass secrets through env: so they appear as environment variables on the runner; avoid ${{ secrets.* }} inside shell strings that might leak into debug logs.
  • Build JSON with jq -n (preinstalled on GitHub-hosted Ubuntu) so special characters in refs do not break the body.
  • Include the run URL — that is what on-call clicks at 2 AM.

Pattern B — Release published announce

# .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}"
          # Keep body short; full notes live on the GitHub Release page
          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 with types: [published] fires when a GitHub Release goes live (including “Create a new release” publish). Do not put the entire changelog in email if it can be huge — link html_url and keep the message transactional.

Pattern C — Successful deploy notify

      - 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"

Reserve success mail for production deploy workflows — not every PR CI — or the inbox trains people to ignore it. Gate with if: success() && github.ref == 'refs/heads/main' when the job also runs on other branches.

Optional: Node fetch instead of curl

If the job already has 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

Same API, same idempotency rules. Prefer sending_access keys in Actions (Authentication).

Multi-recipient and tags

to accepts a string or an array (cap 50 across to / cc / bcc; billing is per recipient) (Send Email API). For a small on-call list:

# NOTIFY_TO secret can be a single address; or pass a JSON array in jq:
--argjson to '["oncall@example.com","founders@example.com"]'

Optional tags on the message help you filter in the Rovela dashboard / webhooks later (kind, run_id, repo).

Failure modes worth handling

SymptomLikely cause
Step green, no mailWrong secret name, or if: never true
Rovela 401Bad / revoked ROVELA_API_KEY
Rovela 400 naming the domainMAIL_FROM not on a verified domain
Rovela 429Rate limit — retry with backoff, same Idempotency-Key
Duplicate alertsKey regenerated randomly, or run_attempt added unintentionally
Secret empty in logs as *** but send 401Secret not available to that environment / fork PR
Fork PR cannot sendRepository secrets are not passed to workflows from forks by default — expected

Fork pull requests should not receive production sending keys. Keep notify steps on push to main, release, or Environment-protected deploy jobs.

Do not echo the API key. GitHub redacts known secrets in logs, but treat that as a backstop, not a license to print them.

Deploy checklist

  1. Verify the sending domain in Rovela (SPF / DKIM / DMARC / return-path as the dashboard shows).
  2. Create a sending_access key; set ROVELA_API_KEY, MAIL_FROM, NOTIFY_TO as Actions secrets.
  3. Add a failure / release / deploy workflow step with Idempotency-Key: gha-…-${{ github.run_id }}.
  4. Trigger a controlled failure or a draft→publish release; confirm one message id and one inbox delivery.
  5. Re-run the same workflow within 24 hours — confirm Rovela returns the original id (no second email) when the key is run_id-scoped.
  6. Subscribe to Rovela delivery webhooks if ops needs bounce visibility (reading messages needs full_access or a session; Actions should keep sending_access).

What this does not cover

  • Slack / Discord webhooks as the primary channel — same if: failure() pattern, different URL; this post is email via Rovela
  • Building digests from your production database on a schedule — see Vercel Cron Digest Email
  • Ad-hoc agent tooling — see AI agents send email over HTTP
  • Marketing blasts from Actions — keep campaigns on a marketing tool; this path is transactional ops / release mail only

Summary

GitHub Actions already knows when CI failed, a release published, or deploy succeeded. Put Rovela credentials in Actions secrets, call POST https://api.rovela.dev/emails with curl or fetch, and pin Idempotency-Key to github.run_id (add run_attempt only when a re-run must mail again). GitHub owns the workflow; Rovela owns delivery — without a custom mail worker in your app.