diff --git a/.github/actions/playground-preview/README.md b/.github/actions/playground-preview/README.md
new file mode 100644
index 0000000..093df06
--- /dev/null
+++ b/.github/actions/playground-preview/README.md
@@ -0,0 +1,210 @@
+# playground-preview
+
+Writes one comment on a pull request with links that open the pull request's build in
+[WordPress Playground](https://playground.wordpress.net/), a throwaway WordPress in the browser,
+and a link to download the zip. While the zip is built the comment says "building". If the build
+fails it says "failed", with a link to the run.
+
+Most repositories do not use this action directly. They call the reusable workflow
+[`.github/workflows/playground-preview.yml`](../../workflows/playground-preview.yml), which builds
+the zip, runs this action and keeps the comment up to date. A caller to copy is in
+[`templates/workflows/playground-preview.yml`](../../../templates/workflows/playground-preview.yml):
+
+```yaml
+on:
+ pull_request:
+ types: [opened, reopened, synchronize]
+
+concurrency:
+ group: playground-preview-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ playground-preview:
+ permissions:
+ contents: read
+ pull-requests: write
+ uses: stellarwp/plugin-toolbox/.github/workflows/playground-preview.yml@main
+ secrets:
+ s3-bucket: ${{ secrets.ZIP_S3_BUCKET }}
+ s3-access-key-id: ${{ secrets.ZIP_S3_ACCESS_KEY_ID }}
+ s3-secret-access-key: ${{ secrets.ZIP_S3_SECRET_ACCESS_KEY }}
+ s3-region: ${{ secrets.ZIP_S3_REGION }}
+ s3-endpoint: ${{ secrets.ZIP_S3_ENDPOINT }}
+```
+
+## What the repository needs
+
+- A `.puprc` that builds the plugin, as for the release zip. The zip is built by
+ [stellarwp/github-actions' `zip.yml`](https://github.com/stellarwp/github-actions/blob/main/.github/workflows/zip.yml)
+ with pup.
+- A `pup` Composer script that puts `pup.phar` in `bin/` and runs it:
+
+ ```json
+ "pup": [
+ "test -f ./bin/pup.phar || curl -o bin/pup.phar -L -C - https://github.com/stellarwp/pup/releases/download/2.0.0/pup.phar",
+ "@php bin/pup.phar"
+ ]
+ ```
+
+- The bucket secrets, passed one by one. `secrets: inherit` only works when the caller and this
+ repository are in the same organization.
+- Nothing else for the default preview. Blueprint files only if you want more than the default.
+
+## The comment
+
+```
+🎉 Playground preview ready for commit 15e470c
+
+[Built 01/15 05:43 PM EST]
+
+- 🛝 Try in WordPress Playground
+- 🛝 Try in WordPress Playground (With sample data)
+- 💾 Download zip
+
+Debug output is on.
+With sample data: 100 sample orders in test mode. Opens a few minutes slower.
+```
+
+One comment per pull request, replaced on every push. It is found by a hidden first line,
+``. Pull requests from forks get no preview: their runs
+cannot see the bucket secrets, and their token cannot write a comment.
+
+## Blueprints
+
+A [blueprint](https://wordpress.github.io/wordpress-playground/blueprints/) is a JSON file that
+tells Playground what to set up. Each blueprint file is one link in the comment.
+
+With no `blueprints` input, the default blueprint next to this action is used. It works for any
+plugin: it installs the zip, activates it, turns on `WP_DEBUG` and `WP_DEBUG_DISPLAY`, logs in and
+opens the plugins screen.
+
+Anything specific to a plugin goes in the plugin's own blueprint file, not in a workflow input: the
+landing page, the theme, extra plugins, WP-CLI steps, PHP and WordPress versions. Put the files in
+the repository, for example in `.github/playground/`, and list them:
+
+```yaml
+ with:
+ blueprints: |
+ .github/playground/blueprint.json
+ .github/playground/blueprint-sample-data.json
+```
+
+Two examples to copy are in [`templates/playground/`](../../../templates/playground/).
+
+The rules:
+
+- `{{ZIP_URL}}` is replaced with the link to the pull request's zip. Every blueprint has to use it,
+ usually in an `installPlugin` step. A file without it fails the run, because its link would not
+ show the pull request at all.
+- Any other `{{NAME}}` in capitals fails the run, so a typo does not reach Playground. Other text,
+ such as PHP in a `wp eval` command, is left alone.
+- `meta.title` is the link's label: `Try in WordPress Playground (
)`. A file without one gets
+ `Try in WordPress Playground`. With several files, at most one may have no title, and no two may
+ share one.
+- `meta.description` is a short note under the links.
+- `meta` and `$schema` are removed before the blueprint goes into the link. Playground does not use
+ them, and Playground's schema would also ask for `meta.author`. An editor that reads `$schema`
+ may warn about the missing `author` and `title`; the warning does not matter here.
+
+## The link
+
+The blueprint goes into the link itself, as base64 JSON in the URL hash:
+`https://playground.wordpress.net/#eyJzdGVwcyI6...`. Nothing is hosted anywhere, and private
+repositories work the same as public ones.
+
+- The hash never leaves the browser, so no server limit on the length of a request applies.
+- base64 is plain ASCII, so a Markdown link keeps it as it is, and it is shorter than
+ percent-encoded JSON. Playground reads the bytes as UTF-8, so text like `Café` survives.
+- A blueprint with sample data and an extra plugin makes a link of about 1,300 characters.
+
+The limit that applies first is GitHub's: a comment can hold at most 65,536 characters. When the
+links would make the comment longer, the run fails and names each link's length. Make the largest
+blueprint smaller, for example by moving setup into a plugin or a WP-CLI command.
+
+## The zip link
+
+pup names the zip after the version, the commit time and the commit hash, for example
+`my-plugin.1.2.0-dev-1793396265-15e470cb.zip`. The shared zip workflow has no outputs, so the
+reusable workflow asks pup for the same name again after the build, and adds it to `zip-link-base`
+(`https://evnt.is/test-zip?file=` by default, the public link to the packaging bucket).
+
+To skip the build and use a zip from elsewhere, pass `zip-url`. No secret is needed then. This is
+how a repository with its own zip workflow uses this, and how to test it without bucket secrets.
+
+## The check names
+
+A job in a called workflow reports as ` / `. With the template's job id,
+the checks are `playground-preview / Playground preview start`, `playground-preview / Zip / ...`,
+`playground-preview / Playground preview` and, after a failure, `playground-preview / Playground
+preview failed`. Update branch protection if it required the old names.
+
+## Inputs of the workflow
+
+| Input | Default | What it does |
+|---|---|---|
+| `blueprints` | empty: the default blueprint | Blueprint files, one per line. Each is one link |
+| `php-version` | `7.4` | PHP that builds `vendor/`. Use the oldest the plugin supports |
+| `i18n` | `no` | `yes` runs `pup i18n` in the build |
+| `additional-commands` | empty | Run after `pup build`, before `pup package`. Prefer `.puprc` |
+| `zip-url` | empty: build the zip | Use this zip instead of building one |
+| `zip-link-base` | `https://evnt.is/test-zip?file=` | Public link to the bucket. The zip name is added to the end |
+| `runs-on` | `ubuntu-latest` | Runner of the job that posts the links |
+| `time-zone` | `America/New_York` | Time zone of the badge |
+
+| Secret | Needed | What it does |
+|---|---|---|
+| `s3-bucket`, `s3-access-key-id`, `s3-secret-access-key`, `s3-region`, `s3-endpoint` | unless `zip-url` is set | Upload the zip. A missing one fails the first job with its name |
+| `bot-token` | no | Checks out the repository for the build. Defaults to the workflow's token |
+| `jenkins-secret` | no | The shared zip workflow requires it, for Slack messages this workflow never sends |
+| `composer-token` | no | Private Composer packages |
+| `package-token` | no | Private npm or bun packages |
+
+### Permissions the caller grants
+
+```yaml
+permissions:
+ contents: read # check out the repository
+ pull-requests: write # the sticky comment
+```
+
+A called workflow can only lower these. If the caller grants less than a job asks for, GitHub does
+not start the run.
+
+## Inputs of the action
+
+| Input | Required | Default | What it does |
+|---|---|---|---|
+| `status` | yes | | `building`, `ready` or `failed` |
+| `zip-url` | for `ready` | | The zip. Must be https |
+| `blueprints` | no | the default blueprint | Blueprint files, read from the checkout |
+| `comment-marker` | no | `` | First line of the comment |
+| `time-zone` | no | `America/New_York` | Time zone of the badge |
+| `sha` | no | the pull request's head commit | The commit the comment names |
+| `pull-request` | no | the pull request of the event | Where to comment |
+| `token` | no | `github.token` | Needs `pull-requests: write` |
+
+## Running it locally
+
+You need Node 20 or newer. From a checkout of this repository, print the comment for any zip and
+your blueprint files:
+
+```bash
+node .github/actions/playground-preview/playground-preview.js \
+ --zip-url 'https://evnt.is/test-zip?file=my-plugin.1.2.0-dev-1793396265-15e470cb.zip' \
+ ../my-plugin/.github/playground/blueprint.json \
+ ../my-plugin/.github/playground/blueprint-sample-data.json
+```
+
+With no file, the default blueprint is used. Nothing is posted. Open a link from the output to try
+the blueprint. A file that breaks a rule above exits 1 with the reason.
+
+## How it is tested
+
+`tests/actions/playground-preview/` loads the script in `node --test`, with the real
+`@actions/core` and a real Octokit whose network is replaced, so the tests assert the API requests
+the action would send. It also runs the script from the command line. Run `npm install` once, then
+`node --test`.
diff --git a/.github/actions/playground-preview/action.yml b/.github/actions/playground-preview/action.yml
new file mode 100644
index 0000000..396abe2
--- /dev/null
+++ b/.github/actions/playground-preview/action.yml
@@ -0,0 +1,78 @@
+# Writes the "Try in WordPress Playground" comment on a pull request: "building", the links when the
+# zip is ready, or "failed". One comment per pull request, found by a hidden first line and replaced
+# each time.
+#
+# It does not build the zip. The reusable workflow .github/workflows/playground-preview.yml does,
+# and calls this action from its three comment jobs, so the action also works with a zip from
+# anywhere.
+#
+# The work is in playground-preview.js, run by actions/github-script, so there is nothing to install
+# or build: the file in the repository is the file that runs.
+name: Playground preview
+description: Posts the pull request comment with WordPress Playground links for a plugin zip
+
+inputs:
+ status:
+ description: '`building`, `ready` or `failed`. Picks the comment to write.'
+ required: true
+ zip-url:
+ description: >-
+ Public https link to the plugin zip. Required for `ready`: it replaces {{ZIP_URL}} in the
+ blueprints and is the "Download zip" link.
+ required: false
+ default: ''
+ blueprints:
+ description: >-
+ Blueprint files, one path per line, relative to the repository root. Each file becomes one
+ link, in this order. Empty uses the default blueprint next to this action: install the zip,
+ activate it, turn on WP_DEBUG and WP_DEBUG_DISPLAY. Read only for `ready`, from the checkout,
+ so the job has to check out the repository first.
+ required: false
+ default: ''
+ comment-marker:
+ description: >-
+ Hidden first line of the comment, used to find it again. Empty uses
+ ``.
+ required: false
+ default: ''
+ time-zone:
+ description: 'IANA time zone of the time on the badge, e.g. America/New_York.'
+ required: false
+ default: 'America/New_York'
+ sha:
+ description: 'The commit the comment names. Only the first 7 characters are shown.'
+ required: false
+ default: ${{ github.event.pull_request.head.sha }}
+ pull-request:
+ description: 'Number of the pull request to comment on.'
+ required: false
+ default: ${{ github.event.pull_request.number }}
+ token:
+ description: 'Token that reads and writes the comment. It needs `pull-requests: write`.'
+ required: false
+ default: ${{ github.token }}
+
+runs:
+ using: 'composite'
+ steps:
+ - name: Write the Playground preview comment
+ uses: actions/github-script@v9
+ env:
+ # github-script's require() resolves against the workspace of the repository being built,
+ # not this action's directory, so the path has to be passed in.
+ ACTION_PATH: ${{ github.action_path }}
+ # Read from process.env rather than written into the script, so an input is data and never
+ # part of the program.
+ INPUT_STATUS: ${{ inputs.status }}
+ INPUT_ZIP_URL: ${{ inputs.zip-url }}
+ INPUT_BLUEPRINTS: ${{ inputs.blueprints }}
+ INPUT_COMMENT_MARKER: ${{ inputs.comment-marker }}
+ INPUT_TIME_ZONE: ${{ inputs.time-zone }}
+ INPUT_SHA: ${{ inputs.sha }}
+ INPUT_PULL_REQUEST: ${{ inputs.pull-request }}
+ with:
+ # github-script builds its `github` client from this input, not from the env.
+ github-token: ${{ inputs.token }}
+ script: |
+ const { run } = require(`${process.env.ACTION_PATH}/playground-preview.js`)
+ await run({ github, core, env: process.env })
diff --git a/.github/actions/playground-preview/blueprints/default.json b/.github/actions/playground-preview/blueprints/default.json
new file mode 100644
index 0000000..17bec02
--- /dev/null
+++ b/.github/actions/playground-preview/blueprints/default.json
@@ -0,0 +1,37 @@
+{
+ "$schema": "https://playground.wordpress.net/blueprint-schema.json",
+ "meta": {
+ "description": "Debug output is on."
+ },
+ "landingPage": "/wp-admin/plugins.php",
+ "preferredVersions": {
+ "php": "8.3",
+ "wp": "latest"
+ },
+ "features": {
+ "networking": true
+ },
+ "steps": [
+ {
+ "step": "defineWpConfigConsts",
+ "consts": {
+ "WP_DEBUG": true,
+ "WP_DEBUG_DISPLAY": true
+ }
+ },
+ {
+ "step": "login",
+ "username": "admin"
+ },
+ {
+ "step": "installPlugin",
+ "pluginData": {
+ "resource": "url",
+ "url": "{{ZIP_URL}}"
+ },
+ "options": {
+ "activate": true
+ }
+ }
+ ]
+}
diff --git a/.github/actions/playground-preview/playground-preview.js b/.github/actions/playground-preview/playground-preview.js
new file mode 100644
index 0000000..dc462c3
--- /dev/null
+++ b/.github/actions/playground-preview/playground-preview.js
@@ -0,0 +1,555 @@
+'use strict'
+
+/**
+ * Writes the "Try in WordPress Playground" comment on a pull request: "building" while the zip is
+ * made, the links when it is ready, "failed" when something broke.
+ *
+ * The links come from blueprint files: the plugin repository's own, or the default next to this
+ * script. Each file becomes one link. The `{{ZIP_URL}}` placeholder in a file is replaced with the
+ * zip of the pull request, and the blueprint goes into the link itself, base64 encoded in the URL
+ * hash. Nothing is hosted anywhere.
+ *
+ * run() is the entry point actions/github-script calls. The functions it is built from are pure,
+ * apart from reading the blueprint files and the API calls, so a test can assert each one. Run as
+ * a script, it prints the comment for local blueprint files instead of posting it; see main().
+ */
+
+const fs = require('node:fs')
+const path = require('node:path')
+
+/** Used when the caller lists no blueprint files. Works for any plugin. */
+const DEFAULT_BLUEPRINT = path.join(__dirname, 'blueprints', 'default.json')
+
+/** Where a blueprint in the URL hash opens. Playground reads JSON or base64 JSON from the hash. */
+const PLAYGROUND_URL = 'https://playground.wordpress.net/'
+
+/**
+ * GitHub refuses a comment body longer than this. The hash is never sent to a server, so the
+ * comment, not the link, is the limit that applies first.
+ */
+const MAX_COMMENT_LENGTH = 65536
+
+/** A placeholder: a name in capitals between double braces. Anything else is left alone. */
+const PLACEHOLDER = /\{\{([A-Z][A-Z0-9_]*)\}\}/g
+
+/** Keys a blueprint file may hold that Playground does not need, removed to keep the link short. */
+const DROPPED_KEYS = ['$schema', 'meta']
+
+const STATUSES = ['building', 'ready', 'failed']
+
+/**
+ * Splits the `blueprints` input into file paths, one per line. Empty lines are skipped.
+ *
+ * @param {string} text The input.
+ *
+ * @returns {string[]} The paths, trimmed. Empty when the input is empty.
+ */
+function readBlueprintList(text) {
+ return String(text ?? '')
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((line) => line !== '')
+}
+
+/**
+ * Checks the zip URL. It ends up in every link, so a wrong one would make every preview fail in
+ * the browser, long after this run looked green.
+ *
+ * @param {string} zipUrl The `zip-url` input.
+ *
+ * @throws {Error} When it is empty or not an https URL.
+ *
+ * @returns {string} The URL, trimmed.
+ */
+function validateZipUrl(zipUrl) {
+ const trimmed = String(zipUrl ?? '').trim()
+ let parsed = null
+
+ try {
+ parsed = new URL(trimmed)
+ } catch {
+ // Reported below, with the same message as a URL that is not https.
+ }
+
+ if (!parsed || parsed.protocol !== 'https:') {
+ throw new Error(
+ `The zip URL '${trimmed}' is not an https URL. Playground downloads the plugin from it in ` +
+ 'the browser, so it has to be a public https link.'
+ )
+ }
+
+ return trimmed
+}
+
+/**
+ * Reads one blueprint file.
+ *
+ * @param {string} file The path, relative to `cwd` or absolute.
+ * @param {string} cwd The directory relative paths start from: the checkout.
+ *
+ * @throws {Error} When the file is missing, is not JSON, or is not a JSON object.
+ *
+ * @returns {object} The parsed blueprint.
+ */
+function loadBlueprint(file, cwd) {
+ const full = path.resolve(cwd, file)
+
+ if (!fs.existsSync(full)) {
+ throw new Error(`The blueprint file ${file} does not exist. Paths are relative to the repository root.`)
+ }
+
+ let blueprint = null
+ try {
+ blueprint = JSON.parse(fs.readFileSync(full, 'utf8'))
+ } catch (error) {
+ throw new Error(`The blueprint file ${file} is not valid JSON: ${error.message}`)
+ }
+
+ if (blueprint === null || typeof blueprint !== 'object' || Array.isArray(blueprint)) {
+ throw new Error(`The blueprint file ${file} has to hold a JSON object, as Playground expects.`)
+ }
+
+ return blueprint
+}
+
+/**
+ * Replaces the placeholders in every string of a blueprint.
+ *
+ * The values go into the parsed strings, never into the raw file, so a value with a quote or a
+ * backslash cannot break the JSON.
+ *
+ * @param {*} value The blueprint, or a part of it.
+ * @param {object} values Each placeholder name mapped to its value, e.g. {ZIP_URL: 'https://...'}.
+ * @param {string} file The file it came from, for the error message.
+ * @param {Set} used Collects the names that were replaced.
+ *
+ * @throws {Error} On a placeholder with no value, which is most likely a typo.
+ *
+ * @returns {*} A copy with the placeholders replaced.
+ */
+function fillPlaceholders(value, values, file, used = new Set()) {
+ if (typeof value === 'string') {
+ return value.replace(PLACEHOLDER, (match, name) => {
+ if (!Object.hasOwn(values, name)) {
+ const known = Object.keys(values).map((key) => `{{${key}}}`).join(', ')
+ throw new Error(`The blueprint file ${file} uses ${match}, which this action does not fill. It fills ${known}.`)
+ }
+ used.add(name)
+ return values[name]
+ })
+ }
+
+ if (Array.isArray(value)) {
+ return value.map((item) => fillPlaceholders(item, values, file, used))
+ }
+
+ if (value !== null && typeof value === 'object') {
+ return Object.fromEntries(
+ Object.entries(value).map(([key, item]) => [key, fillPlaceholders(item, values, file, used)])
+ )
+ }
+
+ return value
+}
+
+/**
+ * Turns one blueprint file into a link: its label, its note, and the blueprint as it goes into the
+ * URL.
+ *
+ * The label and the note come from the file's `meta.title` and `meta.description`, so everything
+ * about a variant is in one file. `meta` is then removed: Playground does not use it, and its schema
+ * would also ask for `meta.author`.
+ *
+ * @param {string} file The path, for messages.
+ * @param {object} blueprint The parsed file.
+ * @param {string} zipUrl The zip to install.
+ *
+ * @throws {Error} When the blueprint never installs the zip: the link would not show this pull
+ * request at all.
+ *
+ * @returns {{file: string, title: string, description: string, blueprint: object}} The variant.
+ */
+function prepareVariant(file, blueprint, zipUrl) {
+ const used = new Set()
+ const filled = fillPlaceholders(blueprint, { ZIP_URL: zipUrl }, file, used)
+
+ if (!used.has('ZIP_URL')) {
+ throw new Error(
+ `The blueprint file ${file} does not use {{ZIP_URL}}, so its link would not install this pull ` +
+ 'request. Install the plugin with a step like {"step": "installPlugin", "pluginData": ' +
+ '{"resource": "url", "url": "{{ZIP_URL}}"}}.'
+ )
+ }
+
+ const meta = filled.meta && typeof filled.meta === 'object' ? filled.meta : {}
+ for (const key of DROPPED_KEYS) {
+ delete filled[key]
+ }
+
+ return {
+ file,
+ title: typeof meta.title === 'string' ? meta.title.trim() : '',
+ description: typeof meta.description === 'string' ? meta.description.trim() : '',
+ blueprint: filled,
+ }
+}
+
+/**
+ * Checks that the links can be told apart in the comment.
+ *
+ * @param {object[]} variants From prepareVariant.
+ *
+ * @throws {Error} When two have the same title, or more than one has none.
+ */
+function validateVariants(variants) {
+ const untitled = variants.filter((variant) => variant.title === '')
+ if (untitled.length > 1) {
+ throw new Error(
+ `${untitled.map((variant) => variant.file).join(' and ')} have no meta.title. With several ` +
+ 'blueprints, at most one may go without, so the links can be told apart.'
+ )
+ }
+
+ const seen = new Map()
+ for (const variant of variants) {
+ if (variant.title !== '' && seen.has(variant.title)) {
+ throw new Error(`${seen.get(variant.title)} and ${variant.file} have the same meta.title '${variant.title}'.`)
+ }
+ seen.set(variant.title, variant.file)
+ }
+}
+
+/**
+ * The Playground link for a blueprint.
+ *
+ * Base64 in the hash, rather than JSON in a `blueprint-url` query: the hash never leaves the browser,
+ * so no server limit on the request line applies; base64 is about a seventh shorter than
+ * percent-encoded JSON; and it is plain ASCII, which a Markdown link keeps as it is. Playground
+ * decodes the bytes as UTF-8, so non-ASCII text in a blueprint survives.
+ *
+ * @param {object} blueprint The blueprint, placeholders filled.
+ *
+ * @returns {string} The link.
+ */
+function playgroundUrl(blueprint) {
+ return `${PLAYGROUND_URL}#${Buffer.from(JSON.stringify(blueprint), 'utf8').toString('base64')}`
+}
+
+/**
+ * The time on the badge, like `date '+%m/%d %I:%M %p %Z'`: 01/15 05:43 PM EST.
+ *
+ * @param {Date} now The time to show.
+ * @param {string} timeZone An IANA time zone, e.g. America/New_York.
+ *
+ * @throws {Error} On a time zone the runner does not know.
+ *
+ * @returns {string} The stamp.
+ */
+function formatStamp(now, timeZone) {
+ let format = null
+ try {
+ format = new Intl.DateTimeFormat('en-US', {
+ timeZone,
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: true,
+ timeZoneName: 'short',
+ })
+ } catch {
+ throw new Error(`'${timeZone}' is not a time zone. Use an IANA name such as America/New_York.`)
+ }
+
+ const parts = Object.fromEntries(format.formatToParts(now).map((part) => [part.type, part.value]))
+
+ return `${parts.month}/${parts.day} ${parts.hour}:${parts.minute} ${parts.dayPeriod} ${parts.timeZoneName}`
+}
+
+/**
+ * A shields.io badge with a label, a message and a color.
+ *
+ * shields.io splits the path on single dashes and reads underscores as spaces, so both are doubled
+ * first. A time zone without a short name prints as GMT-3, which would otherwise lose its dash.
+ *
+ * @param {string} label Left side, e.g. "Build Started".
+ * @param {string} message Right side.
+ * @param {string} color A shields.io color name.
+ *
+ * @returns {string} The image URL.
+ */
+function badgeUrl(label, message, color) {
+ const escape = (text) => encodeURIComponent(text.replace(/-/g, '--').replace(/_/g, '__'))
+
+ return `https://img.shields.io/badge/${escape(label)}-${escape(message)}-${color}`
+}
+
+/**
+ * Escapes the characters that would end a Markdown link label early.
+ *
+ * @param {string} text A title from a blueprint file.
+ *
+ * @returns {string} The text, safe inside [ ].
+ */
+function escapeLinkText(text) {
+ return text.replace(/[\\[\]]/g, '\\$&')
+}
+
+/**
+ * The comment body. Its first line is the marker the next run looks for.
+ *
+ * @param {object} options
+ * @param {string} options.status building, ready or failed.
+ * @param {string} options.marker Hidden first line.
+ * @param {string} options.sha The commit, full or short.
+ * @param {string} options.stamp The time for the badge.
+ * @param {object[]} options.variants From prepareVariant, for "ready".
+ * @param {string} options.zipUrl For "ready".
+ * @param {string} options.runUrl The workflow run, for "failed".
+ *
+ * @returns {string} The Markdown body.
+ */
+function buildComment({ status, marker, sha, stamp, variants = [], zipUrl = '', runUrl = '' }) {
+ const commit = String(sha ?? '').slice(0, 7)
+
+ if (status === 'building') {
+ return [
+ marker,
+ `🏗️ Playground preview building for commit ${commit}`,
+ '',
+ `})`,
+ '',
+ 'This takes a few minutes.',
+ '',
+ ].join('\n')
+ }
+
+ if (status === 'failed') {
+ return [
+ marker,
+ `❌ Playground preview build failed for commit ${commit}`,
+ '',
+ `})`,
+ '',
+ `See the [workflow run](${runUrl}) for the error. Push a commit to try again.`,
+ '',
+ ].join('\n')
+ }
+
+ const links = variants.map((variant) => {
+ const label = variant.title === '' ? 'Try in WordPress Playground' : `Try in WordPress Playground (${escapeLinkText(variant.title)})`
+ return `- 🛝 [${label}](${playgroundUrl(variant.blueprint)})`
+ })
+
+ const notes = variants
+ .filter((variant) => variant.description !== '')
+ .map((variant) => (variant.title === '' ? variant.description : `**${variant.title}:** ${variant.description}`))
+
+ return [
+ marker,
+ `🎉 Playground preview ready for commit ${commit}`,
+ '',
+ `})`,
+ '',
+ ...links,
+ `- 💾 [Download zip](${zipUrl})`,
+ '',
+ ...(notes.length ? [...notes, ''] : []),
+ ].join('\n')
+}
+
+/**
+ * Fails when the comment is longer than GitHub accepts, naming each link's length, so the blueprint
+ * to shorten is obvious.
+ *
+ * @param {string} body The comment.
+ * @param {object[]} variants The variants in it.
+ *
+ * @throws {Error} When the body is too long.
+ */
+function checkCommentLength(body, variants) {
+ if (body.length <= MAX_COMMENT_LENGTH) {
+ return
+ }
+
+ const sizes = variants.map((variant) => `${variant.file}: ${playgroundUrl(variant.blueprint).length}`).join(', ')
+ throw new Error(
+ `The comment would be ${body.length} characters, and GitHub takes at most ${MAX_COMMENT_LENGTH}. ` +
+ `Link lengths: ${sizes}. Make the largest blueprint smaller, for example by moving setup into ` +
+ 'a plugin or a WP-CLI command it runs.'
+ )
+}
+
+/**
+ * Builds the "ready" comment from the blueprint files.
+ *
+ * @param {object} options
+ * @param {string[]} options.files Blueprint paths. Empty uses the default blueprint.
+ * @param {string} options.cwd Where relative paths start.
+ * @param {string} options.zipUrl The zip.
+ * @param {string} options.marker The hidden first line.
+ * @param {string} options.sha The commit.
+ * @param {string} options.stamp The badge time.
+ *
+ * @returns {{body: string, variants: object[]}} The comment and the variants in it.
+ */
+function readyComment({ files, cwd, zipUrl, marker, sha, stamp }) {
+ const url = validateZipUrl(zipUrl)
+ const list = files.length ? files : [DEFAULT_BLUEPRINT]
+ const variants = list.map((file) => prepareVariant(file, loadBlueprint(file, cwd), url))
+
+ validateVariants(variants)
+
+ const body = buildComment({ status: 'ready', marker, sha, stamp, variants, zipUrl: url })
+ checkCommentLength(body, variants)
+
+ return { body, variants }
+}
+
+/**
+ * Replaces the comment that starts with the marker, or creates one.
+ *
+ * Found by its first line only, not by author, so it is still found when the caller passes a token
+ * other than the workflow's own.
+ *
+ * @param {object} options
+ * @param {object} options.github An Octokit.
+ * @param {string} options.owner Repository owner.
+ * @param {string} options.repo Repository name.
+ * @param {number} options.issue Pull request number.
+ * @param {string} options.marker The first line to look for.
+ * @param {string} options.body The new body.
+ *
+ * @returns {Promise<{id: number, created: boolean}>} The comment, and whether it is new.
+ */
+async function upsertComment({ github, owner, repo, issue, marker, body }) {
+ const comments = await github.paginate(github.rest.issues.listComments, {
+ owner,
+ repo,
+ issue_number: issue,
+ per_page: 100,
+ })
+ const existing = comments.find((comment) => typeof comment.body === 'string' && comment.body.startsWith(marker))
+
+ if (existing) {
+ await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body })
+ return { id: existing.id, created: false }
+ }
+
+ const { data } = await github.rest.issues.createComment({ owner, repo, issue_number: issue, body })
+ return { id: data.id, created: true }
+}
+
+/**
+ * The entry point actions/github-script calls. The inputs arrive as INPUT_* variables, so they
+ * are data and never part of the script.
+ *
+ * @param {object} options
+ * @param {object} options.github An Octokit, from github-script.
+ * @param {object} options.core @actions/core, from github-script.
+ * @param {object} options.env process.env.
+ * @param {Date} options.now The current time, for tests.
+ *
+ * @throws {Error} On a bad input or blueprint. github-script then fails the step with the message.
+ */
+async function run({ github, core, env, now = new Date() }) {
+ const status = String(env.INPUT_STATUS ?? '').trim()
+ if (!STATUSES.includes(status)) {
+ throw new Error(`Unknown status '${status}'. Use ${STATUSES.join(', ')}.`)
+ }
+
+ const issue = Number(env.INPUT_PULL_REQUEST)
+ if (!Number.isInteger(issue) || issue <= 0) {
+ throw new Error('No pull request number. This action only runs for pull_request events.')
+ }
+
+ const [owner, repo] = String(env.GITHUB_REPOSITORY ?? '').split('/')
+ const marker = env.INPUT_COMMENT_MARKER || ``
+ const stamp = formatStamp(now, env.INPUT_TIME_ZONE || 'America/New_York')
+ const sha = env.INPUT_SHA
+
+ let body = ''
+ if (status === 'ready') {
+ body = readyComment({
+ files: readBlueprintList(env.INPUT_BLUEPRINTS),
+ cwd: env.GITHUB_WORKSPACE || process.cwd(),
+ zipUrl: env.INPUT_ZIP_URL,
+ marker,
+ sha,
+ stamp,
+ }).body
+ } else {
+ const runUrl = `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`
+ body = buildComment({ status, marker, sha, stamp, runUrl })
+ }
+
+ // The job summary keeps the links when posting the comment fails, and is where a test run reads them.
+ await core.summary.addRaw(body).write()
+
+ const comment = await upsertComment({ github, owner, repo, issue, marker, body })
+ core.info(`${comment.created ? 'Created' : 'Updated'} comment ${comment.id} on #${issue} (${status}).`)
+}
+
+/**
+ * Local use: prints the "ready" comment for blueprint files on this machine, posts nothing.
+ *
+ * node playground-preview.js --zip-url https://example.com/my-plugin.zip [blueprint.json ...]
+ *
+ * With no file, the default blueprint is used. Open a link from the output to try it.
+ *
+ * @param {string[]} argv The arguments after the script.
+ *
+ * @returns {number} The exit code.
+ */
+function main(argv) {
+ const { parseArgs } = require('node:util')
+
+ try {
+ const { values, positionals } = parseArgs({
+ args: argv,
+ allowPositionals: true,
+ options: {
+ 'zip-url': { type: 'string', default: '' },
+ sha: { type: 'string', default: 'local' },
+ 'time-zone': { type: 'string', default: 'America/New_York' },
+ },
+ })
+ const { body } = readyComment({
+ files: positionals,
+ cwd: process.cwd(),
+ zipUrl: values['zip-url'],
+ marker: '',
+ sha: values.sha,
+ stamp: formatStamp(new Date(), values['time-zone']),
+ })
+ process.stdout.write(body)
+ return 0
+ } catch (error) {
+ process.stderr.write(`${error.message}\n`)
+ return 1
+ }
+}
+
+if (require.main === module) {
+ process.exitCode = main(process.argv.slice(2))
+}
+
+module.exports = {
+ run,
+ main,
+ readBlueprintList,
+ validateZipUrl,
+ loadBlueprint,
+ fillPlaceholders,
+ prepareVariant,
+ validateVariants,
+ playgroundUrl,
+ formatStamp,
+ badgeUrl,
+ buildComment,
+ checkCommentLength,
+ readyComment,
+ upsertComment,
+ DEFAULT_BLUEPRINT,
+ MAX_COMMENT_LENGTH,
+}
diff --git a/.github/workflows/playground-preview.yml b/.github/workflows/playground-preview.yml
new file mode 100644
index 0000000..c7bed8e
--- /dev/null
+++ b/.github/workflows/playground-preview.yml
@@ -0,0 +1,271 @@
+name: Playground Preview
+
+# Adds a "Try in WordPress Playground" comment to a pull request: a throwaway WordPress in the
+# browser with the pull request's build installed, and a link to download the zip. The zip is built
+# by the shared pup workflow in stellarwp/github-actions and lands in the same public bucket the
+# packaging bot uses, so nothing new is hosted anywhere.
+#
+# A reusable workflow. A plugin repository calls it from a thin workflow of its own; copy
+# templates/workflows/playground-preview.yml.
+#
+# Four jobs, so the comment never stays on "building":
+# pending replaces the last comment with "building" at once, and checks the secrets.
+# zip builds the zip and uploads it, once pending is done. Skipped when the caller passes
+# `zip-url`.
+# button works out the zip's public link and posts the Playground links.
+# failed replaces "building" with "failed" when anything above failed.
+#
+# Only pull requests from the repository itself get a preview. A fork's run cannot see the bucket
+# secrets, and its token cannot write a comment.
+#
+# The caller has to grant every permission a job below asks for: a called workflow can lower the
+# token's permissions, never raise them. `concurrency` is the caller's too: here it would be
+# evaluated with the caller's context and could clash with the caller's own group.
+#
+# Run it locally: `node .github/actions/playground-preview/playground-preview.js --zip-url
+# [blueprint.json ...]` prints the comment for any zip and blueprint files. See the action's README.
+
+on:
+ workflow_call:
+ inputs:
+ blueprints:
+ description: >-
+ Blueprint files, one path per line, relative to the repository root, e.g.
+ `.github/playground/blueprint.json`. Each file becomes one link. Empty uses the default
+ blueprint: install the zip, activate it, turn on WP_DEBUG and WP_DEBUG_DISPLAY. See the
+ playground-preview action's README for the file format.
+ required: false
+ type: string
+ default: ''
+ php-version:
+ description: >-
+ PHP that builds vendor/. Use the oldest version the plugin supports, as its release does,
+ so Composer resolves dependencies that still run there.
+ required: false
+ type: string
+ default: '7.4'
+ i18n:
+ description: '`yes` runs `pup i18n` in the build. A preview needs no translations, so `no` by default.'
+ required: false
+ type: string
+ default: 'no'
+ additional-commands:
+ description: >-
+ Commands the zip build runs after `pup build` and before `pup package`. Passed to the
+ shared zip workflow as `additional_commands`. Prefer `.puprc`: this is a second place
+ that defines the build.
+ required: false
+ type: string
+ default: ''
+ zip-url:
+ description: >-
+ Use this zip instead of building one, e.g. for a repository with its own zip workflow, or
+ to test this workflow without bucket secrets. No secret is needed then.
+ required: false
+ type: string
+ default: ''
+ zip-link-base:
+ description: >-
+ Public link to a zip in the bucket, without the file name. The zip's name, as pup makes
+ it, is added to the end.
+ required: false
+ type: string
+ default: 'https://evnt.is/test-zip?file='
+ runs-on:
+ description: 'Runner label of the job that posts the links, of runner version 2.336.0 or newer. The other jobs run on ubuntu-latest.'
+ required: false
+ type: string
+ default: 'ubuntu-latest'
+ time-zone:
+ description: 'IANA time zone of the time on the badge, e.g. America/Chicago.'
+ required: false
+ type: string
+ default: 'America/New_York'
+ secrets:
+ # All optional here, so a caller that passes `zip-url` needs none. The pending job fails
+ # with a clear message when the build needs one that is missing.
+ s3-bucket:
+ description: 'Bucket the zip is uploaded to. Required unless `zip-url` is set.'
+ required: false
+ s3-access-key-id:
+ description: 'Required unless `zip-url` is set.'
+ required: false
+ s3-secret-access-key:
+ description: 'Required unless `zip-url` is set.'
+ required: false
+ s3-region:
+ description: 'Required unless `zip-url` is set.'
+ required: false
+ s3-endpoint:
+ description: 'Required unless `zip-url` is set.'
+ required: false
+ bot-token:
+ description: >-
+ Token that checks out the repository for the build. Empty uses the workflow's own token,
+ which is enough unless the build reaches other private repositories.
+ required: false
+ jenkins-secret:
+ description: >-
+ Passed on to the shared zip workflow, which requires it but only uses it for Slack
+ messages. This workflow never sends one.
+ required: false
+ composer-token:
+ description: 'GitHub token for private Composer packages. Becomes COMPOSER_AUTH in the build.'
+ required: false
+ package-token:
+ description: 'Token for private npm or bun packages. Becomes NODE_AUTH_TOKEN and BUN_AUTH_TOKEN in the build.'
+ required: false
+
+env:
+ # Hidden first line of the sticky comment. Named after the repository, so two plugin repositories
+ # never share a marker.
+ COMMENT_MARKER: ''
+
+jobs:
+ # Replaces the comment from the last push at once, so a build that takes minutes does not leave
+ # links to an older zip on the pull request. "zip" and "button" wait for it, so the finished
+ # comment can never be overwritten by this one, and a missing secret fails here in seconds instead
+ # of after a build that was never going to upload.
+ pending:
+ name: Playground preview start
+ if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ pull-requests: write
+ steps:
+ # A failed API call here must not stop the preview.
+ - name: Post "building"
+ continue-on-error: true
+ uses: $/.github/actions/playground-preview
+ with:
+ status: building
+ comment-marker: ${{ env.COMMENT_MARKER }}
+ time-zone: ${{ inputs.time-zone }}
+
+ # After the comment, so the "failed" job replaces it with a link to this message. Without it,
+ # missing secrets only show up minutes later, as an upload error deep in the zip build.
+ - name: Check the secrets
+ if: ${{ inputs.zip-url == '' }}
+ env:
+ S3_BUCKET: ${{ secrets.s3-bucket }}
+ S3_ACCESS_KEY_ID: ${{ secrets.s3-access-key-id }}
+ S3_SECRET_ACCESS_KEY: ${{ secrets.s3-secret-access-key }}
+ S3_REGION: ${{ secrets.s3-region }}
+ S3_ENDPOINT: ${{ secrets.s3-endpoint }}
+ run: |
+ missing=()
+ [ -n "$S3_BUCKET" ] || missing+=(s3-bucket)
+ [ -n "$S3_ACCESS_KEY_ID" ] || missing+=(s3-access-key-id)
+ [ -n "$S3_SECRET_ACCESS_KEY" ] || missing+=(s3-secret-access-key)
+ [ -n "$S3_REGION" ] || missing+=(s3-region)
+ [ -n "$S3_ENDPOINT" ] || missing+=(s3-endpoint)
+ if [ ${#missing[@]} -gt 0 ]; then
+ echo "::error title=Playground preview::The zip build needs these secrets, and the caller did not pass them: ${missing[*]}. Pass them, or pass zip-url to use a zip from elsewhere."
+ exit 1
+ fi
+
+ zip:
+ name: Zip
+ needs: pending
+ if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && inputs.zip-url == '' }}
+ # The shared workflow only checks out and builds this repository.
+ permissions:
+ contents: read
+ uses: stellarwp/github-actions/.github/workflows/zip.yml@main
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
+ # A dev build: its name has the commit in it, so every push gets its own zip.
+ production: 'no'
+ php_version: ${{ inputs.php-version }}
+ i18n: ${{ inputs.i18n }}
+ additional_commands: ${{ inputs.additional-commands }}
+ secrets:
+ GH_BOT_TOKEN: ${{ secrets.bot-token || secrets.GITHUB_TOKEN }}
+ JENKINS_SECRET: ${{ secrets.jenkins-secret }}
+ COMPOSER_TOKEN: ${{ secrets.composer-token }}
+ NODE_AUTH_TOKEN: ${{ secrets.package-token }}
+ S3_BUCKET: ${{ secrets.s3-bucket }}
+ S3_ACCESS_KEY_ID: ${{ secrets.s3-access-key-id }}
+ S3_SECRET_ACCESS_KEY: ${{ secrets.s3-secret-access-key }}
+ S3_REGION: ${{ secrets.s3-region }}
+ S3_ENDPOINT: ${{ secrets.s3-endpoint }}
+
+ button:
+ name: Playground preview
+ needs: [pending, zip]
+ # "zip" is skipped when the caller passes zip-url, which would skip this job too.
+ if: ${{ !cancelled() && needs.pending.result == 'success' && (needs.zip.result == 'success' || needs.zip.result == 'skipped') }}
+ runs-on: ${{ inputs.runs-on }}
+ timeout-minutes: 10
+
+ # Writes one comment on the pull request and reads the checkout, nothing else.
+ permissions:
+ contents: read
+ pull-requests: write
+
+ steps:
+ # For the blueprint files, and for pup to name the zip.
+ - uses: actions/checkout@v7
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
+ persist-credentials: false
+
+ # The runner's own Composer can be out of date, so set up PHP for a current one before
+ # Composer downloads pup.
+ - name: Set Up PHP
+ if: ${{ inputs.zip-url == '' }}
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+ coverage: none
+
+ # pup names the zip after the version, the commit time and the commit hash, and the shared
+ # zip workflow has no outputs, so the name is asked of pup again rather than rebuilt here.
+ # Same commands, same commit, same name.
+ - name: Find the zip
+ id: zip
+ env:
+ ZIP_URL: ${{ inputs.zip-url }}
+ ZIP_LINK_BASE: ${{ inputs.zip-link-base }}
+ run: |
+ if [ -n "$ZIP_URL" ]; then
+ echo "url=$ZIP_URL" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ composer run -q pup > /dev/null
+ version=$(php -d display_errors=0 bin/pup.phar get-version --dev)
+ name=$(php -d display_errors=0 bin/pup.phar zip-name "$version")
+ echo "url=${ZIP_LINK_BASE}${name}.zip" >> "$GITHUB_OUTPUT"
+
+ # `$/` is this repository at the commit that runs this workflow, so the action is always the
+ # same revision as the workflow, whichever ref the caller used, and it needs no checkout. It
+ # needs a runner of version 2.336.0 or newer, which `runs-on` has to give. Not ./, which in a
+ # called workflow resolves against the caller's repository.
+ - name: Post the links
+ uses: $/.github/actions/playground-preview
+ with:
+ status: ready
+ zip-url: ${{ steps.zip.outputs.url }}
+ blueprints: ${{ inputs.blueprints }}
+ comment-marker: ${{ env.COMMENT_MARKER }}
+ time-zone: ${{ inputs.time-zone }}
+
+ # Without this, a failed build would leave "building" on the pull request for good. A run
+ # cancelled by a newer push is not a failure: the newer run's "building" replaces the comment.
+ failed:
+ name: Playground preview failed
+ needs: [pending, zip, button]
+ if: ${{ failure() }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ pull-requests: write
+ steps:
+ - name: Post "failed"
+ continue-on-error: true
+ uses: $/.github/actions/playground-preview
+ with:
+ status: failed
+ comment-marker: ${{ env.COMMENT_MARKER }}
+ time-zone: ${{ inputs.time-zone }}
diff --git a/templates/playground/blueprint-sample-data.json b/templates/playground/blueprint-sample-data.json
new file mode 100644
index 0000000..b204e02
--- /dev/null
+++ b/templates/playground/blueprint-sample-data.json
@@ -0,0 +1,66 @@
+{
+ "$schema": "https://playground.wordpress.net/blueprint-schema.json",
+ "meta": {
+ "title": "With sample data",
+ "description": "100 sample orders in test mode. Opens a few minutes slower."
+ },
+ "landingPage": "/wp-admin/admin.php?page=my-plugin",
+ "preferredVersions": {
+ "php": "8.3",
+ "wp": "latest"
+ },
+ "features": {
+ "networking": true
+ },
+ "steps": [
+ {
+ "step": "defineWpConfigConsts",
+ "consts": {
+ "WP_DEBUG": true,
+ "WP_DEBUG_DISPLAY": true
+ }
+ },
+ {
+ "step": "login",
+ "username": "admin"
+ },
+ {
+ "step": "installTheme",
+ "themeData": {
+ "resource": "wordpress.org/themes",
+ "slug": "twentytwentyfive"
+ },
+ "options": {
+ "activate": true
+ }
+ },
+ {
+ "step": "installPlugin",
+ "pluginData": {
+ "resource": "url",
+ "url": "{{ZIP_URL}}"
+ },
+ "options": {
+ "activate": true
+ }
+ },
+ {
+ "step": "installPlugin",
+ "pluginData": {
+ "resource": "url",
+ "url": "https://github.com/example-org/my-plugin-sample-data/releases/download/1.0.0/my-plugin-sample-data.1.0.0.zip"
+ },
+ "options": {
+ "activate": true
+ }
+ },
+ {
+ "step": "wp-cli",
+ "command": "wp eval 'update_option(\"my_plugin_test_mode\", \"enabled\");'"
+ },
+ {
+ "step": "wp-cli",
+ "command": "wp my-plugin-sample-data orders 100"
+ }
+ ]
+}
diff --git a/templates/playground/blueprint.json b/templates/playground/blueprint.json
new file mode 100644
index 0000000..ac8e5a8
--- /dev/null
+++ b/templates/playground/blueprint.json
@@ -0,0 +1,47 @@
+{
+ "$schema": "https://playground.wordpress.net/blueprint-schema.json",
+ "meta": {
+ "description": "Debug output is on."
+ },
+ "landingPage": "/wp-admin/admin.php?page=my-plugin",
+ "preferredVersions": {
+ "php": "8.3",
+ "wp": "latest"
+ },
+ "features": {
+ "networking": true
+ },
+ "steps": [
+ {
+ "step": "defineWpConfigConsts",
+ "consts": {
+ "WP_DEBUG": true,
+ "WP_DEBUG_DISPLAY": true
+ }
+ },
+ {
+ "step": "login",
+ "username": "admin"
+ },
+ {
+ "step": "installTheme",
+ "themeData": {
+ "resource": "wordpress.org/themes",
+ "slug": "twentytwentyfive"
+ },
+ "options": {
+ "activate": true
+ }
+ },
+ {
+ "step": "installPlugin",
+ "pluginData": {
+ "resource": "url",
+ "url": "{{ZIP_URL}}"
+ },
+ "options": {
+ "activate": true
+ }
+ }
+ ]
+}
diff --git a/templates/workflows/playground-preview.yml b/templates/workflows/playground-preview.yml
new file mode 100644
index 0000000..d3d4895
--- /dev/null
+++ b/templates/workflows/playground-preview.yml
@@ -0,0 +1,48 @@
+name: Playground Preview
+
+# Adds a "Try in WordPress Playground" comment to every pull request, with links that open a
+# throwaway WordPress with the pull request's build installed, and a link to download the zip.
+#
+# A file to copy into .github/workflows/ of a plugin repository. The work is in
+# stellarwp/plugin-toolbox/.github/workflows/playground-preview.yml. The repository needs a .puprc
+# and a `pup` Composer script, and the bucket secrets below; see the playground-preview action's
+# README. Pull requests from forks get no preview: they cannot see the secrets.
+#
+# Run it locally: `node .github/actions/playground-preview/playground-preview.js --zip-url
+# [blueprint.json ...]` from a checkout of stellarwp/plugin-toolbox prints the comment.
+
+on:
+ pull_request:
+ types: [opened, reopened, synchronize]
+
+# One run per pull request at a time. A push while a preview is still building cancels that build,
+# so an older zip can never overwrite a newer comment.
+concurrency:
+ group: playground-preview-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ playground-preview:
+ # The most any job of the shared workflow asks for. A called workflow cannot go above this.
+ permissions:
+ contents: read # check out the repository
+ pull-requests: write # the sticky pull request comment
+ uses: stellarwp/plugin-toolbox/.github/workflows/playground-preview.yml@main
+ with:
+ # The oldest PHP the plugin supports, so vendor/ is built for it.
+ php-version: '7.4'
+ # Leave out for the default blueprint. Each file is one link in the comment.
+ # blueprints: |
+ # .github/playground/blueprint.json
+ # .github/playground/blueprint-sample-data.json
+ # Passed one by one, not with `secrets: inherit`: inherit only works inside one organization.
+ secrets:
+ s3-bucket: ${{ secrets.ZIP_S3_BUCKET }}
+ s3-access-key-id: ${{ secrets.ZIP_S3_ACCESS_KEY_ID }}
+ s3-secret-access-key: ${{ secrets.ZIP_S3_SECRET_ACCESS_KEY }}
+ s3-region: ${{ secrets.ZIP_S3_REGION }}
+ s3-endpoint: ${{ secrets.ZIP_S3_ENDPOINT }}
+ jenkins-secret: ${{ secrets.ZIP_JENKINS_SECRET }}
diff --git a/tests/actions/playground-preview/fixtures/minimal.json b/tests/actions/playground-preview/fixtures/minimal.json
new file mode 100644
index 0000000..d07e766
--- /dev/null
+++ b/tests/actions/playground-preview/fixtures/minimal.json
@@ -0,0 +1,14 @@
+{
+ "steps": [
+ {
+ "step": "installPlugin",
+ "pluginData": {
+ "resource": "url",
+ "url": "{{ZIP_URL}}"
+ },
+ "options": {
+ "activate": true
+ }
+ }
+ ]
+}
diff --git a/tests/actions/playground-preview/fixtures/no-zip.json b/tests/actions/playground-preview/fixtures/no-zip.json
new file mode 100644
index 0000000..3d89f15
--- /dev/null
+++ b/tests/actions/playground-preview/fixtures/no-zip.json
@@ -0,0 +1,8 @@
+{
+ "steps": [
+ {
+ "step": "login",
+ "username": "admin"
+ }
+ ]
+}
diff --git a/tests/actions/playground-preview/playground-preview.test.js b/tests/actions/playground-preview/playground-preview.test.js
new file mode 100644
index 0000000..c087d07
--- /dev/null
+++ b/tests/actions/playground-preview/playground-preview.test.js
@@ -0,0 +1,494 @@
+'use strict'
+
+const { describe, it } = require('node:test')
+const assert = require('node:assert')
+const fs = require('node:fs')
+const os = require('node:os')
+const path = require('node:path')
+const { spawnSync } = require('node:child_process')
+
+const {
+ run,
+ readBlueprintList,
+ validateZipUrl,
+ loadBlueprint,
+ fillPlaceholders,
+ prepareVariant,
+ validateVariants,
+ playgroundUrl,
+ formatStamp,
+ badgeUrl,
+ buildComment,
+ checkCommentLength,
+ readyComment,
+ upsertComment,
+ DEFAULT_BLUEPRINT,
+ MAX_COMMENT_LENGTH,
+} = require('../../../.github/actions/playground-preview/playground-preview.js')
+const { actionsFor, octokitFor } = require('../../support/actions.js')
+
+const SCRIPT = path.join(__dirname, '../../../.github/actions/playground-preview/playground-preview.js')
+const FIXTURES = path.join(__dirname, 'fixtures')
+const TEMPLATES = path.join(__dirname, '../../../templates/playground')
+
+const ZIP_URL = 'https://evnt.is/test-zip?file=my-plugin.1.2.0-dev-1793396265-15e470cb.zip'
+const SHA = '15e470cb0123456789abcdef0123456789abcdef'
+const MARKER = ''
+
+/** 22:43 UTC on a winter day: 05:43 PM EST in New York. */
+const WINTER = new Date('2026-01-15T22:43:00Z')
+
+/** 21:43 UTC on a summer day: 05:43 PM EDT in New York. */
+const SUMMER = new Date('2026-07-15T21:43:00Z')
+
+/**
+ * Decodes the blueprint a Playground link carries, the way Playground does: base64, then UTF-8.
+ *
+ * @param {string} url A link from playgroundUrl.
+ *
+ * @returns {object} The blueprint.
+ */
+function decodeLink(url) {
+ assert.ok(url.startsWith('https://playground.wordpress.net/#'), `not a Playground hash link: ${url}`)
+
+ return JSON.parse(Buffer.from(url.split('#')[1], 'base64').toString('utf8'))
+}
+
+/**
+ * A temporary directory holding the given files, standing in for the checkout.
+ *
+ * @param {object} files Each relative path mapped to its contents. An object is written as JSON.
+ *
+ * @returns {string} The directory.
+ */
+function checkoutWith(files = {}) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'playground-preview-'))
+
+ for (const [name, contents] of Object.entries(files)) {
+ const full = path.join(dir, name)
+ fs.mkdirSync(path.dirname(full), { recursive: true })
+ fs.writeFileSync(full, typeof contents === 'string' ? contents : JSON.stringify(contents))
+ }
+
+ return dir
+}
+
+/**
+ * A blueprint that installs the zip, with optional meta.
+ *
+ * @param {object} meta The meta object, or undefined for none.
+ * @param {object} extra More top-level keys.
+ *
+ * @returns {object} The blueprint.
+ */
+function blueprintWith(meta, extra = {}) {
+ return {
+ ...(meta ? { meta } : {}),
+ steps: [{ step: 'installPlugin', pluginData: { resource: 'url', url: '{{ZIP_URL}}' }, options: { activate: true } }],
+ ...extra,
+ }
+}
+
+/**
+ * The environment the action manifest hands the script, over valid inputs.
+ *
+ * @param {object} overrides Values to change.
+ *
+ * @returns {object} The environment.
+ */
+function actionEnv(overrides = {}) {
+ return {
+ GITHUB_REPOSITORY: 'example-org/my-plugin',
+ GITHUB_SERVER_URL: 'https://github.com',
+ GITHUB_RUN_ID: '777',
+ INPUT_STATUS: 'ready',
+ INPUT_ZIP_URL: ZIP_URL,
+ INPUT_BLUEPRINTS: '',
+ INPUT_COMMENT_MARKER: '',
+ INPUT_TIME_ZONE: 'America/New_York',
+ INPUT_SHA: SHA,
+ INPUT_PULL_REQUEST: '12',
+ ...overrides,
+ }
+}
+
+/** API answers for a pull request with no preview comment yet. */
+const NO_COMMENT = [
+ { method: 'GET', path: /^\/repos\/example-org\/my-plugin\/issues\/12\/comments/, body: [{ id: 1, body: 'Looks good.' }] },
+ { method: 'POST', path: '/repos/example-org/my-plugin/issues/12/comments', status: 201, body: { id: 99 } },
+]
+
+describe('playground-preview.js', () => {
+ describe('readBlueprintList', () => {
+ it('reads one path per line and skips blank lines', () => {
+ assert.deepEqual(readBlueprintList(' a.json\n\n b.json \n'), ['a.json', 'b.json'])
+ })
+
+ it('reads an empty input as no files, which means the default blueprint', () => {
+ assert.deepEqual(readBlueprintList(''), [])
+ assert.deepEqual(readBlueprintList(undefined), [])
+ })
+ })
+
+ describe('validateZipUrl', () => {
+ it('takes an https URL', () => {
+ assert.equal(validateZipUrl(` ${ZIP_URL} `), ZIP_URL)
+ })
+
+ it('refuses an empty value, which is what a failed zip name lookup gives', () => {
+ assert.throws(() => validateZipUrl(''), /is not an https URL/)
+ })
+
+ it('refuses http, which the browser would block inside Playground', () => {
+ assert.throws(() => validateZipUrl('http://example.com/my-plugin.zip'), /is not an https URL/)
+ })
+ })
+
+ describe('loadBlueprint', () => {
+ it('reads a file relative to the checkout', () => {
+ const dir = checkoutWith({ '.github/playground/blueprint.json': blueprintWith() })
+
+ assert.equal(loadBlueprint('.github/playground/blueprint.json', dir).steps.length, 1)
+ })
+
+ it('fails on a missing file and names it', () => {
+ assert.throws(
+ () => loadBlueprint('.github/playground/missing.json', checkoutWith()),
+ /The blueprint file \.github\/playground\/missing\.json does not exist/
+ )
+ })
+
+ it('fails on a file that is not JSON and says why', () => {
+ const dir = checkoutWith({ 'broken.json': '{"steps": [,]}' })
+
+ assert.throws(() => loadBlueprint('broken.json', dir), /The blueprint file broken\.json is not valid JSON: /)
+ })
+
+ it('fails on JSON that is not an object', () => {
+ const dir = checkoutWith({ 'list.json': '[]' })
+
+ assert.throws(() => loadBlueprint('list.json', dir), /has to hold a JSON object/)
+ })
+ })
+
+ describe('fillPlaceholders', () => {
+ it('replaces the placeholder in every string, however deep', () => {
+ const filled = fillPlaceholders(
+ { a: '{{ZIP_URL}}', b: [{ c: 'x {{ZIP_URL}} y' }], d: 3, e: true, f: null },
+ { ZIP_URL: 'https://z' },
+ 'f.json'
+ )
+
+ assert.deepEqual(filled, { a: 'https://z', b: [{ c: 'x https://z y' }], d: 3, e: true, f: null })
+ })
+
+ it('keeps the JSON valid when the value has quotes and backslashes', () => {
+ const value = 'https://example.com/a"b\\c.zip'
+ const filled = fillPlaceholders({ url: '{{ZIP_URL}}' }, { ZIP_URL: value }, 'f.json')
+
+ assert.equal(JSON.parse(JSON.stringify(filled)).url, value)
+ })
+
+ it('fails on a placeholder it does not fill, which is most likely a typo', () => {
+ assert.throws(
+ () => fillPlaceholders({ url: '{{ZIP_ULR}}' }, { ZIP_URL: 'https://z' }, 'f.json'),
+ /f\.json uses \{\{ZIP_ULR\}\}, which this action does not fill\. It fills \{\{ZIP_URL\}\}\./
+ )
+ })
+
+ it('leaves braces that are not a placeholder alone, such as PHP in a WP-CLI step', () => {
+ const command = "wp eval 'echo json_encode([\"a\" => \"{{b}}\"]); $x = \"${y}\";'"
+
+ assert.equal(fillPlaceholders(command, { ZIP_URL: 'https://z' }, 'f.json'), command)
+ })
+ })
+
+ describe('prepareVariant', () => {
+ it('reads the label and the note from meta, then removes meta and $schema', () => {
+ const variant = prepareVariant(
+ 'sample.json',
+ blueprintWith({ title: ' With sample data ', description: 'Opens slower.' }, { $schema: 'https://playground.wordpress.net/blueprint-schema.json' }),
+ ZIP_URL
+ )
+
+ assert.equal(variant.title, 'With sample data')
+ assert.equal(variant.description, 'Opens slower.')
+ assert.equal(variant.blueprint.meta, undefined, 'meta would need meta.author to pass the schema')
+ assert.equal(variant.blueprint.$schema, undefined)
+ assert.equal(variant.blueprint.steps[0].pluginData.url, ZIP_URL)
+ })
+
+ it('fails when the blueprint never installs the zip', () => {
+ assert.throws(
+ () => prepareVariant('other.json', { steps: [{ step: 'login', username: 'admin' }] }, ZIP_URL),
+ /other\.json does not use \{\{ZIP_URL\}\}/
+ )
+ })
+ })
+
+ describe('validateVariants', () => {
+ const variant = (file, title) => ({ file, title, description: '', blueprint: {} })
+
+ it('takes one untitled blueprint next to titled ones', () => {
+ assert.doesNotThrow(() => validateVariants([variant('a.json', ''), variant('b.json', 'With sample data')]))
+ })
+
+ it('fails on two untitled blueprints, which would give two links with the same label', () => {
+ assert.throws(() => validateVariants([variant('a.json', ''), variant('b.json', '')]), /a\.json and b\.json have no meta\.title/)
+ })
+
+ it('fails on two blueprints with the same title', () => {
+ assert.throws(
+ () => validateVariants([variant('a.json', 'Shop'), variant('b.json', 'Shop')]),
+ /a\.json and b\.json have the same meta\.title 'Shop'/
+ )
+ })
+ })
+
+ describe('playgroundUrl', () => {
+ it('puts the blueprint in the hash as base64 JSON', () => {
+ const blueprint = { landingPage: '/wp-admin/', steps: [] }
+
+ assert.deepEqual(decodeLink(playgroundUrl(blueprint)), blueprint)
+ })
+
+ it('keeps non-ASCII text, which Playground decodes as UTF-8', () => {
+ const blueprint = { steps: [{ step: 'wp-cli', command: 'wp option update blogname "Café ✓"' }] }
+
+ assert.deepEqual(decodeLink(playgroundUrl(blueprint)), blueprint)
+ assert.match(playgroundUrl(blueprint), /^[\x21-\x7e]+$/, 'only printable ASCII in the link')
+ })
+ })
+
+ describe('formatStamp and badgeUrl', () => {
+ it('prints the time like date "+%m/%d %I:%M %p %Z", with EST in winter and EDT in summer', () => {
+ assert.equal(formatStamp(WINTER, 'America/New_York'), '01/15 05:43 PM EST')
+ assert.equal(formatStamp(SUMMER, 'America/New_York'), '07/15 05:43 PM EDT')
+ })
+
+ it('takes another time zone', () => {
+ assert.equal(formatStamp(WINTER, 'America/Chicago'), '01/15 04:43 PM CST')
+ })
+
+ it('fails on a time zone that does not exist', () => {
+ assert.throws(() => formatStamp(WINTER, 'America/Atlantis'), /'America\/Atlantis' is not a time zone/)
+ })
+
+ it('encodes spaces and slashes, and doubles dashes so shields.io does not split on them', () => {
+ assert.equal(
+ badgeUrl('Build Started', '01/15 02:43 PM GMT-3', 'blue'),
+ 'https://img.shields.io/badge/Build%20Started-01%2F15%2002%3A43%20PM%20GMT--3-blue'
+ )
+ })
+ })
+
+ describe('buildComment', () => {
+ it('writes "building" with the marker first and the short commit', () => {
+ const body = buildComment({ status: 'building', marker: MARKER, sha: SHA, stamp: '01/15 05:43 PM EST' })
+
+ assert.equal(
+ body,
+ [
+ MARKER,
+ '🏗️ Playground preview building for commit 15e470c',
+ '',
+ '',
+ '',
+ 'This takes a few minutes.',
+ '',
+ ].join('\n')
+ )
+ })
+
+ it('writes "failed" with a link to the run', () => {
+ const body = buildComment({
+ status: 'failed',
+ marker: MARKER,
+ sha: SHA,
+ stamp: '01/15 05:43 PM EST',
+ runUrl: 'https://github.com/example-org/my-plugin/actions/runs/777',
+ })
+
+ assert.ok(body.startsWith(`${MARKER}\n❌ Playground preview build failed for commit 15e470c\n`))
+ assert.match(body, /Build%20Failed-.*-red\)/)
+ assert.match(body, /See the \[workflow run\]\(https:\/\/github\.com\/example-org\/my-plugin\/actions\/runs\/777\) for the error\./)
+ })
+
+ it('writes one link per blueprint, the zip link, then the notes', () => {
+ const variants = [
+ { file: 'a.json', title: '', description: 'Debug output is on.', blueprint: { steps: [] } },
+ { file: 'b.json', title: 'With [sample] data', description: 'Opens slower.', blueprint: { steps: [1] } },
+ ]
+ const lines = buildComment({ status: 'ready', marker: MARKER, sha: SHA, stamp: 's', variants, zipUrl: ZIP_URL }).split('\n')
+
+ assert.equal(lines[0], MARKER)
+ assert.equal(lines[1], '🎉 Playground preview ready for commit 15e470c')
+ assert.equal(lines[3], '')
+ assert.equal(lines[5], `- 🛝 [Try in WordPress Playground](${playgroundUrl({ steps: [] })})`)
+ assert.equal(lines[6], `- 🛝 [Try in WordPress Playground (With \\[sample\\] data)](${playgroundUrl({ steps: [1] })})`)
+ assert.equal(lines[7], `- 💾 [Download zip](${ZIP_URL})`)
+ assert.equal(lines[9], 'Debug output is on.')
+ assert.equal(lines[10], '**With [sample] data:** Opens slower.')
+ })
+ })
+
+ describe('checkCommentLength', () => {
+ it('passes a comment GitHub accepts', () => {
+ assert.doesNotThrow(() => checkCommentLength('x'.repeat(MAX_COMMENT_LENGTH), []))
+ })
+
+ it('fails a longer one and names each link length', () => {
+ const variants = [{ file: 'big.json', blueprint: { a: 'b' } }]
+
+ assert.throws(
+ () => checkCommentLength('x'.repeat(MAX_COMMENT_LENGTH + 1), variants),
+ /would be 65537 characters, and GitHub takes at most 65536\. Link lengths: big\.json: \d+\./
+ )
+ })
+ })
+
+ describe('readyComment', () => {
+ it('uses the default blueprint when no file is listed', () => {
+ const { variants } = readyComment({ files: [], cwd: checkoutWith(), zipUrl: ZIP_URL, marker: MARKER, sha: SHA, stamp: 's' })
+
+ assert.equal(variants.length, 1)
+ assert.equal(variants[0].file, DEFAULT_BLUEPRINT)
+ const steps = variants[0].blueprint.steps
+ assert.deepEqual(steps.find((step) => step.step === 'defineWpConfigConsts').consts, { WP_DEBUG: true, WP_DEBUG_DISPLAY: true })
+ assert.deepEqual(steps.find((step) => step.step === 'installPlugin'), {
+ step: 'installPlugin',
+ pluginData: { resource: 'url', url: ZIP_URL },
+ options: { activate: true },
+ })
+ })
+
+ it('builds the two example blueprints from templates/ into two links', () => {
+ const files = [path.join(TEMPLATES, 'blueprint.json'), path.join(TEMPLATES, 'blueprint-sample-data.json')]
+ const { body, variants } = readyComment({ files, cwd: '/', zipUrl: ZIP_URL, marker: MARKER, sha: SHA, stamp: 's' })
+
+ assert.deepEqual(variants.map((variant) => variant.title), ['', 'With sample data'])
+ assert.equal(body.match(/🛝/g).length, 2)
+ const seeded = decodeLink(body.match(/\(With sample data\)\]\((.+?)\)/)[1])
+ assert.ok(seeded.steps.some((step) => step.step === 'wp-cli'), 'the WP-CLI steps survive')
+ })
+
+ it('fails when a blueprint would make the comment too long', () => {
+ const big = blueprintWith(undefined, { landingPage: `/${'x'.repeat(60000)}` })
+ const dir = checkoutWith({ 'big.json': big })
+
+ assert.throws(() => readyComment({ files: ['big.json'], cwd: dir, zipUrl: ZIP_URL, marker: MARKER, sha: SHA, stamp: 's' }), /GitHub takes at most 65536/)
+ })
+ })
+
+ describe('upsertComment', () => {
+ it('replaces the comment that starts with the marker, even on a later page', async () => {
+ const { github, requests } = await octokitFor([
+ {
+ method: 'GET',
+ path: /^\/repos\/example-org\/my-plugin\/issues\/12\/comments/,
+ responses: [
+ {
+ body: [{ id: 1, body: `> ${MARKER} quoted by a person` }],
+ headers: { link: '; rel="next"' },
+ },
+ { body: [{ id: 2, body: `${MARKER}\nold` }] },
+ ],
+ },
+ { method: 'PATCH', path: '/repos/example-org/my-plugin/issues/comments/2', body: { id: 2 } },
+ ])
+
+ const result = await upsertComment({ github, owner: 'example-org', repo: 'my-plugin', issue: 12, marker: MARKER, body: 'new' })
+
+ assert.deepEqual(result, { id: 2, created: false })
+ assert.deepEqual(requests.at(-1), {
+ method: 'PATCH',
+ url: 'https://api.github.com/repos/example-org/my-plugin/issues/comments/2',
+ path: '/repos/example-org/my-plugin/issues/comments/2',
+ body: { body: 'new' },
+ })
+ })
+
+ it('creates a comment when none starts with the marker', async () => {
+ const { github, requests } = await octokitFor(NO_COMMENT)
+
+ const result = await upsertComment({ github, owner: 'example-org', repo: 'my-plugin', issue: 12, marker: MARKER, body: 'new' })
+
+ assert.deepEqual(result, { id: 99, created: true })
+ assert.equal(requests.at(-1).method, 'POST')
+ assert.deepEqual(requests.at(-1).body, { body: 'new' })
+ })
+ })
+
+ describe('run', () => {
+ it('posts the links under the marker named after the repository', async (t) => {
+ const { github, requests, core, summary } = await actionsFor(t, NO_COMMENT)
+
+ await run({ github, core, env: actionEnv({ GITHUB_WORKSPACE: checkoutWith() }), now: WINTER })
+
+ const body = requests.at(-1).body.body
+ assert.ok(body.startsWith('\n🎉 Playground preview ready for commit 15e470c\n'))
+ assert.match(body, /Built-01%2F15%2005%3A43%20PM%20EST-darkgreen/)
+ assert.match(summary(), /Playground preview ready/, 'the links are in the job summary too')
+ })
+
+ it('uses the marker it is given', async (t) => {
+ const { github, requests, core } = await actionsFor(t, NO_COMMENT)
+
+ await run({ github, core, env: actionEnv({ INPUT_STATUS: 'building', INPUT_COMMENT_MARKER: '' }), now: WINTER })
+
+ assert.ok(requests.at(-1).body.body.startsWith('\n🏗️ Playground preview building'))
+ })
+
+ it('links "failed" to this run', async (t) => {
+ const { github, requests, core } = await actionsFor(t, NO_COMMENT)
+
+ await run({ github, core, env: actionEnv({ INPUT_STATUS: 'failed', INPUT_ZIP_URL: '' }), now: WINTER })
+
+ assert.match(requests.at(-1).body.body, /\(https:\/\/github\.com\/example-org\/my-plugin\/actions\/runs\/777\)/)
+ })
+
+ it('refuses an unknown status before calling the API', async (t) => {
+ const { github, requests, core } = await actionsFor(t, NO_COMMENT)
+
+ await assert.rejects(run({ github, core, env: actionEnv({ INPUT_STATUS: 'done' }) }), /Unknown status 'done'/)
+ assert.equal(requests.length, 0)
+ })
+
+ it('refuses to run outside a pull request', async (t) => {
+ const { github, core } = await actionsFor(t, NO_COMMENT)
+
+ await assert.rejects(run({ github, core, env: actionEnv({ INPUT_PULL_REQUEST: '' }) }), /only runs for pull_request events/)
+ })
+
+ it('posts nothing when a blueprint is broken, so the "failed" job can say so', async (t) => {
+ const { github, requests, core } = await actionsFor(t, NO_COMMENT)
+ const dir = checkoutWith({ 'bad.json': 'not json' })
+
+ await assert.rejects(run({ github, core, env: actionEnv({ GITHUB_WORKSPACE: dir, INPUT_BLUEPRINTS: 'bad.json' }) }), /bad\.json is not valid JSON/)
+ assert.equal(requests.length, 0)
+ })
+ })
+
+ describe('as a script', () => {
+ it('prints the comment for local blueprint files and posts nothing', () => {
+ const result = spawnSync('node', [SCRIPT, '--zip-url', ZIP_URL, path.join(FIXTURES, 'minimal.json')], {
+ encoding: 'utf8',
+ env: { PATH: process.env.PATH },
+ })
+
+ assert.equal(result.status, 0, result.stderr)
+ assert.match(result.stdout, /^\n🎉 Playground preview ready for commit local\n/)
+ assert.match(result.stdout, /- 🛝 \[Try in WordPress Playground\]\(https:\/\/playground\.wordpress\.net\/#/)
+ })
+
+ it('exits 1 with the reason on a bad blueprint', () => {
+ const result = spawnSync('node', [SCRIPT, '--zip-url', ZIP_URL, path.join(FIXTURES, 'no-zip.json')], {
+ encoding: 'utf8',
+ env: { PATH: process.env.PATH },
+ })
+
+ assert.equal(result.status, 1)
+ assert.match(result.stderr, /no-zip\.json does not use \{\{ZIP_URL\}\}/)
+ })
+ })
+})