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.
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:
- Keep
ROVELA_API_KEY,MAIL_FROM, andNOTIFY_TOin repository / environment secrets — never in the YAML, never inecho - Send on release published, CI / deploy failure (
if: failure()), or successful deploy (if: success()) - Call Rovela with
curl(Ubuntu runners ship it) or a tiny Nodefetch - 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:
- Vercel Cron Digest Email — scheduled production
GETthat aggregates app state; not a GitHub Actions runner step - AI agents send email over HTTP — ad-hoc agent / Cursor
curlscaffolding, not CI/CD notify onrelease/failure() - Cloudflare Workers transactional email — edge Worker + Wrangler secrets, not Actions
What GitHub Actions does vs what Rovela sends
| Concern | Owner |
|---|---|
Workflow triggers (push, release, workflow_run, …) | GitHub Actions |
Runner + secrets.* injection | GitHub |
| Choosing failure vs success vs release | Your workflow if: |
Subject / HTML / text from github.* context | Your step |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key ≈ gha-{kind}-{run_id} | Your step |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
Prerequisites
- A verified sending domain in the Rovela dashboard. Unverified
fromdomains are refused with400(Quick Start). Preferci.example.comormail.example.com. - An API key shaped like
re_…. Prefersending_accessfor CI notify (Authentication). - Repository (or Environment) secrets:
ROVELA_API_KEY,MAIL_FROM,NOTIFY_TOunder Settings → Secrets and variables → Actions. - A workflow file under
.github/workflows/. GitHub-hostedubuntu-latestrunners includecurlandjq.
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 workflowgithub.run_attempt— starts at1, increments on each re-rungithub.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:
| Key | Use 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
| Symptom | Likely cause |
|---|---|
| Step green, no mail | Wrong secret name, or if: never true |
Rovela 401 | Bad / revoked ROVELA_API_KEY |
Rovela 400 naming the domain | MAIL_FROM not on a verified domain |
Rovela 429 | Rate limit — retry with backoff, same Idempotency-Key |
| Duplicate alerts | Key regenerated randomly, or run_attempt added unintentionally |
Secret empty in logs as *** but send 401 | Secret not available to that environment / fork PR |
| Fork PR cannot send | Repository 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
- Verify the sending domain in Rovela (SPF / DKIM / DMARC / return-path as the dashboard shows).
- Create a
sending_accesskey; setROVELA_API_KEY,MAIL_FROM,NOTIFY_TOas Actions secrets. - Add a failure / release / deploy workflow step with
Idempotency-Key: gha-…-${{ github.run_id }}. - Trigger a controlled failure or a draft→publish release; confirm one message id and one inbox delivery.
- Re-run the same workflow within 24 hours — confirm Rovela returns the original id (no second email) when the key is
run_id-scoped. - Subscribe to Rovela delivery webhooks if ops needs bounce visibility (reading messages needs
full_accessor a session; Actions should keepsending_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.