How to Auto-Generate a PDF Changelog When You Publish a GitHub Release
When you publish a GitHub release, automatically render the release notes as a branded PDF and attach it to the release assets — for clients, compliance, or enterprise customers.
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
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)`);
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: 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} · Released ${date} ·
<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>`;
}
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>\"
}]
}"
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",
}],
});
}
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 }}
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.
Get Started Free
100 requests/month, no credit card
Auto-generate branded PDF changelogs on every GitHub release — attach to release assets, notify Slack, email enterprise customers.
Get Your Free API Key →