How to Generate a PDF Report When a GitHub Actions Workflow Completes
Test runs produce JSON. Coverage tools produce LCOV. Deployment pipelines log to stdout. None of it is readable by a manager, a client, or anyone outside the engineering team.
Here's how to add a final step to any GitHub Actions workflow that renders a PDF report and either attaches it as an artifact or emails it — without adding a headless browser to your CI environment.
The pattern
Add a report job that runs after your main jobs complete:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test -- --reporter=json > test-results.json
- uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results.json
report:
runs-on: ubuntu-latest
needs: test
if: always() # run even if tests fail
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: test-results
- name: Generate PDF report
env:
PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }}
REPORT_RECIPIENTS: ${{ vars.REPORT_RECIPIENTS }}
run: node scripts/generate-report.js
- uses: actions/upload-artifact@v4
with:
name: pdf-report
path: report.pdf
Report generation script
// scripts/generate-report.js
import fs from "fs/promises";
const results = JSON.parse(await fs.readFile("test-results.json", "utf8"));
const html = renderTestReportHtml({
runId: process.env.GITHUB_RUN_ID,
repo: process.env.GITHUB_REPOSITORY,
branch: process.env.GITHUB_REF_NAME,
commit: process.env.GITHUB_SHA?.slice(0, 7),
triggeredBy: process.env.GITHUB_ACTOR,
timestamp: new Date().toUTCString(),
results,
});
const res = await fetch("https://pagebolt.dev/api/v1/pdf", {
method: "POST",
headers: {
"x-api-key": process.env.PAGEBOLT_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ html }),
});
const pdf = Buffer.from(await res.arrayBuffer());
await fs.writeFile("report.pdf", pdf);
console.log(`PDF report saved (${pdf.length} bytes)`);
// Optionally email it
if (process.env.REPORT_RECIPIENTS) {
await emailReport(pdf);
}
HTML report template
function renderTestReportHtml({ runId, repo, branch, commit, triggeredBy, timestamp, results }) {
const passed = results.testResults?.filter((t) => t.status === "passed").length ?? 0;
const failed = results.testResults?.filter((t) => t.status === "failed").length ?? 0;
const total = passed + failed;
const passRate = total > 0 ? ((passed / total) * 100).toFixed(1) : "0";
const statusColor = failed > 0 ? "#ef4444" : "#22c55e";
const failedTests = results.testResults
?.filter((t) => t.status === "failed")
.map(
(t) => `
<tr>
<td>${t.fullName ?? t.title}</td>
<td style="color:#ef4444">FAILED</td>
<td><code style="font-size:12px">${(t.failureMessages?.[0] ?? "").slice(0, 120)}</code></td>
</tr>`
)
.join("") ?? "";
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, sans-serif; color: #111; max-width: 800px; margin: 40px auto; padding: 0 24px; }
h1 { font-size: 22px; margin-bottom: 4px; }
.meta { color: #666; font-size: 13px; margin-bottom: 32px; line-height: 1.8; }
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 40px; }
.metric { background: #f5f5f5; border-radius: 8px; padding: 16px; }
.metric-value { font-size: 28px; font-weight: 700; }
.metric-label { color: #666; font-size: 12px; margin-top: 4px; }
.status-badge {
display: inline-block; padding: 4px 12px; border-radius: 12px;
background: ${statusColor}20; color: ${statusColor};
font-weight: 600; font-size: 13px;
}
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; border-bottom: 2px solid #111; padding: 8px 0; }
td { padding: 8px 0; border-bottom: 1px solid #eee; vertical-align: top; }
h2 { font-size: 16px; margin: 32px 0 12px; }
code { background: #f5f5f5; padding: 2px 6px; border-radius: 4px; }
</style>
</head>
<body>
<h1>Test Report <span class="status-badge">${failed > 0 ? "FAILED" : "PASSED"}</span></h1>
<div class="meta">
<div><strong>Repo:</strong> ${repo} · <strong>Branch:</strong> ${branch} · <strong>Commit:</strong> ${commit}</div>
<div><strong>Run:</strong> #${runId} · <strong>Triggered by:</strong> ${triggeredBy} · <strong>Time:</strong> ${timestamp}</div>
</div>
<div class="summary">
<div class="metric"><div class="metric-value">${total}</div><div class="metric-label">Total tests</div></div>
<div class="metric"><div class="metric-value" style="color:#22c55e">${passed}</div><div class="metric-label">Passed</div></div>
<div class="metric"><div class="metric-value" style="color:#ef4444">${failed}</div><div class="metric-label">Failed</div></div>
<div class="metric"><div class="metric-value">${passRate}%</div><div class="metric-label">Pass rate</div></div>
</div>
${failedTests ? `
<h2>Failed tests</h2>
<table>
<thead><tr><th>Test</th><th>Status</th><th>Error</th></tr></thead>
<tbody>${failedTests}</tbody>
</table>` : "<p style='color:#22c55e;font-weight:600'>All tests passed ✓</p>"}
</body>
</html>`;
}
Email the report via SendGrid
async function emailReport(pdfBuffer) {
const recipients = process.env.REPORT_RECIPIENTS.split(",");
const repo = process.env.GITHUB_REPOSITORY;
const runId = process.env.GITHUB_RUN_ID;
const branch = process.env.GITHUB_REF_NAME;
await fetch("https://api.sendgrid.com/v3/mail/send", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SENDGRID_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
personalizations: [{ to: recipients.map((r) => ({ email: r.trim() })) }],
from: { email: "ci@yourapp.com" },
subject: `CI Report: ${repo} #${runId} (${branch})`,
content: [{ type: "text/plain", value: "Test report attached." }],
attachments: [
{
content: pdfBuffer.toString("base64"),
filename: `report-${runId}.pdf`,
type: "application/pdf",
disposition: "attachment",
},
],
}),
});
}
Deployment manifest PDF
For deployment pipelines, generate a manifest instead of test results:
function renderDeploymentReportHtml({ env, version, services, timestamp }) {
const rows = services
.map(
(s) => `
<tr>
<td>${s.name}</td>
<td><code>${s.previousVersion}</code></td>
<td><code>${s.newVersion}</code></td>
<td style="color:${s.status === "deployed" ? "#22c55e" : "#ef4444"}">${s.status}</td>
</tr>`
)
.join("");
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, sans-serif; max-width: 700px; margin: 40px auto; padding: 0 24px; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; border-bottom: 2px solid #111; padding: 8px 0; }
td { padding: 10px 0; border-bottom: 1px solid #eee; font-size: 14px; }
code { background: #f5f5f5; padding: 2px 6px; border-radius: 4px; font-size: 12px; }
</style>
</head>
<body>
<h1>Deployment Report</h1>
<p>Environment: <strong>${env}</strong> · Version: <strong>${version}</strong> · ${timestamp}</p>
<table>
<thead><tr><th>Service</th><th>Previous</th><th>New</th><th>Status</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</body>
</html>`;
}
Coverage report PDF
- name: Generate coverage report
run: |
npm test -- --coverage --coverageReporters=json-summary
node scripts/coverage-to-pdf.js coverage/coverage-summary.json
// scripts/coverage-to-pdf.js
const summary = JSON.parse(await fs.readFile(process.argv[2], "utf8"));
const html = renderCoverageHtml(summary);
// ... same fetch + save pattern
The PDF attaches directly to the Actions run, accessible to anyone with repo access — no extra tooling needed to open it.
Try it free — 100 requests/month, no credit card. → Get started in 2 minutes
Top comments (0)