Reusable composite actions for deploying PR previews to Cloudflare Pages and cleaning them up when the PR closes.
-
deploy— Takes a pre-built static HTML directory and:- Injects
X-Robots-Tag: noindexheaders (via_headers) and a<meta name="robots" content="noindex">tag into every HTML page so previews aren't crawled by search engines (optional, on by default) - Deploys to Cloudflare Pages under a per-PR branch alias
(
pr-<number>by default) giving a stable URL across force-pushes - Posts a PR comment with the preview URLs — either updating a sticky
comment in place (
comment-mode: sticky, default) or minimizing prior preview comments as OUTDATED and reposting fresh (comment-mode: repost)
- Injects
-
cleanup— On PR close:- Deletes all Cloudflare Pages deployments for the PR's branch alias
- Updates the sticky PR comment to indicate cleanup
- A Cloudflare Pages project must be created in the Cloudflare dashboard
first. Pass its name as
project-name. - Two GitHub Actions secrets (see below).
Create these secrets in the caller repo: GitHub → repo → Settings → Secrets and variables → Actions → New repository secret.
| Repo secret name | Action input | Value |
|---|---|---|
CF_PAGES_PREVIEW_API_TOKEN |
api-token |
Cloudflare API token with Account → Cloudflare Pages → Edit permission, scoped to the single account that owns your Pages project |
CF_PAGES_PREVIEW_ACCOUNT_ID |
account-id |
Cloudflare account ID (hex string from the dashboard sidebar) |
Both deploy and cleanup need the same two secrets.
Cloudflare dashboard → My Profile → API Tokens → Create Token → Create Custom Token. Configure:
| Type | Item | Permission |
|---|---|---|
| Account | Cloudflare Pages | Edit |
Under Account Resources, restrict to the single account that owns your Pages project. Do not reuse an account-wide or general-purpose token — a dedicated per-project token limits the blast radius of a leak.
Cloudflare dashboard → select any domain, or the Workers & Pages overview → the Account ID is in the right sidebar (copyable hex string).
Not actually secret, but stored as one to keep it co-located with the token.
The action defaults to ${{ github.token }} (the automatic GITHUB_TOKEN),
which is sufficient for PR commenting with pull-requests: write permission.
The calling workflow must configure:
| Permissions | contents: read, pull-requests: write |
| Fork-PR guard | if: github.event.pull_request.head.repo.full_name == github.repository — forks do not have access to secrets, so deploys would fail anyway |
| Concurrency | Per-PR group with cancel-in-progress: true to avoid racing deploys |
| Build | Caller is responsible for checkout + building the static site before calling deploy |
name: Preview
on:
pull_request:
types: [opened, synchronize, reopened, closed]
paths:
- "site/**"
permissions:
contents: read
pull-requests: write
concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
build-and-deploy:
if: >-
github.event.action != 'closed' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
# ... your build steps — produce static HTML in site/public ...
- uses: modelcontextprotocol/actions/cloudflare-pages-preview/deploy@main
with:
directory: site/public
project-name: my-cf-pages-project
api-token: ${{ secrets.CF_PAGES_PREVIEW_API_TOKEN }}
account-id: ${{ secrets.CF_PAGES_PREVIEW_ACCOUNT_ID }}
cleanup:
if: >-
github.event.action == 'closed' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: modelcontextprotocol/actions/cloudflare-pages-preview/cleanup@main
with:
project-name: my-cf-pages-project
api-token: ${{ secrets.CF_PAGES_PREVIEW_API_TOKEN }}
account-id: ${{ secrets.CF_PAGES_PREVIEW_ACCOUNT_ID }}Note: cleanup does not require actions/checkout — it only calls the
Cloudflare API and GitHub API.
| Input | Required | Default | Description |
|---|---|---|---|
directory |
✅ | — | Path to built static HTML |
project-name |
✅ | — | Cloudflare Pages project name |
api-token |
✅ | — | Cloudflare API token (Pages:Edit) |
account-id |
✅ | — | Cloudflare account ID |
branch |
pr-<PR number> |
CF Pages branch alias | |
inject-noindex |
'true' |
Inject _headers + <meta> noindex |
|
comment-marker |
<!-- cf-pages-preview --> |
Sticky-comment identifier | |
comment-mode |
sticky |
sticky updates the existing marker comment in place. repost minimizes all prior marker comments as OUTDATED (GitHub's native hide) and posts a fresh one. |
|
comment-title |
Preview Deployed |
Heading in PR comment | |
github-token |
${{ github.token }} |
Token for PR commenting | |
pr-number |
${{ github.event.pull_request.number }} |
PR number to comment on — override when running on workflow_dispatch or other triggers without payload.pull_request |
|
commit-sha |
${{ github.sha }} |
SHA shown in the sticky comment — override when the checked-out ref differs from github.sha |
| Input | Required | Default | Description |
|---|---|---|---|
project-name |
✅ | — | Cloudflare Pages project name |
api-token |
✅ | — | Cloudflare API token (Pages:Edit) |
account-id |
✅ | — | Cloudflare account ID |
branch |
pr-<PR number> |
CF Pages branch alias to delete | |
comment-marker |
<!-- cf-pages-preview --> |
Sticky-comment identifier | |
comment-mode |
sticky |
Must match deploy's comment-mode. In repost mode, minimizes all marker comments and posts a final cleanup comment. |
|
github-token |
${{ github.token }} |
Token for PR commenting | |
pr-number |
${{ github.event.pull_request.number }} |
PR number to update the sticky comment on — override when running on workflow_dispatch or other triggers without payload.pull_request |
| Output | Description |
|---|---|
deployment-url |
URL of this specific deployment (changes per push) |
alias-url |
Stable branch-alias URL (same across pushes) |