Skip to content

Deploy PR Preview

Deploy PR Preview #700

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