|
| 1 | +name: Preview |
| 2 | + |
| 3 | +# Per-PR preview deployments, on the Cloudflare account named by the CLOUDFLARE_ACCOUNT_ID |
| 4 | +# repository variable. |
| 5 | +# |
| 6 | +# SECURITY — read this before changing the triggers. |
| 7 | +# |
| 8 | +# Deploying a preview needs a Cloudflare API token that can create Workers, KV namespaces and R2 |
| 9 | +# buckets on a Cloudflare-owned account. This repository is public, so anyone can open a pull |
| 10 | +# request. The load-bearing control is the TRIGGER, not any `if:` below: on a public repository |
| 11 | +# GitHub structurally withholds repository secrets from `pull_request` runs whose head is a fork, |
| 12 | +# so `secrets.CLOUDFLARE_API_TOKEN` interpolates to the empty string there and no fork PR can |
| 13 | +# deploy anything. That is the same guarantee workers-sdk's deploy-previews.yml relies on. |
| 14 | +# |
| 15 | +# Therefore: `pull_request` only. Never `pull_request_target`, never `workflow_run`, never |
| 16 | +# `issue_comment` — each of those runs privileged with the secret available while the code, the |
| 17 | +# PR number, or the artifact naming it comes from an untrusted contributor. |
| 18 | +# |
| 19 | +# The `if:` conditions and the in-job guard are defence in depth, each fail-closed, and exist so |
| 20 | +# that a future edit which weakens the trigger still does not leak the token. They are not what |
| 21 | +# makes this safe today. |
| 22 | +# |
| 23 | +# The `cache: pnpm` below is deliberate and is not a poisoning path: a fork PR's Actions cache is |
| 24 | +# scoped to `refs/pull/<n>/merge` and cannot be read from another PR or from `main`. |
| 25 | + |
| 26 | +on: |
| 27 | + pull_request: |
| 28 | + types: [opened, synchronize, reopened, closed] |
| 29 | + schedule: |
| 30 | + # Nightly, off the hour. GitHub has no equivalent of GitLab's `environment.auto_stop_in`, so |
| 31 | + # this is what stops abandoned previews from leaking a KV pair and an R2 bucket each. |
| 32 | + - cron: "37 4 * * *" |
| 33 | + workflow_dispatch: |
| 34 | + |
| 35 | +# Escalated per job. The preview jobs need no write scope at all beyond the sticky comment. |
| 36 | +permissions: {} |
| 37 | + |
| 38 | +env: |
| 39 | + NODE_VERSION: "24.19.0" |
| 40 | + |
| 41 | +jobs: |
| 42 | + deploy: |
| 43 | + name: Deploy preview |
| 44 | + if: >- |
| 45 | + github.event_name == 'pull_request' && |
| 46 | + github.event.action != 'closed' && |
| 47 | + github.event.pull_request.head.repo.id == github.event.pull_request.base.repo.id && |
| 48 | + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) && |
| 49 | + github.event.pull_request.user.type != 'Bot' && |
| 50 | + github.head_ref != 'main' && |
| 51 | + github.repository_owner == 'cloudflare' |
| 52 | + runs-on: ubuntu-latest |
| 53 | + timeout-minutes: 45 |
| 54 | + concurrency: |
| 55 | + # Never cancel: a half-applied preview is worse than a slow one, since the tiers are |
| 56 | + # deployed in sequence and an interrupted run leaves the instance wired to stale previews. |
| 57 | + group: preview-${{ github.event.pull_request.number }} |
| 58 | + cancel-in-progress: false |
| 59 | + permissions: |
| 60 | + contents: read |
| 61 | + pull-requests: write |
| 62 | + steps: |
| 63 | + # First, before any step can reference a secret. Catches a future edit that flips the |
| 64 | + # trigger to `pull_request_target`, and a branch pushed by a since-demoted account or a bot. |
| 65 | + - name: Verify the pull request is from a maintainer |
| 66 | + env: |
| 67 | + GH_TOKEN: ${{ github.token }} |
| 68 | + GH_REPO: ${{ github.repository }} |
| 69 | + PR_AUTHOR: ${{ github.event.pull_request.user.login }} |
| 70 | + run: | |
| 71 | + permission=$(gh api "repos/$GH_REPO/collaborators/$PR_AUTHOR/permission" \ |
| 72 | + --jq '.permission') |
| 73 | + if [[ ! "$permission" =~ ^(admin|maintain|write)$ ]]; then |
| 74 | + echo "$PR_AUTHOR has '$permission' on $GH_REPO; previews require write access." |
| 75 | + exit 1 |
| 76 | + fi |
| 77 | +
|
| 78 | + - name: Check out repository |
| 79 | + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| 80 | + with: |
| 81 | + persist-credentials: false |
| 82 | + |
| 83 | + # Ahead of setup-node, which shells out to `pnpm store path` to find the directory it caches. |
| 84 | + - name: Enable Corepack |
| 85 | + run: corepack enable |
| 86 | + |
| 87 | + - name: Set up Node.js |
| 88 | + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 |
| 89 | + with: |
| 90 | + node-version: ${{ env.NODE_VERSION }} |
| 91 | + cache: pnpm |
| 92 | + |
| 93 | + - name: Install dependencies |
| 94 | + run: pnpm install --frozen-lockfile |
| 95 | + |
| 96 | + - name: Deploy preview |
| 97 | + env: |
| 98 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} |
| 99 | + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} |
| 100 | + PREVIEW_WORKERS_DEV_HOST: ${{ vars.PREVIEW_WORKERS_DEV_HOST }} |
| 101 | + # Secrets, not vars: these three are uploaded to the backend's Previews settings with |
| 102 | + # `wrangler preview secret bulk`, and never written into a config file — Wrangler prints |
| 103 | + # the values it finds in one, and this log is public. |
| 104 | + PREVIEW_ADMINS: ${{ secrets.PREVIEW_ADMINS }} |
| 105 | + CF_ACCESS_AUD: ${{ secrets.CF_ACCESS_AUD }} |
| 106 | + CF_ACCESS_ISS: ${{ secrets.CF_ACCESS_ISS }} |
| 107 | + # AI Gateway, so a preview's chats use server-managed keys rather than asking each user |
| 108 | + # for their own. Optional as a group: with CF_AI_GATEWAY unset the preview is BYOK, but |
| 109 | + # once it is set the account id and the Run + Read token are required. |
| 110 | + CF_AI_GATEWAY: ${{ secrets.CF_AI_GATEWAY }} |
| 111 | + # This is delibaretely set to CF_OS_AI_GATEWAY_ACCOUNT_ID (gets uploaded as CF_AI_GATEWAY_ACCOUNT_ID) |
| 112 | + CF_AI_GATEWAY_ACCOUNT_ID: ${{ secrets.CF_OS_AI_GATEWAY_ACCOUNT_ID }} |
| 113 | + CF_AI_GATEWAY_API_TOKEN: ${{ secrets.CF_AI_GATEWAY_API_TOKEN }} |
| 114 | + CF_AI_GATEWAY_PROVIDERS: ${{ secrets.CF_AI_GATEWAY_PROVIDERS }} |
| 115 | + CF_AI_GATEWAY_WAI_DIRECT: ${{ secrets.CF_AI_GATEWAY_WAI_DIRECT }} |
| 116 | + # The branch, because the preview name is the first label of its hostname: a preview reads |
| 117 | + # as `my-branch-router.<subdomain>.workers.dev`. The cleanup job below passes the |
| 118 | + # same ref; the nightly sweep matches previews back to pull requests by slugifying every |
| 119 | + # recent head ref the same way. |
| 120 | + PREVIEW_NAME: ${{ github.head_ref }} |
| 121 | + run: node scripts/preview/preview.ts deploy |
| 122 | + |
| 123 | + # In this same job, deliberately: handing the comment to a privileged second workflow is |
| 124 | + # the pattern workers-sdk deleted, because the privileged half read the PR number out of an |
| 125 | + # artifact the unprivileged half had named. |
| 126 | + - name: Comment the preview URL |
| 127 | + env: |
| 128 | + GH_TOKEN: ${{ github.token }} |
| 129 | + GH_REPO: ${{ github.repository }} |
| 130 | + PR_NUMBER: ${{ github.event.pull_request.number }} |
| 131 | + MARKER: "<!-- preview-deployment -->" |
| 132 | + run: | |
| 133 | + { |
| 134 | + printf '%s\n\n' "$MARKER" |
| 135 | + cat output/preview-comment.md |
| 136 | + } > output/preview-comment-body.md |
| 137 | + jq -n --rawfile body output/preview-comment-body.md '{body: $body}' \ |
| 138 | + > output/preview-comment.json |
| 139 | +
|
| 140 | + # No `| head -1`: the shell runs with `pipefail`, and gh would take a SIGPIPE. |
| 141 | + matches=$(gh api "repos/$GH_REPO/issues/$PR_NUMBER/comments" --paginate --jq ' |
| 142 | + first(.[] |
| 143 | + | select(.user.login == "github-actions[bot]") |
| 144 | + | select(.body | startswith(env.MARKER)) |
| 145 | + | .id)') |
| 146 | + id=${matches%%$'\n'*} |
| 147 | +
|
| 148 | + if [[ -n "$id" ]]; then |
| 149 | + gh api --silent --method PATCH "repos/$GH_REPO/issues/comments/$id" \ |
| 150 | + --input output/preview-comment.json |
| 151 | + else |
| 152 | + gh api --silent --method POST "repos/$GH_REPO/issues/$PR_NUMBER/comments" \ |
| 153 | + --input output/preview-comment.json |
| 154 | + fi |
| 155 | +
|
| 156 | + cleanup: |
| 157 | + name: Delete preview |
| 158 | + if: >- |
| 159 | + github.event_name == 'pull_request' && |
| 160 | + github.event.action == 'closed' && |
| 161 | + github.event.pull_request.head.repo.id == github.event.pull_request.base.repo.id && |
| 162 | + github.event.pull_request.user.type != 'Bot' && |
| 163 | + github.head_ref != 'main' && |
| 164 | + github.repository_owner == 'cloudflare' |
| 165 | + runs-on: ubuntu-latest |
| 166 | + timeout-minutes: 30 |
| 167 | + concurrency: |
| 168 | + group: preview-${{ github.event.pull_request.number }} |
| 169 | + cancel-in-progress: false |
| 170 | + permissions: |
| 171 | + contents: read |
| 172 | + steps: |
| 173 | + # The default branch, not the PR's merge ref: a teardown needs the branch's name and nothing |
| 174 | + # else from it. |
| 175 | + - name: Check out repository |
| 176 | + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| 177 | + with: |
| 178 | + ref: ${{ github.event.repository.default_branch }} |
| 179 | + persist-credentials: false |
| 180 | + |
| 181 | + - name: Enable Corepack |
| 182 | + run: corepack enable |
| 183 | + |
| 184 | + - name: Set up Node.js |
| 185 | + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 |
| 186 | + with: |
| 187 | + node-version: ${{ env.NODE_VERSION }} |
| 188 | + cache: pnpm |
| 189 | + |
| 190 | + - name: Install dependencies |
| 191 | + run: pnpm install --frozen-lockfile |
| 192 | + |
| 193 | + - name: Delete preview |
| 194 | + env: |
| 195 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} |
| 196 | + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} |
| 197 | + PREVIEW_WORKERS_DEV_HOST: ${{ vars.PREVIEW_WORKERS_DEV_HOST }} |
| 198 | + # The same ref the deploy job used, which is what names the preview. GitHub sets it on the |
| 199 | + # closed event too. No admin or Access secret is needed to tear one down. |
| 200 | + PREVIEW_NAME: ${{ github.head_ref }} |
| 201 | + run: node scripts/preview/preview.ts delete |
| 202 | + |
| 203 | + sweep: |
| 204 | + name: Sweep abandoned previews |
| 205 | + if: >- |
| 206 | + (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && |
| 207 | + github.repository_owner == 'cloudflare' |
| 208 | + runs-on: ubuntu-latest |
| 209 | + timeout-minutes: 60 |
| 210 | + concurrency: |
| 211 | + group: preview-sweep |
| 212 | + cancel-in-progress: false |
| 213 | + permissions: |
| 214 | + contents: read |
| 215 | + pull-requests: read |
| 216 | + steps: |
| 217 | + - name: Check out repository |
| 218 | + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| 219 | + with: |
| 220 | + persist-credentials: false |
| 221 | + |
| 222 | + - name: Enable Corepack |
| 223 | + run: corepack enable |
| 224 | + |
| 225 | + - name: Set up Node.js |
| 226 | + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 |
| 227 | + with: |
| 228 | + node-version: ${{ env.NODE_VERSION }} |
| 229 | + cache: pnpm |
| 230 | + |
| 231 | + - name: Install dependencies |
| 232 | + run: pnpm install --frozen-lockfile |
| 233 | + |
| 234 | + - name: Sweep previews whose PR is closed, or older than 7 days |
| 235 | + env: |
| 236 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} |
| 237 | + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} |
| 238 | + PREVIEW_WORKERS_DEV_HOST: ${{ vars.PREVIEW_WORKERS_DEV_HOST }} |
| 239 | + # Reads every recent pull request's head branch, to match live previews back to them. |
| 240 | + GITHUB_TOKEN: ${{ github.token }} |
| 241 | + run: node scripts/preview/preview.ts sweep |
0 commit comments