Skip to content

Docs Playground Preview Publish #5

Docs Playground Preview Publish

Docs Playground Preview Publish #5

name: Docs Playground Preview Publish
on: # zizmor: ignore[dangerous-triggers] Publishes only the preview-build artifact after validating workflow_run event/conclusion, artifact name, PR number, and head SHA.
workflow_run:
workflows:
- 'Docs Playground Preview Build'
types:
- completed
# Serialize publication for the same pull request commit.
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: false
# Disable permissions for all available scopes by default.
# Any needed permissions should be configured at the job level.
permissions: {}
env:
DOCS_PLAYGROUND_RELEASE_TAG: docs-playground-preview-artifacts
jobs:
publish-docs-playground-preview:
name: Publish docs Playground preview
if: ${{ github.repository == 'WordPress/wordpress-develop' || github.repository == 'dmsnell/wordpress-develop' }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: write
issues: write
pull-requests: write
steps:
- name: Guard against misuse
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
WORKFLOW_RUN_EVENT: ${{ github.event.workflow_run.event }}
WORKFLOW_RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" != "workflow_run" ]; then
echo "::error::Docs Playground preview publication must be invoked from a workflow_run trigger." >&2
exit 1
fi
if [ "$WORKFLOW_RUN_EVENT" != "pull_request" ]; then
echo "Source workflow was not a pull_request ($WORKFLOW_RUN_EVENT); skipping."
echo "SKIP=1" >> "$GITHUB_ENV"
exit 0
fi
if [ "$WORKFLOW_RUN_CONCLUSION" != "success" ]; then
echo "Source workflow did not succeed ($WORKFLOW_RUN_CONCLUSION); skipping."
echo "SKIP=1" >> "$GITHUB_ENV"
exit 0
fi
- name: Identify artifact bundle
id: meta
if: env.SKIP != '1'
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
env:
SOURCE_RUN_ID: ${{ github.event.workflow_run.id }}
with:
script: |
const runId = Number(process.env.SOURCE_RUN_ID);
const list = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: runId,
});
const artifacts = list.data.artifacts.filter((artifact) =>
/^wp-playground-preview-pr\d+-[0-9a-f]+$/i.test(artifact.name)
);
if (artifacts.length === 0) {
throw new Error('No wp-playground-preview-pr* bundle found in source run.');
}
if (artifacts.length !== 1) {
throw new Error(`Expected exactly one wp-playground-preview-pr* bundle, found ${artifacts.length}: ${artifacts.map((artifact) => artifact.name).join(', ')}`);
}
const artifact = artifacts[0];
const match = artifact.name.match(/^wp-playground-preview-pr(\d+)-(.+)$/);
if (!match) {
throw new Error(`Cannot parse artifact name: ${artifact.name}`);
}
const [, prNumber, commitSha] = match;
const expectedSha = context.payload.workflow_run.head_sha;
if (commitSha !== expectedSha) {
throw new Error(`Artifact SHA ${commitSha} does not match source workflow_run.head_sha ${expectedSha}.`);
}
const prResponse = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: Number(prNumber),
});
if (prResponse.data.head.sha !== expectedSha) {
throw new Error(`Artifact PR number ${prNumber} points to head SHA ${prResponse.data.head.sha}, not workflow_run.head_sha ${expectedSha}.`);
}
core.setOutput('artifact-id', String(artifact.id));
core.setOutput('artifact-name', artifact.name);
core.setOutput('pr-number', prNumber);
core.setOutput('commit-sha', commitSha);
- name: Download artifact bundle
if: env.SKIP != '1'
shell: bash
env:
ARTIFACT_ID: ${{ steps.meta.outputs.artifact-id }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
mkdir -p bundle
gh api -X GET "/repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > bundle.zip
unzip -oq bundle.zip -d bundle
find bundle -maxdepth 3 -type f -print
test -f bundle/zips/docs-playground-snapshot.zip
test -f bundle/zips/docs-preview-diagnostics.zip
- name: Ensure release exists
if: env.SKIP != '1'
shell: bash
env:
RELEASE_TAG: ${{ env.DOCS_PLAYGROUND_RELEASE_TAG }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then
exit 0
fi
gh release create "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--prerelease \
--notes "Automated prerelease for WordPress Playground CI artifacts"
- name: Upload zips and resolve URLs
id: urls
if: env.SKIP != '1'
shell: bash
env:
RELEASE_TAG: ${{ env.DOCS_PLAYGROUND_RELEASE_TAG }}
PR_NUMBER: ${{ steps.meta.outputs.pr-number }}
COMMIT_SHA: ${{ steps.meta.outputs.commit-sha }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
shopt -s nullglob
mapping_file="$RUNNER_TEMP/artifact-urls.json"
echo '{}' > "$mapping_file"
for src in bundle/zips/*.zip; do
name="$(basename "$src" .zip)"
asset="pr-${PR_NUMBER}-${COMMIT_SHA}-${name}.zip"
cp "$src" "$asset"
gh release upload "$RELEASE_TAG" "$asset" --repo "$GITHUB_REPOSITORY" --clobber
url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/${asset}"
node -e '
const fs = require("fs");
const f = process.argv[1];
const k = process.argv[2];
const v = process.argv[3];
const json = JSON.parse(fs.readFileSync(f, "utf8"));
json[k] = v;
fs.writeFileSync(f, JSON.stringify(json));
' "$mapping_file" "$name" "$url"
echo "Exposed: $name -> $url"
done
echo "mapping-file=$mapping_file" >> "$GITHUB_OUTPUT"
echo "snapshot-url=$(node -e 'const j = require(process.argv[1]); console.log(j[\"docs-playground-snapshot\"] || \"\")' "$mapping_file")" >> "$GITHUB_OUTPUT"
echo "diagnostics-url=$(node -e 'const j = require(process.argv[1]); console.log(j[\"docs-preview-diagnostics\"] || \"\")' "$mapping_file")" >> "$GITHUB_OUTPUT"
- name: Render Playground blueprint
id: blueprint
if: env.SKIP != '1'
shell: bash
env:
MAPPING_FILE: ${{ steps.urls.outputs.mapping-file }}
BLUEPRINT_INPUT: |
{
"$schema": "https://playground.wordpress.net/blueprint-schema.json",
"meta": {
"title": "WordPress Core API Docs Preview",
"author": "WordPress Core",
"description": "Official DevHub Code Reference preview generated from a WordPress Core pull request."
},
"preferredVersions": {
"php": "8.4",
"wp": "6.9.1"
},
"landingPage": "/reference/classes/wp_html_tag_processor/",
"login": true,
"steps": [
{
"step": "unzip",
"zipFile": {
"resource": "url",
"url": "{{ARTIFACT_URL:docs-playground-snapshot}}"
},
"extractToPath": "/wordpress"
},
{
"step": "runPHP",
"code": "<?php\nrequire_once '/wordpress/wp-load.php';\n\n$scheme = ( ! empty( $_SERVER['HTTPS'] ) && 'off' !== $_SERVER['HTTPS'] ) ? 'https' : 'http';\n$host = $_SERVER['HTTP_HOST'] ?? '127.0.0.1';\n$path = dirname( $_SERVER['SCRIPT_NAME'] ?? '' );\n\nif ( '.' === $path || '/' === $path ) {\n\t$path = '';\n}\n\n$url = rtrim( $scheme . '://' . $host . $path, '/' );\nupdate_option( 'siteurl', $url );\nupdate_option( 'home', $url );\nflush_rewrite_rules( false );\n"
}
]
}
run: |
set -euo pipefail
node <<'NODE'
const fs = require('fs');
const urls = JSON.parse(fs.readFileSync(process.env.MAPPING_FILE, 'utf8'));
const proxyUrl = (url) => `https://wordpress-playground-cors-proxy.net/${url}`;
const playgroundUrls = {
...urls,
'docs-playground-snapshot': proxyUrl(urls['docs-playground-snapshot']),
};
const substitute = (template) =>
template.replace(/\{\{\s*ARTIFACT_URL:([a-zA-Z0-9_-]+)\s*\}\}/g, (_, name) => {
if (!(name in playgroundUrls)) {
throw new Error(`Blueprint references unknown artifact: ${name}. Available: ${Object.keys(playgroundUrls).join(', ') || '(none)'}`);
}
return JSON.stringify(playgroundUrls[name]).slice(1, -1);
});
const blueprint = substitute(process.env.BLUEPRINT_INPUT);
const parsed = JSON.parse(blueprint);
const minified = JSON.stringify(parsed);
const playgroundUrl = `https://playground.wordpress.net/?blueprint-url=data:application/json,${encodeURIComponent(minified)}`;
fs.writeFileSync('docs-playground-blueprint.json', `${JSON.stringify(parsed, null, 2)}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `blueprint-json<<DOCS_BLUEPRINT_JSON\n${minified}\nDOCS_BLUEPRINT_JSON\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `playground-url=${playgroundUrl}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `snapshot-proxy-url=${playgroundUrls['docs-playground-snapshot']}\n`);
NODE
- name: Print Playground URL
if: env.SKIP != '1'
shell: bash
env:
PLAYGROUND_URL: ${{ steps.blueprint.outputs.playground-url }}
SNAPSHOT_URL: ${{ steps.urls.outputs.snapshot-url }}
SNAPSHOT_PROXY_URL: ${{ steps.blueprint.outputs.snapshot-proxy-url }}
DIAGNOSTICS_URL: ${{ steps.urls.outputs.diagnostics-url }}
run: |
set -euo pipefail
echo "Docs Playground preview URL: $PLAYGROUND_URL"
{
echo "### Docs Playground preview"
echo ""
echo "- Playground: $PLAYGROUND_URL"
echo "- Blueprint: inline data URL embedded in the Playground link"
echo "- Snapshot: $SNAPSHOT_URL"
echo "- Snapshot via Playground CORS proxy: $SNAPSHOT_PROXY_URL"
echo "- Diagnostics: $DIAGNOSTICS_URL"
} >> "$GITHUB_STEP_SUMMARY"
- name: Assert proxied snapshot URL
if: env.SKIP != '1'
shell: bash
env:
SNAPSHOT_PROXY_URL: ${{ steps.blueprint.outputs.snapshot-proxy-url }}
run: |
set -euo pipefail
max_proxy_bytes=$((100 * 1024 * 1024))
snapshot_bytes="$(wc -c < bundle/zips/docs-playground-snapshot.zip)"
if [ "$snapshot_bytes" -ge "$max_proxy_bytes" ]; then
echo "::error::Docs Playground snapshot is too large for the Playground CORS proxy: $snapshot_bytes >= $max_proxy_bytes" >&2
exit 1
fi
headers="$RUNNER_TEMP/docs-playground-snapshot-proxy-headers.txt"
body="$RUNNER_TEMP/docs-playground-snapshot-proxy-body.txt"
status="$(curl -sS -D "$headers" -o "$body" -H 'Origin: https://playground.wordpress.net' -w '%{http_code}' "$SNAPSHOT_PROXY_URL")"
case "$status" in
2*|3*) ;;
*)
echo "::error::Playground CORS proxy returned HTTP $status for the snapshot URL." >&2
sed -n '1,120p' "$headers" >&2
sed -n '1,40p' "$body" >&2
exit 1
;;
esac
if ! grep -iq '^x-playground-cors-proxy: true' "$headers"; then
echo "::error::Snapshot URL response did not include X-Playground-Cors-Proxy." >&2
sed -n '1,120p' "$headers" >&2
exit 1
fi
if ! grep -iq '^access-control-allow-origin: https://playground.wordpress.net' "$headers"; then
echo "::error::Snapshot URL response did not allow the Playground origin." >&2
sed -n '1,120p' "$headers" >&2
exit 1
fi
if [ "${status#3}" != "$status" ] && ! grep -Eiq '^location: /https://' "$headers"; then
echo "::error::Playground CORS proxy redirect was not rewritten back through the proxy." >&2
sed -n '1,120p' "$headers" >&2
exit 1
fi
- name: Post diagnostic Playground preview comment
if: env.SKIP != '1'
uses: WordPress/action-wp-playground-pr-preview@c8607529dac8d2bf9a1e8493865fc97cd1c3c87b # v2
with:
mode: comment
blueprint: ${{ steps.blueprint.outputs.blueprint-json }}
pr-number: ${{ steps.meta.outputs.pr-number }}
github-token: ${{ secrets.GITHUB_TOKEN }}
comment-template: |
### WordPress Playground Preview
A docs preview was generated for this pull request, but the docs smoke validation has not passed yet. Use this preview for manual inspection while the failing validation is investigated.
{{PLAYGROUND_BUTTON}}
- name: Assert docs preview smoke result
if: env.SKIP != '1'
shell: bash
run: |
set -euo pipefail
rm -rf diagnostics
mkdir -p diagnostics
unzip -oq bundle/zips/docs-preview-diagnostics.zip -d diagnostics
set +e
node <<'NODE'
const fs = require('fs');
const manifestPath = 'diagnostics/docs-preview-manifest.json';
if (!fs.existsSync(manifestPath)) {
throw new Error(`Missing diagnostics manifest: ${manifestPath}`);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const smoke = manifest.checks && manifest.checks.restoreSmoke;
if (!smoke || smoke.ok !== true) {
const message = smoke && smoke.error ? smoke.error : 'Missing restore smoke result.';
console.error(`::error::Docs Playground restore smoke failed: ${message.split(/\r?\n/)[0]}`);
console.error(message);
process.exit(1);
}
console.log('Docs Playground restore smoke passed.');
NODE
status=$?
set -e
if [ "$status" -ne 0 ]; then
echo "::group::Docs preview diagnostics"
if [ -f diagnostics/docs-preview-manifest.json ]; then
sed -n '1,220p' diagnostics/docs-preview-manifest.json
fi
if [ -f diagnostics/logs/restore-smoke.log ]; then
sed -n '1,260p' diagnostics/logs/restore-smoke.log
fi
echo "::endgroup::"
exit "$status"
fi
- name: Cleanup old artifacts
if: env.SKIP != '1'
shell: bash
env:
RELEASE_TAG: ${{ env.DOCS_PLAYGROUND_RELEASE_TAG }}
PR_NUMBER: ${{ steps.meta.outputs.pr-number }}
ARTIFACTS_TO_KEEP: '2'
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json assets \
--jq ".assets[] | select(.name | startswith(\"pr-$PR_NUMBER-\")) | \"\(.createdAt)|\(.name)\"" \
> pr-assets.txt || true
[ -s pr-assets.txt ] || exit 0
sort -r pr-assets.txt | awk -F'|' -v keep="$ARTIFACTS_TO_KEEP" '
{
name=$2;
split(name, parts, "-");
sha=parts[3];
if (!(sha in seen)) { seen[sha]=++distinct }
if (seen[sha] > keep) print name;
}
' > to-delete.txt
delete_errors=0
while IFS= read -r asset; do
[ -z "$asset" ] && continue
if ! gh release delete-asset "$RELEASE_TAG" "$asset" --repo "$GITHUB_REPOSITORY" --yes; then
echo "::error::Failed to delete release asset: $asset" >&2
delete_errors=1
fi
done < to-delete.txt
if [ "$delete_errors" -ne 0 ]; then
exit 1
fi
- name: Post Playground preview button
if: env.SKIP != '1'
uses: WordPress/action-wp-playground-pr-preview@c8607529dac8d2bf9a1e8493865fc97cd1c3c87b # v2
with:
mode: comment
blueprint: ${{ steps.blueprint.outputs.blueprint-json }}
pr-number: ${{ steps.meta.outputs.pr-number }}
github-token: ${{ secrets.GITHUB_TOKEN }}