This workflow automatically adds a "Try it in Playground" button to your pull requests, enabling easy testing and feedback for WordPress plugins and themes.
Say you're developing a plugin called my-awesome-plugin and your source code lives in the repository root. Even though this workflow supports testing CI artifacts, for now assume your plugin doesn't have a build step.
To enable the "Try it in Playground" button, create a .github/workflows/pr-preview.yml file in your repository with the following content:
name: PR Preview
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs:
preview:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Post Playground Preview Button
uses: WordPress/action-wp-playground-pr-preview@v2
with:
# "append-to-description" – add the button to the PR description
# "comment" – create a new comment with the preview button
mode: "append-to-description"
# Use "." if plugin is in repository root
plugin-path: .
github-token: ${{ secrets.GITHUB_TOKEN }}Important:
WordPress/action-wp-playground-pr-preview@v2is a regular action. Always reference it inside a job step (underjobs.<job_id>.steps). GitHub only allowsjobs.<job_id>.usesfor reusable workflows that point to another workflow file such asowner/repo/.github/workflows/workflow.yml@ref.
See the usage example above. You may also want to inspect a live repository that uses this action: adamziel/preview-in-playground-button-plugin-example.
See the preview-in-playground-button-built-artifact-example section below for an example of how to test built artifacts in WordPress Playground.
name: PR Preview
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs:
preview:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Post Playground Preview Button
uses: WordPress/action-wp-playground-pr-preview@v2
with:
# Use "." if theme is in repository root
theme-path: .
github-token: ${{ secrets.GITHUB_TOKEN }}If your plugin lives in plugins/my-awesome-plugin/:
with:
plugin-path: plugins/my-awesome-pluginwith:
plugin-path: .
mode: commentFor advanced configurations, you can provide a custom blueprint:
name: PR Playground Preview
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs:
create-blueprint:
name: Create Blueprint
runs-on: ubuntu-latest
outputs:
blueprint: ${{ steps.blueprint.outputs.result }}
steps:
- name: Create Blueprint
id: blueprint
uses: actions/github-script@v7
with:
script: |
const blueprint = {
steps: [
{
step: "installPlugin",
pluginData: {
resource: "git:directory",
url: `https://github.com/${context.repo.owner}/${context.repo.repo}.git`,
ref: context.payload.pull_request.head.ref,
path: "/"
}
},
{
"step": "installPlugin",
"pluginData": {
"resource": "wordpress.org/plugins",
"slug": "woocommerce"
}
}
]
};
return JSON.stringify(blueprint);
result-encoding: string
playground-preview:
name: Post Playground Preview Button
needs: create-blueprint
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: WordPress/action-wp-playground-pr-preview@v2
with:
blueprint: ${{ needs.create-blueprint.outputs.blueprint }}
github-token: ${{ secrets.GITHUB_TOKEN }}Already hosting your blueprint JSON elsewhere? Provide a blueprint-url input pointing to that file:
with:
mode: append-to-description
blueprint-url: https://example.com/path/to/blueprint.jsonWhen blueprint-url is set, you can omit plugin-path, theme-path, and blueprint—the action links directly to the remote blueprint via ?blueprint-url=....
Customize the preview button appearance:
with:
plugin-path: .
description-template: |
### Test this PR in WordPress Playground
{{PLAYGROUND_BUTTON}}
**Branch:** {{PR_HEAD_REF}}
**Testing:** Plugin `{{PLUGIN_SLUG}}`Or customize comment format:
with:
mode: comment
comment-template: |
## Preview Changes in WordPress Playground
{{PLAYGROUND_BUTTON}}
### Testing Instructions
1. Click the button above to open Playground
2. Navigate to Plugins → Installed Plugins
3. Verify that `{{PLUGIN_SLUG}}` is active
4. Test the new functionality
**PR:** #{{PR_NUMBER}} - {{PR_TITLE}}- WordPress/blueprints: CI workflow, Sample PR
- adamziel/preview-in-playground-button-plugin-example: CI workflow, Sample PR
- adamziel/preview-in-playground-button-built-artifact-example: CI workflow, Sample PR
Optional How to publish the preview button.
Accepted values:
append-to-description(default) – Automatically updates the PR description with a managed block containing the preview button. The block is wrapped in HTML comment markers (<!-- wp-playground-preview:start -->and<!-- wp-playground-preview:end -->) so it can be updated on subsequent workflow runs.comment– Posts the preview button as a PR comment. Updates the same comment on subsequent runs rather than creating duplicates.
Default: append-to-description
Optional Base WordPress Playground host URL used to build the preview link.
The workflow appends blueprint parameters to this URL to create the final preview link.
Default: https://playground.wordpress.net
Optional Custom WordPress Blueprint as a JSON string.
When provided, this blueprint is used as-is and the plugin-path and theme-path inputs are ignored. If omitted, the workflow automatically generates a blueprint based on plugin-path or theme-path.
The blueprint must be a complete, ready-to-use JSON object (not a template). It will be URL-encoded and passed to Playground via the blueprint-url parameter.
Learn more about blueprints: https://wordpress.github.io/wordpress-playground/blueprints/
Example (custom blueprint with specific WordPress version):
with:
blueprint: |
{
"$schema": "https://playground.wordpress.net/blueprint-schema.json",
"preferredVersions": {
"php": "8.3",
"wp": "6.4"
},
"steps": [
{
"step": "installPlugin",
"pluginData": {
"resource": "git:directory",
"url": "https://github.com/owner/repo.git",
"ref": "feature-branch",
"path": "my-plugin"
},
"options": { "activate": true }
}
]
}Optional Installs and activates a plugin from a path inside the repository.
This is a shortcut for plugins that don't need any bundling and can be installed directly from the repository.
The path string should point to a directory containing a valid WordPress plugin with a main plugin file.
This option is ignored if the blueprint input is provided.
Example (plugin in repository root):
with:
plugin-path: .Example (plugin in subdirectory):
with:
plugin-path: plugins/my-awesome-pluginOptional Installs and activates a theme from a path inside the repository.
The path string should point to a directory containing a valid WordPress theme with a style.css file.
This option is ignored if the blueprint input is provided.
Example (theme in repository root):
with:
theme-path: .Example (theme in subdirectory):
with:
theme-path: themes/my-cool-themeExample (testing theme + plugin):
with:
plugin-path: plugins/my-plugin
theme-path: themes/my-themeOptional Custom markdown/HTML template for the content added to PR descriptions (only used in append-to-description mode).
The template supports variable interpolation using {{VARIABLE_NAME}} syntax (case-insensitive). The rendered content will be wrapped in HTML comment markers so it can be updated on subsequent runs.
Available template variables:
{{PLAYGROUND_BUTTON}}- Rendered preview button HTML (recommended to include){{PLAYGROUND_URL}}- Full URL to the Playground preview{{PLAYGROUND_BUTTON_IMAGE_URL}}- URL to the button image{{PLAYGROUND_BLUEPRINT_JSON}}- Complete blueprint JSON string{{PLAYGROUND_BLUEPRINT_DATA_URL}}- Data URL containing the blueprint{{PLAYGROUND_HOST}}- Playground host URL{{PR_NUMBER}}- Pull request number{{PR_TITLE}}- Pull request title{{PR_HEAD_REF}}- Source branch name{{PR_HEAD_SHA}}- Latest commit SHA{{PR_BASE_REF}}- Target branch name{{REPO_OWNER}}- Repository owner username/org{{REPO_NAME}}- Repository name{{REPO_FULL_NAME}}- Full repository name (owner/repo){{REPO_SLUG}}- Sanitized repository name{{PLUGIN_PATH}}- Plugin path (if provided){{PLUGIN_SLUG}}- Derived plugin slug{{THEME_PATH}}- Theme path (if provided){{THEME_SLUG}}- Derived theme slug
Default template:
{{PLAYGROUND_BUTTON}}
Example (custom template with additional context):
with:
description-template: |
### Test this PR in WordPress Playground
{{PLAYGROUND_BUTTON}}
**Branch:** {{PR_HEAD_REF}}
**Testing:** Plugin `{{PLUGIN_SLUG}}`Optional Custom markdown/HTML template for PR comments (only used in comment mode).
The template supports variable interpolation using {{VARIABLE_NAME}} syntax (case-insensitive). The rendered comment will include a hidden identifier marker so it can be updated on subsequent runs.
Available template variables: Same as description-template above.
Default template:
### WordPress Playground Preview
The changes in this pull request can previewed and tested using a WordPress Playground instance.
{{PLAYGROUND_BUTTON}}Example (custom comment with testing instructions):
with:
mode: comment
comment-template: |
## Preview Changes in WordPress Playground
{{PLAYGROUND_BUTTON}}
### Testing Instructions
1. Click the button above to open Playground
2. Navigate to Plugins → Installed Plugins
3. Verify that `{{PLUGIN_SLUG}}` is active
4. Test the new functionality
**PR:** #{{PR_NUMBER}} - {{PR_TITLE}}Optional Only applies to append-to-description mode.
Controls whether the preview button is automatically restored to the PR description if removed by the PR author.
When true (default):
- If PR author completely removes the button markers → workflow re-adds them on next run
- If PR author replaces button with custom placeholder → workflow respects it (does not update)
When false:
- If PR author completely removes the button markers → they stay removed
- If markers exist with custom placeholder → workflow respects it (does not update)
- If markers exist with the button → workflow updates the button normally
How PR authors can keep the button removed:
-
Replace with placeholder (always works):
<!-- wp-playground-preview:start --> <!-- Preview button hidden by PR author --> <!-- wp-playground-preview:end -->
-
Delete completely (only works when this is set to false): Delete the entire managed block including the markers
Example (respect when PR author removes button):
with:
mode: append-to-description
restore-button-if-removed: falseDefault: true
Optional GitHub token used to update PR descriptions and post/update comments.
If not provided, defaults to the calling workflow's GITHUB_TOKEN (recommended for most cases).
Required permissions:
pull-requests: write- To update PR descriptions and manage commentscontents: read- To access repository information
The default GITHUB_TOKEN automatically has these permissions in most workflows.
Only provide a custom token if you need to:
- Use a fine-grained personal access token with specific permissions
- Work around workflow restrictions in your repository
Example:
steps:
- uses: WordPress/action-wp-playground-pr-preview@v2
with:
plugin-path: .
github-token: ${{ secrets.CUSTOM_TOKEN }}preview-url: The full URL to the WordPress Playground preview.blueprint-json: The complete blueprint JSON string used for the preview.rendered-description: The rendered description content (when usingappend-to-descriptionmode).rendered-comment: The rendered comment content (when usingcommentmode).mode: The mode used for publishing the preview.comment-id: The ID of the created/updated comment (when usingcommentmode).
If your plugin or theme requires a build step, you can use the expose-artifact-on-public-url action to publish CI artifacts on a URL that WordPress Playground can fetch. Under the hood the action uploads ZIP files to one draft release (shared across all PRs) and keeps only the most recent artifacts you tell it to keep.
⚠️ Important Notice:
Before using the preview button with artifacts you must make the draft release public (publish it or flag it as a pre-release). Otherwise WordPress Playground cannot download the ZIP and the button fails.
Pull requests from forks run with the more restrictive pull_request security model: they cannot access repository secrets, cannot write to releases, and cannot update PR descriptions. The safest pattern is therefore to split the process into two workflows:
PR Playground Preview - Buildruns on everypull_requestwith the default read-only token. It builds your ZIP and uploads it as an artifact. Because forked PRs run this workflow in the base repository, the artifact always ends up in a trusted account even when the code came from a fork.PR Playground Preview - Publishis triggered viaworkflow_runonly after the build workflow succeeds. This job runs withcontents: writeandpull-requests: write, so it can expose the artifact on a release, generate a Playground blueprint, and update the PR description. It never checks out the untrusted code—it just manipulates artifacts produced by the build workflow.
This separation keeps secrets and write permissions away from untrusted code while still giving fork contributors the same Playground experience.
Create .github/workflows/pr-playground-preview-build.yml (or similar) with a minimal set of permissions. The example below builds a Gutenberg ZIP and names the artifact with both the PR number and the head SHA so the publish workflow can map the correct preview back to the PR.
name: PR Playground Preview - Build
# Use pull_request for untrusted code with read-only permissions
# No access to secrets, no write permissions
on:
pull_request:
types: [opened, synchronize, reopened, edited]
permissions:
contents: read
jobs:
build-plugin-zip:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Explicitly disable credential persistence for security
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: npm
- name: Build Gutenberg plugin zip
env:
NO_CHECKS: 1
run: npm run build:plugin-zip
- name: Upload Gutenberg plugin zip
uses: actions/upload-artifact@v4
with:
name: gutenberg-plugin-zip-pr${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}
path: gutenberg.zip
if-no-files-found: errorCreate a second workflow (for example .github/workflows/pr-playground-preview-publish.yml) that listens for the build workflow to finish. Because it runs in a separate, privileged workflow you can safely grant it contents: write and pull-requests: write. The script step at the beginning finds the artifact that belongs to the originating PR, and the remaining steps expose the ZIP, build a blueprint, and append the Playground button to the PR description.
name: PR Playground Preview - Publish
# Use workflow_run for privileged operations
# Runs with write permissions and access to secrets
# Operates on artifacts from the unprivileged build workflow
on:
workflow_run:
workflows: ["PR Playground Preview - Build"]
types:
- completed
permissions:
contents: write
pull-requests: write
jobs:
publish-preview:
runs-on: ubuntu-latest
# Only run if the build workflow succeeded and was triggered by a pull_request
if: >
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
outputs:
artifact-url: ${{ steps.expose.outputs.artifact-url }}
artifact-name: ${{ steps.expose.outputs.artifact-name }}
steps:
- name: Extract PR metadata from artifact name
id: pr-metadata
uses: actions/github-script@v7
with:
script: |
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: ${{ github.event.workflow_run.id }},
});
const artifact = artifacts.data.artifacts.find(a =>
a.name.startsWith("gutenberg-plugin-zip-pr")
);
if (!artifact) {
throw new Error('Could not find plugin artifact');
}
// Parse: gutenberg-plugin-zip-pr123-abc123def...
const match = artifact.name.match(/^gutenberg-plugin-zip-pr(\d+)-(.+)$/);
if (!match) {
throw new Error(`Could not parse artifact name: ${artifact.name}`);
}
const [, prNumber, commitSha] = match;
core.setOutput('pr-number', prNumber);
core.setOutput('commit-sha', commitSha);
core.setOutput('artifact-name', artifact.name);
- name: Expose built artifact
id: expose
uses: WordPress/action-wp-playground-pr-preview/.github/actions/expose-artifact-on-public-url@v2
with:
artifact-name: ${{ steps.pr-metadata.outputs.artifact-name }}
artifact-filename: gutenberg.zip
pr-number: ${{ steps.pr-metadata.outputs.pr-number }}
commit-sha: ${{ steps.pr-metadata.outputs.commit-sha }}
artifact-source-run-id: ${{ github.event.workflow_run.id }}
artifacts-to-keep: '2'
- name: Generate Playground blueprint JSON
id: blueprint
run: |
node - <<'NODE' >> "$GITHUB_OUTPUT"
const url = process.env.ARTIFACT_URL;
if (!url) {
throw new Error('ARTIFACT_URL is required');
}
const blueprint = {
steps: [
{
step: 'installPlugin',
pluginZipFile: {
resource: 'url',
url,
},
},
],
};
console.log(`blueprint=${JSON.stringify(blueprint)}`);
NODE
env:
ARTIFACT_URL: ${{ steps.expose.outputs.artifact-url }}
- name: Post Playground preview button
uses: WordPress/action-wp-playground-pr-preview@v2
with:
mode: append-to-description
blueprint: ${{ steps.blueprint.outputs.blueprint }}
pr-number: ${{ steps.pr-metadata.outputs.pr-number }}
github-token: ${{ secrets.GITHUB_TOKEN }}Key takeaways from this setup:
artifact-source-run-idtells the action to read artifacts created by the build workflow. You never need to redownload or re-upload ZIPs manually.- The naming convention
gutenberg-plugin-zip-pr${PR}-${SHA}makes it trivial to recover the PR number and commit from inside the publish workflow. artifacts-to-keepautomatically prunes old ZIPs for the same PR so your release draft does not grow without bounds.
You can adapt the same pattern for theme builds, different package managers, or multiple artifacts—just ensure the publish workflow can deterministically find the right artifact name for each PR.
Required Name of the GitHub Actions artifact to expose on a public URL
This should match the name used in actions/upload-artifact@v4 in the build job. The artifact should contain a single zip file.
Some artifacts have dynamic names, e.g. built-plugin-${{ github.event.pull_request.number }}-${{ github.sha }}.
You can use the same syntax to format the artifact-name for this job.
Example: 'built-plugin'
Optional Name of the zip file inside the downloaded artifact bundle.
Default: plugin.zip
Set this if your artifact uploads a differently named ZIP (for example theme.zip).
Optional ID of the workflow run that originally uploaded the artifact.
Default: Uses the current workflow run.
Set this input when you're running the action in a workflow_run (or any other) workflow that needs to pull artifacts from a different run. Example: ${{ github.event.workflow_run.id }}.
Optional Repository (owner/name) that owns the workflow run referenced by artifact-source-run-id.
Default: Uses the repository that invokes the action.
Only override this when your build workflow runs in another repository.
Required The current pull request number.
Example: ${{ github.event.pull_request.number }}
Required The current commit SHA.
Example: ${{ github.sha }}
Optional Number of most recent artifacts to keep for this PR (default: 2)
After exposing a new artifact, this workflow automatically deletes older artifacts for the same PR, keeping only the N most recent.
Optional GitHub release tag to use for exposing artifacts.
Default: ci-artifacts
Optional Target repository in owner/name form when you want to store artifacts somewhere other than the current repository.
Default: Uses the repository that runs the workflow.
Optional Automatically creates the release-tag if it does not already exist.
Default: true
Optional Set to false to skip deleting older artifacts for the same PR.
Default: true
Optional Token with contents: write access to the release repository.
If omitted, the action falls back to the workflow's ${{ secrets.GITHUB_TOKEN }}.
Public download URL for the exposed artifact.
Format: https://github.com/OWNER/REPO/releases/download/TAG/pr-NUMBER-SHA.zip
Filename of the exposed artifact.
Format: pr-NUMBER-SHA.zip
If you see reusable workflow call ... is not following the format "owner/repo/path/to/workflow.yml@ref", it means you tried to run this action as a reusable workflow. WordPress/action-wp-playground-pr-preview@v2 is a regular action, so keep it under jobs.<job_id>.steps:
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: WordPress/action-wp-playground-pr-preview@v2
with:
plugin-path: .
github-token: ${{ secrets.GITHUB_TOKEN }}As mentioned in Advanced: Testing Built CI Artifacts, the artifact helper stores files on a single draft release. Draft releases remain private until they are published. Publish or mark that release as a pre-release so the download URL becomes public; otherwise WordPress Playground cannot fetch the zip and the preview button fails.
Updating PR descriptions or comments requires the workflow (or custom token) to have pull-requests: write plus contents: read. Add the permissions block from the basic example or provide a PAT with the same scopes. Without those permissions GitHub blocks the API call and you will see this error in the Post Playground Preview Button step.
The action needs either plugin-path, theme-path, blueprint, or blueprint-url. Forgetting to set any of them causes an early failure. Point plugin-path or theme-path to the folder that contains my-plugin.php or style.css, or pass a custom blueprint if you have more complex needs.
When the plugin lives in a subdirectory (for example, plugins/my-awesome-plugin), you must point plugin-path at that subfolder. Otherwise the action zips the repository root and Playground never loads your updated code. The same applies to built artifacts—ensure the uploaded ZIP contains the build you expect.
Custom blueprints are JSON strings; a missing comma or dangling comment will break the preview. Validate the blueprint locally (e.g., node -e 'JSON.parse(fs.readFileSync("blueprint.json"))') before passing it through the workflow, or store it in a separate .json file and feed it via blueprint-url.
This project is licensed under the GPL-2.0-or-later License - see the LICENSE file for details.