Skip to content

Commit 517a4a8

Browse files
adamzielCopilot
andauthored
[Website] Support previewing WordPress and Gutenberg branches, not just PRs (#2868)
## Motivation for the change, related issues Adds support for previewing branches, not just PRs in the Import PR modal: <img width="2288" height="1500" alt="CleanShot 2025-11-05 at 15 13 58@2x" src="https://github.com/user-attachments/assets/d7efc465-43bc-4786-a66d-493d70ec60b8" /> @SirLouen asked how to preview the latest trunk trunk, not a PR, and I didn't have a good answer. This change makes it easy. ## Implementation details * Website: adds `?core-branch` and `?gutenberg-branch` Query API params to indicate which branch to preview. * Plugin proxy: Adds a `?branch=` query API param that's an alternative to `?pr=`. There's no artifact validation when previewing a branch. If the build for the most recent commit is not ready yet, the next available build is used. ## Testing Instructions (or ideally a Blueprint) Try these locally and confirm they do the right thing. You can see the WordPress version number at the bottom of wp-admin and also use the file browser to confirm the files changed in specific PRs are present in the filesystem: * http://127.0.0.1:5400/website-server/?core-branch=trunk * http://127.0.0.1:5401/website-server/?core-pr=9500 (see WordPress/wordpress-develop#9500) * http://127.0.0.1:5400/website-server/?gutenberg-branch=trunk * http://127.0.0.1:5401/website-server/?gutenberg-pr=73010 (see https://github.com/WordPress/wordpress-develop/pull/73010) --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
1 parent aa4d85c commit 517a4a8

File tree

5 files changed

+162
-107
lines changed

5 files changed

+162
-107
lines changed

packages/docs/site/docs/developers/06-apis/query-api/01-index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ You can go ahead and try it out. The Playground will automatically install the t
4141
| `language` | `en_US` | Sets the locale for the WordPress instance. This must be used in combination with `networking=yes` otherwise WordPress won't be able to download translations. |
4242
| `core-pr` | | Installs a specific https://github.com/WordPress/wordpress-develop core PR. Accepts the PR number. For example, `core-pr=6883`. |
4343
| `gutenberg-pr` | | Installs a specific https://github.com/WordPress/gutenberg PR. Accepts the PR number. For example, `gutenberg-pr=65337`. |
44+
| `core-branch` | | Installs a specific branch from https://github.com/WordPress/wordpress-develop. Accepts the branch name. For example, `core-branch=trunk`. |
45+
| `gutenberg-branch` | | Installs a specific branch from https://github.com/WordPress/gutenberg. Accepts the branch name. For example, `gutenberg-branch=trunk`. |
4446
| `if-stored-site-missing` | | Indicates how to handle the scenario where the `site-slug` parameter identifies a site that does not exist. Use `if-stored-site-missing=prompt` to indicate that the user should be asked whether they would like to save a new site with the specified `site-slug`. |
4547

4648
For example, the following code embeds a Playground with a preinstalled Gutenberg plugin and opens the post editor:

packages/playground/website/public/plugin-proxy.php

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,9 @@ public function streamFromDirectory($name, $directory)
4545
}
4646
}
4747

48-
public function streamFromGithubPR($organization, $repo, $pr, $workflow_name, $artifact_name)
48+
private function streamArtifactFromBranch($organization, $repo, $branchName, $workflow_name, $artifact_name)
4949
{
50-
$prDetails = $this->gitHubRequest("https://api.github.com/repos/$organization/$repo/pulls/$pr")['body'];
51-
if (!$prDetails) {
52-
throw new ApiException('invalid_pr_number');
53-
}
54-
$branchName = urlencode($prDetails->head->ref);
50+
$branchName = urlencode($branchName);
5551
$ciRuns = $this->gitHubRequest("https://api.github.com/repos/$organization/$repo/actions/runs?branch=$branchName")['body'];
5652
if (!$ciRuns) {
5753
throw new ApiException('no_ci_runs');
@@ -76,7 +72,13 @@ public function streamFromGithubPR($organization, $repo, $pr, $workflow_name, $a
7672
}
7773

7874
foreach ($artifacts->artifacts as $artifact) {
79-
if ($artifact_name === $artifact->name) {
75+
// Support prefix matching if artifact name ends with '-'
76+
// This is used for branches where artifact names include commit hashes
77+
$is_match = (substr($artifact_name, -1) === '-')
78+
? (strpos($artifact->name, $artifact_name) === 0)
79+
: ($artifact_name === $artifact->name);
80+
81+
if ($is_match) {
8082
if ($artifact->size_in_bytes < 3000) {
8183
throw new ApiException('artifact_invalid');
8284
}
@@ -141,6 +143,20 @@ public function streamFromGithubPR($organization, $repo, $pr, $workflow_name, $a
141143
}
142144
}
143145

146+
public function streamFromGithubBranch($organization, $repo, $branch, $workflow_name, $artifact_name)
147+
{
148+
$this->streamArtifactFromBranch($organization, $repo, $branch, $workflow_name, $artifact_name);
149+
}
150+
151+
public function streamFromGithubPR($organization, $repo, $pr, $workflow_name, $artifact_name)
152+
{
153+
$prDetails = $this->gitHubRequest("https://api.github.com/repos/$organization/$repo/pulls/$pr")['body'];
154+
if (!$prDetails) {
155+
throw new ApiException('invalid_pr_number');
156+
}
157+
$this->streamArtifactFromBranch($organization, $repo, $prDetails->head->ref, $workflow_name, $artifact_name);
158+
}
159+
144160
public function streamFromGithubReleases($repo, $name)
145161
{
146162
$zipUrl = "https://github.com/$repo/releases/latest/download/$name";
@@ -293,6 +309,19 @@ function ($curl, $body) use (&$extra_headers_sent, $default_response_headers) {
293309
$_GET['workflow'],
294310
$_GET['artifact']
295311
);
312+
} else if (isset($_GET['org']) && isset($_GET['repo']) && isset($_GET['workflow']) && isset($_GET['branch']) && isset($_GET['artifact'])) {
313+
// Don't reveal the allowed orgs to the client, just give an error.
314+
// Lowercase the org name to make the check case-insensitive.
315+
if (! in_array(strtolower($_GET['org']), PluginDownloader::ALLOWED_ORGS, true)) {
316+
throw new ApiException('Invalid org. This organization is not allowed.');
317+
}
318+
$downloader->streamFromGithubBranch(
319+
$_GET['org'],
320+
$_GET['repo'],
321+
$_GET['branch'],
322+
$_GET['workflow'],
323+
$_GET['artifact']
324+
);
296325
} else if (isset($_GET['repo']) && isset($_GET['name'])) {
297326
// Verify repo string contains org/repo format
298327
$parts = explode('/', $_GET['repo']);

packages/playground/website/src/github/preview-pr/form.tsx

Lines changed: 98 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -56,95 +56,115 @@ export default function PreviewPRForm({
5656
await previewPr(value);
5757
}
5858

59-
function renderRetryIn(retryIn: number) {
59+
function renderRetryIn(retryIn: number, isBranch: boolean) {
6060
setError(
61-
`Waiting for GitHub to finish building PR ${value}. This might take 15 minutes or more! Retrying in ${
61+
`Waiting for GitHub to finish building ${
62+
isBranch ? 'branch' : 'PR'
63+
} ${value}. This might take 15 minutes or more! Retrying in ${
6264
retryIn / 1000
6365
}...`
6466
);
6567
}
6668

69+
function buildArtifactUrl(ref: string, isBranch: boolean): string {
70+
const refType = isBranch ? 'branch' : 'pr';
71+
// For WordPress PRs: artifact name is wordpress-build-{PR_NUMBER}
72+
// For WordPress branches: artifact name is wordpress-build-{COMMIT_HASH}
73+
// We use wordpress-build- (with trailing dash) to trigger prefix matching
74+
// For Gutenberg: artifact name is always gutenberg-plugin
75+
let artifactSuffix = '';
76+
if (target === 'wordpress') {
77+
artifactSuffix = isBranch ? '-' : ref;
78+
}
79+
return `https://playground.wordpress.net/plugin-proxy.php?org=WordPress&repo=${targetParams[target].repo}&workflow=${targetParams[target].workflow}&artifact=${targetParams[target].artifact}${artifactSuffix}&${refType}=${ref}`;
80+
}
81+
6782
async function previewPr(prValue: string) {
6883
let cleanupRetry = () => {};
6984
if (cleanupRetry) {
7085
cleanupRetry();
7186
}
7287

7388
let prNumber: string = prValue;
89+
let branchName: string | null = null;
7490
setSubmitting(true);
7591

7692
// Extract number from a GitHub URL
7793
if (prNumber.toLowerCase().includes(targetParams[target].pull)) {
7894
prNumber = prNumber.match(/\/pull\/(\d+)/)![1];
95+
} else if (!/^\d+$/.test(prNumber)) {
96+
// If it's not a number and not a PR URL, treat it as a branch name
97+
branchName = prNumber;
7998
}
8099

81-
// Verify that the PR exists and that GitHub CI finished building it
82-
const zipArtifactUrl = `https://playground.wordpress.net/plugin-proxy.php?org=WordPress&repo=${
83-
targetParams[target].repo
84-
}&workflow=${targetParams[target].workflow}&artifact=${
85-
targetParams[target].artifact
86-
}${target === 'wordpress' ? prNumber : ''}&pr=${prNumber}`;
87-
// Send the HEAD request to zipArtifactUrl to confirm the PR and the artifact both exist
88-
const response = await fetch(zipArtifactUrl + '&verify_only=true');
89-
if (response.status !== 200) {
90-
let error = 'invalid_pr_number';
91-
try {
92-
const json = await response.json();
93-
if (json.error) {
94-
error = json.error;
100+
const ref = branchName || prNumber;
101+
const isBranch = !!branchName;
102+
103+
// For branches, skip verification since we'll use the most recent artifact with prefix matching
104+
// For PRs, verify that the specific PR build exists
105+
if (!isBranch) {
106+
const zipArtifactUrl = buildArtifactUrl(ref, isBranch);
107+
const response = await fetch(zipArtifactUrl + '&verify_only=true');
108+
if (response.status !== 200) {
109+
let error = 'invalid_pr_number';
110+
try {
111+
const json = await response.json();
112+
if (json.error) {
113+
error = json.error;
114+
}
115+
} catch (e) {
116+
logger.error(e);
117+
setError('An unexpected error occurred. Please try again.');
118+
return;
95119
}
96-
} catch (e) {
97-
logger.error(e);
98-
setError('An unexpected error occurred. Please try again.');
99-
return;
100-
}
101120

102-
if (error === 'invalid_pr_number') {
103-
setError(`The PR ${prNumber} does not exist.`);
104-
} else if (
105-
error === 'artifact_not_found' ||
106-
error === 'artifact_not_available'
107-
) {
108-
if (parseInt(prNumber) < 5749) {
121+
if (error === 'invalid_pr_number' || error === 'no_ci_runs') {
122+
setError(`The PR ${ref} does not exist.`);
123+
} else if (
124+
error === 'artifact_not_found' ||
125+
error === 'artifact_not_available'
126+
) {
127+
if (parseInt(ref) < 5749) {
128+
setError(
129+
`The PR ${ref} predates the Pull Request previewer and requires a rebase before it can be previewed.`
130+
);
131+
} else {
132+
// For PRs, retry since we expect a specific build to complete
133+
let retryIn = 30000;
134+
renderRetryIn(retryIn, false);
135+
const timerInterval = setInterval(() => {
136+
retryIn -= 1000;
137+
if (retryIn <= 0) {
138+
retryIn = 0;
139+
}
140+
renderRetryIn(retryIn, false);
141+
}, 1000);
142+
const scheduledRetry = setTimeout(() => {
143+
previewPr(ref);
144+
}, retryIn);
145+
cleanupRetry = () => {
146+
clearInterval(timerInterval);
147+
clearTimeout(scheduledRetry);
148+
cleanupRetry = () => {};
149+
};
150+
}
151+
} else if (error === 'artifact_invalid') {
109152
setError(
110-
`The PR ${prNumber} predates the Pull Request previewer and requires a rebase before it can be previewed.`
153+
`The PR ${ref} requires a rebase before it can be previewed.`
111154
);
112155
} else {
113-
let retryIn = 30000;
114-
renderRetryIn(retryIn);
115-
const timerInterval = setInterval(() => {
116-
retryIn -= 1000;
117-
if (retryIn <= 0) {
118-
retryIn = 0;
119-
}
120-
renderRetryIn(retryIn);
121-
}, 1000);
122-
const scheduledRetry = setTimeout(() => {
123-
previewPr(prNumber);
124-
}, retryIn);
125-
cleanupRetry = () => {
126-
clearInterval(timerInterval);
127-
clearTimeout(scheduledRetry);
128-
cleanupRetry = () => {};
129-
};
156+
setError(
157+
`The PR ${ref} couldn't be previewed due to an unexpected error. Please try again later or fill an issue in the WordPress Playground repository.`
158+
);
159+
// https://github.com/WordPress/wordpress-playground/issues/new
130160
}
131-
} else if (error === 'artifact_invalid') {
132-
setError(
133-
`The PR ${prNumber} requires a rebase before it can be previewed.`
134-
);
135-
} else {
136-
setError(
137-
`The PR ${prNumber} couldn't be previewed due to an unexpected error. Please try again later or fill an issue in the WordPress Playground repository.`
138-
);
139-
// https://github.com/WordPress/wordpress-playground/issues/new
140-
}
141-
142-
setSubmitting(false);
143161

144-
return;
162+
setSubmitting(false);
163+
return;
164+
}
145165
}
146166

147-
// Redirect to the Playground site with the Blueprint to download and apply the PR
167+
// Redirect to the Playground site with the Blueprint to download and apply the PR/branch
148168
const blueprint: BlueprintV1Declaration = {
149169
landingPage: urlParams.get('url') || '/wp-admin',
150170
login: true,
@@ -154,26 +174,25 @@ export default function PreviewPRForm({
154174
steps: [],
155175
};
156176

177+
const refParam = isBranch
178+
? `${target === 'wordpress' ? 'core' : 'gutenberg'}-branch`
179+
: `${target === 'wordpress' ? 'core' : 'gutenberg'}-pr`;
180+
const urlWithPreview = new URL(
181+
window.location.pathname,
182+
window.location.href
183+
);
184+
157185
if (target === 'wordpress') {
158186
// [wordpress] Passthrough the mode query parameter if it exists
159-
const targetParams = new URLSearchParams();
160187
if (urlParams.has('mode')) {
161-
targetParams.set('mode', urlParams.get('mode') as string);
188+
urlWithPreview.searchParams.set(
189+
'mode',
190+
urlParams.get('mode') as string
191+
);
162192
}
163-
targetParams.set('core-pr', prNumber);
164-
165-
const blueprintJson = JSON.stringify(blueprint);
166-
const urlWithPreview = new URL(
167-
window.location.pathname,
168-
window.location.href
169-
);
170-
urlWithPreview.search = targetParams.toString();
171-
urlWithPreview.hash = encodeURI(blueprintJson);
172-
173-
window.location.href = urlWithPreview.toString();
193+
urlWithPreview.searchParams.set(refParam, ref);
174194
} else if (target === 'gutenberg') {
175195
// [gutenberg] If there's a import-site query parameter, pass that to the blueprint
176-
const urlParams = new URLSearchParams(window.location.search);
177196
try {
178197
const importSite = new URL(
179198
urlParams.get('import-site') as string
@@ -191,18 +210,11 @@ export default function PreviewPRForm({
191210
} catch {
192211
logger.error('Invalid import-site URL');
193212
}
194-
195-
const blueprintJson = JSON.stringify(blueprint);
196-
197-
const urlWithPreview = new URL(
198-
window.location.pathname,
199-
window.location.href
200-
);
201-
urlWithPreview.searchParams.set('gutenberg-pr', prNumber);
202-
urlWithPreview.hash = encodeURI(blueprintJson);
203-
204-
window.location.href = urlWithPreview.toString();
213+
urlWithPreview.searchParams.set(refParam, ref);
205214
}
215+
216+
urlWithPreview.hash = encodeURI(JSON.stringify(blueprint));
217+
window.location.href = urlWithPreview.toString();
206218
}
207219

208220
return (
@@ -215,7 +227,7 @@ export default function PreviewPRForm({
215227
)}
216228
<TextControl
217229
disabled={submitting}
218-
label="Pull request number or URL"
230+
label="PR number, URL, or a branch name"
219231
value={value}
220232
autoFocus
221233
onChange={(e) => {

packages/playground/website/src/github/preview-pr/modal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export function PreviewPRModal({ target }: PreviewPRModalProps) {
2121
return (
2222
<Modal
2323
small
24-
title={`Preview a ${targetName[target]} PR`}
24+
title={`Preview a ${targetName[target]} PR or Branch`}
2525
onRequestClose={closeModal}
2626
>
2727
<PreviewPRForm onClose={closeModal} target={target} />

0 commit comments

Comments
 (0)