Skip to content

Commit f7e570c

Browse files
authored
Support previews for stacked pull requests (#1807)
Supports documentation previews for pull requests targeting any branch in `ruby/rdoc`, including stacked PRs. Previews remain automatic, with no maintainer deployment approval. Fork workflow events can omit the PR number. The resolver finds each open PR with the same source repository, branch, and head commit. If one head branch targets multiple bases, each PR gets its own preview URL and comment. Each deployment checks that PR's head and base again. Cloudflare secrets remain limited to the deployment step, and the trusted workflow does not check out PR code. Base retargets start a new preview build. Title and body edits skip the build without cancelling active work or causing missing-artifact errors. A successful build that actually ran must still provide a valid artifact. The preview uses the exact PR head, not a merge with its base branch. A parent branch update therefore does not rebuild the child preview. Restacking the child starts a new build through the `synchronize` event.
1 parent ab0170d commit f7e570c

2 files changed

Lines changed: 73 additions & 48 deletions

File tree

‎.github/workflows/cloudflare-preview.yml‎

Lines changed: 64 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,22 @@ jobs:
1919
actions: read
2020
pull-requests: read
2121
outputs:
22-
current: ${{ steps.resolve.outputs.current }}
23-
number: ${{ steps.resolve.outputs.number }}
24-
head_sha: ${{ steps.resolve.outputs.head_sha }}
22+
pull_requests: ${{ steps.resolve.outputs.pull_requests }}
2523
steps:
2624
# A forked workflow_run can omit pull request details, so the artifact
27-
# must match the exact open pull request and commit.
28-
- name: Resolve current pull request and artifact
25+
# must match the source repository, branch, and commit of each open PR.
26+
- name: Resolve current pull requests and artifact
2927
id: resolve
3028
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
3129
with:
3230
script: |
3331
const run = context.payload.workflow_run;
3432
const expectedRepository = 'ruby/rdoc';
35-
const expectedBase = 'master';
3633
const artifactName = 'pr-preview-site';
3734
const maximumArchiveBytes = 500 * 1024 * 1024;
35+
core.setOutput('pull_requests', '[]');
3836
3937
if (!['success', 'failure'].includes(run.conclusion)) {
40-
core.setOutput('current', 'false');
4138
core.notice(`Skipped a preview build with conclusion: ${run.conclusion}.`);
4239
return;
4340
}
@@ -66,7 +63,6 @@ jobs:
6663
repo: context.repo.repo,
6764
state: 'open',
6865
head: `${headOwner}:${headBranch}`,
69-
base: expectedBase,
7066
per_page: 100,
7167
});
7268
candidateNumbers = new Set(
@@ -77,7 +73,6 @@ jobs:
7773
}
7874
7975
if (candidateNumbers.size === 0) {
80-
core.setOutput('current', 'false');
8176
core.notice('No open pull request uses this preview build.');
8277
return;
8378
}
@@ -93,25 +88,15 @@ jobs:
9388
}
9489
9590
const matches = candidates.filter(pull =>
91+
pull.state === 'open' &&
9692
pull.base.repo.full_name === expectedRepository &&
97-
pull.base.ref === expectedBase &&
9893
pull.head.repo?.full_name === headRepository &&
99-
pull.head.ref === headBranch
94+
pull.head.ref === headBranch &&
95+
pull.head.sha === run.head_sha
10096
);
10197
102-
if (matches.length !== 1) {
103-
core.setFailed(`Expected one pull request for this preview build, found ${matches.length}.`);
104-
return;
105-
}
106-
107-
const pull = matches[0];
108-
const current = pull.state === 'open' && pull.head.sha === run.head_sha;
109-
core.setOutput('current', current.toString());
110-
core.setOutput('number', pull.number.toString());
111-
core.setOutput('head_sha', run.head_sha);
112-
113-
if (!current) {
114-
core.notice('The pull request changed or closed after this preview build.');
98+
if (matches.length === 0) {
99+
core.notice('No current open pull request uses this preview build.');
115100
return;
116101
}
117102
@@ -130,11 +115,26 @@ jobs:
130115
);
131116
132117
if (matchingArtifacts.length === 0 && run.conclusion === 'failure') {
133-
core.setOutput('current', 'false');
134118
core.notice('The failed preview build did not publish an artifact.');
135119
return;
136120
}
137121
122+
// Title and body edits skip the build job but can still complete successfully.
123+
// Keep missing uploads an error when a build actually ran.
124+
if (matchingArtifacts.length === 0) {
125+
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
126+
owner: context.repo.owner,
127+
repo: context.repo.repo,
128+
run_id: run.id,
129+
filter: 'latest',
130+
per_page: 100,
131+
});
132+
if (jobs.length > 0 && jobs.every(job => job.conclusion === 'skipped')) {
133+
core.notice('The preview build was skipped.');
134+
return;
135+
}
136+
}
137+
138138
if (matchingArtifacts.length !== 1) {
139139
core.setFailed(`Expected one ${artifactName} artifact, found ${matchingArtifacts.length}.`);
140140
return;
@@ -143,16 +143,30 @@ jobs:
143143
const artifact = matchingArtifacts[0];
144144
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > maximumArchiveBytes) {
145145
core.setFailed(`The preview artifact archive has an invalid size: ${artifact.size_in_bytes} bytes.`);
146+
return;
146147
}
147148
149+
// The site comes from the head commit, not a merge with the base branch.
150+
// PRs with the same source repository, branch, and commit can share it.
151+
core.setOutput('pull_requests', JSON.stringify(matches.map(pull => ({
152+
number: pull.number,
153+
head_sha: run.head_sha,
154+
base_ref: pull.base.ref,
155+
}))));
156+
148157
deploy:
149-
name: Deploy Preview
158+
name: Deploy Preview for PR ${{ matrix.pull_request.number }}
150159
needs: resolve
151-
if: needs.resolve.outputs.current == 'true'
160+
if: needs.resolve.outputs.pull_requests != '[]'
152161
runs-on: ubuntu-latest
153162
timeout-minutes: 15
163+
# Each PR keeps its own preview and stale-data checks, even when the source is shared.
164+
strategy:
165+
fail-fast: false
166+
matrix:
167+
pull_request: ${{ fromJSON(needs.resolve.outputs.pull_requests) }}
154168
concurrency:
155-
group: pr-preview-deploy-${{ needs.resolve.outputs.number }}
169+
group: pr-preview-deploy-${{ matrix.pull_request.number }}
156170
cancel-in-progress: true
157171
permissions:
158172
actions: read
@@ -254,18 +268,22 @@ jobs:
254268
core.info(`Accepted ${fileCount} static files (${totalBytes} bytes).`);
255269
256270
# The pull request can change after artifact selection. A second head
257-
# comparison blocks deployment of a stale commit.
258-
- name: Confirm pull request head
271+
# and base comparison blocks deployment with stale pull request data.
272+
- name: Confirm pull request head and base
259273
id: current
260274
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
261275
env:
262-
PR_NUMBER: ${{ needs.resolve.outputs.number }}
263-
EXPECTED_SHA: ${{ needs.resolve.outputs.head_sha }}
276+
PR_NUMBER: ${{ matrix.pull_request.number }}
277+
EXPECTED_SHA: ${{ matrix.pull_request.head_sha }}
278+
EXPECTED_BASE_REF: ${{ matrix.pull_request.base_ref }}
264279
with:
265280
script: |
266281
const number = process.env.PR_NUMBER;
267282
const expectedSha = process.env.EXPECTED_SHA;
268-
if (!/^[1-9][0-9]*$/.test(number) || !/^[0-9a-f]{40}$/.test(expectedSha)) {
283+
const expectedBaseRef = process.env.EXPECTED_BASE_REF;
284+
if (!/^[1-9][0-9]*$/.test(number) ||
285+
!/^[0-9a-f]{40}$/.test(expectedSha) ||
286+
!expectedBaseRef) {
269287
core.setFailed('The resolved pull request metadata is invalid.');
270288
return;
271289
}
@@ -278,7 +296,7 @@ jobs:
278296
const current =
279297
pull.state === 'open' &&
280298
pull.base.repo.full_name === 'ruby/rdoc' &&
281-
pull.base.ref === 'master' &&
299+
pull.base.ref === expectedBaseRef &&
282300
pull.head.sha === expectedSha;
283301
284302
core.setOutput('current', current.toString());
@@ -305,29 +323,33 @@ jobs:
305323
command: >-
306324
pages deploy "${{ runner.temp }}/pr-preview-site"
307325
--project-name=rdoc
308-
--branch="${{ needs.resolve.outputs.number }}-preview"
309-
--commit-hash="${{ needs.resolve.outputs.head_sha }}"
326+
--branch="${{ matrix.pull_request.number }}-preview"
327+
--commit-hash="${{ matrix.pull_request.head_sha }}"
310328
311-
# The workflow reuses one marked comment to avoid notification spam and
312-
# show the exact commit for the preview.
329+
# The workflow rechecks the pull request after deployment, then reuses
330+
# one marked comment to avoid stale links and notification spam.
313331
- name: Update preview comment
314332
if: steps.current.outputs.current == 'true'
315333
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
316334
env:
317-
PR_NUMBER: ${{ needs.resolve.outputs.number }}
318-
EXPECTED_SHA: ${{ needs.resolve.outputs.head_sha }}
335+
PR_NUMBER: ${{ matrix.pull_request.number }}
336+
EXPECTED_SHA: ${{ matrix.pull_request.head_sha }}
337+
EXPECTED_BASE_REF: ${{ matrix.pull_request.base_ref }}
319338
PREVIEW_ALIAS_URL: ${{ steps.deploy.outputs.pages-deployment-alias-url }}
320339
PREVIEW_DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }}
321340
with:
322341
script: |
323342
const marker = '<!-- rdoc-pr-preview -->';
324343
const number = process.env.PR_NUMBER;
325344
const expectedSha = process.env.EXPECTED_SHA;
345+
const expectedBaseRef = process.env.EXPECTED_BASE_REF;
326346
const previewUrl = (
327347
process.env.PREVIEW_ALIAS_URL || process.env.PREVIEW_DEPLOYMENT_URL || ''
328348
).trim();
329349
330-
if (!/^[1-9][0-9]*$/.test(number) || !/^[0-9a-f]{40}$/.test(expectedSha)) {
350+
if (!/^[1-9][0-9]*$/.test(number) ||
351+
!/^[0-9a-f]{40}$/.test(expectedSha) ||
352+
!expectedBaseRef) {
331353
core.setFailed('The preview comment metadata is invalid.');
332354
return;
333355
}
@@ -353,7 +375,7 @@ jobs:
353375
const current =
354376
pull.state === 'open' &&
355377
pull.base.repo.full_name === 'ruby/rdoc' &&
356-
pull.base.ref === 'master' &&
378+
pull.base.ref === expectedBaseRef &&
357379
pull.head.sha === expectedSha;
358380
if (!current) {
359381
core.notice('Skipped the preview comment because the pull request changed.');

‎.github/workflows/pr-preview-check.yml‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,24 @@ name: Build PR Preview
22

33
on:
44
pull_request:
5-
types: [opened, synchronize, reopened]
5+
types: [opened, synchronize, reopened, edited]
66

77
permissions:
88
contents: read
99

10-
concurrency:
11-
group: pr-preview-build-${{ github.event.pull_request.number }}
12-
cancel-in-progress: true
13-
1410
jobs:
1511
build:
1612
name: Build Preview
17-
if: github.repository == 'ruby/rdoc'
13+
# Base retargets need a preview, but title and body edits do not change the site.
14+
if: >-
15+
github.repository == 'ruby/rdoc' &&
16+
(github.event.action != 'edited' || github.event.changes.base)
1817
runs-on: ubuntu-latest
1918
timeout-minutes: 20
19+
# A skipped edit must not cancel an active build for the same PR.
20+
concurrency:
21+
group: pr-preview-build-${{ github.event.pull_request.number }}
22+
cancel-in-progress: true
2023
steps:
2124
# This job executes untrusted pull request code. It must not receive secrets
2225
# or a token with write access.

0 commit comments

Comments
 (0)