From 5bbc45f43b4c9a19e418b5ac01adbcf2d3216735 Mon Sep 17 00:00:00 2001 From: Paulo Iankoski Date: Fri, 30 Oct 2026 21:22:29 -0300 Subject: [PATCH 1/9] feat(playground-preview): add the shared Playground preview workflow and comment action --- .github/actions/playground-preview/README.md | 210 +++++++ .github/actions/playground-preview/action.yml | 78 +++ .../blueprints/default.json | 37 ++ .../playground-preview/playground-preview.js | 555 ++++++++++++++++++ .github/workflows/playground-preview.yml | 266 +++++++++ .../playground/blueprint-sample-data.json | 66 +++ templates/playground/blueprint.json | 47 ++ templates/workflows/playground-preview.yml | 48 ++ .../playground-preview/fixtures/minimal.json | 14 + .../playground-preview/fixtures/no-zip.json | 8 + .../playground-preview.test.js | 494 ++++++++++++++++ 11 files changed, 1823 insertions(+) create mode 100644 .github/actions/playground-preview/README.md create mode 100644 .github/actions/playground-preview/action.yml create mode 100644 .github/actions/playground-preview/blueprints/default.json create mode 100644 .github/actions/playground-preview/playground-preview.js create mode 100644 .github/workflows/playground-preview.yml create mode 100644 templates/playground/blueprint-sample-data.json create mode 100644 templates/playground/blueprint.json create mode 100644 templates/workflows/playground-preview.yml create mode 100644 tests/actions/playground-preview/fixtures/minimal.json create mode 100644 tests/actions/playground-preview/fixtures/no-zip.json create mode 100644 tests/actions/playground-preview/playground-preview.test.js 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 `<caller job id> / <job name>`. 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 | `<!-- playground-preview:<owner>/<repo> -->` | 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 + `<!-- playground-preview:<owner>/<repo> -->`. + 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}`, + '', + `![Build timestamp](${badgeUrl('Build Started', stamp, 'blue')})`, + '', + 'This takes a few minutes.', + '', + ].join('\n') + } + + if (status === 'failed') { + return [ + marker, + `❌ Playground preview build failed for commit ${commit}`, + '', + `![Build timestamp](${badgeUrl('Build Failed', stamp, 'red')})`, + '', + `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}`, + '', + `![Build timestamp](${badgeUrl('Built', stamp, 'darkgreen')})`, + '', + ...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 || `<!-- playground-preview:${env.GITHUB_REPOSITORY} -->` + 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: '<!-- playground-preview:local -->', + 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..20e804a --- /dev/null +++ b/.github/workflows/playground-preview.yml @@ -0,0 +1,266 @@ +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. +# zip builds the zip and uploads it. 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 <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. 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: '<!-- playground-preview:${{ github.repository }} -->' + +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. It runs next to "zip", and "button" waits for it, so + # the finished comment can never be overwritten by this one. + 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: stellarwp/plugin-toolbox/.github/actions/playground-preview@main + 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 + 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 -- --version > /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" + + # Full path, never ./: in a called workflow a relative path resolves against the caller's + # repository. @main, like the callers: this repository publishes no releases. + - name: Post the links + uses: stellarwp/plugin-toolbox/.github/actions/playground-preview@main + 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: stellarwp/plugin-toolbox/.github/actions/playground-preview@main + 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 <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 = '<!-- playground-preview:example-org/my-plugin -->' + +/** 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', + '', + '![Build timestamp](https://img.shields.io/badge/Build%20Started-01%2F15%2005%3A43%20PM%20EST-blue)', + '', + '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], '![Build timestamp](https://img.shields.io/badge/Built-s-darkgreen)') + 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: '<https://api.github.com/repos/example-org/my-plugin/issues/12/comments?per_page=100&page=2>; 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('<!-- playground-preview:example-org/my-plugin -->\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: '<!-- custom -->' }), now: WINTER }) + + assert.ok(requests.at(-1).body.body.startsWith('<!-- custom -->\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, /^<!-- playground-preview:local -->\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\}\}/) + }) + }) +}) From 9862c63058cf39fc106653267f2ff1ac985d9a22 Mon Sep 17 00:00:00 2001 From: Paulo Iankoski <me@wpdev.com.br> Date: Fri, 30 Oct 2026 21:30:37 -0300 Subject: [PATCH 2/9] fix(playground-preview): start the zip build after the secrets check --- .github/workflows/playground-preview.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/playground-preview.yml b/.github/workflows/playground-preview.yml index 20e804a..e47de31 100644 --- a/.github/workflows/playground-preview.yml +++ b/.github/workflows/playground-preview.yml @@ -9,8 +9,9 @@ name: Playground Preview # templates/workflows/playground-preview.yml. # # Four jobs, so the comment never stays on "building": -# pending replaces the last comment with "building" at once. -# zip builds the zip and uploads it. Skipped when the caller passes `zip-url`. +# 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. # @@ -122,8 +123,9 @@ env: 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. It runs next to "zip", and "button" waits for it, so - # the finished comment can never be overwritten by this one. + # 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 }} @@ -165,6 +167,7 @@ jobs: 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: From 5441eb32a5ff26d4ed8c6edcb95dc66bc5845771 Mon Sep 17 00:00:00 2001 From: Paulo Iankoski <me@wpdev.com.br> Date: Fri, 30 Oct 2026 22:38:54 -0300 Subject: [PATCH 3/9] fix(playground-preview): bootstrap pup without forwarding arguments --- .github/workflows/playground-preview.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/playground-preview.yml b/.github/workflows/playground-preview.yml index e47de31..a71c059 100644 --- a/.github/workflows/playground-preview.yml +++ b/.github/workflows/playground-preview.yml @@ -233,7 +233,7 @@ jobs: echo "url=$ZIP_URL" >> "$GITHUB_OUTPUT" exit 0 fi - composer run -q pup -- --version > /dev/null + 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" From be49a9ef940bbf115b09d400939dcb2d2d148af0 Mon Sep 17 00:00:00 2001 From: Paulo Iankoski <me@wpdev.com.br> Date: Fri, 30 Oct 2026 23:11:07 -0300 Subject: [PATCH 4/9] fix(playground-preview): call the comment action with the self-repository reference --- .github/workflows/playground-preview.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/playground-preview.yml b/.github/workflows/playground-preview.yml index a71c059..c7bed8e 100644 --- a/.github/workflows/playground-preview.yml +++ b/.github/workflows/playground-preview.yml @@ -72,7 +72,7 @@ on: type: string default: 'https://evnt.is/test-zip?file=' runs-on: - description: 'Runner label of the job that posts the links. The other jobs run on ubuntu-latest.' + 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' @@ -137,7 +137,7 @@ jobs: # A failed API call here must not stop the preview. - name: Post "building" continue-on-error: true - uses: stellarwp/plugin-toolbox/.github/actions/playground-preview@main + uses: $/.github/actions/playground-preview with: status: building comment-marker: ${{ env.COMMENT_MARKER }} @@ -238,10 +238,12 @@ jobs: name=$(php -d display_errors=0 bin/pup.phar zip-name "$version") echo "url=${ZIP_LINK_BASE}${name}.zip" >> "$GITHUB_OUTPUT" - # Full path, never ./: in a called workflow a relative path resolves against the caller's - # repository. @main, like the callers: this repository publishes no releases. + # `$/` 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: stellarwp/plugin-toolbox/.github/actions/playground-preview@main + uses: $/.github/actions/playground-preview with: status: ready zip-url: ${{ steps.zip.outputs.url }} @@ -262,7 +264,7 @@ jobs: steps: - name: Post "failed" continue-on-error: true - uses: stellarwp/plugin-toolbox/.github/actions/playground-preview@main + uses: $/.github/actions/playground-preview with: status: failed comment-marker: ${{ env.COMMENT_MARKER }} From 43c1161e1295ca9d0facd688943e72f9436f35d9 Mon Sep 17 00:00:00 2001 From: Paulo Iankoski <me@wpdev.com.br> Date: Tue, 3 Nov 2026 14:25:09 -0300 Subject: [PATCH 5/9] Write the Playground comment with the shared pr-comment module --- .../playground-preview/playground-preview.js | 47 +++-------------- .../playground-preview.test.js | 52 +++++-------------- 2 files changed, 18 insertions(+), 81 deletions(-) diff --git a/.github/actions/playground-preview/playground-preview.js b/.github/actions/playground-preview/playground-preview.js index dc462c3..659812a 100644 --- a/.github/actions/playground-preview/playground-preview.js +++ b/.github/actions/playground-preview/playground-preview.js @@ -23,11 +23,10 @@ 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 +// This file is loaded as an ordinary module, so a relative path resolves against it, both in the +// tests and in the copy of the repository the runner downloads. The comment is written by the same +// code every action here uses. +const { upsertComment, MAX_COMMENT_LENGTH } = require('../pr-comment/pr-comment.js') /** A placeholder: a name in capitals between double braces. Anything else is left alone. */ const PLACEHOLDER = /\{\{([A-Z][A-Z0-9_]*)\}\}/g @@ -360,7 +359,8 @@ function buildComment({ status, marker, sha, stamp, variants = [], zipUrl = '', /** * Fails when the comment is longer than GitHub accepts, naming each link's length, so the blueprint - * to shorten is obvious. + * to shorten is obvious. The hash is never sent to a server, so the comment, not the link, is the + * limit that applies first. * * @param {string} body The comment. * @param {object[]} variants The variants in it. @@ -406,40 +406,6 @@ function readyComment({ files, cwd, zipUrl, marker, sha, stamp }) { 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. @@ -549,7 +515,6 @@ module.exports = { buildComment, checkCommentLength, readyComment, - upsertComment, DEFAULT_BLUEPRINT, MAX_COMMENT_LENGTH, } diff --git a/tests/actions/playground-preview/playground-preview.test.js b/tests/actions/playground-preview/playground-preview.test.js index c087d07..b893f00 100644 --- a/tests/actions/playground-preview/playground-preview.test.js +++ b/tests/actions/playground-preview/playground-preview.test.js @@ -21,7 +21,6 @@ const { buildComment, checkCommentLength, readyComment, - upsertComment, DEFAULT_BLUEPRINT, MAX_COMMENT_LENGTH, } = require('../../../.github/actions/playground-preview/playground-preview.js') @@ -380,45 +379,6 @@ describe('playground-preview.js', () => { }) }) - 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: '<https://api.github.com/repos/example-org/my-plugin/issues/12/comments?per_page=100&page=2>; 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) @@ -431,6 +391,18 @@ describe('playground-preview.js', () => { assert.match(summary(), /Playground preview ready/, 'the links are in the job summary too') }) + it('replaces the earlier preview comment rather than adding a second one', async (t) => { + const { github, requests, core } = await actionsFor(t, [ + { method: 'GET', path: /^\/repos\/example-org\/my-plugin\/issues\/12\/comments/, body: [{ id: 5, body: `${MARKER}\nbuilding` }] }, + { method: 'PATCH', path: '/repos/example-org/my-plugin/issues/comments/5', body: { id: 5 } }, + ]) + + await run({ github, core, env: actionEnv({ INPUT_STATUS: 'building' }), now: WINTER }) + + assert.equal(requests.at(-1).method, 'PATCH') + assert.equal(requests.filter((request) => request.method === 'POST').length, 0) + }) + it('uses the marker it is given', async (t) => { const { github, requests, core } = await actionsFor(t, NO_COMMENT) From 0730c5b6871ab92b3c98c1b031e5fb9ec84fbd04 Mon Sep 17 00:00:00 2001 From: Paulo Iankoski <me@wpdev.com.br> Date: Tue, 3 Nov 2026 14:25:09 -0300 Subject: [PATCH 6/9] Say that the Playground comment comes from pr-comment --- .github/actions/playground-preview/README.md | 5 ++++- .github/actions/playground-preview/action.yml | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/actions/playground-preview/README.md b/.github/actions/playground-preview/README.md index 093df06..9d6cde8 100644 --- a/.github/actions/playground-preview/README.md +++ b/.github/actions/playground-preview/README.md @@ -73,6 +73,9 @@ One comment per pull request, replaced on every push. It is found by a hidden fi `<!-- playground-preview:<owner>/<repo> -->`. Pull requests from forks get no preview: their runs cannot see the bucket secrets, and their token cannot write a comment. +The comment is written by the shared [pr-comment](../pr-comment/) module, the same code every +action in this repository uses to find and replace a comment. + ## Blueprints A [blueprint](https://wordpress.github.io/wordpress-playground/blueprints/) is a JSON file that @@ -206,5 +209,5 @@ the blueprint. A file that breaks a rule above exits 1 with the reason. `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 +the action would send. Finding and replacing the comment is tested with pr-comment. 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 index 396abe2..02f19ad 100644 --- a/.github/actions/playground-preview/action.yml +++ b/.github/actions/playground-preview/action.yml @@ -7,7 +7,8 @@ # 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. +# or build: the file in the repository is the file that runs. The comment is written by +# ../pr-comment/pr-comment.js. name: Playground preview description: Posts the pull request comment with WordPress Playground links for a plugin zip From 55af73c4871a6622d2ab069a17cae191796791f9 Mon Sep 17 00:00:00 2001 From: Paulo Iankoski <me@wpdev.com.br> Date: Tue, 3 Nov 2026 17:35:53 -0300 Subject: [PATCH 7/9] Say that composer pup installs and runs pup, and why bin/pup.phar still matters --- .github/actions/playground-preview/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/actions/playground-preview/README.md b/.github/actions/playground-preview/README.md index 9d6cde8..fb1f78e 100644 --- a/.github/actions/playground-preview/README.md +++ b/.github/actions/playground-preview/README.md @@ -41,7 +41,9 @@ jobs: - 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: +- A `pup` Composer script that downloads `pup.phar` when it is missing and then runs it, so one + `composer pup` both installs and runs pup. The workflow also runs `php bin/pup.phar` itself, to + ask pup for the zip name, so keep the phar at `bin/pup.phar`: ```json "pup": [ From 69b5730d8b88b25784251c99127f09db99f853a2 Mon Sep 17 00:00:00 2001 From: Paulo Iankoski <me@wpdev.com.br> Date: Wed, 4 Nov 2026 14:02:10 -0300 Subject: [PATCH 8/9] Playground preview writes the text; the workflow posts it with pr-comment and names the zip with pup --- .github/actions/playground-preview/README.md | 62 +++++++++----- .github/actions/playground-preview/action.yml | 40 ++++----- .../playground-preview/playground-preview.js | 46 ++++++----- .github/workflows/playground-preview.yml | 69 ++++++++++++---- templates/workflows/playground-preview.yml | 3 + .../playground-preview.test.js | 82 ++++++++++--------- 6 files changed, 188 insertions(+), 114 deletions(-) diff --git a/.github/actions/playground-preview/README.md b/.github/actions/playground-preview/README.md index fb1f78e..cf3b26b 100644 --- a/.github/actions/playground-preview/README.md +++ b/.github/actions/playground-preview/README.md @@ -1,13 +1,16 @@ # playground-preview -Writes one comment on a pull request with links that open the pull request's build in +Writes the text of a pull request comment 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. +The action only writes the text, to a file. It posts nothing. The +[pr-comment](../pr-comment/) action posts the file. + 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 +the zip, runs this action, posts its text with pr-comment and keeps the comment up to date. A caller to copy is in [`templates/workflows/playground-preview.yml`](../../../templates/workflows/playground-preview.yml): ```yaml @@ -42,15 +45,9 @@ jobs: [stellarwp/github-actions' `zip.yml`](https://github.com/stellarwp/github-actions/blob/main/.github/workflows/zip.yml) with pup. - A `pup` Composer script that downloads `pup.phar` when it is missing and then runs it, so one - `composer pup` both installs and runs pup. The workflow also runs `php bin/pup.phar` itself, to - ask pup for the zip name, so keep the phar at `bin/pup.phar`: - - ```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" - ] - ``` + `composer pup` both installs and runs pup. The zip workflow runs `composer -- pup`. Where the phar + lives is up to the repository: this workflow downloads its own copy of pup to ask for the zip name + (see [The zip link](#the-zip-link)), and never reads the repository's. - The bucket secrets, passed one by one. `secrets: inherit` only works when the caller and this repository are in the same organization. @@ -75,8 +72,9 @@ One comment per pull request, replaced on every push. It is found by a hidden fi `<!-- playground-preview:<owner>/<repo> -->`. Pull requests from forks get no preview: their runs cannot see the bucket secrets, and their token cannot write a comment. -The comment is written by the shared [pr-comment](../pr-comment/) module, the same code every -action in this repository uses to find and replace a comment. +The comment is posted by the shared [pr-comment](../pr-comment/) action, the same code every +workflow in this repository uses to find and replace a comment. The workflow passes it the file this +action writes and the same hidden first line. ## Blueprints @@ -127,7 +125,8 @@ repositories work the same as public ones. - 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 +links would make the comment longer, the step that writes the text fails and names each link's +length. (pr-comment refuses a longer body too, but only says the number.) Make the largest blueprint smaller, for example by moving setup into a plugin or a WP-CLI command. ## The zip link @@ -137,6 +136,12 @@ pup names the zip after the version, the commit time and the commit hash, for ex 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). +It asks with the shared [pup](../pup/) action, which downloads the pup release named by +`pup-version` (`2.0.0` by default) and reads the name from it. **Set `pup-version` to the release +your `composer pup` script downloads.** The zip workflow builds the zip with the repository's own +pup, and a name made by another release could differ from it, which would make the link point at a +zip that does not exist. + 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. @@ -156,6 +161,7 @@ preview failed`. Update branch protection if it required the old names. | `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 | +| `pup-version` | `2.0.0` | The pup release that works out the zip's name. Use the one your `composer pup` script downloads | | `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 | @@ -189,8 +195,26 @@ not start the run. | `comment-marker` | no | `<!-- playground-preview:<owner>/<repo> -->` | 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` | + +| Output | What it is | +|---|---| +| `comment-file` | Path of the file that holds the comment text. Pass it to pr-comment as `body-file` | + +In a workflow, the two actions go together: + +```yaml +- id: links + uses: stellarwp/plugin-toolbox/.github/actions/playground-preview@main + with: + status: ready + zip-url: https://example.com/my-plugin.zip +- uses: stellarwp/plugin-toolbox/.github/actions/pr-comment@main + with: + marker: '<!-- playground-preview:example-org/my-plugin -->' + body-file: ${{ steps.links.outputs.comment-file }} +``` + +The action needs no token: it does not call the GitHub API. ## Running it locally @@ -210,6 +234,6 @@ 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. Finding and replacing the comment is tested with pr-comment. It also runs the script from the command line. Run `npm install` once, then -`node --test`. +`@actions/core`, and asserts the text it writes and the output it sets. Finding and replacing the +comment is tested with pr-comment. 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 index 02f19ad..7bffba6 100644 --- a/.github/actions/playground-preview/action.yml +++ b/.github/actions/playground-preview/action.yml @@ -1,16 +1,15 @@ -# 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. +# Builds the text of the "Try in WordPress Playground" comment for a pull request: "building", the +# links when the zip is ready, or "failed". The text starts with a hidden first line, the marker. # -# 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. +# It neither builds the zip nor posts the comment. The reusable workflow +# .github/workflows/playground-preview.yml builds the zip, and passes the file this action writes +# (the `comment-file` output) to the pr-comment action, which finds the earlier comment by its marker +# and replaces it. So the action also works with a zip from anywhere, and with any way of posting. # # 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. The comment is written by -# ../pr-comment/pr-comment.js. +# 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 +description: Writes the pull request comment text with WordPress Playground links for a plugin zip inputs: status: @@ -44,19 +43,19 @@ inputs: 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 }} + +outputs: + comment-file: + description: >- + Path of the file that holds the comment text, under the runner's temp directory. Pass it to + the pr-comment action as `body-file`. + value: ${{ steps.comment.outputs.comment-file }} runs: using: 'composite' steps: - - name: Write the Playground preview comment + - name: Write the Playground preview comment text + id: comment uses: actions/github-script@v9 env: # github-script's require() resolves against the workspace of the repository being built, @@ -70,10 +69,7 @@ runs: 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 }) + await run({ core, env: process.env }) diff --git a/.github/actions/playground-preview/playground-preview.js b/.github/actions/playground-preview/playground-preview.js index 659812a..99e23d6 100644 --- a/.github/actions/playground-preview/playground-preview.js +++ b/.github/actions/playground-preview/playground-preview.js @@ -1,20 +1,24 @@ '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. + * Writes the text of the "Try in WordPress Playground" comment for 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. * + * This file only builds the text. It never calls the GitHub API: the workflow hands the file run() + * writes to the pr-comment action, which finds and replaces the comment. + * * 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(). + * apart from reading the blueprint files, so a test can assert each one. Run as a script, it prints + * the comment for local blueprint files; see main(). */ const fs = require('node:fs') +const os = require('node:os') const path = require('node:path') /** Used when the caller lists no blueprint files. Works for any plugin. */ @@ -23,10 +27,15 @@ 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/' -// This file is loaded as an ordinary module, so a relative path resolves against it, both in the -// tests and in the copy of the repository the runner downloads. The comment is written by the same -// code every action here uses. -const { upsertComment, MAX_COMMENT_LENGTH } = require('../pr-comment/pr-comment.js') +/** + * The most characters GitHub takes in a comment. The pr-comment action refuses a longer body too, + * but only with that number. Checking here as well lets the message name each link's length, so the + * blueprint to shorten is obvious, and fails the step that builds the text, not the one that posts it. + */ +const MAX_COMMENT_LENGTH = 65536 + +/** The file the comment text is written to, under the runner's temp directory. */ +const COMMENT_FILE = 'playground-preview-comment.md' /** A placeholder: a name in capitals between double braces. Anything else is left alone. */ const PLACEHOLDER = /\{\{([A-Z][A-Z0-9_]*)\}\}/g @@ -410,26 +419,22 @@ function readyComment({ files, cwd, zipUrl, marker, sha, stamp }) { * The entry point actions/github-script calls. The inputs arrive as INPUT_* variables, so they * are data and never part of the script. * + * It writes the comment to a file and sets the `comment-file` output to its path. The workflow + * passes that file to the pr-comment action. + * * @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() }) { +async function run({ 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 || `<!-- playground-preview:${env.GITHUB_REPOSITORY} -->` const stamp = formatStamp(now, env.INPUT_TIME_ZONE || 'America/New_York') const sha = env.INPUT_SHA @@ -449,11 +454,13 @@ async function run({ github, core, env, now = new Date() }) { body = buildComment({ status, marker, sha, stamp, runUrl }) } + const file = path.join(env.RUNNER_TEMP || os.tmpdir(), COMMENT_FILE) + fs.writeFileSync(file, body) + core.setOutput('comment-file', file) + // 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}).`) + core.info(`Wrote the ${status} comment to ${file}.`) } /** @@ -517,4 +524,5 @@ module.exports = { readyComment, DEFAULT_BLUEPRINT, MAX_COMMENT_LENGTH, + COMMENT_FILE, } diff --git a/.github/workflows/playground-preview.yml b/.github/workflows/playground-preview.yml index c7bed8e..4e1b579 100644 --- a/.github/workflows/playground-preview.yml +++ b/.github/workflows/playground-preview.yml @@ -15,6 +15,9 @@ name: Playground Preview # button works out the zip's public link and posts the Playground links. # failed replaces "building" with "failed" when anything above failed. # +# The playground-preview action only writes the text of the comment. The pr-comment action posts it, +# so the comment is found and replaced by the same code every workflow here uses. +# # 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. # @@ -64,6 +67,14 @@ on: required: false type: string default: '' + pup-version: + description: >- + The pup release that works out the zip's name, e.g. `2.0.0`. Use the release the + repository's own `composer pup` script downloads: the zip workflow builds the zip with that + one, and a name made by another release could differ, which would break the links. + required: false + type: string + default: '2.0.0' zip-link-base: description: >- Public link to a zip in the bucket, without the file name. The zip's name, as pup makes @@ -134,8 +145,9 @@ jobs: permissions: pull-requests: write steps: - # A failed API call here must not stop the preview. - - name: Post "building" + # A failed comment here must not stop the preview. + - name: Write the "building" comment + id: building continue-on-error: true uses: $/.github/actions/playground-preview with: @@ -143,6 +155,13 @@ jobs: comment-marker: ${{ env.COMMENT_MARKER }} time-zone: ${{ inputs.time-zone }} + - name: Post "building" + continue-on-error: true + uses: $/.github/actions/pr-comment + with: + marker: ${{ env.COMMENT_MARKER }} + body-file: ${{ steps.building.outputs.comment-file }} + # 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 @@ -211,8 +230,8 @@ jobs: 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. + # pup is a PHP archive, and the runner image's PHP depends on its version and on a custom + # `runs-on`, so set up a known one to run it. - name: Set Up PHP if: ${{ inputs.zip-url == '' }} uses: shivammathur/setup-php@v2 @@ -222,27 +241,35 @@ jobs: # 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. + # Same release, same commands, same commit, same name. + - name: Ask pup for the zip name + id: pup + if: ${{ inputs.zip-url == '' }} + uses: $/.github/actions/pup + with: + pup-version: ${{ inputs.pup-version }} + read: zip-name + dev: 'true' + - name: Find the zip id: zip env: ZIP_URL: ${{ inputs.zip-url }} ZIP_LINK_BASE: ${{ inputs.zip-link-base }} + ZIP_NAME: ${{ steps.pup.outputs.zip-name }} run: | if [ -n "$ZIP_URL" ]; then echo "url=$ZIP_URL" >> "$GITHUB_OUTPUT" - exit 0 + else + echo "url=${ZIP_LINK_BASE}${ZIP_NAME}.zip" >> "$GITHUB_OUTPUT" 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 + # `$/` is this repository at the commit that runs this workflow, so the actions are always the + # same revision as the workflow, whichever ref the caller used, and they need 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 + - name: Write the links + id: links uses: $/.github/actions/playground-preview with: status: ready @@ -251,6 +278,12 @@ jobs: comment-marker: ${{ env.COMMENT_MARKER }} time-zone: ${{ inputs.time-zone }} + - name: Post the links + uses: $/.github/actions/pr-comment + with: + marker: ${{ env.COMMENT_MARKER }} + body-file: ${{ steps.links.outputs.comment-file }} + # 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: @@ -262,10 +295,18 @@ jobs: permissions: pull-requests: write steps: - - name: Post "failed" + - name: Write the "failed" comment + id: failed continue-on-error: true uses: $/.github/actions/playground-preview with: status: failed comment-marker: ${{ env.COMMENT_MARKER }} time-zone: ${{ inputs.time-zone }} + + - name: Post "failed" + continue-on-error: true + uses: $/.github/actions/pr-comment + with: + marker: ${{ env.COMMENT_MARKER }} + body-file: ${{ steps.failed.outputs.comment-file }} diff --git a/templates/workflows/playground-preview.yml b/templates/workflows/playground-preview.yml index d3d4895..feb6dee 100644 --- a/templates/workflows/playground-preview.yml +++ b/templates/workflows/playground-preview.yml @@ -34,6 +34,9 @@ jobs: with: # The oldest PHP the plugin supports, so vendor/ is built for it. php-version: '7.4' + # The pup release your `composer pup` script downloads. The zip name is asked of this one, and + # must equal the name the zip workflow's pup makes. + pup-version: '2.0.0' # Leave out for the default blueprint. Each file is one link in the comment. # blueprints: | # .github/playground/blueprint.json diff --git a/tests/actions/playground-preview/playground-preview.test.js b/tests/actions/playground-preview/playground-preview.test.js index b893f00..abbe7ec 100644 --- a/tests/actions/playground-preview/playground-preview.test.js +++ b/tests/actions/playground-preview/playground-preview.test.js @@ -23,8 +23,9 @@ const { readyComment, DEFAULT_BLUEPRINT, MAX_COMMENT_LENGTH, + COMMENT_FILE, } = require('../../../.github/actions/playground-preview/playground-preview.js') -const { actionsFor, octokitFor } = require('../../support/actions.js') +const { coreWithOutputs } = require('../../support/actions.js') const SCRIPT = path.join(__dirname, '../../../.github/actions/playground-preview/playground-preview.js') const FIXTURES = path.join(__dirname, 'fixtures') @@ -106,17 +107,11 @@ function actionEnv(overrides = {}) { INPUT_COMMENT_MARKER: '', INPUT_TIME_ZONE: 'America/New_York', INPUT_SHA: SHA, - INPUT_PULL_REQUEST: '12', + RUNNER_TEMP: fs.mkdtempSync(path.join(os.tmpdir(), 'playground-preview-temp-')), ...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', () => { @@ -380,64 +375,71 @@ describe('playground-preview.js', () => { }) 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) + /** What the `comment-file` output points to. */ + const written = (outputs) => fs.readFileSync(outputs()['comment-file'], 'utf8') + + it('writes the links under the marker named after the repository', async (t) => { + const { core, outputs, summary } = await coreWithOutputs(t) + const env = actionEnv({ GITHUB_WORKSPACE: checkoutWith() }) - await run({ github, core, env: actionEnv({ GITHUB_WORKSPACE: checkoutWith() }), now: WINTER }) + await run({ core, env, now: WINTER }) - const body = requests.at(-1).body.body + assert.equal(outputs()['comment-file'], path.join(env.RUNNER_TEMP, COMMENT_FILE)) + const body = written(outputs) assert.ok(body.startsWith('<!-- playground-preview:example-org/my-plugin -->\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('replaces the earlier preview comment rather than adding a second one', async (t) => { - const { github, requests, core } = await actionsFor(t, [ - { method: 'GET', path: /^\/repos\/example-org\/my-plugin\/issues\/12\/comments/, body: [{ id: 5, body: `${MARKER}\nbuilding` }] }, - { method: 'PATCH', path: '/repos/example-org/my-plugin/issues/comments/5', body: { id: 5 } }, - ]) + it('uses the marker it is given', async (t) => { + const { core, outputs } = await coreWithOutputs(t) - await run({ github, core, env: actionEnv({ INPUT_STATUS: 'building' }), now: WINTER }) + await run({ core, env: actionEnv({ INPUT_STATUS: 'building', INPUT_COMMENT_MARKER: '<!-- custom -->' }), now: WINTER }) - assert.equal(requests.at(-1).method, 'PATCH') - assert.equal(requests.filter((request) => request.method === 'POST').length, 0) + assert.ok(written(outputs).startsWith('<!-- custom -->\n🏗️ Playground preview building')) }) - it('uses the marker it is given', async (t) => { - const { github, requests, core } = await actionsFor(t, NO_COMMENT) + it('links "failed" to this run', async (t) => { + const { core, outputs } = await coreWithOutputs(t) - await run({ github, core, env: actionEnv({ INPUT_STATUS: 'building', INPUT_COMMENT_MARKER: '<!-- custom -->' }), now: WINTER }) + await run({ core, env: actionEnv({ INPUT_STATUS: 'failed', INPUT_ZIP_URL: '' }), now: WINTER }) - assert.ok(requests.at(-1).body.body.startsWith('<!-- custom -->\n🏗️ Playground preview building')) + assert.match(written(outputs), /\(https:\/\/github\.com\/example-org\/my-plugin\/actions\/runs\/777\)/) }) - it('links "failed" to this run', async (t) => { - const { github, requests, core } = await actionsFor(t, NO_COMMENT) + it('replaces the file of an earlier status, so the next step reads the newest text', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const env = actionEnv({ INPUT_STATUS: 'building' }) - await run({ github, core, env: actionEnv({ INPUT_STATUS: 'failed', INPUT_ZIP_URL: '' }), now: WINTER }) + await run({ core, env, now: WINTER }) + await run({ core, env: { ...env, INPUT_STATUS: 'failed' }, now: WINTER }) - assert.match(requests.at(-1).body.body, /\(https:\/\/github\.com\/example-org\/my-plugin\/actions\/runs\/777\)/) + assert.match(written(outputs), /build failed for commit 15e470c/) }) - it('refuses an unknown status before calling the API', async (t) => { - const { github, requests, core } = await actionsFor(t, NO_COMMENT) + it('refuses an unknown status and writes nothing', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const env = actionEnv({ INPUT_STATUS: 'done' }) - await assert.rejects(run({ github, core, env: actionEnv({ INPUT_STATUS: 'done' }) }), /Unknown status 'done'/) - assert.equal(requests.length, 0) + await assert.rejects(run({ core, env }), /Unknown status 'done'/) + assert.equal(fs.existsSync(path.join(env.RUNNER_TEMP, COMMENT_FILE)), false) + assert.equal(outputs()['comment-file'], undefined) }) - it('refuses to run outside a pull request', async (t) => { - const { github, core } = await actionsFor(t, NO_COMMENT) + it('writes nothing when a blueprint is broken, so the "failed" job can say so', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const dir = checkoutWith({ 'bad.json': 'not json' }) + const env = actionEnv({ GITHUB_WORKSPACE: dir, INPUT_BLUEPRINTS: 'bad.json' }) - await assert.rejects(run({ github, core, env: actionEnv({ INPUT_PULL_REQUEST: '' }) }), /only runs for pull_request events/) + await assert.rejects(run({ core, env }), /bad\.json is not valid JSON/) + assert.equal(fs.existsSync(path.join(env.RUNNER_TEMP, COMMENT_FILE)), false) + assert.equal(outputs()['comment-file'], undefined) }) - 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' }) + it('does not call the GitHub API, so it needs no token', async (t) => { + const { core } = await coreWithOutputs(t) - 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) + await assert.doesNotReject(run({ core, env: actionEnv({ INPUT_STATUS: 'building' }), now: WINTER })) }) }) From a3ea44356ed9b36330aaf9627ded1d93b0dd13f8 Mon Sep 17 00:00:00 2001 From: Paulo Iankoski <me@wpdev.com.br> Date: Wed, 4 Nov 2026 19:10:50 -0300 Subject: [PATCH 9/9] Name the Playground zip with the zip-name action --- .github/actions/playground-preview/README.md | 2 +- .github/workflows/playground-preview.yml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/actions/playground-preview/README.md b/.github/actions/playground-preview/README.md index cf3b26b..adc99f1 100644 --- a/.github/actions/playground-preview/README.md +++ b/.github/actions/playground-preview/README.md @@ -136,7 +136,7 @@ pup names the zip after the version, the commit time and the commit hash, for ex 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). -It asks with the shared [pup](../pup/) action, which downloads the pup release named by +It asks with the shared [zip-name](../zip-name/) action, which downloads the pup release named by `pup-version` (`2.0.0` by default) and reads the name from it. **Set `pup-version` to the release your `composer pup` script downloads.** The zip workflow builds the zip with the repository's own pup, and a name made by another release could differ from it, which would make the link point at a diff --git a/.github/workflows/playground-preview.yml b/.github/workflows/playground-preview.yml index 4e1b579..43f0529 100644 --- a/.github/workflows/playground-preview.yml +++ b/.github/workflows/playground-preview.yml @@ -240,16 +240,16 @@ jobs: 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 release, same commands, same commit, same name. + # zip workflow has no outputs, so the name is asked of the zip-name action again rather than + # rebuilt here. Same pup release, same commit, same name as the zip workflow's QA build. - name: Ask pup for the zip name id: pup if: ${{ inputs.zip-url == '' }} - uses: $/.github/actions/pup + uses: $/.github/actions/zip-name with: + production: 'no' + prerelease: dev pup-version: ${{ inputs.pup-version }} - read: zip-name - dev: 'true' - name: Find the zip id: zip