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:
parent
12edb490d8
commit
d0955aa69d
4 changed files with 354 additions and 0 deletions
|
|
@ -1,2 +1,6 @@
|
||||||
# actions
|
# actions
|
||||||
GitHub Actions relevant to the management of MCP repositories.
|
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
|
||||||
|
|
|
||||||
126
cloudflare-pages-preview/README.md
Normal file
126
cloudflare-pages-preview/README.md
Normal 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) |
|
||||||
87
cloudflare-pages-preview/cleanup/action.yml
Normal file
87
cloudflare-pages-preview/cleanup/action.yml
Normal 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._`
|
||||||
|
});
|
||||||
|
}
|
||||||
137
cloudflare-pages-preview/deploy/action.yml
Normal file
137
cloudflare-pages-preview/deploy/action.yml
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue