From dcd7f6f1a8a9053e3a29e14a68e9754fc2d57010 Mon Sep 17 00:00:00 2001 From: st0012 Date: Mon, 31 Aug 2026 22:04:21 +0100 Subject: [PATCH 1/5] Build PR previews without maintainer approval --- .github/workflows/cloudflare-preview.yml | 377 +++++++++++++++++++--- .github/workflows/fork-preview-deploy.yml | 66 ---- .github/workflows/pr-preview-check.yml | 76 ++--- 3 files changed, 371 insertions(+), 148 deletions(-) delete mode 100644 .github/workflows/fork-preview-deploy.yml diff --git a/.github/workflows/cloudflare-preview.yml b/.github/workflows/cloudflare-preview.yml index cbe0695065..b0ca7576c0 100644 --- a/.github/workflows/cloudflare-preview.yml +++ b/.github/workflows/cloudflare-preview.yml @@ -1,77 +1,374 @@ -name: Build and Deploy Cloudflare Preview +name: Deploy PR Preview on: - repository_dispatch: - types: [pr-preview-deploy] + workflow_run: + workflows: ["Build PR Preview"] + types: [completed] -permissions: - pull-requests: write # To allow commenting on the PR +permissions: {} jobs: - build-deploy-and-comment: - name: Build, Deploy, and Comment + resolve: + name: Resolve Preview + if: >- + github.repository == 'ruby/rdoc' && + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: read + pull-requests: read + outputs: + current: ${{ steps.resolve.outputs.current }} + number: ${{ steps.resolve.outputs.number }} + head_sha: ${{ steps.resolve.outputs.head_sha }} steps: - - name: Checkout PR Code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Resolve current pull request and artifact + id: resolve + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - repository: ${{ github.event.client_payload.pr_checkout_repository }} - ref: ${{ github.event.client_payload.pr_head_sha }} + script: | + const run = context.payload.workflow_run; + const expectedRepository = 'ruby/rdoc'; + const expectedBase = 'master'; + const artifactName = 'pr-preview-site'; + const maximumArchiveBytes = 500 * 1024 * 1024; + + if (!/^[0-9a-f]{40}$/.test(run.head_sha)) { + core.setFailed('The preview build supplied an invalid head SHA.'); + return; + } + + const headRepository = run.head_repository?.full_name; + const headOwner = run.head_repository?.owner?.login; + const headBranch = run.head_branch; + if (!headRepository || !headOwner || !headBranch) { + core.setFailed('The preview build did not identify its head repository and branch.'); + return; + } + + let candidateNumbers = new Set( + (run.pull_requests || []).map(pull => pull.number) + ); + + // workflow_run.pull_requests can be empty for fork pull requests. + if (candidateNumbers.size === 0) { + const branchPulls = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${headOwner}:${headBranch}`, + base: expectedBase, + per_page: 100, + }); + candidateNumbers = new Set( + branchPulls + .filter(pull => pull.head.sha === run.head_sha) + .map(pull => pull.number) + ); + } + + if (candidateNumbers.size === 0) { + core.setOutput('current', 'false'); + core.notice('No open pull request uses this preview build.'); + return; + } + + const candidates = []; + for (const number of candidateNumbers) { + const { data: pull } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: number, + }); + candidates.push(pull); + } - - name: Setup Ruby - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + const matches = candidates.filter(pull => + pull.base.repo.full_name === expectedRepository && + pull.base.ref === expectedBase && + pull.head.repo?.full_name === headRepository && + pull.head.ref === headBranch + ); + + if (matches.length !== 1) { + core.setFailed(`Expected one pull request for this preview build, found ${matches.length}.`); + return; + } + + const pull = matches[0]; + const current = pull.state === 'open' && pull.head.sha === run.head_sha; + core.setOutput('current', current.toString()); + core.setOutput('number', pull.number.toString()); + core.setOutput('head_sha', run.head_sha); + + if (!current) { + core.notice('The pull request changed or closed after this preview build.'); + return; + } + + const artifacts = await github.paginate( + github.rest.actions.listWorkflowRunArtifacts, + { + owner: context.repo.owner, + repo: context.repo.repo, + run_id: run.id, + name: artifactName, + per_page: 100, + } + ); + const matchingArtifacts = artifacts.filter(artifact => + artifact.name === artifactName && !artifact.expired + ); + + if (matchingArtifacts.length !== 1) { + core.setFailed(`Expected one ${artifactName} artifact, found ${matchingArtifacts.length}.`); + return; + } + + const artifact = matchingArtifacts[0]; + if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > maximumArchiveBytes) { + core.setFailed(`The preview artifact archive has an invalid size: ${artifact.size_in_bytes} bytes.`); + } + + deploy: + name: Deploy Preview + needs: resolve + if: needs.resolve.outputs.current == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + concurrency: + group: pr-preview-deploy-${{ needs.resolve.outputs.number }} + cancel-in-progress: true + permissions: + actions: read + pull-requests: write + steps: + # The artifact is untrusted data. This job never checks out or executes + # pull request code. + - name: Download preview site + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - ruby-version: '3.4' - bundler-cache: true + name: pr-preview-site + path: ${{ runner.temp }}/pr-preview-site + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} - - name: Build site - run: bundle exec rake rdoc + - name: Validate preview site + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + SITE_ROOT: ${{ runner.temp }}/pr-preview-site + with: + script: | + const fs = require('node:fs/promises'); + const path = require('node:path'); + + const root = path.resolve(process.env.SITE_ROOT); + const maximumFiles = 20_000; + const maximumFileBytes = 25 * 1024 * 1024; + const maximumTotalBytes = 500 * 1024 * 1024; + const blockedRootEntries = new Set([ + '.assetsignore', + '_headers', + '_redirects', + '_routes.json', + '_worker.bundle', + '_worker.js', + 'functions', + 'wrangler.json', + 'wrangler.jsonc', + 'wrangler.toml', + ]); + + const rootMetadata = await fs.lstat(root); + if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) { + throw new Error('The preview artifact root is not a regular directory.'); + } + + let fileCount = 0; + let totalBytes = 0; + + async function inspect(directory, relativeDirectory = '') { + const entries = await fs.readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(directory, entry.name); + const relativePath = path.posix.join(relativeDirectory, entry.name); + const metadata = await fs.lstat(fullPath); + + if (metadata.isSymbolicLink()) { + throw new Error(`The preview artifact contains a symbolic link: ${relativePath}`); + } + + if (!relativeDirectory && blockedRootEntries.has(entry.name)) { + throw new Error(`The preview artifact contains a blocked Pages entry: ${relativePath}`); + } + + if (metadata.isDirectory()) { + await inspect(fullPath, relativePath); + continue; + } + + if (!metadata.isFile()) { + throw new Error(`The preview artifact contains a special file: ${relativePath}`); + } + + fileCount += 1; + totalBytes += metadata.size; + if (fileCount > maximumFiles) { + throw new Error(`The preview artifact exceeds ${maximumFiles} files.`); + } + if (metadata.size > maximumFileBytes) { + throw new Error(`The preview artifact contains a file larger than ${maximumFileBytes} bytes: ${relativePath}`); + } + if (totalBytes > maximumTotalBytes) { + throw new Error(`The preview artifact exceeds ${maximumTotalBytes} bytes.`); + } + } + } + + await inspect(root); + if (fileCount === 0) throw new Error('The preview artifact is empty.'); + + const indexMetadata = await fs.lstat(path.join(root, 'index.html')); + if (!indexMetadata.isFile() || indexMetadata.isSymbolicLink()) { + throw new Error('The preview artifact does not contain a regular index.html file.'); + } + + core.info(`Accepted ${fileCount} static files (${totalBytes} bytes).`); + + - name: Confirm pull request head + id: current + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + PR_NUMBER: ${{ needs.resolve.outputs.number }} + EXPECTED_SHA: ${{ needs.resolve.outputs.head_sha }} + with: + script: | + const number = process.env.PR_NUMBER; + const expectedSha = process.env.EXPECTED_SHA; + if (!/^[1-9][0-9]*$/.test(number) || !/^[0-9a-f]{40}$/.test(expectedSha)) { + core.setFailed('The resolved pull request metadata is invalid.'); + return; + } + + const { data: pull } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: Number(number), + }); + const current = + pull.state === 'open' && + pull.base.repo.full_name === 'ruby/rdoc' && + pull.base.ref === 'master' && + pull.head.sha === expectedSha; + + core.setOutput('current', current.toString()); + if (!current) core.notice('Skipped a stale preview deployment.'); + + - name: Prepare trusted Wrangler directory + if: steps.current.outputs.current == 'true' + run: mkdir -p "$RUNNER_TEMP/trusted-preview-deploy" - name: Deploy to Cloudflare Pages + if: steps.current.outputs.current == 'true' id: deploy - uses: cloudflare/wrangler-action@v4 + uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: pages deploy ./_site --project-name=rdoc --branch="${{ github.event.client_payload.pr_number }}-preview" + packageManager: npm + wranglerVersion: '4.81.0' + workingDirectory: ${{ runner.temp }}/trusted-preview-deploy + command: >- + pages deploy "${{ runner.temp }}/pr-preview-site" + --project-name=rdoc + --branch="${{ needs.resolve.outputs.number }}-preview" + --commit-hash="${{ needs.resolve.outputs.head_sha }}" - - name: Comment on PR with preview URL - uses: actions/github-script@v9 + - name: Update preview comment + if: steps.current.outputs.current == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + PR_NUMBER: ${{ needs.resolve.outputs.number }} + EXPECTED_SHA: ${{ needs.resolve.outputs.head_sha }} + PREVIEW_ALIAS_URL: ${{ steps.deploy.outputs.pages-deployment-alias-url }} + PREVIEW_DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }} with: - github-token: ${{ secrets.MATZBOT_GITHUB_TOKEN }} script: | - const prNumber = ${{ github.event.client_payload.pr_number }}; - const url = "${{ steps.deploy.outputs.deployment-url }}"; - const commentMarker = "🚀 Preview deployment available at:"; - const commitSha = '${{ github.event.client_payload.pr_head_sha }}'; + const marker = ''; + const number = process.env.PR_NUMBER; + const expectedSha = process.env.EXPECTED_SHA; + const previewUrl = ( + process.env.PREVIEW_ALIAS_URL || process.env.PREVIEW_DEPLOYMENT_URL || '' + ).trim(); + + if (!/^[1-9][0-9]*$/.test(number) || !/^[0-9a-f]{40}$/.test(expectedSha)) { + core.setFailed('The preview comment metadata is invalid.'); + return; + } - const comments = await github.rest.issues.listComments({ - issue_number: prNumber, + let parsedUrl; + try { + parsedUrl = new URL(previewUrl); + } catch { + core.setFailed('Cloudflare did not return a valid preview URL.'); + return; + } + if (parsedUrl.protocol !== 'https:' || !parsedUrl.hostname.endsWith('.pages.dev')) { + core.setFailed('Cloudflare returned an unexpected preview URL.'); + return; + } + + const pullNumber = Number(number); + const { data: pull } = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, - per_page: 100 + pull_number: pullNumber, }); + const current = + pull.state === 'open' && + pull.base.repo.full_name === 'ruby/rdoc' && + pull.base.ref === 'master' && + pull.head.sha === expectedSha; + if (!current) { + core.notice('Skipped the preview comment because the pull request changed.'); + return; + } - const existingComment = comments.data.find(comment => - comment.body.includes(commentMarker) - ); + const shortSha = expectedSha.slice(0, 7); + const body = [ + marker, + '### Documentation preview', + '', + `[View the preview](${parsedUrl.href})`, + '', + `Commit: \`${shortSha}\``, + ].join('\n'); - const commentBody = `${commentMarker} [${url}](${url}) (commit: ${commitSha})`; + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullNumber, + per_page: 100, + }); + const existing = comments.find(comment => + comment.user?.login === 'github-actions[bot]' && + comment.body?.startsWith(marker) + ); - if (existingComment) { + if (existing) { await github.rest.issues.updateComment({ - comment_id: existingComment.id, owner: context.repo.owner, repo: context.repo.repo, - body: commentBody + comment_id: existing.id, + body, }); - console.log("Updated existing preview comment"); } else { await github.rest.issues.createComment({ - issue_number: prNumber, owner: context.repo.owner, repo: context.repo.repo, - body: commentBody + issue_number: pullNumber, + body, }); - console.log("Created new preview comment"); } diff --git a/.github/workflows/fork-preview-deploy.yml b/.github/workflows/fork-preview-deploy.yml deleted file mode 100644 index c69804e2e0..0000000000 --- a/.github/workflows/fork-preview-deploy.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: Dispatch Fork PR Preview Deployment - -on: - workflow_run: - workflows: ["PR Preview Check"] - types: [completed] - -jobs: - deploy-fork: - name: Trigger Preview Build and Deploy (Fork) - runs-on: ubuntu-latest - if: | - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'pull_request' - steps: - - name: Download PR information - uses: actions/download-artifact@v8 - with: - name: pr - github-token: ${{ secrets.GITHUB_TOKEN }} - run-id: ${{ github.event.workflow_run.id }} - - - name: Read PR information and trigger deployment - uses: actions/github-script@v9 - with: - script: | - const fs = require('fs'); - - // Check if this was a fork PR by checking if approve-fork job ran - const jobs = await github.rest.actions.listJobsForWorkflowRun({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: context.payload.workflow_run.id, - }); - - const approveJob = jobs.data.jobs.find(job => job.name === 'Approve Fork PR'); - if (!approveJob || approveJob.conclusion !== 'success') { - core.setFailed('Not a fork PR approval workflow run'); - return; - } - - // Read PR information from artifacts - let prNumber, prHeadSha, prCheckoutRepo; - try { - prNumber = fs.readFileSync('./pr_number', 'utf8').trim(); - prHeadSha = fs.readFileSync('./pr_head_sha', 'utf8').trim(); - prCheckoutRepo = fs.readFileSync('./pr_checkout_repository', 'utf8').trim(); - } catch (error) { - core.setFailed(`Failed to read PR information: ${error.message}`); - return; - } - - console.log(`Deploying approved fork PR #${prNumber}`); - - // Trigger deployment via repository dispatch - await github.rest.repos.createDispatchEvent({ - owner: context.repo.owner, - repo: context.repo.repo, - event_type: 'pr-preview-deploy', - client_payload: { - pr_number: prNumber, - pr_head_sha: prHeadSha, - pr_checkout_repository: prCheckoutRepo, - is_fork: 'true' - } - }); diff --git a/.github/workflows/pr-preview-check.yml b/.github/workflows/pr-preview-check.yml index 74310d88b8..8ce96d0887 100644 --- a/.github/workflows/pr-preview-check.yml +++ b/.github/workflows/pr-preview-check.yml @@ -1,53 +1,45 @@ -name: PR Preview Check +name: Build PR Preview on: pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: pr-preview-build-${{ github.event.pull_request.number }} + cancel-in-progress: true jobs: - # Deploy main repo PRs directly - deploy-for-main: - name: Trigger Preview Build and Deploy (Main Repo) + build: + name: Build Preview + if: github.repository == 'ruby/rdoc' runs-on: ubuntu-latest - if: github.event.pull_request.head.repo.fork == false - permissions: - contents: write + timeout-minutes: 20 steps: - - name: Trigger preview deployment - uses: actions/github-script@v9 + # This job executes untrusted pull request code. It must not receive secrets + # or a token with write access. + - name: Checkout PR code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - await github.rest.repos.createDispatchEvent({ - owner: context.repo.owner, - repo: context.repo.repo, - event_type: 'pr-preview-deploy', - client_payload: { - pr_number: '${{ github.event.pull_request.number }}', - pr_head_sha: '${{ github.event.pull_request.head.sha }}', - pr_checkout_repository: '${{ github.repository }}', - is_fork: 'false' - } - }); - console.log('Triggered main repo preview deployment'); + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - # Approval gate for fork PRs - approve-for-fork: - name: Approve Fork PR - runs-on: ubuntu-latest - if: github.event.pull_request.head.repo.fork == true - environment: fork-preview-protection - steps: - - name: Save PR information - run: | - echo "Fork PR #${{ github.event.pull_request.number }} approved for preview deployment" - mkdir -p ./pr - echo "${{ github.event.pull_request.number }}" > ./pr/pr_number - echo "${{ github.event.pull_request.head.sha }}" > ./pr/pr_head_sha - echo "${{ github.event.pull_request.head.repo.full_name }}" > ./pr/pr_checkout_repository - - - name: Upload PR information - uses: actions/upload-artifact@v7 + - name: Setup Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: '3.4' + bundler-cache: true + + - name: Build site + run: bundle exec rake rdoc + + - name: Upload preview site + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: pr - path: pr/ + name: pr-preview-site + path: _site/ + if-no-files-found: error retention-days: 1 From cad93f279230f4cb8b134168a8460550d1cd09ac Mon Sep 17 00:00:00 2001 From: st0012 Date: Mon, 31 Aug 2026 23:57:10 +0100 Subject: [PATCH 2/5] Deploy valid artifacts from failed preview runs --- .github/workflows/cloudflare-preview.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cloudflare-preview.yml b/.github/workflows/cloudflare-preview.yml index b0ca7576c0..e01bc87bd8 100644 --- a/.github/workflows/cloudflare-preview.yml +++ b/.github/workflows/cloudflare-preview.yml @@ -12,8 +12,7 @@ jobs: name: Resolve Preview if: >- github.repository == 'ruby/rdoc' && - github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.conclusion == 'success' + github.event.workflow_run.event == 'pull_request' runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -35,6 +34,12 @@ jobs: const artifactName = 'pr-preview-site'; const maximumArchiveBytes = 500 * 1024 * 1024; + if (!['success', 'failure'].includes(run.conclusion)) { + core.setOutput('current', 'false'); + core.notice(`Skipped a preview build with conclusion: ${run.conclusion}.`); + return; + } + if (!/^[0-9a-f]{40}$/.test(run.head_sha)) { core.setFailed('The preview build supplied an invalid head SHA.'); return; @@ -122,6 +127,12 @@ jobs: artifact.name === artifactName && !artifact.expired ); + if (matchingArtifacts.length === 0 && run.conclusion === 'failure') { + core.setOutput('current', 'false'); + core.notice('The failed preview build did not publish an artifact.'); + return; + } + if (matchingArtifacts.length !== 1) { core.setFailed(`Expected one ${artifactName} artifact, found ${matchingArtifacts.length}.`); return; From f0a039c695ea2f4510cf8f73e8d36431b5e43f40 Mon Sep 17 00:00:00 2001 From: st0012 Date: Tue, 1 Sep 2026 01:26:48 +0100 Subject: [PATCH 3/5] Explain preview deployment steps --- .github/workflows/cloudflare-preview.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cloudflare-preview.yml b/.github/workflows/cloudflare-preview.yml index e01bc87bd8..dc889272eb 100644 --- a/.github/workflows/cloudflare-preview.yml +++ b/.github/workflows/cloudflare-preview.yml @@ -23,6 +23,7 @@ jobs: number: ${{ steps.resolve.outputs.number }} head_sha: ${{ steps.resolve.outputs.head_sha }} steps: + # Find the current pull request and its preview artifact. - name: Resolve current pull request and artifact id: resolve uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -156,8 +157,7 @@ jobs: actions: read pull-requests: write steps: - # The artifact is untrusted data. This job never checks out or executes - # pull request code. + # Download the preview artifact without running pull request code. - name: Download preview site uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -166,6 +166,7 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} + # Reject unsafe or oversized files before deployment. - name: Validate preview site uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: @@ -248,6 +249,7 @@ jobs: core.info(`Accepted ${fileCount} static files (${totalBytes} bytes).`); + # Make sure that the pull request still uses the expected commit. - name: Confirm pull request head id: current uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -277,10 +279,12 @@ jobs: core.setOutput('current', current.toString()); if (!current) core.notice('Skipped a stale preview deployment.'); + # Create a separate working directory for the trusted deployment tools. - name: Prepare trusted Wrangler directory if: steps.current.outputs.current == 'true' run: mkdir -p "$RUNNER_TEMP/trusted-preview-deploy" + # Upload the static preview site to Cloudflare Pages. - name: Deploy to Cloudflare Pages if: steps.current.outputs.current == 'true' id: deploy @@ -297,6 +301,7 @@ jobs: --branch="${{ needs.resolve.outputs.number }}-preview" --commit-hash="${{ needs.resolve.outputs.head_sha }}" + # Add or update one pull request comment with the preview link. - name: Update preview comment if: steps.current.outputs.current == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 From 4f3ca86886265650509dbe40f816fcc7bde84c2e Mon Sep 17 00:00:00 2001 From: st0012 Date: Tue, 1 Sep 2026 01:33:48 +0100 Subject: [PATCH 4/5] Explain preview deployment safeguards --- .github/workflows/cloudflare-preview.yml | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/cloudflare-preview.yml b/.github/workflows/cloudflare-preview.yml index dc889272eb..ee86b5695a 100644 --- a/.github/workflows/cloudflare-preview.yml +++ b/.github/workflows/cloudflare-preview.yml @@ -23,7 +23,8 @@ jobs: number: ${{ steps.resolve.outputs.number }} head_sha: ${{ steps.resolve.outputs.head_sha }} steps: - # Find the current pull request and its preview artifact. + # A forked workflow_run can omit pull request details, so the artifact + # must match the exact open pull request and commit. - name: Resolve current pull request and artifact id: resolve uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -157,7 +158,8 @@ jobs: actions: read pull-requests: write steps: - # Download the preview artifact without running pull request code. + # The workflow treats the artifact as untrusted and uses no source + # checkout, so pull request code cannot run on the trusted runner. - name: Download preview site uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -166,7 +168,9 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} - # Reject unsafe or oversized files before deployment. + # Pages control files can change deployment behavior. This gate blocks + # them and oversized artifacts before the deployment step receives + # Cloudflare secrets. - name: Validate preview site uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: @@ -249,7 +253,8 @@ jobs: core.info(`Accepted ${fileCount} static files (${totalBytes} bytes).`); - # Make sure that the pull request still uses the expected commit. + # The pull request can change after artifact selection. A second head + # comparison blocks deployment of a stale commit. - name: Confirm pull request head id: current uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -279,12 +284,14 @@ jobs: core.setOutput('current', current.toString()); if (!current) core.notice('Skipped a stale preview deployment.'); - # Create a separate working directory for the trusted deployment tools. + # Pull request packages and configuration files cannot affect Wrangler + # because it runs from a separate trusted directory. - name: Prepare trusted Wrangler directory if: steps.current.outputs.current == 'true' run: mkdir -p "$RUNNER_TEMP/trusted-preview-deploy" - # Upload the static preview site to Cloudflare Pages. + # Only this step receives Cloudflare secrets, after all safety checks. + # Each pull request commit uses its own preview branch. - name: Deploy to Cloudflare Pages if: steps.current.outputs.current == 'true' id: deploy @@ -301,7 +308,8 @@ jobs: --branch="${{ needs.resolve.outputs.number }}-preview" --commit-hash="${{ needs.resolve.outputs.head_sha }}" - # Add or update one pull request comment with the preview link. + # The workflow reuses one marked comment to avoid notification spam and + # show the exact commit for the preview. - name: Update preview comment if: steps.current.outputs.current == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 From cd08836802f1db255697d115c2af1392daec98ba Mon Sep 17 00:00:00 2001 From: st0012 Date: Tue, 1 Sep 2026 10:59:55 +0100 Subject: [PATCH 5/5] Correct preview branch comment --- .github/workflows/cloudflare-preview.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cloudflare-preview.yml b/.github/workflows/cloudflare-preview.yml index ee86b5695a..d9eae21eae 100644 --- a/.github/workflows/cloudflare-preview.yml +++ b/.github/workflows/cloudflare-preview.yml @@ -291,7 +291,7 @@ jobs: run: mkdir -p "$RUNNER_TEMP/trusted-preview-deploy" # Only this step receives Cloudflare secrets, after all safety checks. - # Each pull request commit uses its own preview branch. + # Each pull request uses its own preview branch. - name: Deploy to Cloudflare Pages if: steps.current.outputs.current == 'true' id: deploy