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..c7bed8e --- /dev/null +++ b/.github/workflows/playground-preview.yml @@ -0,0 +1,271 @@ +name: Playground Preview + +# Adds a "Try in WordPress Playground" comment to a pull request: a throwaway WordPress in the +# browser with the pull request's build installed, and a link to download the zip. The zip is built +# by the shared pup workflow in stellarwp/github-actions and lands in the same public bucket the +# packaging bot uses, so nothing new is hosted anywhere. +# +# A reusable workflow. A plugin repository calls it from a thin workflow of its own; copy +# templates/workflows/playground-preview.yml. +# +# Four jobs, so the comment never stays on "building": +# pending replaces the last comment with "building" at once, and checks the secrets. +# zip builds the zip and uploads it, once pending is done. Skipped when the caller passes +# `zip-url`. +# button works out the zip's public link and posts the Playground links. +# failed replaces "building" with "failed" when anything above failed. +# +# Only pull requests from the repository itself get a preview. A fork's run cannot see the bucket +# secrets, and its token cannot write a comment. +# +# The caller has to grant every permission a job below asks for: a called workflow can lower the +# token's permissions, never raise them. `concurrency` is the caller's too: here it would be +# evaluated with the caller's context and could clash with the caller's own group. +# +# Run it locally: `node .github/actions/playground-preview/playground-preview.js --zip-url <url> +# [blueprint.json ...]` prints the comment for any zip and blueprint files. See the action's README. + +on: + workflow_call: + inputs: + blueprints: + description: >- + Blueprint files, one path per line, relative to the repository root, e.g. + `.github/playground/blueprint.json`. Each file becomes one link. Empty uses the default + blueprint: install the zip, activate it, turn on WP_DEBUG and WP_DEBUG_DISPLAY. See the + playground-preview action's README for the file format. + required: false + type: string + default: '' + php-version: + description: >- + PHP that builds vendor/. Use the oldest version the plugin supports, as its release does, + so Composer resolves dependencies that still run there. + required: false + type: string + default: '7.4' + i18n: + description: '`yes` runs `pup i18n` in the build. A preview needs no translations, so `no` by default.' + required: false + type: string + default: 'no' + additional-commands: + description: >- + Commands the zip build runs after `pup build` and before `pup package`. Passed to the + shared zip workflow as `additional_commands`. Prefer `.puprc`: this is a second place + that defines the build. + required: false + type: string + default: '' + zip-url: + description: >- + Use this zip instead of building one, e.g. for a repository with its own zip workflow, or + to test this workflow without bucket secrets. No secret is needed then. + required: false + type: string + default: '' + zip-link-base: + description: >- + Public link to a zip in the bucket, without the file name. The zip's name, as pup makes + it, is added to the end. + required: false + type: string + default: 'https://evnt.is/test-zip?file=' + runs-on: + description: 'Runner label of the job that posts the links, of runner version 2.336.0 or newer. The other jobs run on ubuntu-latest.' + required: false + type: string + default: 'ubuntu-latest' + time-zone: + description: 'IANA time zone of the time on the badge, e.g. America/Chicago.' + required: false + type: string + default: 'America/New_York' + secrets: + # All optional here, so a caller that passes `zip-url` needs none. The pending job fails + # with a clear message when the build needs one that is missing. + s3-bucket: + description: 'Bucket the zip is uploaded to. Required unless `zip-url` is set.' + required: false + s3-access-key-id: + description: 'Required unless `zip-url` is set.' + required: false + s3-secret-access-key: + description: 'Required unless `zip-url` is set.' + required: false + s3-region: + description: 'Required unless `zip-url` is set.' + required: false + s3-endpoint: + description: 'Required unless `zip-url` is set.' + required: false + bot-token: + description: >- + Token that checks out the repository for the build. Empty uses the workflow's own token, + which is enough unless the build reaches other private repositories. + required: false + jenkins-secret: + description: >- + Passed on to the shared zip workflow, which requires it but only uses it for Slack + messages. This workflow never sends one. + required: false + composer-token: + description: 'GitHub token for private Composer packages. Becomes COMPOSER_AUTH in the build.' + required: false + package-token: + description: 'Token for private npm or bun packages. Becomes NODE_AUTH_TOKEN and BUN_AUTH_TOKEN in the build.' + required: false + +env: + # Hidden first line of the sticky comment. Named after the repository, so two plugin repositories + # never share a marker. + COMMENT_MARKER: '<!-- 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. "zip" and "button" wait for it, so the finished + # comment can never be overwritten by this one, and a missing secret fails here in seconds instead + # of after a build that was never going to upload. + pending: + name: Playground preview start + if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + pull-requests: write + steps: + # A failed API call here must not stop the preview. + - name: Post "building" + continue-on-error: true + uses: $/.github/actions/playground-preview + with: + status: building + comment-marker: ${{ env.COMMENT_MARKER }} + time-zone: ${{ inputs.time-zone }} + + # After the comment, so the "failed" job replaces it with a link to this message. Without it, + # missing secrets only show up minutes later, as an upload error deep in the zip build. + - name: Check the secrets + if: ${{ inputs.zip-url == '' }} + env: + S3_BUCKET: ${{ secrets.s3-bucket }} + S3_ACCESS_KEY_ID: ${{ secrets.s3-access-key-id }} + S3_SECRET_ACCESS_KEY: ${{ secrets.s3-secret-access-key }} + S3_REGION: ${{ secrets.s3-region }} + S3_ENDPOINT: ${{ secrets.s3-endpoint }} + run: | + missing=() + [ -n "$S3_BUCKET" ] || missing+=(s3-bucket) + [ -n "$S3_ACCESS_KEY_ID" ] || missing+=(s3-access-key-id) + [ -n "$S3_SECRET_ACCESS_KEY" ] || missing+=(s3-secret-access-key) + [ -n "$S3_REGION" ] || missing+=(s3-region) + [ -n "$S3_ENDPOINT" ] || missing+=(s3-endpoint) + if [ ${#missing[@]} -gt 0 ]; then + echo "::error title=Playground preview::The zip build needs these secrets, and the caller did not pass them: ${missing[*]}. Pass them, or pass zip-url to use a zip from elsewhere." + exit 1 + fi + + zip: + name: Zip + needs: pending + if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && inputs.zip-url == '' }} + # The shared workflow only checks out and builds this repository. + permissions: + contents: read + uses: stellarwp/github-actions/.github/workflows/zip.yml@main + with: + ref: ${{ github.event.pull_request.head.sha }} + # A dev build: its name has the commit in it, so every push gets its own zip. + production: 'no' + php_version: ${{ inputs.php-version }} + i18n: ${{ inputs.i18n }} + additional_commands: ${{ inputs.additional-commands }} + secrets: + GH_BOT_TOKEN: ${{ secrets.bot-token || secrets.GITHUB_TOKEN }} + JENKINS_SECRET: ${{ secrets.jenkins-secret }} + COMPOSER_TOKEN: ${{ secrets.composer-token }} + NODE_AUTH_TOKEN: ${{ secrets.package-token }} + S3_BUCKET: ${{ secrets.s3-bucket }} + S3_ACCESS_KEY_ID: ${{ secrets.s3-access-key-id }} + S3_SECRET_ACCESS_KEY: ${{ secrets.s3-secret-access-key }} + S3_REGION: ${{ secrets.s3-region }} + S3_ENDPOINT: ${{ secrets.s3-endpoint }} + + button: + name: Playground preview + needs: [pending, zip] + # "zip" is skipped when the caller passes zip-url, which would skip this job too. + if: ${{ !cancelled() && needs.pending.result == 'success' && (needs.zip.result == 'success' || needs.zip.result == 'skipped') }} + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 10 + + # Writes one comment on the pull request and reads the checkout, nothing else. + permissions: + contents: read + pull-requests: write + + steps: + # For the blueprint files, and for pup to name the zip. + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + # The runner's own Composer can be out of date, so set up PHP for a current one before + # Composer downloads pup. + - name: Set Up PHP + if: ${{ inputs.zip-url == '' }} + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + + # pup names the zip after the version, the commit time and the commit hash, and the shared + # zip workflow has no outputs, so the name is asked of pup again rather than rebuilt here. + # Same commands, same commit, same name. + - name: Find the zip + id: zip + env: + ZIP_URL: ${{ inputs.zip-url }} + ZIP_LINK_BASE: ${{ inputs.zip-link-base }} + run: | + if [ -n "$ZIP_URL" ]; then + echo "url=$ZIP_URL" >> "$GITHUB_OUTPUT" + exit 0 + fi + composer run -q pup > /dev/null + version=$(php -d display_errors=0 bin/pup.phar get-version --dev) + name=$(php -d display_errors=0 bin/pup.phar zip-name "$version") + echo "url=${ZIP_LINK_BASE}${name}.zip" >> "$GITHUB_OUTPUT" + + # `$/` is this repository at the commit that runs this workflow, so the action is always the + # same revision as the workflow, whichever ref the caller used, and it needs no checkout. It + # needs a runner of version 2.336.0 or newer, which `runs-on` has to give. Not ./, which in a + # called workflow resolves against the caller's repository. + - name: Post the links + uses: $/.github/actions/playground-preview + with: + status: ready + zip-url: ${{ steps.zip.outputs.url }} + blueprints: ${{ inputs.blueprints }} + comment-marker: ${{ env.COMMENT_MARKER }} + time-zone: ${{ inputs.time-zone }} + + # Without this, a failed build would leave "building" on the pull request for good. A run + # cancelled by a newer push is not a failure: the newer run's "building" replaces the comment. + failed: + name: Playground preview failed + needs: [pending, zip, button] + if: ${{ failure() }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + pull-requests: write + steps: + - name: Post "failed" + continue-on-error: true + uses: $/.github/actions/playground-preview + with: + status: failed + comment-marker: ${{ env.COMMENT_MARKER }} + time-zone: ${{ inputs.time-zone }} diff --git a/templates/playground/blueprint-sample-data.json b/templates/playground/blueprint-sample-data.json new file mode 100644 index 0000000..b204e02 --- /dev/null +++ b/templates/playground/blueprint-sample-data.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://playground.wordpress.net/blueprint-schema.json", + "meta": { + "title": "With sample data", + "description": "100 sample orders in test mode. Opens a few minutes slower." + }, + "landingPage": "/wp-admin/admin.php?page=my-plugin", + "preferredVersions": { + "php": "8.3", + "wp": "latest" + }, + "features": { + "networking": true + }, + "steps": [ + { + "step": "defineWpConfigConsts", + "consts": { + "WP_DEBUG": true, + "WP_DEBUG_DISPLAY": true + } + }, + { + "step": "login", + "username": "admin" + }, + { + "step": "installTheme", + "themeData": { + "resource": "wordpress.org/themes", + "slug": "twentytwentyfive" + }, + "options": { + "activate": true + } + }, + { + "step": "installPlugin", + "pluginData": { + "resource": "url", + "url": "{{ZIP_URL}}" + }, + "options": { + "activate": true + } + }, + { + "step": "installPlugin", + "pluginData": { + "resource": "url", + "url": "https://github.com/example-org/my-plugin-sample-data/releases/download/1.0.0/my-plugin-sample-data.1.0.0.zip" + }, + "options": { + "activate": true + } + }, + { + "step": "wp-cli", + "command": "wp eval 'update_option(\"my_plugin_test_mode\", \"enabled\");'" + }, + { + "step": "wp-cli", + "command": "wp my-plugin-sample-data orders 100" + } + ] +} diff --git a/templates/playground/blueprint.json b/templates/playground/blueprint.json new file mode 100644 index 0000000..ac8e5a8 --- /dev/null +++ b/templates/playground/blueprint.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://playground.wordpress.net/blueprint-schema.json", + "meta": { + "description": "Debug output is on." + }, + "landingPage": "/wp-admin/admin.php?page=my-plugin", + "preferredVersions": { + "php": "8.3", + "wp": "latest" + }, + "features": { + "networking": true + }, + "steps": [ + { + "step": "defineWpConfigConsts", + "consts": { + "WP_DEBUG": true, + "WP_DEBUG_DISPLAY": true + } + }, + { + "step": "login", + "username": "admin" + }, + { + "step": "installTheme", + "themeData": { + "resource": "wordpress.org/themes", + "slug": "twentytwentyfive" + }, + "options": { + "activate": true + } + }, + { + "step": "installPlugin", + "pluginData": { + "resource": "url", + "url": "{{ZIP_URL}}" + }, + "options": { + "activate": true + } + } + ] +} diff --git a/templates/workflows/playground-preview.yml b/templates/workflows/playground-preview.yml new file mode 100644 index 0000000..d3d4895 --- /dev/null +++ b/templates/workflows/playground-preview.yml @@ -0,0 +1,48 @@ +name: Playground Preview + +# Adds a "Try in WordPress Playground" comment to every pull request, with links that open a +# throwaway WordPress with the pull request's build installed, and a link to download the zip. +# +# A file to copy into .github/workflows/ of a plugin repository. The work is in +# stellarwp/plugin-toolbox/.github/workflows/playground-preview.yml. The repository needs a .puprc +# and a `pup` Composer script, and the bucket secrets below; see the playground-preview action's +# README. Pull requests from forks get no preview: they cannot see the secrets. +# +# Run it locally: `node .github/actions/playground-preview/playground-preview.js --zip-url <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\}\}/) + }) + }) +})