PR preview comments

A GitHub Action that comments on content pull requests with instant preview links for every changed page.

Every pull request against your docs is already live at /pr/:number β€” nothing to build, nothing to deploy. This optional GitHub Action closes the loop: when a pull request touches content/, it posts (and keeps updated) a comment linking each changed page to its live preview.

Pull requests from forks are handled too: their previews stay disabled until a maintainer adds the preview:enabled label (see fork pull requests), and the comment tells contributors so. Once the label is added, the comment updates itself with the preview links.

The workflow

Create .github/workflows/docs-preview-comment.yml in your docs repository. Replace https://docs.example.com with your production URL, and adjust CONTENT_DIR if your content directory differs:

.github/workflows/docs-preview-comment.yml
name: docs preview comment

on:
  pull_request_target:
    branches:
      - main
    paths:
      - 'content/**'
    types:
      - opened
      - reopened
      - synchronize
      - labeled
      - unlabeled

permissions:
  pull-requests: write

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number }}

jobs:
  comment:
    runs-on: ubuntu-latest

    steps:
      - name: Create or update preview comment
        uses: actions/github-script@v8
        env:
          SITE_URL: https://docs.example.com
          CONTENT_DIR: content
          PREVIEW_LABEL: preview:enabled
        with:
          script: |
            const marker = '<!-- comark-docs-preview -->'
            const { SITE_URL, CONTENT_DIR, PREVIEW_LABEL } = process.env
            const { owner, repo } = context.repo
            const pr = context.payload.pull_request

            const internal = pr.head.repo && pr.head.repo.full_name === `${owner}/${repo}`
            const enabled = internal || pr.labels.some((label) => label.name === PREVIEW_LABEL)

            let body
            if (!enabled) {
              body = [
                marker,
                '## Documentation previews',
                '',
                'Previews are disabled for pull requests from forks.',
                `A maintainer can add the \`${PREVIEW_LABEL}\` label to enable them.`,
              ].join('\n')
            } else {
              const previewRoot = `${SITE_URL}/pr/${pr.number}`
              const files = await github.paginate(github.rest.pulls.listFiles, {
                owner,
                repo,
                pull_number: pr.number,
                per_page: 100,
              })

              const prefix = `${CONTENT_DIR}/`
              const pages = files
                .filter((file) =>
                  file.status !== 'removed'
                  && file.filename.startsWith(prefix)
                  && file.filename.endsWith('.md'),
                )
                .map((file) => {
                  const relativePath = file.filename
                    .slice(prefix.length)
                    .replace(/\.md$/, '')
                  const routeSegments = relativePath
                    .split('/')
                    .map((segment) => segment.replace(/^\d+\./, ''))
                    .map(encodeURIComponent)
                  if (routeSegments.at(-1) === 'index') {
                    routeSegments.pop()
                  }
                  const route = routeSegments.join('/')

                  return {
                    filename: file.filename,
                    route,
                    url: route ? `${previewRoot}/${route}` : `${previewRoot}/`,
                  }
                })
                .sort((a, b) => a.filename.localeCompare(b.filename))

              body = [
                marker,
                '## Documentation previews',
                '',
                `πŸ“š [Preview all documentation changes](${previewRoot}) (follows new pushes)`,
                ...(pages.length
                  ? [
                      '',
                      ...pages.map((page) => `- [/${page.route}](${page.url})`),
                    ]
                  : []),
                '',
                `Pinned to the current head: [\`${pr.head.sha.slice(0, 7)}\`](${SITE_URL}/blob/${pr.head.sha})`,
                ...(internal
                  ? []
                  : ['', `Enabled by the \`${PREVIEW_LABEL}\` label β€” remove it to disable the preview.`]),
              ].join('\n')
            }

            const comments = await github.paginate(github.rest.issues.listComments, {
              owner,
              repo,
              issue_number: pr.number,
              per_page: 100,
            })
            const existingComment = comments.find((comment) =>
              comment.user?.type === 'Bot' && comment.body?.includes(marker),
            )

            if (existingComment) {
              await github.rest.issues.updateComment({
                owner,
                repo,
                comment_id: existingComment.id,
                body,
              })
            } else {
              await github.rest.issues.createComment({
                owner,
                repo,
                issue_number: pr.number,
                body,
              })
            }

How it works

  • The paths filter means the workflow only runs when the PR touches content/.
  • The labeled and unlabeled triggers refresh the comment when a maintainer toggles preview:enabled, so the links appear (or disappear) without a new push.
  • Each changed Markdown file is mapped to its route the same way the site does it: numeric prefixes stripped from every segment, trailing index removed. content/1.getting-started/2.installation.md becomes /pr/<number>/getting-started/installation.
  • The comment carries a hidden HTML marker, so subsequent pushes update the existing comment instead of stacking new ones.
  • Removed files are excluded since their preview would 404.
The workflow uses pull_request_target so it also runs for fork PRs, which grants the run a write-scoped token. That's safe only as long as the job never checks out or executes code from the PR β€” this one only reads PR metadata and writes a comment. Keep it that way if you extend it.

The site enforces the label gate server-side too: /pr/:number (and /blob/ of the PR's commits) answer 404 for fork PRs until the label is present, so a comment edited by hand can't expose anything.

No token setup is needed β€” the workflow only uses the built-in GITHUB_TOKEN with pull-requests: write permission.

Trying it out

Open a pull request that edits a page under content/, and the comment appears within seconds:

Documentation previews

πŸ“š Preview all documentation changes (follows new pushes)

Pinned to the current head: 4f2a9c1

Reviewers can read the rendered pages β€” navigation, search, and all β€” before the PR merges, and the links stay current as the branch moves. For a fork PR, add the preview:enabled label first; the comment updates with the links right after.