---
title: "PR preview comments"
description: "A GitHub Action that comments on content pull requests with instant preview links for every changed page."
canonical_url: "https://docs-template.comark.dev/deployment/pr-preview-comments"
---
# 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`](https://docs-template.comark.dev/concepts/versioned-previews) — 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](https://docs-template.comark.dev/concepts/versioned-previews#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:

```yaml [.github/workflows/docs-preview-comment.yml]
name: docs preview comment

on:
  pull_request_target:
    branches:
      - main
    paths:
      - 'docs/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: docs/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.

<warning>
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.
</warning>

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:

<callout>
## Documentation previews {.mt-2}



📚 [Preview all documentation changes](https://docs.example.com/pr/42) (follows new pushes)



- [/getting-started/installation](https://docs.example.com/pr/42/getting-started/installation)



Pinned to the current head: [`4f2a9c1`](https://docs.example.com/blob/4f2a9c1e8b7d6a5f4e3c2b1a0f9e8d7c6b5a4938)
</callout>

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.


## Sitemap

See the full [sitemap](https://docs-template.comark.dev/sitemap.md) for all pages.
