PaperType

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.pdf

Several 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.pdf

Limits 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 with grep -v '^\[!\[' or sed before rendering.
  • Relative image paths (docs/arch.png) cannot be resolved by the renderer. Inline the file as a data: 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.dev footer 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 on paths. The response headers X-Quota-Used and X-Quota-Limit show 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