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 + }); + }