From bbeed37e66837755a83868500cc6c7300930d501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 5 Nov 2025 15:02:41 +0100 Subject: [PATCH 1/2] Support previewing WordPress and Gutenberg branches, not just PRs --- .../website/public/plugin-proxy.php | 43 ++++++-- .../website/src/github/preview-pr/form.tsx | 100 ++++++++++-------- .../website/src/github/preview-pr/modal.tsx | 2 +- .../state/url/resolve-blueprint-from-url.ts | 38 ++++--- 4 files changed, 118 insertions(+), 65 deletions(-) diff --git a/packages/playground/website/public/plugin-proxy.php b/packages/playground/website/public/plugin-proxy.php index 4903ac6390..47acb283da 100644 --- a/packages/playground/website/public/plugin-proxy.php +++ b/packages/playground/website/public/plugin-proxy.php @@ -45,13 +45,9 @@ public function streamFromDirectory($name, $directory) } } - public function streamFromGithubPR($organization, $repo, $pr, $workflow_name, $artifact_name) + private function streamArtifactFromBranch($organization, $repo, $branchName, $workflow_name, $artifact_name) { - $prDetails = $this->gitHubRequest("https://api.github.com/repos/$organization/$repo/pulls/$pr")['body']; - if (!$prDetails) { - throw new ApiException('invalid_pr_number'); - } - $branchName = urlencode($prDetails->head->ref); + $branchName = urlencode($branchName); $ciRuns = $this->gitHubRequest("https://api.github.com/repos/$organization/$repo/actions/runs?branch=$branchName")['body']; if (!$ciRuns) { throw new ApiException('no_ci_runs'); @@ -76,7 +72,13 @@ public function streamFromGithubPR($organization, $repo, $pr, $workflow_name, $a } foreach ($artifacts->artifacts as $artifact) { - if ($artifact_name === $artifact->name) { + // Support prefix matching if artifact name ends with '-' + // This is used for branches where artifact names include commit hashes + $is_match = (substr($artifact_name, -1) === '-') + ? (strpos($artifact->name, $artifact_name) === 0) + : ($artifact_name === $artifact->name); + + if ($is_match) { if ($artifact->size_in_bytes < 3000) { throw new ApiException('artifact_invalid'); } @@ -141,6 +143,20 @@ public function streamFromGithubPR($organization, $repo, $pr, $workflow_name, $a } } + public function streamFromGithubBranch($organization, $repo, $branch, $workflow_name, $artifact_name) + { + $this->streamArtifactFromBranch($organization, $repo, $branch, $workflow_name, $artifact_name); + } + + public function streamFromGithubPR($organization, $repo, $pr, $workflow_name, $artifact_name) + { + $prDetails = $this->gitHubRequest("https://api.github.com/repos/$organization/$repo/pulls/$pr")['body']; + if (!$prDetails) { + throw new ApiException('invalid_pr_number'); + } + $this->streamArtifactFromBranch($organization, $repo, $prDetails->head->ref, $workflow_name, $artifact_name); + } + public function streamFromGithubReleases($repo, $name) { $zipUrl = "https://github.com/$repo/releases/latest/download/$name"; @@ -293,6 +309,19 @@ function ($curl, $body) use (&$extra_headers_sent, $default_response_headers) { $_GET['workflow'], $_GET['artifact'] ); + } else if (isset($_GET['org']) && isset($_GET['repo']) && isset($_GET['workflow']) && isset($_GET['branch']) && isset($_GET['artifact'])) { + // Don't reveal the allowed orgs to the client, just give an error. + // Lowercase the org name to make the check case-insensitive. + if (! in_array(strtolower($_GET['org']), PluginDownloader::ALLOWED_ORGS, true)) { + throw new ApiException('Invalid org. This organization is not allowed.'); + } + $downloader->streamFromGithubBranch( + $_GET['org'], + $_GET['repo'], + $_GET['branch'], + $_GET['workflow'], + $_GET['artifact'] + ); } else if (isset($_GET['repo']) && isset($_GET['name'])) { // Verify repo string contains org/repo format $parts = explode('/', $_GET['repo']); diff --git a/packages/playground/website/src/github/preview-pr/form.tsx b/packages/playground/website/src/github/preview-pr/form.tsx index 7faa6cfc7e..ba67522a9e 100644 --- a/packages/playground/website/src/github/preview-pr/form.tsx +++ b/packages/playground/website/src/github/preview-pr/form.tsx @@ -56,14 +56,29 @@ export default function PreviewPRForm({ await previewPr(value); } - function renderRetryIn(retryIn: number) { + function renderRetryIn(retryIn: number, isBranch: boolean) { setError( - `Waiting for GitHub to finish building PR ${value}. This might take 15 minutes or more! Retrying in ${ + `Waiting for GitHub to finish building ${ + isBranch ? 'branch' : 'PR' + } ${value}. This might take 15 minutes or more! Retrying in ${ retryIn / 1000 }...` ); } + function buildArtifactUrl(ref: string, isBranch: boolean): string { + const refType = isBranch ? 'branch' : 'pr'; + // For WordPress PRs: artifact name is wordpress-build-{PR_NUMBER} + // For WordPress branches: artifact name is wordpress-build-{COMMIT_HASH} + // We use wordpress-build- (with trailing dash) to trigger prefix matching + // For Gutenberg: artifact name is always gutenberg-plugin + let artifactSuffix = ''; + if (target === 'wordpress') { + artifactSuffix = isBranch ? '-' : ref; + } + 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}`; + } + async function previewPr(prValue: string) { let cleanupRetry = () => {}; if (cleanupRetry) { @@ -71,20 +86,23 @@ export default function PreviewPRForm({ } let prNumber: string = prValue; + let branchName: string | null = null; setSubmitting(true); // Extract number from a GitHub URL if (prNumber.toLowerCase().includes(targetParams[target].pull)) { prNumber = prNumber.match(/\/pull\/(\d+)/)![1]; + } else if (!/^\d+$/.test(prNumber)) { + // If it's not a number and not a PR URL, treat it as a branch name + branchName = prNumber; } - // Verify that the PR exists and that GitHub CI finished building it - const zipArtifactUrl = `https://playground.wordpress.net/plugin-proxy.php?org=WordPress&repo=${ - targetParams[target].repo - }&workflow=${targetParams[target].workflow}&artifact=${ - targetParams[target].artifact - }${target === 'wordpress' ? prNumber : ''}&pr=${prNumber}`; - // Send the HEAD request to zipArtifactUrl to confirm the PR and the artifact both exist + const ref = branchName || prNumber; + const isBranch = !!branchName; + + // Verify that the PR/branch exists and that GitHub CI finished building it + const zipArtifactUrl = buildArtifactUrl(ref, isBranch); + // Send the HEAD request to zipArtifactUrl to confirm the PR/branch and the artifact both exist const response = await fetch(zipArtifactUrl + '&verify_only=true'); if (response.status !== 200) { let error = 'invalid_pr_number'; @@ -99,28 +117,30 @@ export default function PreviewPRForm({ return; } + const refType = isBranch ? 'branch' : 'PR'; + if (error === 'invalid_pr_number') { - setError(`The PR ${prNumber} does not exist.`); + setError(`The ${refType} ${ref} does not exist.`); } else if ( error === 'artifact_not_found' || error === 'artifact_not_available' ) { - if (parseInt(prNumber) < 5749) { + if (!isBranch && parseInt(ref) < 5749) { setError( - `The PR ${prNumber} predates the Pull Request previewer and requires a rebase before it can be previewed.` + `The PR ${ref} predates the Pull Request previewer and requires a rebase before it can be previewed.` ); } else { let retryIn = 30000; - renderRetryIn(retryIn); + renderRetryIn(retryIn, isBranch); const timerInterval = setInterval(() => { retryIn -= 1000; if (retryIn <= 0) { retryIn = 0; } - renderRetryIn(retryIn); + renderRetryIn(retryIn, isBranch); }, 1000); const scheduledRetry = setTimeout(() => { - previewPr(prNumber); + previewPr(ref); }, retryIn); cleanupRetry = () => { clearInterval(timerInterval); @@ -130,11 +150,11 @@ export default function PreviewPRForm({ } } else if (error === 'artifact_invalid') { setError( - `The PR ${prNumber} requires a rebase before it can be previewed.` + `The ${refType} ${ref} requires a rebase before it can be previewed.` ); } else { setError( - `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.` + `The ${refType} ${ref} couldn't be previewed due to an unexpected error. Please try again later or fill an issue in the WordPress Playground repository.` ); // https://github.com/WordPress/wordpress-playground/issues/new } @@ -144,7 +164,7 @@ export default function PreviewPRForm({ return; } - // Redirect to the Playground site with the Blueprint to download and apply the PR + // Redirect to the Playground site with the Blueprint to download and apply the PR/branch const blueprint: BlueprintV1Declaration = { landingPage: urlParams.get('url') || '/wp-admin', login: true, @@ -154,26 +174,25 @@ export default function PreviewPRForm({ steps: [], }; + const refParam = isBranch + ? `${target === 'wordpress' ? 'core' : 'gutenberg'}-branch` + : `${target === 'wordpress' ? 'core' : 'gutenberg'}-pr`; + const urlWithPreview = new URL( + window.location.pathname, + window.location.href + ); + if (target === 'wordpress') { // [wordpress] Passthrough the mode query parameter if it exists - const targetParams = new URLSearchParams(); if (urlParams.has('mode')) { - targetParams.set('mode', urlParams.get('mode') as string); + urlWithPreview.searchParams.set( + 'mode', + urlParams.get('mode') as string + ); } - targetParams.set('core-pr', prNumber); - - const blueprintJson = JSON.stringify(blueprint); - const urlWithPreview = new URL( - window.location.pathname, - window.location.href - ); - urlWithPreview.search = targetParams.toString(); - urlWithPreview.hash = encodeURI(blueprintJson); - - window.location.href = urlWithPreview.toString(); + urlWithPreview.searchParams.set(refParam, ref); } else if (target === 'gutenberg') { // [gutenberg] If there's a import-site query parameter, pass that to the blueprint - const urlParams = new URLSearchParams(window.location.search); try { const importSite = new URL( urlParams.get('import-site') as string @@ -191,18 +210,11 @@ export default function PreviewPRForm({ } catch { logger.error('Invalid import-site URL'); } - - const blueprintJson = JSON.stringify(blueprint); - - const urlWithPreview = new URL( - window.location.pathname, - window.location.href - ); - urlWithPreview.searchParams.set('gutenberg-pr', prNumber); - urlWithPreview.hash = encodeURI(blueprintJson); - - window.location.href = urlWithPreview.toString(); + urlWithPreview.searchParams.set(refParam, ref); } + + urlWithPreview.hash = encodeURI(JSON.stringify(blueprint)); + window.location.href = urlWithPreview.toString(); } return ( @@ -215,7 +227,7 @@ export default function PreviewPRForm({ )} { diff --git a/packages/playground/website/src/github/preview-pr/modal.tsx b/packages/playground/website/src/github/preview-pr/modal.tsx index e5321e4e44..bd6173b873 100644 --- a/packages/playground/website/src/github/preview-pr/modal.tsx +++ b/packages/playground/website/src/github/preview-pr/modal.tsx @@ -21,7 +21,7 @@ export function PreviewPRModal({ target }: PreviewPRModalProps) { return ( diff --git a/packages/playground/website/src/lib/state/url/resolve-blueprint-from-url.ts b/packages/playground/website/src/lib/state/url/resolve-blueprint-from-url.ts index 71101455cd..dc23b5259b 100644 --- a/packages/playground/website/src/lib/state/url/resolve-blueprint-from-url.ts +++ b/packages/playground/website/src/lib/state/url/resolve-blueprint-from-url.ts @@ -244,32 +244,44 @@ function applyQueryOverridesToDeclaration( }); } - if (query.has('core-pr')) { - const prNumber = query.get('core-pr'); - blueprint.preferredVersions!.wp = `https://playground.wordpress.net/plugin-proxy.php?org=WordPress&repo=wordpress-develop&workflow=Test%20Build%20Processes&artifact=wordpress-build-${prNumber}&pr=${prNumber}`; + // Handle WordPress core PR or branch preview + const coreRef = query.get('core-pr') || query.get('core-branch'); + if (coreRef) { + const refType = query.has('core-pr') ? 'pr' : 'branch'; + // For WordPress PRs: artifact name is wordpress-build-{PR_NUMBER} + // For WordPress branches: artifact name is wordpress-build-{COMMIT_HASH} + // We use wordpress-build- (with trailing dash) to trigger prefix matching in plugin-proxy.php + const artifactName = query.has('core-pr') + ? `wordpress-build-${coreRef}` + : 'wordpress-build-'; + blueprint.preferredVersions!.wp = `https://playground.wordpress.net/plugin-proxy.php?org=WordPress&repo=wordpress-develop&workflow=Test%20Build%20Processes&artifact=${artifactName}&${refType}=${coreRef}`; } - if (query.has('gutenberg-pr')) { - const prNumber = query.get('gutenberg-pr'); + // Handle Gutenberg PR or branch preview + const gutenbergRef = + query.get('gutenberg-pr') || query.get('gutenberg-branch'); + if (gutenbergRef) { + const refType = query.has('gutenberg-pr') ? 'pr' : 'branch'; + const refLabel = query.has('gutenberg-pr') ? 'PR' : 'branch'; blueprint.steps = blueprint.steps || []; blueprint.steps.unshift( { step: 'mkdir', - path: '/tmp/pr', + path: '/tmp/gutenberg', }, { step: 'writeFile', - path: '/tmp/pr/pr.zip', + path: '/tmp/gutenberg/artifact.zip', data: { resource: 'url', - url: `/plugin-proxy.php?org=WordPress&repo=gutenberg&workflow=Build%20Gutenberg%20Plugin%20Zip&artifact=gutenberg-plugin&pr=${prNumber}`, - caption: `Downloading Gutenberg PR ${prNumber}`, + url: `/plugin-proxy.php?org=WordPress&repo=gutenberg&workflow=Build%20Gutenberg%20Plugin%20Zip&artifact=gutenberg-plugin&${refType}=${gutenbergRef}`, + caption: `Downloading Gutenberg ${refLabel} ${gutenbergRef}`, }, }, /** * GitHub CI artifacts are doubly zipped: * - * pr.zip + * artifact.zip * gutenberg.zip * gutenberg.php * ... other files ... @@ -280,14 +292,14 @@ function applyQueryOverridesToDeclaration( */ { step: 'unzip', - zipPath: '/tmp/pr/pr.zip', - extractToPath: '/tmp/pr', + zipPath: '/tmp/gutenberg/artifact.zip', + extractToPath: '/tmp/gutenberg', }, { step: 'installPlugin', pluginData: { resource: 'vfs', - path: '/tmp/pr/gutenberg.zip', + path: '/tmp/gutenberg/gutenberg.zip', }, } ); From 5459f9e5f142bbebe82d191790d47624e110005a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 5 Nov 2025 15:08:19 +0100 Subject: [PATCH 2/2] =?UTF-8?q?Skip=20artifact=20validation=20when=20previ?= =?UTF-8?q?ewing=20a=20branch=20=E2=80=93=20this=20will=20grab=20the=20pre?= =?UTF-8?q?vious=20artifact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../website/src/github/preview-pr/form.tsx | 110 +++++++++--------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/packages/playground/website/src/github/preview-pr/form.tsx b/packages/playground/website/src/github/preview-pr/form.tsx index ba67522a9e..c0b12fb7f2 100644 --- a/packages/playground/website/src/github/preview-pr/form.tsx +++ b/packages/playground/website/src/github/preview-pr/form.tsx @@ -100,68 +100,68 @@ export default function PreviewPRForm({ const ref = branchName || prNumber; const isBranch = !!branchName; - // Verify that the PR/branch exists and that GitHub CI finished building it - const zipArtifactUrl = buildArtifactUrl(ref, isBranch); - // Send the HEAD request to zipArtifactUrl to confirm the PR/branch and the artifact both exist - const response = await fetch(zipArtifactUrl + '&verify_only=true'); - if (response.status !== 200) { - let error = 'invalid_pr_number'; - try { - const json = await response.json(); - if (json.error) { - error = json.error; + // For branches, skip verification since we'll use the most recent artifact with prefix matching + // For PRs, verify that the specific PR build exists + if (!isBranch) { + const zipArtifactUrl = buildArtifactUrl(ref, isBranch); + const response = await fetch(zipArtifactUrl + '&verify_only=true'); + if (response.status !== 200) { + let error = 'invalid_pr_number'; + try { + const json = await response.json(); + if (json.error) { + error = json.error; + } + } catch (e) { + logger.error(e); + setError('An unexpected error occurred. Please try again.'); + return; } - } catch (e) { - logger.error(e); - setError('An unexpected error occurred. Please try again.'); - return; - } - - const refType = isBranch ? 'branch' : 'PR'; - if (error === 'invalid_pr_number') { - setError(`The ${refType} ${ref} does not exist.`); - } else if ( - error === 'artifact_not_found' || - error === 'artifact_not_available' - ) { - if (!isBranch && parseInt(ref) < 5749) { + if (error === 'invalid_pr_number' || error === 'no_ci_runs') { + setError(`The PR ${ref} does not exist.`); + } else if ( + error === 'artifact_not_found' || + error === 'artifact_not_available' + ) { + if (parseInt(ref) < 5749) { + setError( + `The PR ${ref} predates the Pull Request previewer and requires a rebase before it can be previewed.` + ); + } else { + // For PRs, retry since we expect a specific build to complete + let retryIn = 30000; + renderRetryIn(retryIn, false); + const timerInterval = setInterval(() => { + retryIn -= 1000; + if (retryIn <= 0) { + retryIn = 0; + } + renderRetryIn(retryIn, false); + }, 1000); + const scheduledRetry = setTimeout(() => { + previewPr(ref); + }, retryIn); + cleanupRetry = () => { + clearInterval(timerInterval); + clearTimeout(scheduledRetry); + cleanupRetry = () => {}; + }; + } + } else if (error === 'artifact_invalid') { setError( - `The PR ${ref} predates the Pull Request previewer and requires a rebase before it can be previewed.` + `The PR ${ref} requires a rebase before it can be previewed.` ); } else { - let retryIn = 30000; - renderRetryIn(retryIn, isBranch); - const timerInterval = setInterval(() => { - retryIn -= 1000; - if (retryIn <= 0) { - retryIn = 0; - } - renderRetryIn(retryIn, isBranch); - }, 1000); - const scheduledRetry = setTimeout(() => { - previewPr(ref); - }, retryIn); - cleanupRetry = () => { - clearInterval(timerInterval); - clearTimeout(scheduledRetry); - cleanupRetry = () => {}; - }; + setError( + `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.` + ); + // https://github.com/WordPress/wordpress-playground/issues/new } - } else if (error === 'artifact_invalid') { - setError( - `The ${refType} ${ref} requires a rebase before it can be previewed.` - ); - } else { - setError( - `The ${refType} ${ref} couldn't be previewed due to an unexpected error. Please try again later or fill an issue in the WordPress Playground repository.` - ); - // https://github.com/WordPress/wordpress-playground/issues/new - } - - setSubmitting(false); - return; + setSubmitting(false); + return; + } } // Redirect to the Playground site with the Blueprint to download and apply the PR/branch