PR preview comments
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:
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
pathsfilter means the workflow only runs when the PR touchescontent/. - The
labeledandunlabeledtriggers refresh the comment when a maintainer togglespreview: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
indexremoved.content/1.getting-started/2.installation.mdbecomes/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.
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.