Generate a PDF in GitHub Actions
Attach a typeset PDF of your release notes, handbook or report to every release without installing a browser or a PDF toolchain on the runner. The workflow posts the Markdown file to the API with curl and uploads the returned PDF as a release asset or workflow artifact. Nothing else is downloaded; the step takes a few seconds.
Setup: one secret
Create a free API key at /signup and store it in the repository under Settings → Secrets and variables → Actions → New repository secret as PAPERTYPE_API_KEY. Never put the key in the workflow file itself; forks and logs would expose it.
Release notes PDF attached to every release
The step below runs when a release is published, renders CHANGELOG.md with a cover page and a contents page, and uploads the PDF to the release. jq is preinstalled on GitHub-hosted runners and builds the JSON body from the file so quotes and newlines are escaped correctly.
name: Release PDF
on:
release:
types: [published]
jobs:
pdf:
runs-on: ubuntu-latest
permissions:
contents: write # upload the asset
steps:
- uses: actions/checkout@v4
- name: Render CHANGELOG.md to PDF
env:
PAPERTYPE_API_KEY: ${{ secrets.PAPERTYPE_API_KEY }}
run: |
jq -n --rawfile md CHANGELOG.md \
--arg title "Release notes ${{ github.ref_name }}" \
--arg footer "${{ github.repository }} · ${{ github.ref_name }}" \
'{markdown: $md, title: $title, footer: $footer, theme: "report", cover: true, toc: true}' \
| curl --fail-with-body -sS -X POST https://papertype.dev/v1/pdf \
-H "Authorization: Bearer $PAPERTYPE_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- -o release-notes.pdf
- uses: softprops/action-gh-release@v2
with:
files: release-notes.pdf--fail-with-body makes the step fail on a 4xx or 5xx and prints the JSON error, so a quota or a rendering problem stops the job instead of attaching an empty file.
Docs as a workflow artifact on every push
For a PDF you want to download from the run page rather than publish, upload it as an artifact. The same render step applies; only the trigger and the last step change.
on:
push:
paths: ["docs/**"]
jobs:
handbook:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Render docs/handbook.md
env:
PAPERTYPE_API_KEY: ${{ secrets.PAPERTYPE_API_KEY }}
run: |
jq -n --rawfile md docs/handbook.md '{markdown: $md, theme: "clean", toc: true, page_size: "Letter"}' \
| curl --fail-with-body -sS -X POST https://papertype.dev/v1/pdf \
-H "Authorization: Bearer $PAPERTYPE_API_KEY" -H "Content-Type: application/json" \
--data-binary @- -o handbook.pdf
- uses: actions/upload-artifact@v4
with:
name: handbook
path: handbook.pdfSeveral Markdown files? Concatenate them first (cat docs/*.md > all.md) and render once, with a ## heading per file so the contents page lists them.
Invoice or certificate from JSON
The hosted templates take a JSON object instead of Markdown, which suits workflows that already produce structured data. Fields are listed on /templates/invoice, /templates/certificate and /templates/report.
- name: Render invoice
env:
PAPERTYPE_API_KEY: ${{ secrets.PAPERTYPE_API_KEY }}
run: |
jq -n --slurpfile d billing/invoice-1042.json '{template: "invoice", data: $d[0]}' \
| curl --fail-with-body -sS -X POST https://papertype.dev/v1/pdf \
-H "Authorization: Bearer $PAPERTYPE_API_KEY" -H "Content-Type: application/json" \
--data-binary @- -o invoice-1042.pdfLimits that matter in CI
- Badges and remote images. The renderer has no network access. Images, fonts and stylesheets at external URLs are never fetched; inline them as
data:URIs. A request that references an external URL fails with a 422 that names the URL, rather than silently dropping the asset. READMEs and changelogs often start with badge lines; strip them withgrep -v '^\[!\['orsedbefore rendering. - Relative image paths (
docs/arch.png) cannot be resolved by the renderer. Inline the file as adata:URI in a preprocessing step, or drop the image. - 2 MB of source per request, 300 pages per document, 2 concurrent renders per account.
- Quota. Free-plan output carries a small
papertype.devfooter mark. Paid plans (from $9/month) do not. A workflow that renders on every push can exhaust the free plan's 50 documents; trigger on releases or tags, or filter onpaths. The response headersX-Quota-UsedandX-Quota-Limitshow where you stand. - No JavaScript is executed. Send the final HTML; anything a script would have drawn or filled in will not appear.
Questions
Is there a ready-made action instead of the curl step?
A composite action that wraps this call (inputs for the Markdown or HTML file, template, theme, cover, toc and output path) is finished and will be published on the GitHub Marketplace under the papertype-dev organisation; this page will show the one-line `uses:` snippet as soon as it is public. The curl step above works today and is what the action does internally.
Does the runner need Python, Node or a browser?
No. curl and jq are preinstalled on all GitHub-hosted runners (Ubuntu, macOS, Windows). On a self-hosted runner install those two packages. The rendering happens on the API side.
Is my source stored?
No. The API returns the PDF and does not keep a copy of the source or the output. Only a per-account usage count and the page count of each render are logged.
Can the step run on pull requests from forks?
Secrets are not available to workflows triggered by pull requests from forks, so the step will fail there with a 401. Run it on push, release or workflow_dispatch events instead, or guard it with an if: condition on the event name.
What does a failed render look like in the log?
A JSON error body and a non-zero exit from curl (because of --fail-with-body): 401 for a bad key, 413 for source over 2 MB, 422 with the reason (most often an external image URL), 429 when the monthly quota is reached. Failed renders are not counted against the quota.
Related
Free previews carry a watermark and are deleted after 24 hours. Free API output carries a footer mark. The renderer runs no JavaScript and never fetches external URLs. Privacy · Terms · Refunds