Deploy PR Preview #700
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Deploy PR Preview | |
| on: | |
| workflow_run: | |
| workflows: ["Build PR Preview"] | |
| types: [completed] | |
| permissions: {} | |
| jobs: | |
| resolve: | |
| name: Resolve Preview | |
| if: >- | |
| github.repository == 'ruby/rdoc' && | |
| github.event.workflow_run.event == 'pull_request' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| actions: read | |
| pull-requests: read | |
| outputs: | |
| pull_requests: ${{ steps.resolve.outputs.pull_requests }} | |
| steps: | |
| # A forked workflow_run can omit pull request details, so the artifact | |
| # must match the source repository, branch, and commit of each open PR. | |
| - name: Resolve current pull requests and artifact | |
| id: resolve | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| with: | |
| script: | | |
| const run = context.payload.workflow_run; | |
| const expectedRepository = 'ruby/rdoc'; | |
| const artifactName = 'pr-preview-site'; | |
| const maximumArchiveBytes = 500 * 1024 * 1024; | |
| core.setOutput('pull_requests', '[]'); | |
| if (!['success', 'failure'].includes(run.conclusion)) { | |
| 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; | |
| } | |
| 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}`, | |
| per_page: 100, | |
| }); | |
| candidateNumbers = new Set( | |
| branchPulls | |
| .filter(pull => pull.head.sha === run.head_sha) | |
| .map(pull => pull.number) | |
| ); | |
| } | |
| if (candidateNumbers.size === 0) { | |
| 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); | |
| } | |
| const matches = candidates.filter(pull => | |
| pull.state === 'open' && | |
| pull.base.repo.full_name === expectedRepository && | |
| pull.head.repo?.full_name === headRepository && | |
| pull.head.ref === headBranch && | |
| pull.head.sha === run.head_sha | |
| ); | |
| if (matches.length === 0) { | |
| core.notice('No current open pull request uses 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 === 0 && run.conclusion === 'failure') { | |
| core.notice('The failed preview build did not publish an artifact.'); | |
| return; | |
| } | |
| // Title and body edits skip the build job but can still complete successfully. | |
| // Keep missing uploads an error when a build actually ran. | |
| if (matchingArtifacts.length === 0) { | |
| const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| run_id: run.id, | |
| filter: 'latest', | |
| per_page: 100, | |
| }); | |
| if (jobs.length > 0 && jobs.every(job => job.conclusion === 'skipped')) { | |
| core.notice('The preview build was skipped.'); | |
| return; | |
| } | |
| } | |
| 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.`); | |
| return; | |
| } | |
| // The site comes from the head commit, not a merge with the base branch. | |
| // PRs with the same source repository, branch, and commit can share it. | |
| core.setOutput('pull_requests', JSON.stringify(matches.map(pull => ({ | |
| number: pull.number, | |
| head_sha: run.head_sha, | |
| base_ref: pull.base.ref, | |
| })))); | |
| deploy: | |
| name: Deploy Preview for PR ${{ matrix.pull_request.number }} | |
| needs: resolve | |
| if: needs.resolve.outputs.pull_requests != '[]' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| # Each PR keeps its own preview and stale-data checks, even when the source is shared. | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| pull_request: ${{ fromJSON(needs.resolve.outputs.pull_requests) }} | |
| concurrency: | |
| group: pr-preview-deploy-${{ matrix.pull_request.number }} | |
| cancel-in-progress: true | |
| permissions: | |
| actions: read | |
| pull-requests: write | |
| steps: | |
| # 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: | |
| name: pr-preview-site | |
| path: ${{ runner.temp }}/pr-preview-site | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| run-id: ${{ github.event.workflow_run.id }} | |
| # 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: | |
| 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).`); | |
| # The pull request can change after artifact selection. A second head | |
| # and base comparison blocks deployment with stale pull request data. | |
| - name: Confirm pull request head and base | |
| id: current | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| env: | |
| PR_NUMBER: ${{ matrix.pull_request.number }} | |
| EXPECTED_SHA: ${{ matrix.pull_request.head_sha }} | |
| EXPECTED_BASE_REF: ${{ matrix.pull_request.base_ref }} | |
| with: | |
| script: | | |
| const number = process.env.PR_NUMBER; | |
| const expectedSha = process.env.EXPECTED_SHA; | |
| const expectedBaseRef = process.env.EXPECTED_BASE_REF; | |
| if (!/^[1-9][0-9]*$/.test(number) || | |
| !/^[0-9a-f]{40}$/.test(expectedSha) || | |
| !expectedBaseRef) { | |
| 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 === expectedBaseRef && | |
| pull.head.sha === expectedSha; | |
| core.setOutput('current', current.toString()); | |
| if (!current) core.notice('Skipped a stale preview deployment.'); | |
| # 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" | |
| # Only this step receives Cloudflare secrets, after all safety checks. | |
| # Each pull request uses its own preview branch. | |
| - name: Deploy to Cloudflare Pages | |
| if: steps.current.outputs.current == 'true' | |
| id: deploy | |
| uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 | |
| with: | |
| apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| packageManager: npm | |
| wranglerVersion: '4.81.0' | |
| workingDirectory: ${{ runner.temp }}/trusted-preview-deploy | |
| command: >- | |
| pages deploy "${{ runner.temp }}/pr-preview-site" | |
| --project-name=rdoc | |
| --branch="${{ matrix.pull_request.number }}-preview" | |
| --commit-hash="${{ matrix.pull_request.head_sha }}" | |
| # The workflow rechecks the pull request after deployment, then reuses | |
| # one marked comment to avoid stale links and notification spam. | |
| - name: Update preview comment | |
| if: steps.current.outputs.current == 'true' | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| env: | |
| PR_NUMBER: ${{ matrix.pull_request.number }} | |
| EXPECTED_SHA: ${{ matrix.pull_request.head_sha }} | |
| EXPECTED_BASE_REF: ${{ matrix.pull_request.base_ref }} | |
| PREVIEW_ALIAS_URL: ${{ steps.deploy.outputs.pages-deployment-alias-url }} | |
| PREVIEW_DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }} | |
| with: | |
| script: | | |
| const marker = '<!-- rdoc-pr-preview -->'; | |
| const number = process.env.PR_NUMBER; | |
| const expectedSha = process.env.EXPECTED_SHA; | |
| const expectedBaseRef = process.env.EXPECTED_BASE_REF; | |
| 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) || | |
| !expectedBaseRef) { | |
| core.setFailed('The preview comment metadata is invalid.'); | |
| return; | |
| } | |
| 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, | |
| pull_number: pullNumber, | |
| }); | |
| const current = | |
| pull.state === 'open' && | |
| pull.base.repo.full_name === 'ruby/rdoc' && | |
| pull.base.ref === expectedBaseRef && | |
| pull.head.sha === expectedSha; | |
| if (!current) { | |
| core.notice('Skipped the preview comment because the pull request changed.'); | |
| return; | |
| } | |
| const shortSha = expectedSha.slice(0, 7); | |
| const body = [ | |
| marker, | |
| '### Documentation preview', | |
| '', | |
| `[View the preview](${parsedUrl.href})`, | |
| '', | |
| `Commit: \`${shortSha}\``, | |
| ].join('\n'); | |
| 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 (existing) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: existing.id, | |
| body, | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pullNumber, | |
| body, | |
| }); | |
| } |