diff --git a/README.md b/README.md
index 58e983d..7b981f9 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/cloudflare-pages-preview/README.md b/cloudflare-pages-preview/README.md
new file mode 100644
index 0000000..03d7d02
--- /dev/null
+++ b/cloudflare-pages-preview/README.md
@@ -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
+ `` 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-` 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-` | CF Pages branch alias |
+| `inject-noindex` | | `'true'` | Inject `_headers` + `` noindex |
+| `comment-marker` | | `` | 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-` | CF Pages branch alias to delete |
+| `comment-marker` | | `` | 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) |
diff --git a/cloudflare-pages-preview/cleanup/action.yml b/cloudflare-pages-preview/cleanup/action.yml
new file mode 100644
index 0000000..1fe9810
--- /dev/null
+++ b/cloudflare-pages-preview/cleanup/action.yml
@@ -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: ''
+ 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._`
+ });
+ }
diff --git a/cloudflare-pages-preview/deploy/action.yml b/cloudflare-pages-preview/deploy/action.yml
new file mode 100644
index 0000000..f930855
--- /dev/null
+++ b/cloudflare-pages-preview/deploy/action.yml
@@ -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
+ 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: ''
+ 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 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|||'
+
+ - 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
+ });
+ }