Add cloudflare-pages-preview composite actions

Two composite actions extracted from modelcontextprotocol's blog-preview workflow:

- deploy: injects noindex headers/meta, deploys to CF Pages under a per-PR
  branch alias, posts/updates a sticky PR comment with preview URLs
- cleanup: deletes all CF Pages deployments for the PR branch alias on close,
  updates the sticky comment

Callers provide a pre-built static HTML directory; the actions handle
everything Cloudflare-specific.
This commit is contained in:
Den Delimarsky 2026-03-10 16:38:31 +00:00
commit d0955aa69d
No known key found for this signature in database
4 changed files with 354 additions and 0 deletions

View file

@ -1,2 +1,6 @@
# actions
GitHub Actions relevant to the management of MCP repositories.
## Actions
- [`cloudflare-pages-preview`](cloudflare-pages-preview/README.md) — deploy PR previews to Cloudflare Pages and clean them up on close

View file

@ -0,0 +1,126 @@
# cloudflare-pages-preview
Reusable composite actions for deploying **PR previews** to Cloudflare Pages
and cleaning them up when the PR closes.
## What it does
- **`deploy`** — Takes a pre-built static HTML directory and:
1. Injects `X-Robots-Tag: noindex` headers (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)
2. Deploys to Cloudflare Pages under a per-PR branch alias
(`pr-<number>` by default) giving a stable URL across force-pushes
3. Posts or updates a sticky PR comment with the preview URLs
- **`cleanup`** — On PR close:
1. Deletes all Cloudflare Pages deployments for the PR's branch alias
2. Updates the sticky PR comment to indicate cleanup
## Prerequisites
- A **Cloudflare Pages project** must be created in the Cloudflare dashboard
first. Pass its name as `project-name`.
- A **Cloudflare API token** with `Pages:Edit` permission scoped to that
project. Do not reuse a general-purpose token.
- GitHub Actions secrets for the token and account ID.
## Caller workflow requirements
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` |
## Usage
```yaml
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_PREVIEW_API_TOKEN }}
account-id: ${{ secrets.CF_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_PREVIEW_API_TOKEN }}
account-id: ${{ secrets.CF_PREVIEW_ACCOUNT_ID }}
```
Note: `cleanup` does **not** require `actions/checkout` — it only calls the
Cloudflare API and GitHub API.
## Inputs
### `deploy`
| 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-title` | | `Preview Deployed` | Heading in PR comment |
| `github-token` | | `${{ github.token }}` | Token for PR commenting |
### `cleanup`
| 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 |
| `github-token` | | `${{ github.token }}` | Token for PR commenting |
## Outputs
### `deploy`
| Output | Description |
|---|---|
| `deployment-url` | URL of this specific deployment (changes per push) |
| `alias-url` | Stable branch-alias URL (same across pushes) |

View file

@ -0,0 +1,87 @@
name: Cloudflare Pages PR Preview — Cleanup
description: >-
Deletes all Cloudflare Pages deployments for a PR's branch alias and updates
the sticky PR comment to indicate the preview has been cleaned up. Intended
to run on pull_request.closed.
inputs:
project-name:
description: Cloudflare Pages project name
required: true
api-token:
description: Cloudflare API token with Pages:Edit permission on the project
required: true
account-id:
description: Cloudflare account ID
required: true
branch:
description: Cloudflare Pages branch alias whose deployments should be deleted
required: false
default: pr-${{ github.event.pull_request.number }}
comment-marker:
description: HTML comment marker used to locate the sticky PR comment
required: false
default: '<!-- cf-pages-preview -->'
github-token:
description: GitHub token for updating the PR comment
required: false
default: ${{ github.token }}
runs:
using: composite
steps:
- name: Delete preview deployments
shell: bash
env:
CF_API_TOKEN: ${{ inputs.api-token }}
CF_ACCOUNT_ID: ${{ inputs.account-id }}
CF_PROJECT: ${{ inputs.project-name }}
PR_BRANCH: ${{ inputs.branch }}
run: |
set -euo pipefail
api="https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/pages/projects/${CF_PROJECT}/deployments"
echo "Listing deployments for branch alias: ${PR_BRANCH}"
# Cloudflare paginates at 25/page. One PR rarely has >25 deploys, but
# loop until the branch alias returns empty just in case of a long-
# running PR with many pushes.
while :; do
ids=$(curl -sS -H "Authorization: Bearer ${CF_API_TOKEN}" "${api}?per_page=25" \
| jq -r --arg b "$PR_BRANCH" '.result[] | select(.deployment_trigger.metadata.branch == $b) | .id')
if [ -z "$ids" ]; then
echo "No deployments remaining for ${PR_BRANCH}."
break
fi
for id in $ids; do
echo "Deleting deployment ${id}"
curl -sS -X DELETE -H "Authorization: Bearer ${CF_API_TOKEN}" "${api}/${id}?force=true" \
| jq -r '" → success=\(.success) errors=\(.errors // [])"'
done
done
- name: Update PR comment
uses: actions/github-script@v8
env:
COMMENT_MARKER: ${{ inputs.comment-marker }}
with:
github-token: ${{ inputs.github-token }}
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.pull_request.number;
const marker = process.env.COMMENT_MARKER;
const { data: comments } = await github.rest.issues.listComments({
owner, repo, issue_number: prNumber
});
const existing = comments.find(c =>
c.user.type === 'Bot' && c.body.includes(marker)
);
if (existing) {
await github.rest.issues.updateComment({
owner, repo, comment_id: existing.id,
body: `${marker}\n## Preview\n\n_Preview deployments for this PR have been cleaned up._`
});
}

View file

@ -0,0 +1,137 @@
name: Cloudflare Pages PR Preview — Deploy
description: >-
Deploys a pre-built static HTML directory to Cloudflare Pages under a per-PR
branch alias, injects noindex headers/meta tags, and posts/updates a sticky
PR comment with the preview URLs.
inputs:
directory:
description: Path to the built static HTML directory (e.g. blog/public)
required: true
project-name:
description: Cloudflare Pages project name (must already exist in the CF dashboard)
required: true
api-token:
description: Cloudflare API token with Pages:Edit permission on the project
required: true
account-id:
description: Cloudflare account ID
required: true
branch:
description: Cloudflare Pages branch alias for this deployment
required: false
default: pr-${{ github.event.pull_request.number }}
inject-noindex:
description: >-
Whether to inject X-Robots-Tag headers (via _headers file) and a
<meta name="robots"> tag into all HTML pages to prevent search engine
indexing of the preview. Set to 'false' to skip.
required: false
default: 'true'
comment-marker:
description: HTML comment marker used to identify and update the sticky PR comment
required: false
default: '<!-- cf-pages-preview -->'
comment-title:
description: Heading shown in the sticky PR comment
required: false
default: 'Preview Deployed'
github-token:
description: GitHub token for posting/updating the PR comment
required: false
default: ${{ github.token }}
outputs:
deployment-url:
description: URL of the specific deployment (changes per push)
value: ${{ steps.deploy.outputs.deployment-url }}
alias-url:
description: Stable branch-alias URL (same across pushes to this PR)
value: ${{ steps.deploy.outputs.pages-deployment-alias-url }}
runs:
using: composite
steps:
# Prevent preview deployments from being indexed by search engines.
# Cloudflare Pages serves _headers as HTTP headers for matching paths.
# X-Robots-Tag covers all content types (HTML, RSS, sitemaps, etc.)
- name: Add noindex headers
if: inputs.inject-noindex == 'true'
shell: bash
env:
DIR: ${{ inputs.directory }}
run: |
cat > "${DIR}/_headers" <<'EOF'
/*
X-Robots-Tag: noindex, nofollow, noarchive, nosnippet
EOF
# Also inject a <meta> tag into every HTML page as belt-and-suspenders
# in case the site is later served from somewhere that ignores _headers.
- name: Inject noindex meta tag
if: inputs.inject-noindex == 'true'
shell: bash
env:
DIR: ${{ inputs.directory }}
run: |
find "${DIR}" -name '*.html' -print0 | \
xargs -0 -r sed -i 's|<head>|<head><meta name="robots" content="noindex, nofollow, noarchive, nosnippet">|'
- name: Deploy to Cloudflare Pages
id: deploy
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ inputs.api-token }}
accountId: ${{ inputs.account-id }}
command: pages deploy ${{ inputs.directory }} --project-name=${{ inputs.project-name }} --branch=${{ inputs.branch }}
- name: Comment with preview URL
uses: actions/github-script@v8
env:
DEPLOY_URL: ${{ steps.deploy.outputs.deployment-url }}
ALIAS_URL: ${{ steps.deploy.outputs.pages-deployment-alias-url }}
COMMENT_MARKER: ${{ inputs.comment-marker }}
COMMENT_TITLE: ${{ inputs.comment-title }}
INJECT_NOINDEX: ${{ inputs.inject-noindex }}
with:
github-token: ${{ inputs.github-token }}
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.pull_request.number;
const deployUrl = process.env.DEPLOY_URL;
const aliasUrl = process.env.ALIAS_URL || deployUrl;
const marker = process.env.COMMENT_MARKER;
const title = process.env.COMMENT_TITLE;
const noindex = process.env.INJECT_NOINDEX === 'true';
const noindexNote = noindex
? ' All pages served with `noindex, nofollow` — search engines will not crawl this preview.'
: '';
const body = `${marker}
## ${title}
| | |
|---|---|
| **Preview (stable)** | ${aliasUrl} |
| **This commit** | ${deployUrl} |
| **Commit** | \`${context.sha.slice(0, 7)}\` |
_Includes drafts and future-dated posts.${noindexNote}_`;
const { data: comments } = await github.rest.issues.listComments({
owner, repo, issue_number: prNumber
});
const existing = comments.find(c =>
c.user.type === 'Bot' && c.body.includes(marker)
);
if (existing) {
await github.rest.issues.updateComment({
owner, repo, comment_id: existing.id, body
});
} else {
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber, body
});
}