|
name: Docs Preview |
|
|
|
# Builds the docs site for a PR and deploys it to Cloudflare Pages. |
|
# |
|
# Security: the build executes Python from the PR (mkdocstrings imports |
|
# src/mcp, `!!python/name:` config directives run, and heads may ship their |
|
# own build scripts). The build is gated by `authorize` (admin sender on a |
|
# same-repo branch for auto-preview, admin/maintainer commenter for |
|
# /preview-docs) and isolated from Cloudflare secrets — `build` runs PR code |
|
# with no secrets and hands the static site to `deploy` via an artifact, so |
|
# PR code never shares a runner with the Cloudflare token. Fork PRs get no |
|
# automatic preview: actions/checkout refuses to fetch a fork's head in a |
|
# pull_request_target run, so a maintainer requests one with /preview-docs, |
|
# which runs under issue_comment with the same gating and isolation. |
|
# `authorize` and `comment` run .github/scripts/docs_preview.js, checked out |
|
# from the default branch only; those two checkouts must never take a `ref:`. |
|
# |
|
# Required configuration: |
|
# - secrets.CLOUDFLARE_API_TOKEN (scope: Account → Cloudflare Pages → Edit) |
|
# - secrets.CLOUDFLARE_ACCOUNT_ID |
|
# - vars.CLOUDFLARE_PAGES_PROJECT (existing Pages project, e.g. mcp-python-sdk-docs) |
|
|
|
on: |
|
pull_request_target: # zizmor: ignore[dangerous-triggers] build is permission-gated and secret-isolated; see header comment |
|
types: [opened, reopened, synchronize] |
|
paths: |
|
- docs/** |
|
- docs_src/** |
|
- i18n/** |
|
- mkdocs.yml |
|
- scripts/docs/** |
|
- pyproject.toml |
|
issue_comment: |
|
types: [created] |
|
|
|
permissions: {} |
|
|
|
concurrency: |
|
# Workflow-level concurrency is evaluated when the run is queued — before any |
|
# job-level `if:` — so an unrelated PR comment would otherwise cancel an |
|
# in-flight build. Only runs that actually produce a preview share a group; |
|
# everything else falls through to a unique run_id group. |
|
group: >- |
|
docs-preview-pr-${{ |
|
github.event_name == 'pull_request_target' && github.event.pull_request.number |
|
|| (github.event.issue.pull_request && startsWith(github.event.comment.body, '/preview-docs') && github.event.issue.number) |
|
|| github.run_id |
|
}} |
|
cancel-in-progress: true |
|
|
|
jobs: |
|
authorize: |
|
if: >- |
|
github.event_name == 'pull_request_target' || |
|
(github.event.issue.pull_request && startsWith(github.event.comment.body, '/preview-docs')) |
|
runs-on: ubuntu-latest |
|
permissions: |
|
contents: read |
|
pull-requests: read |
|
outputs: |
|
authorized: ${{ steps.check.outputs.authorized }} |
|
pr_number: ${{ steps.check.outputs.pr_number }} |
|
head_sha: ${{ steps.check.outputs.head_sha }} |
|
slash_attempt: ${{ steps.check.outputs.slash_attempt }} |
|
steps: |
|
- name: Check out the scripts (default branch) |
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
|
with: |
|
# No `ref:` here, ever: this job trusts what it checks out (see header). |
|
persist-credentials: false |
|
sparse-checkout: .github/scripts |
|
|
|
- name: Determine authorization |
|
id: check |
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 |
|
with: |
|
script: | |
|
const { authorize } = require('./.github/scripts/docs_preview.js'); |
|
await authorize({ github, context, core }); |
|
|
|
build: |
|
needs: authorize |
|
if: needs.authorize.outputs.authorized == 'true' |
|
runs-on: ubuntu-latest |
|
permissions: |
|
contents: read |
|
steps: |
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
|
with: |
|
ref: ${{ needs.authorize.outputs.head_sha }} |
|
persist-credentials: false |
|
|
|
- name: Install uv |
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 |
|
with: |
|
# Keep the untrusted build away from the shared Actions cache. GitHub |
|
# already limits pull_request_target and issue_comment runs to |
|
# read-only access in the default branch's cache scope, so this is |
|
# defence in depth (and avoids a refused save in the post step). |
|
# Mirrors publish-pypi.yml. |
|
enable-cache: false |
|
version: 0.9.5 |
|
|
|
# Both triggers run this workflow file from the default branch (whatever |
|
# the PR targets), so the whole recipe — dependency sync included — must |
|
# come from the checkout itself: heads that ship scripts/docs/build.sh |
|
# (the Zensical toolchain) build with it; older heads and v1.x heads still |
|
# build with MkDocs. Both arms must write the site to site/. Keep the |
|
# detection in sync with build_site() in scripts/build-docs.sh. |
|
- run: | |
|
if [ -f scripts/docs/build.sh ]; then |
|
bash scripts/docs/build.sh |
|
else |
|
uv sync --frozen --group docs |
|
# The env var silences mkdocs-material's MkDocs 2.0 warning banner. |
|
NO_MKDOCS_2_WARNING=1 uv run --frozen --no-sync mkdocs build |
|
fi |
|
|
|
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 |
|
with: |
|
name: site |
|
path: site/ |
|
retention-days: 1 |
|
# An empty site/ means the build arm broke its output contract; fail |
|
# here instead of surfacing as a confusing download error in deploy. |
|
if-no-files-found: error |
|
|
|
deploy: |
|
needs: [authorize, build] |
|
if: needs.authorize.outputs.authorized == 'true' |
|
runs-on: ubuntu-latest |
|
permissions: {} |
|
outputs: |
|
deployment_url: ${{ steps.wrangler.outputs.deployment-url }} |
|
alias_url: ${{ steps.wrangler.outputs.pages-deployment-alias-url }} |
|
steps: |
|
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 |
|
with: |
|
name: site |
|
path: site |
|
|
|
- name: Deploy to Cloudflare Pages |
|
id: wrangler |
|
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 |
|
with: |
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} |
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} |
|
packageManager: npm |
|
command: >- |
|
pages deploy ./site |
|
--project-name=${{ vars.CLOUDFLARE_PAGES_PROJECT }} |
|
--branch=pr-${{ needs.authorize.outputs.pr_number }} |
|
--commit-hash=${{ needs.authorize.outputs.head_sha }} |
|
--commit-dirty=true |
|
|
|
comment: |
|
needs: [authorize, build, deploy] |
|
if: >- |
|
always() && |
|
needs.deploy.result != 'cancelled' && |
|
(needs.authorize.outputs.authorized == 'true' || needs.authorize.outputs.slash_attempt == 'true') |
|
runs-on: ubuntu-latest |
|
permissions: |
|
contents: read # check out the script from the default branch |
|
pull-requests: write |
|
steps: |
|
- name: Check out the scripts (default branch) |
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
|
with: |
|
# No `ref:` here, ever: this job trusts what it checks out (see header). |
|
persist-credentials: false |
|
sparse-checkout: .github/scripts |
|
|
|
- name: Post or update preview comment |
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 |
|
env: |
|
AUTHORIZED: ${{ needs.authorize.outputs.authorized }} |
|
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }} |
|
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }} |
|
DEPLOY_RESULT: ${{ needs.deploy.result }} |
|
DEPLOYMENT_URL: ${{ needs.deploy.outputs.deployment_url }} |
|
ALIAS_URL: ${{ needs.deploy.outputs.alias_url }} |
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} |
|
with: |
|
script: | |
|
const { comment } = require('./.github/scripts/docs_preview.js'); |
|
await comment({ github, context, core }); |