Skip to content

Commit c21af1b

Browse files
committed
Add PR description Blueprint overrides
1 parent 5e92f5e commit c21af1b

4 files changed

Lines changed: 271 additions & 0 deletions

File tree

README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,64 @@ Use this when a PR should preview a plugin and a theme from the same repository.
209209
github-token: ${{ secrets.GITHUB_TOKEN }}
210210
```
211211

212+
### PR description Blueprint overrides
213+
214+
Use this when the default preview should exist for every pull request, but an
215+
individual PR needs to guide reviewers to a specific screen, install a companion
216+
plugin, or seed a small option without committing a one-off file to the branch.
217+
218+
```yaml
219+
- uses: WordPress/action-wp-playground-pr-preview@v3
220+
with:
221+
plugin-path: .
222+
blueprint-override-source: pr-description
223+
github-token: ${{ secrets.GITHUB_TOKEN }}
224+
```
225+
226+
By default, PRs from forks ignore PR-description Blueprint overrides. If your
227+
project wants community fork PRs to control the review Blueprint, opt in
228+
explicitly:
229+
230+
```yaml
231+
blueprint-override-allow-forks: true
232+
```
233+
234+
Then add a collapsed details block to the PR description:
235+
236+
````md
237+
<details>
238+
<summary>Playground Blueprint</summary>
239+
240+
```json
241+
{
242+
"landingPage": "/wp-admin/admin.php?page=my-plugin",
243+
"siteOptions": {
244+
"blogname": "Review this checkout flow"
245+
},
246+
"appendSteps": [
247+
{
248+
"step": "runPHP",
249+
"code": "<?php require '/wordpress/wp-load.php'; update_option('my_plugin_mode', 'checkout');"
250+
}
251+
]
252+
}
253+
```
254+
255+
</details>
256+
````
257+
258+
The override is merged into the generated Blueprint. `landingPage` and most
259+
top-level Blueprint fields replace the base value, `features` and `siteOptions`
260+
are merged, `prependSteps` run before the base steps, and `appendSteps` run
261+
afterwards. Raw `steps` are rejected so the override cannot accidentally remove
262+
the plugin or theme installation step. `preferredVersions` and
263+
`phpExtensionBundles` stay controlled by the workflow.
264+
265+
Trade-off: the override is parsed from Markdown, so malformed JSON fails the
266+
preview action until the PR description is fixed. That is intentional: a broken
267+
review environment should be visible instead of silently falling back to a
268+
different setup.
269+
212270
### Custom blueprint (companion plugins, version pin, seed data, login)
213271

214272
When you need more than "install this plugin," provide a full Blueprint via `blueprint:`. Example: install your plugin from the PR, also install WooCommerce from .org, pin PHP and WP versions, and log in as admin.
@@ -626,6 +684,9 @@ Use directly when there's no build step, or have the publish workflow call it (i
626684
| `theme-path` | one of four† | — | Path to theme directory. Auto-generates a `git:directory` blueprint. |
627685
| `blueprint` | one of four† | — | Custom Blueprint as a JSON string. When set, `plugin-path` and `theme-path` are ignored. |
628686
| `blueprint-url` | one of four† | — | URL pointing to a hosted Blueprint JSON. Used directly via `?blueprint-url=…`. |
687+
| `blueprint-override-source` | no | `none` | Set to `pr-description` to merge a PR description details-block override into the generated Blueprint. |
688+
| `blueprint-override-summary` | no | `Playground Blueprint` | Exact `<summary>` text identifying the override details block. |
689+
| `blueprint-override-allow-forks` | no | `false` | Set to `true` to allow PR-description Blueprint overrides on pull requests from forks. |
629690
| `description-template` | no | `{{PLAYGROUND_BUTTON}}` | Template for the PR description block. Supports the [template variables](#template-variables). |
630691
| `comment-template` | no | (full default) | Template for the PR comment. Supports the [template variables](#template-variables). |
631692
| `restore-button-if-removed` | no | `true` | If the PR author removes the button block, restore it on the next run. Set `false` to respect deletions. Only applies to `append-to-description` mode. |

action.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@ inputs:
1717
blueprint-url:
1818
type: string
1919
description: 'URL pointing to a remote blueprint. When provided, the Playground link uses `?blueprint-url={url}` directly.'
20+
blueprint-override-source:
21+
type: string
22+
description: 'Optional source for a Blueprint override. Supported values: none, pr-description.'
23+
default: none
24+
blueprint-override-summary:
25+
type: string
26+
description: 'Exact <summary> text of the PR description <details> block containing the override JSON.'
27+
default: Playground Blueprint
28+
blueprint-override-allow-forks:
29+
type: boolean
30+
description: 'Whether PR description Blueprint overrides are allowed on pull requests from forks.'
31+
default: false
2032
plugin-path:
2133
type: string
2234
description: Path to plugin directory inside the repository (e.g., `.` for root or `plugins/my-plugin`)

dist/index.js

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31895,6 +31895,9 @@ const githubLib = __nccwpck_require__(3228);
3189531895
const headRef = pr.head.ref;
3189631896
const headSha = pr.head.sha;
3189731897
const baseRef = pr.base.ref;
31898+
const headRepoFullName = pr.head.repo && pr.head.repo.full_name;
31899+
const baseRepoFullName = pr.base.repo && pr.base.repo.full_name;
31900+
const isForkPullRequest = !headRepoFullName || !baseRepoFullName || headRepoFullName !== baseRepoFullName;
3189831901

3189931902
const playgroundHostRaw = core.getInput('playground-host', {required: false}) || 'https://playground.wordpress.net';
3190031903
const playgroundHost = playgroundHostRaw.replace(/\/+$/, '');
@@ -31903,10 +31906,16 @@ const githubLib = __nccwpck_require__(3228);
3190331906
const themePath = (core.getInput('theme-path', {required: false}) || '').trim();
3190431907
const blueprintInput = core.getInput('blueprint', {required: false}) || '';
3190531908
const blueprintUrlInput = (core.getInput('blueprint-url', {required: false}) || '').trim();
31909+
const blueprintOverrideSource = (core.getInput('blueprint-override-source', {required: false}) || 'none').trim().toLowerCase();
31910+
const blueprintOverrideSummary = (core.getInput('blueprint-override-summary', {required: false}) || 'Playground Blueprint').trim();
31911+
const blueprintOverrideAllowForks = (core.getInput('blueprint-override-allow-forks', {required: false}) || 'false').trim().toLowerCase() === 'true';
3190631912

3190731913
if(!pluginPath && !themePath && !blueprintInput && !blueprintUrlInput) {
3190831914
throw new Error('One of `plugin-path`, `theme-path`, `blueprint`, or `blueprint-url` inputs is required.');
3190931915
}
31916+
if (blueprintOverrideSource !== 'none' && blueprintOverrideSource !== 'pr-description') {
31917+
throw new Error(`Invalid blueprint-override-source: ${blueprintOverrideSource}. Accepted values: none, pr-description.`);
31918+
}
3191031919

3191131920
const descriptionTemplateInput = core.getInput('description-template', {required: false}) || '';
3191231921
const commentTemplateInput = core.getInput('comment-template', {required: false}) || '';
@@ -31926,6 +31935,81 @@ const githubLib = __nccwpck_require__(3228);
3192631935
}
3192731936
};
3192831937

31938+
const stripTags = (value) => String(value || '').replace(/<[^>]+>/g, '').trim();
31939+
const findPrDescriptionBlueprintOverride = (body, summaryText) => {
31940+
const expectedSummary = stripTags(summaryText).toLowerCase();
31941+
if (!expectedSummary) {
31942+
throw new Error('blueprint-override-summary must not be empty.');
31943+
}
31944+
31945+
const detailsPattern = /<details\b[^>]*>([\s\S]*?)<\/details>/gi;
31946+
let detailsMatch;
31947+
while ((detailsMatch = detailsPattern.exec(body || ''))) {
31948+
const details = detailsMatch[1];
31949+
const summaryMatch = details.match(/<summary\b[^>]*>([\s\S]*?)<\/summary>/i);
31950+
if (!summaryMatch || stripTags(summaryMatch[1]).toLowerCase() !== expectedSummary) {
31951+
continue;
31952+
}
31953+
31954+
const codeBlockMatch = details.match(/```json\s*([\s\S]*?)```/i);
31955+
if (!codeBlockMatch || !codeBlockMatch[1].trim()) {
31956+
throw new Error(`Found a "${summaryText}" details block, but it does not contain a non-empty json code block.`);
31957+
}
31958+
return codeBlockMatch[1].trim();
31959+
}
31960+
return '';
31961+
};
31962+
31963+
const mergeBlueprintOverride = (baseBlueprintJson, overrideJson) => {
31964+
const base = safeParseJson('base blueprint', baseBlueprintJson);
31965+
const override = safeParseJson('PR description Blueprint override', overrideJson);
31966+
const protectedFields = ['preferredVersions', 'phpExtensionBundles'];
31967+
31968+
for (const field of protectedFields) {
31969+
if (Object.prototype.hasOwnProperty.call(override, field)) {
31970+
delete override[field];
31971+
core.warning(`Ignoring protected Blueprint override field: ${field}`);
31972+
}
31973+
}
31974+
31975+
if (Object.prototype.hasOwnProperty.call(override, 'steps')) {
31976+
throw new Error('PR description Blueprint overrides must use prependSteps or appendSteps instead of steps.');
31977+
}
31978+
31979+
const prependSteps = Array.isArray(override.prependSteps) ? override.prependSteps : [];
31980+
const appendSteps = Array.isArray(override.appendSteps) ? override.appendSteps : [];
31981+
delete override.prependSteps;
31982+
delete override.appendSteps;
31983+
31984+
const siteOptions = override.siteOptions;
31985+
delete override.siteOptions;
31986+
31987+
const merged = {
31988+
...base,
31989+
...override,
31990+
features: {
31991+
...(base.features || {}),
31992+
...(override.features || {}),
31993+
},
31994+
};
31995+
31996+
const steps = Array.isArray(base.steps) ? [...base.steps] : [];
31997+
if (siteOptions && typeof siteOptions === 'object' && !Array.isArray(siteOptions)) {
31998+
const siteOptionsStep = steps.find((step) => step && step.step === 'setSiteOptions');
31999+
if (siteOptionsStep) {
32000+
siteOptionsStep.options = {
32001+
...(siteOptionsStep.options || {}),
32002+
...siteOptions,
32003+
};
32004+
} else {
32005+
steps.unshift({ step: 'setSiteOptions', options: siteOptions });
32006+
}
32007+
}
32008+
32009+
merged.steps = [...prependSteps, ...steps, ...appendSteps];
32010+
return JSON.stringify(merged);
32011+
};
32012+
3192932013
const archiveBranchSegment = headRef.replace(/[^0-9A-Za-z]/g, '-');
3193032014
const repoArchiveRoot = `${repoName}-${archiveBranchSegment}`;
3193132015
const repoGitUrl = `https://github.com/${repoFullName}.git`;
@@ -32011,6 +32095,21 @@ const githubLib = __nccwpck_require__(3228);
3201132095
blueprintJson = buildAutoBlueprint();
3201232096
}
3201332097

32098+
if (blueprintOverrideSource === 'pr-description') {
32099+
if (isForkPullRequest && !blueprintOverrideAllowForks) {
32100+
core.info('Skipping PR description Blueprint override because this pull request comes from a fork and blueprint-override-allow-forks is false.');
32101+
} else {
32102+
if (!blueprintJson) {
32103+
throw new Error('blueprint-override-source: pr-description requires an inline Blueprint; blueprint-url cannot be merged.');
32104+
}
32105+
const overrideJson = findPrDescriptionBlueprintOverride(pr.body || '', blueprintOverrideSummary);
32106+
if (overrideJson) {
32107+
blueprintJson = mergeBlueprintOverride(blueprintJson, overrideJson);
32108+
core.info(`Merged Blueprint override from PR description details block: ${blueprintOverrideSummary}`);
32109+
}
32110+
}
32111+
}
32112+
3201432113
if (blueprintJson) {
3201532114
try {
3201632115
JSON.parse(blueprintJson);

src/index.js

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ const githubLib = require('@actions/github');
5959
const headRef = pr.head.ref;
6060
const headSha = pr.head.sha;
6161
const baseRef = pr.base.ref;
62+
const headRepoFullName = pr.head.repo && pr.head.repo.full_name;
63+
const baseRepoFullName = pr.base.repo && pr.base.repo.full_name;
64+
const isForkPullRequest = !headRepoFullName || !baseRepoFullName || headRepoFullName !== baseRepoFullName;
6265

6366
const playgroundHostRaw = core.getInput('playground-host', {required: false}) || 'https://playground.wordpress.net';
6467
const playgroundHost = playgroundHostRaw.replace(/\/+$/, '');
@@ -67,10 +70,16 @@ const githubLib = require('@actions/github');
6770
const themePath = (core.getInput('theme-path', {required: false}) || '').trim();
6871
const blueprintInput = core.getInput('blueprint', {required: false}) || '';
6972
const blueprintUrlInput = (core.getInput('blueprint-url', {required: false}) || '').trim();
73+
const blueprintOverrideSource = (core.getInput('blueprint-override-source', {required: false}) || 'none').trim().toLowerCase();
74+
const blueprintOverrideSummary = (core.getInput('blueprint-override-summary', {required: false}) || 'Playground Blueprint').trim();
75+
const blueprintOverrideAllowForks = (core.getInput('blueprint-override-allow-forks', {required: false}) || 'false').trim().toLowerCase() === 'true';
7076

7177
if(!pluginPath && !themePath && !blueprintInput && !blueprintUrlInput) {
7278
throw new Error('One of `plugin-path`, `theme-path`, `blueprint`, or `blueprint-url` inputs is required.');
7379
}
80+
if (blueprintOverrideSource !== 'none' && blueprintOverrideSource !== 'pr-description') {
81+
throw new Error(`Invalid blueprint-override-source: ${blueprintOverrideSource}. Accepted values: none, pr-description.`);
82+
}
7483

7584
const descriptionTemplateInput = core.getInput('description-template', {required: false}) || '';
7685
const commentTemplateInput = core.getInput('comment-template', {required: false}) || '';
@@ -90,6 +99,81 @@ const githubLib = require('@actions/github');
9099
}
91100
};
92101

102+
const stripTags = (value) => String(value || '').replace(/<[^>]+>/g, '').trim();
103+
const findPrDescriptionBlueprintOverride = (body, summaryText) => {
104+
const expectedSummary = stripTags(summaryText).toLowerCase();
105+
if (!expectedSummary) {
106+
throw new Error('blueprint-override-summary must not be empty.');
107+
}
108+
109+
const detailsPattern = /<details\b[^>]*>([\s\S]*?)<\/details>/gi;
110+
let detailsMatch;
111+
while ((detailsMatch = detailsPattern.exec(body || ''))) {
112+
const details = detailsMatch[1];
113+
const summaryMatch = details.match(/<summary\b[^>]*>([\s\S]*?)<\/summary>/i);
114+
if (!summaryMatch || stripTags(summaryMatch[1]).toLowerCase() !== expectedSummary) {
115+
continue;
116+
}
117+
118+
const codeBlockMatch = details.match(/```json\s*([\s\S]*?)```/i);
119+
if (!codeBlockMatch || !codeBlockMatch[1].trim()) {
120+
throw new Error(`Found a "${summaryText}" details block, but it does not contain a non-empty json code block.`);
121+
}
122+
return codeBlockMatch[1].trim();
123+
}
124+
return '';
125+
};
126+
127+
const mergeBlueprintOverride = (baseBlueprintJson, overrideJson) => {
128+
const base = safeParseJson('base blueprint', baseBlueprintJson);
129+
const override = safeParseJson('PR description Blueprint override', overrideJson);
130+
const protectedFields = ['preferredVersions', 'phpExtensionBundles'];
131+
132+
for (const field of protectedFields) {
133+
if (Object.prototype.hasOwnProperty.call(override, field)) {
134+
delete override[field];
135+
core.warning(`Ignoring protected Blueprint override field: ${field}`);
136+
}
137+
}
138+
139+
if (Object.prototype.hasOwnProperty.call(override, 'steps')) {
140+
throw new Error('PR description Blueprint overrides must use prependSteps or appendSteps instead of steps.');
141+
}
142+
143+
const prependSteps = Array.isArray(override.prependSteps) ? override.prependSteps : [];
144+
const appendSteps = Array.isArray(override.appendSteps) ? override.appendSteps : [];
145+
delete override.prependSteps;
146+
delete override.appendSteps;
147+
148+
const siteOptions = override.siteOptions;
149+
delete override.siteOptions;
150+
151+
const merged = {
152+
...base,
153+
...override,
154+
features: {
155+
...(base.features || {}),
156+
...(override.features || {}),
157+
},
158+
};
159+
160+
const steps = Array.isArray(base.steps) ? [...base.steps] : [];
161+
if (siteOptions && typeof siteOptions === 'object' && !Array.isArray(siteOptions)) {
162+
const siteOptionsStep = steps.find((step) => step && step.step === 'setSiteOptions');
163+
if (siteOptionsStep) {
164+
siteOptionsStep.options = {
165+
...(siteOptionsStep.options || {}),
166+
...siteOptions,
167+
};
168+
} else {
169+
steps.unshift({ step: 'setSiteOptions', options: siteOptions });
170+
}
171+
}
172+
173+
merged.steps = [...prependSteps, ...steps, ...appendSteps];
174+
return JSON.stringify(merged);
175+
};
176+
93177
const archiveBranchSegment = headRef.replace(/[^0-9A-Za-z]/g, '-');
94178
const repoArchiveRoot = `${repoName}-${archiveBranchSegment}`;
95179
const repoGitUrl = `https://github.com/${repoFullName}.git`;
@@ -175,6 +259,21 @@ const githubLib = require('@actions/github');
175259
blueprintJson = buildAutoBlueprint();
176260
}
177261

262+
if (blueprintOverrideSource === 'pr-description') {
263+
if (isForkPullRequest && !blueprintOverrideAllowForks) {
264+
core.info('Skipping PR description Blueprint override because this pull request comes from a fork and blueprint-override-allow-forks is false.');
265+
} else {
266+
if (!blueprintJson) {
267+
throw new Error('blueprint-override-source: pr-description requires an inline Blueprint; blueprint-url cannot be merged.');
268+
}
269+
const overrideJson = findPrDescriptionBlueprintOverride(pr.body || '', blueprintOverrideSummary);
270+
if (overrideJson) {
271+
blueprintJson = mergeBlueprintOverride(blueprintJson, overrideJson);
272+
core.info(`Merged Blueprint override from PR description details block: ${blueprintOverrideSummary}`);
273+
}
274+
}
275+
}
276+
178277
if (blueprintJson) {
179278
try {
180279
JSON.parse(blueprintJson);

0 commit comments

Comments
 (0)