DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to auto-generate a PDF changelog when you publish a GitHub release

How to Auto-Generate a PDF Changelog When You Publish a GitHub Release

GitHub release notes live on GitHub. For clients who don't have repo access, compliance teams who need versioned documentation, or enterprise customers who require PDF records of software changes, you need a distributable document.

Here's a GitHub Actions workflow that triggers on every release publish, renders your release notes as a branded PDF, and attaches it directly to the release assets — automatically.

The workflow

# .github/workflows/release-pdf.yml
name: Generate release PDF

on:
  release:
    types: [published]

jobs:
  generate-pdf:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Generate release notes PDF
        env:
          PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
          RELEASE_TAG: ${{ github.event.release.tag_name }}
          RELEASE_NAME: ${{ github.event.release.name }}
          RELEASE_BODY: ${{ github.event.release.body }}
          RELEASE_DATE: ${{ github.event.release.published_at }}
          REPO: ${{ github.repository }}
          RELEASE_URL: ${{ github.event.release.html_url }}
        run: node scripts/generate-release-pdf.js

      - name: Upload PDF to release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh release upload "${{ github.event.release.tag_name }}" \
            "release-${{ github.event.release.tag_name }}.pdf" \
            --clobber
Enter fullscreen mode Exit fullscreen mode

PDF generation script

// scripts/generate-release-pdf.js
import fs from "fs/promises";

const {
  PAGEBOLT_API_KEY,
  RELEASE_TAG,
  RELEASE_NAME,
  RELEASE_BODY,
  RELEASE_DATE,
  REPO,
  RELEASE_URL,
} = process.env;

const html = renderReleaseNotesHtml({
  tag: RELEASE_TAG,
  name: RELEASE_NAME || RELEASE_TAG,
  body: RELEASE_BODY || "",
  date: new Date(RELEASE_DATE).toLocaleDateString("en-US", {
    year: "numeric",
    month: "long",
    day: "numeric",
  }),
  repo: REPO,
  url: RELEASE_URL,
});

const res = await fetch("https://pagebolt.dev/api/v1/pdf", {
  method: "POST",
  headers: {
    "x-api-key": PAGEBOLT_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ html }),
});

if (!res.ok) {
  console.error(`PageBolt error: ${res.status} ${await res.text()}`);
  process.exit(1);
}

const pdf = Buffer.from(await res.arrayBuffer());
const filename = `release-${RELEASE_TAG}.pdf`;
await fs.writeFile(filename, pdf);
console.log(`Generated ${filename} (${pdf.length} bytes)`);
Enter fullscreen mode Exit fullscreen mode

HTML release notes template

function renderReleaseNotesHtml({ tag, name, body, date, repo, url }) {
  // Convert markdown-ish release notes to HTML
  const bodyHtml = body
    .replace(/^### (.+)$/gm, "<h3>$1</h3>")
    .replace(/^## (.+)$/gm, "<h2>$1</h2>")
    .replace(/^- (.+)$/gm, "<li>$1</li>")
    .replace(/(<li>.*<\/li>\n?)+/gs, (match) => `<ul>${match}</ul>`)
    .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
    .replace(/`(.+?)`/g, "<code>$1</code>")
    .replace(/\n\n/g, "</p><p>")
    .replace(/^(?!<[hul])(.+)$/gm, "<p>$1</p>");

  return `<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>
    body {
      font-family: -apple-system, 'Segoe UI', sans-serif;
      color: #111;
      max-width: 720px;
      margin: 48px auto;
      padding: 0 32px;
      line-height: 1.6;
    }
    .header {
      border-bottom: 3px solid #111;
      padding-bottom: 24px;
      margin-bottom: 32px;
    }
    .product { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: #666; }
    h1 { font-size: 32px; margin: 8px 0 4px; }
    .meta { color: #666; font-size: 14px; }
    h2 { font-size: 18px; margin: 28px 0 8px; border-bottom: 1px solid #eee; padding-bottom: 6px; }
    h3 { font-size: 15px; margin: 20px 0 6px; color: #333; }
    ul { margin: 8px 0 16px 20px; }
    li { margin: 4px 0; }
    code {
      background: #f5f5f5;
      padding: 2px 6px;
      border-radius: 4px;
      font-family: 'SFMono-Regular', Consolas, monospace;
      font-size: 13px;
    }
    .footer {
      margin-top: 48px;
      padding-top: 16px;
      border-top: 1px solid #eee;
      color: #999;
      font-size: 12px;
    }
    a { color: #6366f1; }
  </style>
</head>
<body>
  <div class="header">
    <div class="product">${repo.split("/")[1]}</div>
    <h1>${name}</h1>
    <div class="meta">
      Version ${tag} &nbsp;·&nbsp; Released ${date} &nbsp;·&nbsp;
      <a href="${url}">${url}</a>
    </div>
  </div>

  <div class="release-body">
    ${bodyHtml || "<p>No release notes provided.</p>"}
  </div>

  <div class="footer">
    Generated automatically from <a href="${url}">${repo} ${tag}</a>
  </div>
</body>
</html>`;
}
Enter fullscreen mode Exit fullscreen mode

Also notify Slack on release

- name: Notify Slack
  if: success()
  env:
    SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
    RELEASE_TAG: ${{ github.event.release.tag_name }}
    RELEASE_URL: ${{ github.event.release.html_url }}
  run: |
    curl -X POST "$SLACK_WEBHOOK" \
      -H "Content-Type: application/json" \
      -d "{
        \"text\": \"*Release $RELEASE_TAG published* — PDF changelog attached to release assets.\",
        \"attachments\": [{
          \"color\": \"good\",
          \"text\": \"<$RELEASE_URL|View release on GitHub>\"
        }]
      }"
Enter fullscreen mode Exit fullscreen mode

Also email the PDF to enterprise customers

// scripts/notify-customers.js — run as a second job after pdf generation
import { getEnterpriseCustomers } from "./db.js";

const customers = await getEnterpriseCustomers();
const pdf = await fs.readFile(`release-${process.env.RELEASE_TAG}.pdf`);

for (const customer of customers) {
  await sendEmail({
    to: customer.contactEmail,
    subject: `Release notes: ${process.env.RELEASE_NAME} (${process.env.RELEASE_TAG})`,
    text: `A new version has been released. Release notes are attached.`,
    attachments: [{
      filename: `release-${process.env.RELEASE_TAG}.pdf`,
      content: pdf,
      contentType: "application/pdf",
    }],
  });
}
Enter fullscreen mode Exit fullscreen mode
  notify-customers:
    needs: generate-pdf
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: release-pdf
      - run: node scripts/notify-customers.js
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          SMTP_HOST: ${{ secrets.SMTP_HOST }}
          RELEASE_TAG: ${{ github.event.release.tag_name }}
          RELEASE_NAME: ${{ github.event.release.name }}
Enter fullscreen mode Exit fullscreen mode

The PDF is attached to the GitHub release page within seconds of publishing — visible to anyone with repo access and downloadable without navigating to a separate tool.


Try it free — 100 requests/month, no credit card. → Get started in 2 minutes

Top comments (0)