DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to generate a PDF report when a GitHub Actions workflow completes

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>`;
}
Enter fullscreen mode Exit fullscreen mode

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",
        },
      ],
    }),
  });
}
Enter fullscreen mode Exit fullscreen mode

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>`;
}
Enter fullscreen mode Exit fullscreen mode

Coverage report PDF

- name: Generate coverage report
  run: |
    npm test -- --coverage --coverageReporters=json-summary
    node scripts/coverage-to-pdf.js coverage/coverage-summary.json
Enter fullscreen mode Exit fullscreen mode
// scripts/coverage-to-pdf.js
const summary = JSON.parse(await fs.readFile(process.argv[2], "utf8"));
const html = renderCoverageHtml(summary);
// ... same fetch + save pattern
Enter fullscreen mode Exit fullscreen mode

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)