Skip to content

Bump the dev-dependencies group with 3 updates #306

Bump the dev-dependencies group with 3 updates

Bump the dev-dependencies group with 3 updates #306

# This workflow performs comprehensive WordPress plugin compatibility and quality checks.
# It runs multiple validation processes including:
# - WordPress Plugin Check for WordPress.org compatibility
# - PHPUnit tests across supported PHP versions (8.3, 8.4, 8.5)
# - PHP compatibility testing across multiple PHP versions (8.3, 8.4, 8.5)
# - WordPress compatibility testing across multiple WP versions (7.0, latest, nightly)
# - PHPStan static analysis for WordPress-specific code quality
# - Rector dry run, which fails on PHP that a current idiom would replace
# - WordPress security vulnerability scanning using pattern analysis
# - PHPCS code standards validation for WordPress coding standards
# - Psalm static analysis and taint analysis
# - YAML, workflow, Python, Markdown, JavaScript and CSS linting
# (yamllint, actionlint, zizmor, Ruff, mypy, markdownlint, ESLint, Stylelint),
# with modern-JavaScript rules and checks against WordPress's supported browsers
# - Unit test line coverage, with a minimum
# - Code quality analysis and automated issue creation for failures
# The workflow ensures the plugin meets WordPress.org standards and maintains compatibility.
name: WordPress Compatibility & Plugin Check
on:
# Run on pushes to main branch and on all pull requests
push:
branches: [ main ]
pull_request:
# Allow manually triggering the workflow
workflow_dispatch:
# Cancels all previous workflow runs for the same branch that have not yet completed
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
plugin-check:
name: WP Plugin Check (PHP 8.3)
runs-on: ubuntu-latest
# The job files an issue when it fails on main.
permissions:
contents: read
issues: write
timeout-minutes: 45
steps:
- name: Checkout code
id: checkout_code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Always fetch the latest commit, disable any caching
fetch-depth: 0
clean: true
- name: Setup PHP 8.3
id: setup_php_8_3
uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
with:
php-version: '8.3'
extensions: mysqli, curl, zip, intl, gd, mbstring, fileinfo, xml
coverage: none
tools: composer:v2
- name: Clear any existing composer cache
id: clear_any_existing_composer_cache
run: |
composer clear-cache || true
rm -rf vendor/ || true
- name: Install Composer dependencies (no cache)
id: install_composer_dependencies_no_cache
run: |
composer install --prefer-dist --no-progress --no-cache
- name: Clean up potential artifact directories
id: clean_up_potential_artifact_directories
run: |
echo "🧹 Removing any artifact directories to prevent conflicts..."
rm -rf .reports 2>/dev/null || true
rm -rf build 2>/dev/null || true
echo "✅ Cleanup complete"
- name: Prepare Plugin Check build directory
id: prepare_plugin_check_build_directory
run: |
# Stdlib-only automation fixtures run on this runner, not the workstation.
python3 .github/scripts/test-automation.py
rm -rf plugin-check-build 2>/dev/null || true
PLUGIN_SLUG="enginescript-site-exporter"
BUILD_DIR="plugin-check-build/${PLUGIN_SLUG}"
mkdir -p "$BUILD_DIR"
cp enginescript-site-exporter.php "$BUILD_DIR/"
cp readme.txt "$BUILD_DIR/"
cp LICENSE "$BUILD_DIR/"
cp -r includes "$BUILD_DIR/"
cp -r css "$BUILD_DIR/"
cp -r js "$BUILD_DIR/"
cp -r languages "$BUILD_DIR/"
# Inspect a release-shaped ZIP without publishing or changing the PCP input.
PACKAGE_ZIP="$RUNNER_TEMP/${PLUGIN_SLUG}-candidate.zip"
rm -f "$PACKAGE_ZIP"
(cd plugin-check-build && zip -qr "$PACKAGE_ZIP" "$PLUGIN_SLUG")
python3 .github/scripts/check-plugin-package.py "$BUILD_DIR" --zip "$PACKAGE_ZIP"
- name: WordPress Plugin Check
id: wordpress_plugin_check
uses: WordPress/plugin-check-action@10857da14b6c2246d15402b3e69f777edcf8c12e # v1.1.9
with:
# Comment only on trusted, same-repository human PRs. An empty token
# skips the action's optional comment, not checks or annotations.
repo-token: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && github.event.pull_request.user.login != 'dependabot[bot]' && github.token || '' }}
# Production-style plugin directory. Excludes repo-only files such as
# .github, GEMINI.md, and other development artifacts.
build-dir: './plugin-check-build/enginescript-site-exporter'
# Configure which categories to check
categories: |
accessibility
general
performance
plugin_repo
security
# Run the experimental checks as well
include-experimental: true
# Don't ignore warnings or errors, including the low-severity ones
ignore-warnings: false
ignore-errors: false
include-low-severity-errors: true
include-low-severity-warnings: true
# A warning fails the job, as an error does
strict: true
# WordPress version to use
wp-version: 'latest'
- name: Handle plugin check completion
id: handle_plugin_check_completion
if: ${{ always() }}
env:
JOB_STATUS: ${{ job.status }}
run: |
echo "Plugin Check job status: $JOB_STATUS"
- name: Preserve diagnostic summary
id: preserve_diagnostics
if: ${{ always() }}
continue-on-error: true
env:
STEP_RESULTS: |
checkout_code=${{ steps.checkout_code.outcome }}
setup_php_8_3=${{ steps.setup_php_8_3.outcome }}
clear_any_existing_composer_cache=${{ steps.clear_any_existing_composer_cache.outcome }}
install_composer_dependencies_no_cache=${{ steps.install_composer_dependencies_no_cache.outcome }}
clean_up_potential_artifact_directories=${{ steps.clean_up_potential_artifact_directories.outcome }}
prepare_plugin_check_build_directory=${{ steps.prepare_plugin_check_build_directory.outcome }}
wordpress_plugin_check=${{ steps.wordpress_plugin_check.outcome }}
handle_plugin_check_completion=${{ steps.handle_plugin_check_completion.outcome }}
run: |
mkdir -p "$RUNNER_TEMP/sse-diagnostics"
printf '%s\n' "$STEP_RESULTS" > "$RUNNER_TEMP/sse-diagnostics/steps.txt"
printf 'commit=%s\njob=%s\n' "$GITHUB_SHA" "$GITHUB_JOB" > "$RUNNER_TEMP/sse-diagnostics/run.txt"
if command -v php >/dev/null; then
php -r 'echo PHP_VERSION, PHP_EOL;' > "$RUNNER_TEMP/sse-diagnostics/php.txt"
fi
if command -v composer >/dev/null && [ -f vendor/composer/installed.json ]; then
php -r '$data = json_decode(file_get_contents("vendor/composer/installed.json"), true, 512, JSON_THROW_ON_ERROR); foreach ($data["packages"] ?? $data as $package) { echo $package["name"], " ", $package["version"], PHP_EOL; }' > "$RUNNER_TEMP/sse-diagnostics/dependencies.txt"
fi
# Never upload config, authentication data, SQL, exports, or arbitrary logs.
# Refuse oversized or linked diagnostics instead of uploading them.
find "$RUNNER_TEMP/sse-diagnostics" -type l -delete
find "$RUNNER_TEMP/sse-diagnostics" -type f -size +2M -delete
- name: Upload diagnostic summary
id: upload_diagnostics
if: ${{ always() }}
continue-on-error: true
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: wp-compat-plugin-check
path: |
${{ runner.temp }}/sse-diagnostics/run.txt
${{ runner.temp }}/sse-diagnostics/steps.txt
${{ runner.temp }}/sse-diagnostics/php.txt
${{ runner.temp }}/sse-diagnostics/dependencies.txt
${{ runner.temp }}/sse-diagnostics/wordpress.txt
${{ runner.temp }}/sse-diagnostics/junit.xml
retention-days: 7
if-no-files-found: warn
- name: Create issue on plugin check failure
id: create_issue_on_plugin_check_failure
if: ${{ failure() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.actor != 'dependabot[bot]' }}
uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2.9.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FAILURE_STAGE: ${{ steps.checkout_code.outcome == 'failure' && 'checkout_code' || steps.setup_php_8_3.outcome == 'failure' && 'setup_php_8_3' || steps.clear_any_existing_composer_cache.outcome == 'failure' && 'clear_any_existing_composer_cache' || steps.install_composer_dependencies_no_cache.outcome == 'failure' && 'install_composer_dependencies_no_cache' || steps.clean_up_potential_artifact_directories.outcome == 'failure' && 'clean_up_potential_artifact_directories' || steps.prepare_plugin_check_build_directory.outcome == 'failure' && 'prepare_plugin_check_build_directory' || steps.wordpress_plugin_check.outcome == 'failure' && 'wordpress_plugin_check' || steps.handle_plugin_check_completion.outcome == 'failure' && 'handle_plugin_check_completion' || 'unknown_or_canceled' }}
PHP_VERSION: '8.3'
RUN_ID: ${{ github.run_id }}
WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
filename: .github/ISSUE_TEMPLATE/plugin-check-failure.md
update_existing: false
- name: Mark job as failed after issue creation
id: mark_job_as_failed_after_issue_creation
if: ${{ failure() }}
run: |
echo "::error::Plugin Check job failed; see the failed step and reporting outcome."
exit 1
phpcs:
name: PHPCS (PHP 8.3)
runs-on: ubuntu-latest
# The job files an issue when it fails on main.
permissions:
contents: read
issues: write
timeout-minutes: 45
steps:
- name: Checkout code
id: checkout_code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup PHP 8.3
id: setup_php_8_3
uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
with:
php-version: '8.3'
extensions: mysqli, curl, zip, intl, gd, mbstring, fileinfo, xml
coverage: none
tools: composer:v2
- name: Install Composer dependencies
id: install_composer_dependencies
uses: ramsey/composer-install@26d8a556604053a9612623447203a691f406fbe6 # v4
with:
dependency-versions: highest
composer-options: "--prefer-dist --no-progress"
- name: Verify PHPCS standards
id: verify_phpcs_standards
run: |
# Verify the project-local PHPCS installation and registered standards.
vendor/bin/phpcs -i
- name: Run PHPCS
id: run_phpcs
run: |
vendor/bin/phpcs --standard=phpcs.xml
# Static scanner controls; never execute these incompatible PHP fixtures.
CONTROL_DIR="$RUNNER_TEMP/sse-compat-controls"
mkdir -p "$CONTROL_DIR" "$RUNNER_TEMP/sse-diagnostics"
printf '<?php strlen("control");\n' > "$CONTROL_DIR/compatible.php"
printf '<?php each(array());\n' > "$CONTROL_DIR/legacy-incompatible.php"
printf '<?php array_find(array(), "is_int");\n' > "$CONTROL_DIR/modern-incompatible.php"
vendor/bin/phpcs --standard=PHPCompatibilityWP --runtime-set testVersion 8.3 "$CONTROL_DIR/compatible.php"
if vendor/bin/phpcs --standard=PHPCompatibilityWP --runtime-set testVersion 8.3 --report=json "$CONTROL_DIR/legacy-incompatible.php" > "$CONTROL_DIR/legacy.json"; then
echo '::error::The known legacy-incompatible fixture was missed.'
exit 1
else
status=$?
[[ $status == 1 || $status == 2 ]]
fi
php -r '$report = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR); foreach ($report["files"] ?? [] as $file) { foreach ($file["messages"] as $message) { if (str_starts_with($message["source"], "PHPCompatibility.") && str_contains($message["message"], "each")) { exit(0); } } } exit(1);' "$CONTROL_DIR/legacy.json"
if vendor/bin/phpcs --standard=PHPCompatibilityWP --runtime-set testVersion 8.3 --report=json "$CONTROL_DIR/modern-incompatible.php" > "$CONTROL_DIR/modern.json"; then
echo '::warning::CR-TOOL-001: current stable rules miss PHP 8.4 array_find at the PHP 8.3 floor. Modern-rule acceptance remains pending.'
echo 'modern_rule_coverage=known_gap' > "$RUNNER_TEMP/sse-diagnostics/compatibility-controls.txt"
else
status=$?
[[ $status == 1 || $status == 2 ]]
php -r '$report = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR); foreach ($report["files"] ?? [] as $file) { foreach ($file["messages"] as $message) { if (str_starts_with($message["source"], "PHPCompatibility.") && str_contains($message["message"], "array_find")) { exit(0); } } } exit(1);' "$CONTROL_DIR/modern.json"
echo 'modern_rule_coverage=fixture_detected' > "$RUNNER_TEMP/sse-diagnostics/compatibility-controls.txt"
fi
echo 'compatible=passed;legacy_incompatible=rejected' >> "$RUNNER_TEMP/sse-diagnostics/compatibility-controls.txt"
- name: Preserve diagnostic summary
id: preserve_diagnostics
if: ${{ always() }}
continue-on-error: true
env:
STEP_RESULTS: |
checkout_code=${{ steps.checkout_code.outcome }}
setup_php_8_3=${{ steps.setup_php_8_3.outcome }}
install_composer_dependencies=${{ steps.install_composer_dependencies.outcome }}
verify_phpcs_standards=${{ steps.verify_phpcs_standards.outcome }}
run_phpcs=${{ steps.run_phpcs.outcome }}
run: |
mkdir -p "$RUNNER_TEMP/sse-diagnostics"
printf '%s\n' "$STEP_RESULTS" > "$RUNNER_TEMP/sse-diagnostics/steps.txt"
printf 'commit=%s\njob=%s\n' "$GITHUB_SHA" "$GITHUB_JOB" > "$RUNNER_TEMP/sse-diagnostics/run.txt"
if command -v php >/dev/null; then
php -r 'echo PHP_VERSION, PHP_EOL;' > "$RUNNER_TEMP/sse-diagnostics/php.txt"
fi
if command -v composer >/dev/null && [ -f vendor/composer/installed.json ]; then
php -r '$data = json_decode(file_get_contents("vendor/composer/installed.json"), true, 512, JSON_THROW_ON_ERROR); foreach ($data["packages"] ?? $data as $package) { echo $package["name"], " ", $package["version"], PHP_EOL; }' > "$RUNNER_TEMP/sse-diagnostics/dependencies.txt"
fi
# Never upload config, authentication data, SQL, exports, or arbitrary logs.
# Refuse oversized or linked diagnostics instead of uploading them.
find "$RUNNER_TEMP/sse-diagnostics" -type l -delete
find "$RUNNER_TEMP/sse-diagnostics" -type f -size +2M -delete
- name: Upload diagnostic summary
id: upload_diagnostics
if: ${{ always() }}
continue-on-error: true
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: wp-compat-phpcs
path: |
${{ runner.temp }}/sse-diagnostics/run.txt
${{ runner.temp }}/sse-diagnostics/steps.txt
${{ runner.temp }}/sse-diagnostics/php.txt
${{ runner.temp }}/sse-diagnostics/dependencies.txt
${{ runner.temp }}/sse-diagnostics/wordpress.txt
${{ runner.temp }}/sse-diagnostics/junit.xml
retention-days: 7
if-no-files-found: warn
- name: Create issue on PHPCS failure
id: create_issue_on_phpcs_failure
if: ${{ failure() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.actor != 'dependabot[bot]' }}
uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2.9.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FAILURE_STAGE: ${{ steps.checkout_code.outcome == 'failure' && 'checkout_code' || steps.setup_php_8_3.outcome == 'failure' && 'setup_php_8_3' || steps.install_composer_dependencies.outcome == 'failure' && 'install_composer_dependencies' || steps.verify_phpcs_standards.outcome == 'failure' && 'verify_phpcs_standards' || steps.run_phpcs.outcome == 'failure' && 'run_phpcs' || 'unknown_or_canceled' }}
PHP_VERSION: '8.3'
RUN_ID: ${{ github.run_id }}
WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
filename: .github/ISSUE_TEMPLATE/phpcs-failure.md
update_existing: false
vip-phpcs:
name: WP VIP CS (PHP 8.3)
runs-on: ubuntu-latest
# The job files an issue when it fails on main.
permissions:
contents: read
issues: write
timeout-minutes: 45
steps:
- name: Checkout code
id: checkout_code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup PHP 8.3
id: setup_php_8_3
uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
with:
php-version: '8.3'
extensions: mysqli, curl, zip, intl, gd, mbstring, fileinfo, xml
coverage: none
tools: composer:v2
- name: Install Composer dependencies
id: install_composer_dependencies
uses: ramsey/composer-install@26d8a556604053a9612623447203a691f406fbe6 # v4
with:
dependency-versions: highest
composer-options: "--prefer-dist --no-progress"
- name: Install WordPress VIP Coding Standards
id: install_wordpress_vip_coding_standards
run: |
# Install PHPCS and WordPress VIP Coding Standards
composer global config allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
composer global require --dev squizlabs/php_codesniffer:"^3.13.6"
composer global require --dev wp-coding-standards/wpcs:"^3.4.1"
composer global require --dev automattic/vipwpcs:"^3.0.1"
composer global require --dev dealerdirect/phpcodesniffer-composer-installer:"^1.2"
# Add composer bin to PATH
VIP_BIN_DIR="$(composer global config bin-dir --absolute)"
echo "$VIP_BIN_DIR" >> "$GITHUB_PATH"
echo "VIP_PHPCS=$VIP_BIN_DIR/phpcs" >> "$GITHUB_ENV"
export PATH="$VIP_BIN_DIR:$PATH"
# Verify installation and show available standards
"$VIP_BIN_DIR/phpcs" -i
# Verify VIP standards are available
"$VIP_BIN_DIR/phpcs" -i | grep -F WordPress-VIP-Go
- name: Create VIP-specific PHPCS configuration
id: create_vip_specific_phpcs_configuration
run: |
cat > phpcs-vip.xml << 'EOF'
<?xml version="1.0"?>
<ruleset name="WordPress VIP Go Coding Standards">
<description>WordPress VIP Go coding standards for enterprise-level WordPress development</description>
<!-- Files to check -->
<file>.</file>
<!-- Exclude patterns -->
<exclude-pattern>*/vendor/*</exclude-pattern>
<exclude-pattern>*/node_modules/*</exclude-pattern>
<exclude-pattern>*/tests/*</exclude-pattern>
<exclude-pattern>*/assets/*</exclude-pattern>
<exclude-pattern>*/languages/*</exclude-pattern>
<exclude-pattern>*/.git/*</exclude-pattern>
<exclude-pattern>*/.github/*</exclude-pattern>
<exclude-pattern>*.js</exclude-pattern>
<exclude-pattern>*.css</exclude-pattern>
<!-- Use WordPress VIP Go coding standards -->
<rule ref="WordPress-VIP-Go">
<!-- Allow short array syntax [] instead of array() -->
<exclude name="Generic.Arrays.DisallowShortArraySyntax"/>
<!-- Allow longer lines for readability in some cases -->
<exclude name="Generic.Files.LineLength.TooLong"/>
<!-- Exclude JS-related sniffs to avoid deprecation warnings -->
<exclude name="WordPressVIPMinimum.JS"/>
<!-- WordPress.PHP.NoSilencedErrors, in phpcs.xml, checks the @ operator with a list of allowed functions -->
<exclude name="Generic.PHP.NoSilencedErrors"/>
</rule>
<!-- Add specific WordPressVIPMinimum rules (non-JS) -->
<rule ref="WordPressVIPMinimum.Security"/>
<rule ref="WordPressVIPMinimum.Performance"/>
<rule ref="WordPressVIPMinimum.UserExperience"/>
<rule ref="WordPressVIPMinimum.Functions"/>
<rule ref="WordPressVIPMinimum.Variables"/>
<rule ref="WordPressVIPMinimum.Files"/>
<rule ref="WordPressVIPMinimum.Hooks"/>
<!-- WordPress internationalization with plugin text domain -->
<rule ref="WordPress.WP.I18n">
<properties>
<property name="text_domain" type="array">
<element value="enginescript-site-exporter"/>
</property>
</properties>
</rule>
<!-- Only scan PHP files -->
<arg name="extensions" value="php"/>
<!-- Show progress -->
<arg value="p"/>
<!-- Show sniff codes in all reports -->
<arg value="s"/>
<!-- Use colors in output -->
<arg name="colors"/>
<!-- Set report width for better readability -->
<arg name="report-width" value="120"/>
<!-- Report every message, including those rated below the default severity of 5 -->
<arg name="severity" value="1"/>
</ruleset>
EOF
- name: Run WordPress VIP PHPCS
id: run_wordpress_vip_phpcs
run: |
echo "🚀 Running WordPress VIP Go Coding Standards scan..."
echo "📋 This scan focuses on VIP-specific requirements including:"
echo " • File system operation restrictions"
echo " • Performance and caching best practices"
echo " • Security vulnerabilities specific to VIP platform"
echo " • User experience guidelines for enterprise WordPress"
echo " • Uncached function usage patterns"
echo ""
echo "📦 Using standards: WordPress-VIP-Go, WordPressVIPMinimum"
echo "🎯 Scanning PHP files only (excluding JS/CSS to avoid deprecation warnings)"
echo ""
# Run VIP-specific PHPCS scan using our custom configuration
# Only scan PHP files to avoid JS/CSS deprecation warnings
"$VIP_PHPCS" --standard=phpcs-vip.xml --extensions=php . || {
echo ""
echo "⚠️ WordPress VIP coding standards issues found."
echo "💡 Note: These are VIP-specific recommendations for enterprise WordPress platforms."
echo "🔧 Many of these may not apply to standard WordPress installations."
echo "📖 For more info: https://docs.wpvip.com/technical-references/code-quality-and-best-practices/"
echo ""
exit 1
}
echo "✅ WordPress VIP coding standards check completed successfully!"
- name: Preserve diagnostic summary
id: preserve_diagnostics
if: ${{ always() }}
continue-on-error: true
env:
STEP_RESULTS: |
checkout_code=${{ steps.checkout_code.outcome }}
setup_php_8_3=${{ steps.setup_php_8_3.outcome }}
install_composer_dependencies=${{ steps.install_composer_dependencies.outcome }}
install_wordpress_vip_coding_standards=${{ steps.install_wordpress_vip_coding_standards.outcome }}
create_vip_specific_phpcs_configuration=${{ steps.create_vip_specific_phpcs_configuration.outcome }}
run_wordpress_vip_phpcs=${{ steps.run_wordpress_vip_phpcs.outcome }}
run: |
mkdir -p "$RUNNER_TEMP/sse-diagnostics"
printf '%s\n' "$STEP_RESULTS" > "$RUNNER_TEMP/sse-diagnostics/steps.txt"
printf 'commit=%s\njob=%s\n' "$GITHUB_SHA" "$GITHUB_JOB" > "$RUNNER_TEMP/sse-diagnostics/run.txt"
if command -v php >/dev/null; then
php -r 'echo PHP_VERSION, PHP_EOL;' > "$RUNNER_TEMP/sse-diagnostics/php.txt"
fi
if command -v composer >/dev/null && [ -f vendor/composer/installed.json ]; then
php -r '$data = json_decode(file_get_contents("vendor/composer/installed.json"), true, 512, JSON_THROW_ON_ERROR); foreach ($data["packages"] ?? $data as $package) { echo $package["name"], " ", $package["version"], PHP_EOL; }' > "$RUNNER_TEMP/sse-diagnostics/dependencies.txt"
fi
# Never upload config, authentication data, SQL, exports, or arbitrary logs.
# Refuse oversized or linked diagnostics instead of uploading them.
find "$RUNNER_TEMP/sse-diagnostics" -type l -delete
find "$RUNNER_TEMP/sse-diagnostics" -type f -size +2M -delete
- name: Upload diagnostic summary
id: upload_diagnostics
if: ${{ always() }}
continue-on-error: true
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: wp-compat-vip-phpcs
path: |
${{ runner.temp }}/sse-diagnostics/run.txt
${{ runner.temp }}/sse-diagnostics/steps.txt
${{ runner.temp }}/sse-diagnostics/php.txt
${{ runner.temp }}/sse-diagnostics/dependencies.txt
${{ runner.temp }}/sse-diagnostics/wordpress.txt
${{ runner.temp }}/sse-diagnostics/junit.xml
retention-days: 7
if-no-files-found: warn
- name: Create issue on VIP PHPCS failure
id: create_issue_on_vip_phpcs_failure
if: ${{ failure() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.actor != 'dependabot[bot]' }}
uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2.9.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FAILURE_STAGE: ${{ steps.checkout_code.outcome == 'failure' && 'checkout_code' || steps.setup_php_8_3.outcome == 'failure' && 'setup_php_8_3' || steps.install_composer_dependencies.outcome == 'failure' && 'install_composer_dependencies' || steps.install_wordpress_vip_coding_standards.outcome == 'failure' && 'install_wordpress_vip_coding_standards' || steps.create_vip_specific_phpcs_configuration.outcome == 'failure' && 'create_vip_specific_phpcs_configuration' || steps.run_wordpress_vip_phpcs.outcome == 'failure' && 'run_wordpress_vip_phpcs' || 'unknown_or_canceled' }}
PHP_VERSION: '8.3'
RUN_ID: ${{ github.run_id }}
WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
filename: .github/ISSUE_TEMPLATE/vip-phpcs-failure.md
update_existing: false
phpmd:
name: PHPMD (PHP 8.3)
runs-on: ubuntu-latest
# The job files an issue when it fails on main.
permissions:
contents: read
issues: write
timeout-minutes: 45
steps:
- name: Checkout code
id: checkout_code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Always fetch the latest commit, disable any caching
fetch-depth: 0
clean: true
- name: Verify latest commit
id: verify_latest_commit
run: |
echo "=== Git Information ==="
echo "Current commit: $(git rev-parse HEAD)"
echo "Current branch: $(git rev-parse --abbrev-ref HEAD)"
echo "Latest commit message: $(git log -1 --pretty=%B)"
echo "=== End Git Information ==="
- name: Setup PHP 8.3
id: setup_php_8_3
uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
with:
php-version: '8.3'
extensions: mysqli, curl, zip, intl, gd, mbstring, fileinfo, xml
coverage: none
tools: composer:v2
- name: Clear any existing composer cache
id: clear_any_existing_composer_cache
run: |
composer clear-cache || true
rm -rf vendor/ || true
- name: Install Composer dependencies (no cache)
id: install_composer_dependencies_no_cache
run: |
composer install --prefer-dist --no-progress --no-cache
- name: Verify phpmd.xml content
id: verify_phpmd_xml_content
run: |
echo "=== Current phpmd.xml content ==="
cat phpmd.xml
echo "=== End phpmd.xml content ==="
- name: Run PHPMD
id: run_phpmd
run: |
# Use WordPress-specific PHPMD configuration (WordPress snake_case compatible)
echo "Using WordPress-specific PHPMD configuration (WordPress snake_case compatible)..."
# PHPMD 3: the analyze command, and one --exclude per directory (a comma list is no longer split).
vendor/bin/phpmd analyze . --no-progress --format text --ruleset phpmd.xml --exclude vendor --exclude tests
- name: Preserve diagnostic summary
id: preserve_diagnostics
if: ${{ always() }}
continue-on-error: true
env:
STEP_RESULTS: |
checkout_code=${{ steps.checkout_code.outcome }}
verify_latest_commit=${{ steps.verify_latest_commit.outcome }}
setup_php_8_3=${{ steps.setup_php_8_3.outcome }}
clear_any_existing_composer_cache=${{ steps.clear_any_existing_composer_cache.outcome }}
install_composer_dependencies_no_cache=${{ steps.install_composer_dependencies_no_cache.outcome }}
verify_phpmd_xml_content=${{ steps.verify_phpmd_xml_content.outcome }}
run_phpmd=${{ steps.run_phpmd.outcome }}
run: |
mkdir -p "$RUNNER_TEMP/sse-diagnostics"
printf '%s\n' "$STEP_RESULTS" > "$RUNNER_TEMP/sse-diagnostics/steps.txt"
printf 'commit=%s\njob=%s\n' "$GITHUB_SHA" "$GITHUB_JOB" > "$RUNNER_TEMP/sse-diagnostics/run.txt"
if command -v php >/dev/null; then
php -r 'echo PHP_VERSION, PHP_EOL;' > "$RUNNER_TEMP/sse-diagnostics/php.txt"
fi
if command -v composer >/dev/null && [ -f vendor/composer/installed.json ]; then
php -r '$data = json_decode(file_get_contents("vendor/composer/installed.json"), true, 512, JSON_THROW_ON_ERROR); foreach ($data["packages"] ?? $data as $package) { echo $package["name"], " ", $package["version"], PHP_EOL; }' > "$RUNNER_TEMP/sse-diagnostics/dependencies.txt"
fi
# Never upload config, authentication data, SQL, exports, or arbitrary logs.
# Refuse oversized or linked diagnostics instead of uploading them.
find "$RUNNER_TEMP/sse-diagnostics" -type l -delete
find "$RUNNER_TEMP/sse-diagnostics" -type f -size +2M -delete
- name: Upload diagnostic summary
id: upload_diagnostics
if: ${{ always() }}
continue-on-error: true
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: wp-compat-phpmd
path: |
${{ runner.temp }}/sse-diagnostics/run.txt
${{ runner.temp }}/sse-diagnostics/steps.txt
${{ runner.temp }}/sse-diagnostics/php.txt
${{ runner.temp }}/sse-diagnostics/dependencies.txt
${{ runner.temp }}/sse-diagnostics/wordpress.txt
${{ runner.temp }}/sse-diagnostics/junit.xml
retention-days: 7
if-no-files-found: warn
- name: Create issue on PHPMD failure
id: create_issue_on_phpmd_failure
if: ${{ failure() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.actor != 'dependabot[bot]' }}
uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2.9.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FAILURE_STAGE: ${{ steps.checkout_code.outcome == 'failure' && 'checkout_code' || steps.verify_latest_commit.outcome == 'failure' && 'verify_latest_commit' || steps.setup_php_8_3.outcome == 'failure' && 'setup_php_8_3' || steps.clear_any_existing_composer_cache.outcome == 'failure' && 'clear_any_existing_composer_cache' || steps.install_composer_dependencies_no_cache.outcome == 'failure' && 'install_composer_dependencies_no_cache' || steps.verify_phpmd_xml_content.outcome == 'failure' && 'verify_phpmd_xml_content' || steps.run_phpmd.outcome == 'failure' && 'run_phpmd' || 'unknown_or_canceled' }}
PHP_VERSION: '8.3'
RUN_ID: ${{ github.run_id }}
WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
filename: .github/ISSUE_TEMPLATE/phpmd-failure.md
update_existing: false
psalm-analysis:
name: Psalm Static Analysis (PHP 8.3)
runs-on: ubuntu-latest
# The job files an issue when it fails on main.
permissions:
contents: read
issues: write
timeout-minutes: 45
steps:
- name: Checkout code
id: checkout_code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup PHP 8.3
id: setup_php_8_3
uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
with:
php-version: '8.3'
extensions: mysqli, curl, zip, intl, gd, mbstring, fileinfo, xml
coverage: none
tools: composer:v2
- name: Install Composer dependencies
id: install_composer_dependencies
uses: ramsey/composer-install@26d8a556604053a9612623447203a691f406fbe6 # v4
with:
dependency-versions: highest
composer-options: "--prefer-dist --no-progress"
- name: Verify Psalm installation
id: verify_psalm_installation
run: vendor/bin/psalm --version
- name: Run Psalm
id: run_psalm
run: |
vendor/bin/psalm --config=psalm.xml --show-info=true
- name: Run Psalm taint analysis
id: run_psalm_taint_analysis
run: |
vendor/bin/psalm --config=psalm.xml --taint-analysis
- name: Preserve diagnostic summary
id: preserve_diagnostics
if: ${{ always() }}
continue-on-error: true
env:
STEP_RESULTS: |
checkout_code=${{ steps.checkout_code.outcome }}
setup_php_8_3=${{ steps.setup_php_8_3.outcome }}
install_composer_dependencies=${{ steps.install_composer_dependencies.outcome }}
verify_psalm_installation=${{ steps.verify_psalm_installation.outcome }}
run_psalm=${{ steps.run_psalm.outcome }}
run_psalm_taint_analysis=${{ steps.run_psalm_taint_analysis.outcome }}
run: |
mkdir -p "$RUNNER_TEMP/sse-diagnostics"
printf '%s\n' "$STEP_RESULTS" > "$RUNNER_TEMP/sse-diagnostics/steps.txt"
printf 'commit=%s\njob=%s\n' "$GITHUB_SHA" "$GITHUB_JOB" > "$RUNNER_TEMP/sse-diagnostics/run.txt"
if command -v php >/dev/null; then
php -r 'echo PHP_VERSION, PHP_EOL;' > "$RUNNER_TEMP/sse-diagnostics/php.txt"
fi
if command -v composer >/dev/null && [ -f vendor/composer/installed.json ]; then
php -r '$data = json_decode(file_get_contents("vendor/composer/installed.json"), true, 512, JSON_THROW_ON_ERROR); foreach ($data["packages"] ?? $data as $package) { echo $package["name"], " ", $package["version"], PHP_EOL; }' > "$RUNNER_TEMP/sse-diagnostics/dependencies.txt"
fi
# Never upload config, authentication data, SQL, exports, or arbitrary logs.
# Refuse oversized or linked diagnostics instead of uploading them.
find "$RUNNER_TEMP/sse-diagnostics" -type l -delete
find "$RUNNER_TEMP/sse-diagnostics" -type f -size +2M -delete
- name: Upload diagnostic summary
id: upload_diagnostics
if: ${{ always() }}
continue-on-error: true
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: wp-compat-psalm-analysis
path: |
${{ runner.temp }}/sse-diagnostics/run.txt
${{ runner.temp }}/sse-diagnostics/steps.txt
${{ runner.temp }}/sse-diagnostics/php.txt
${{ runner.temp }}/sse-diagnostics/dependencies.txt
${{ runner.temp }}/sse-diagnostics/wordpress.txt
${{ runner.temp }}/sse-diagnostics/junit.xml
retention-days: 7
if-no-files-found: warn
- name: Create issue on Psalm failure
id: create_issue_on_psalm_failure
if: ${{ failure() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.actor != 'dependabot[bot]' }}
uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2.9.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FAILURE_STAGE: ${{ steps.checkout_code.outcome == 'failure' && 'checkout_code' || steps.setup_php_8_3.outcome == 'failure' && 'setup_php_8_3' || steps.install_composer_dependencies.outcome == 'failure' && 'install_composer_dependencies' || steps.verify_psalm_installation.outcome == 'failure' && 'verify_psalm_installation' || steps.run_psalm.outcome == 'failure' && 'run_psalm' || steps.run_psalm_taint_analysis.outcome == 'failure' && 'run_psalm_taint_analysis' || 'unknown_or_canceled' }}
PHP_VERSION: '8.3'
RUN_ID: ${{ github.run_id }}
WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
filename: .github/ISSUE_TEMPLATE/psalm-failure.md
update_existing: false
security-check:
name: Security Scan (PHP 8.3)
runs-on: ubuntu-latest
# The job files an issue when it fails on main.
permissions:
contents: read
issues: write
timeout-minutes: 45
steps:
- name: Checkout code
id: checkout_code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup PHP 8.3
id: setup_php_8_3
uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
with:
php-version: '8.3'
extensions: mysqli, curl, zip, intl, gd, mbstring, fileinfo, xml
coverage: none
tools: composer:v2
- name: Install Composer dependencies
id: install_composer_dependencies
uses: ramsey/composer-install@26d8a556604053a9612623447203a691f406fbe6 # v4
with:
dependency-versions: highest
composer-options: "--prefer-dist --no-progress"
- name: Security Check for known vulnerabilities in dependencies
id: security_check_for_known_vulnerabilities_in_dependencies
uses: symfonycorp/security-checker-action@258311ef7ac571f1310780ef3d79fc5abef642b5 # v5
- name: WordPress Security Scan
id: wordpress_security_scan
run: |
echo "Performing WordPress plugin security analysis..."
# Basic security pattern checks for common WordPress vulnerabilities
echo "Checking for common security issues..."
security_findings=0
grep_php() {
grep -r "$1" \
--include="*.php" \
--exclude-dir=.github \
--exclude-dir=build \
--exclude-dir=coverage \
--exclude-dir=plugin-check-build \
--exclude-dir=stubs \
--exclude-dir=tests \
--exclude-dir=vendor \
. 2>/dev/null
}
# Check for potential SQL injection patterns
if grep_php "mysql_query\|mysqli_query"; then
echo "::error::Direct database queries found. Ensure proper sanitization."
security_findings=1
fi
# Check for potential XSS vulnerabilities (missing escaping)
if grep_php "echo \$_\|print \$_"; then
echo "::error::Potential XSS vulnerability found. Ensure output is escaped."
security_findings=1
fi
# Check for file inclusion vulnerabilities
if grep_php "include.*\$_\|require.*\$_"; then
echo "::error::Potential file inclusion vulnerability found."
security_findings=1
fi
# Check for eval() usage (security risk)
if grep_php "eval("; then
echo "::error::eval() function usage detected. This is a security risk."
security_findings=1
fi
# Check for proper nonce usage
if grep_php "wp_nonce_field\|wp_verify_nonce\|settings_fields" >/dev/null 2>&1; then
echo "WordPress nonce usage detected."
else
echo "Info: Consider adding WordPress nonces for form security."
fi
# Check for proper sanitization functions
if grep_php "sanitize_\|esc_" >/dev/null 2>&1; then
echo "WordPress sanitization functions detected."
else
echo "::error::Limited use of WordPress sanitization functions."
security_findings=1
fi
# Check for capability checks
if grep_php "current_user_can\|user_can" >/dev/null 2>&1; then
echo "WordPress capability checks detected."
else
echo "Info: Consider adding user capability checks where appropriate."
fi
if [ "$security_findings" -ne 0 ]; then
echo "::error::WordPress security scan found blocking findings."
exit 1
fi
echo "WordPress security scan completed."
- name: Preserve diagnostic summary
id: preserve_diagnostics
if: ${{ always() }}
continue-on-error: true
env:
STEP_RESULTS: |
checkout_code=${{ steps.checkout_code.outcome }}
setup_php_8_3=${{ steps.setup_php_8_3.outcome }}
install_composer_dependencies=${{ steps.install_composer_dependencies.outcome }}
security_check_for_known_vulnerabilities_in_dependencies=${{ steps.security_check_for_known_vulnerabilities_in_dependencies.outcome }}
wordpress_security_scan=${{ steps.wordpress_security_scan.outcome }}
run: |
mkdir -p "$RUNNER_TEMP/sse-diagnostics"
printf '%s\n' "$STEP_RESULTS" > "$RUNNER_TEMP/sse-diagnostics/steps.txt"
printf 'commit=%s\njob=%s\n' "$GITHUB_SHA" "$GITHUB_JOB" > "$RUNNER_TEMP/sse-diagnostics/run.txt"
if command -v php >/dev/null; then
php -r 'echo PHP_VERSION, PHP_EOL;' > "$RUNNER_TEMP/sse-diagnostics/php.txt"
fi
if command -v composer >/dev/null && [ -f vendor/composer/installed.json ]; then
php -r '$data = json_decode(file_get_contents("vendor/composer/installed.json"), true, 512, JSON_THROW_ON_ERROR); foreach ($data["packages"] ?? $data as $package) { echo $package["name"], " ", $package["version"], PHP_EOL; }' > "$RUNNER_TEMP/sse-diagnostics/dependencies.txt"
fi
# Never upload config, authentication data, SQL, exports, or arbitrary logs.
# Refuse oversized or linked diagnostics instead of uploading them.
find "$RUNNER_TEMP/sse-diagnostics" -type l -delete
find "$RUNNER_TEMP/sse-diagnostics" -type f -size +2M -delete
- name: Upload diagnostic summary
id: upload_diagnostics
if: ${{ always() }}
continue-on-error: true
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: wp-compat-security-check
path: |
${{ runner.temp }}/sse-diagnostics/run.txt
${{ runner.temp }}/sse-diagnostics/steps.txt
${{ runner.temp }}/sse-diagnostics/php.txt
${{ runner.temp }}/sse-diagnostics/dependencies.txt
${{ runner.temp }}/sse-diagnostics/wordpress.txt
${{ runner.temp }}/sse-diagnostics/junit.xml
retention-days: 7
if-no-files-found: warn
- name: Create issue on security vulnerability
id: create_issue_on_security_vulnerability
if: ${{ failure() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.actor != 'dependabot[bot]' }}
uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2.9.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FAILURE_STAGE: ${{ steps.checkout_code.outcome == 'failure' && 'checkout_code' || steps.setup_php_8_3.outcome == 'failure' && 'setup_php_8_3' || steps.install_composer_dependencies.outcome == 'failure' && 'install_composer_dependencies' || steps.security_check_for_known_vulnerabilities_in_dependencies.outcome == 'failure' && 'security_check_for_known_vulnerabilities_in_dependencies' || steps.wordpress_security_scan.outcome == 'failure' && 'wordpress_security_scan' || 'unknown_or_canceled' }}
PHP_VERSION: '8.3'
RUN_ID: ${{ github.run_id }}
WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
filename: .github/ISSUE_TEMPLATE/security-failure.md
update_existing: false
phpstan-wordpress:
name: PHPStan for WP (PHP 8.3)
runs-on: ubuntu-latest
# The job files an issue when it fails on main.
permissions:
contents: read
issues: write
timeout-minutes: 45
steps:
- name: Checkout code
id: checkout_code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Always fetch the latest commit, disable any caching
fetch-depth: 0
clean: true
- name: Setup PHP 8.3
id: setup_php_8_3
uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
with:
php-version: '8.3'
extensions: mysqli, curl, zip, intl, gd, mbstring, fileinfo, xml
coverage: none
tools: composer:v2
- name: Clear any existing composer cache
id: clear_any_existing_composer_cache
run: |
composer clear-cache || true
rm -rf vendor/ || true
- name: Install Composer dependencies (no cache)
id: install_composer_dependencies_no_cache
run: |
composer install --prefer-dist --no-progress --no-cache
- name: Verify phpstan.neon content
id: verify_phpstan_neon_content
run: |
echo "=== Current phpstan.neon content ==="
cat phpstan.neon
echo "=== End phpstan.neon content ==="
- name: PHPStan for WordPress Analysis
id: phpstan_for_wordpress_analysis
run: |
echo "Running PHPStan analysis with WordPress stubs..."
vendor/bin/phpstan analyse --no-progress --error-format=table
echo "✅ PHPStan analysis completed successfully!"
- name: Preserve diagnostic summary
id: preserve_diagnostics
if: ${{ always() }}
continue-on-error: true
env:
STEP_RESULTS: |
checkout_code=${{ steps.checkout_code.outcome }}
setup_php_8_3=${{ steps.setup_php_8_3.outcome }}
clear_any_existing_composer_cache=${{ steps.clear_any_existing_composer_cache.outcome }}
install_composer_dependencies_no_cache=${{ steps.install_composer_dependencies_no_cache.outcome }}
verify_phpstan_neon_content=${{ steps.verify_phpstan_neon_content.outcome }}
phpstan_for_wordpress_analysis=${{ steps.phpstan_for_wordpress_analysis.outcome }}
run: |
mkdir -p "$RUNNER_TEMP/sse-diagnostics"
printf '%s\n' "$STEP_RESULTS" > "$RUNNER_TEMP/sse-diagnostics/steps.txt"
printf 'commit=%s\njob=%s\n' "$GITHUB_SHA" "$GITHUB_JOB" > "$RUNNER_TEMP/sse-diagnostics/run.txt"
if command -v php >/dev/null; then
php -r 'echo PHP_VERSION, PHP_EOL;' > "$RUNNER_TEMP/sse-diagnostics/php.txt"
fi
if command -v composer >/dev/null && [ -f vendor/composer/installed.json ]; then
php -r '$data = json_decode(file_get_contents("vendor/composer/installed.json"), true, 512, JSON_THROW_ON_ERROR); foreach ($data["packages"] ?? $data as $package) { echo $package["name"], " ", $package["version"], PHP_EOL; }' > "$RUNNER_TEMP/sse-diagnostics/dependencies.txt"
fi
# Never upload config, authentication data, SQL, exports, or arbitrary logs.
# Refuse oversized or linked diagnostics instead of uploading them.
find "$RUNNER_TEMP/sse-diagnostics" -type l -delete
find "$RUNNER_TEMP/sse-diagnostics" -type f -size +2M -delete
- name: Upload diagnostic summary
id: upload_diagnostics
if: ${{ always() }}
continue-on-error: true
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: wp-compat-phpstan-wordpress
path: |
${{ runner.temp }}/sse-diagnostics/run.txt
${{ runner.temp }}/sse-diagnostics/steps.txt
${{ runner.temp }}/sse-diagnostics/php.txt
${{ runner.temp }}/sse-diagnostics/dependencies.txt
${{ runner.temp }}/sse-diagnostics/wordpress.txt
${{ runner.temp }}/sse-diagnostics/junit.xml
retention-days: 7
if-no-files-found: warn
- name: Create issue on PHPStan failure
id: create_issue_on_phpstan_failure
if: ${{ failure() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.actor != 'dependabot[bot]' }}
uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2.9.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FAILURE_STAGE: ${{ steps.checkout_code.outcome == 'failure' && 'checkout_code' || steps.setup_php_8_3.outcome == 'failure' && 'setup_php_8_3' || steps.clear_any_existing_composer_cache.outcome == 'failure' && 'clear_any_existing_composer_cache' || steps.install_composer_dependencies_no_cache.outcome == 'failure' && 'install_composer_dependencies_no_cache' || steps.verify_phpstan_neon_content.outcome == 'failure' && 'verify_phpstan_neon_content' || steps.phpstan_for_wordpress_analysis.outcome == 'failure' && 'phpstan_for_wordpress_analysis' || 'unknown_or_canceled' }}
PHP_VERSION: '8.3'
RUN_ID: ${{ github.run_id }}
WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
filename: .github/ISSUE_TEMPLATE/phpstan-failure.md
update_existing: false
rector:
name: Rector (PHP 8.3)
runs-on: ubuntu-latest
# The job files an issue when it fails on main.
permissions:
contents: read
issues: write
timeout-minutes: 30
steps:
- name: Checkout code
id: checkout_code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Always fetch the latest commit, disable any caching
fetch-depth: 0
clean: true
- name: Setup PHP 8.3
id: setup_php_8_3
uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
with:
php-version: '8.3'
extensions: mysqli, curl, zip, intl, gd, mbstring, fileinfo, xml
coverage: none
tools: composer:v2
- name: Clear any existing composer cache
id: clear_any_existing_composer_cache
run: |
composer clear-cache || true
rm -rf vendor/ || true
- name: Install Composer dependencies (no cache)
id: install_composer_dependencies_no_cache
run: |
composer install --prefer-dist --no-progress --no-cache
- name: Run Rector dry run
id: run_rector_dry_run
# The dry run changes nothing and fails when any configured rule would change the code.
run: |
vendor/bin/rector --version
vendor/bin/rector process --dry-run --no-progress-bar
- name: Preserve diagnostic summary
id: preserve_diagnostics
if: ${{ always() }}
continue-on-error: true
env:
STEP_RESULTS: |
checkout_code=${{ steps.checkout_code.outcome }}
setup_php_8_3=${{ steps.setup_php_8_3.outcome }}
clear_any_existing_composer_cache=${{ steps.clear_any_existing_composer_cache.outcome }}
install_composer_dependencies_no_cache=${{ steps.install_composer_dependencies_no_cache.outcome }}
run_rector_dry_run=${{ steps.run_rector_dry_run.outcome }}
run: |
mkdir -p "$RUNNER_TEMP/sse-diagnostics"
printf '%s\n' "$STEP_RESULTS" > "$RUNNER_TEMP/sse-diagnostics/steps.txt"
printf 'commit=%s\njob=%s\n' "$GITHUB_SHA" "$GITHUB_JOB" > "$RUNNER_TEMP/sse-diagnostics/run.txt"
if command -v php >/dev/null; then
php -r 'echo PHP_VERSION, PHP_EOL;' > "$RUNNER_TEMP/sse-diagnostics/php.txt"
fi
if command -v composer >/dev/null && [ -f vendor/composer/installed.json ]; then
php -r '$data = json_decode(file_get_contents("vendor/composer/installed.json"), true, 512, JSON_THROW_ON_ERROR); foreach ($data["packages"] ?? $data as $package) { echo $package["name"], " ", $package["version"], PHP_EOL; }' > "$RUNNER_TEMP/sse-diagnostics/dependencies.txt"
fi
# Never upload config, authentication data, SQL, exports, or arbitrary logs.
# Refuse oversized or linked diagnostics instead of uploading them.
find "$RUNNER_TEMP/sse-diagnostics" -type l -delete
find "$RUNNER_TEMP/sse-diagnostics" -type f -size +2M -delete
- name: Upload diagnostic summary
id: upload_diagnostics
if: ${{ always() }}
continue-on-error: true
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: wp-compat-rector
path: |
${{ runner.temp }}/sse-diagnostics/run.txt
${{ runner.temp }}/sse-diagnostics/steps.txt
${{ runner.temp }}/sse-diagnostics/php.txt
${{ runner.temp }}/sse-diagnostics/dependencies.txt
retention-days: 7
if-no-files-found: warn
- name: Create issue on Rector failure
id: create_issue_on_rector_failure
if: ${{ failure() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.actor != 'dependabot[bot]' }}
uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2.9.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FAILURE_STAGE: ${{ steps.checkout_code.outcome == 'failure' && 'checkout_code' || steps.setup_php_8_3.outcome == 'failure' && 'setup_php_8_3' || steps.clear_any_existing_composer_cache.outcome == 'failure' && 'clear_any_existing_composer_cache' || steps.install_composer_dependencies_no_cache.outcome == 'failure' && 'install_composer_dependencies_no_cache' || steps.run_rector_dry_run.outcome == 'failure' && 'run_rector_dry_run' || 'unknown_or_canceled' }}
PHP_VERSION: '8.3'
RUN_ID: ${{ github.run_id }}
WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
filename: .github/ISSUE_TEMPLATE/rector-failure.md
update_existing: false
javascript-tests:
name: JavaScript Tests (Node 24)
runs-on: ubuntu-latest
# The job files an issue when it fails on main.
permissions:
contents: read
issues: write
timeout-minutes: 15
steps:
- name: Checkout code
id: checkout_code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup PHP 8.3
id: setup_php_8_3
uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
with:
# PHP renders the page markup and script settings the tests load.
php-version: '8.3'
coverage: none
tools: none
- name: Setup Node.js 24
id: setup_node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '24'
- name: Install npm dependencies
id: install_npm_dependencies
run: |
npm ci --no-audit --no-fund
npm ls jsdom
- name: Run JavaScript tests
id: run_javascript_tests
run: |
mkdir -p "$RUNNER_TEMP/sse-diagnostics"
node --version
node --test --test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=tap --test-reporter-destination="$RUNNER_TEMP/sse-diagnostics/javascript-tests.tap" \
"tests/js/**/*.test.mjs"
# A run that found no tests must not pass.
grep -Eq '^# pass [1-9]' "$RUNNER_TEMP/sse-diagnostics/javascript-tests.tap"
grep -q '^# fail 0$' "$RUNNER_TEMP/sse-diagnostics/javascript-tests.tap"
# This control must fail because of its assertion.
printf '%s\n' \
"import assert from 'node:assert/strict';" \
"import { test } from 'node:test';" \
"test( 'intentional failure', () => assert.equal( 1, 2, 'JavaScript intentional assertion control' ) );" \
> "$RUNNER_TEMP/sse-intentional-failure.test.mjs"
if node --test "$RUNNER_TEMP/sse-intentional-failure.test.mjs" > "$RUNNER_TEMP/sse-intentional-failure.txt" 2>&1; then
echo '::error::An intentionally failing assertion incorrectly succeeded.'
exit 1
fi
grep -q 'JavaScript intentional assertion control' "$RUNNER_TEMP/sse-intentional-failure.txt"
echo 'Intentional-assertion control failed as expected.'
- name: Preserve diagnostic summary
id: preserve_diagnostics
if: ${{ always() }}
continue-on-error: true
env:
STEP_RESULTS: |
checkout_code=${{ steps.checkout_code.outcome }}
setup_php_8_3=${{ steps.setup_php_8_3.outcome }}
setup_node=${{ steps.setup_node.outcome }}
install_npm_dependencies=${{ steps.install_npm_dependencies.outcome }}
run_javascript_tests=${{ steps.run_javascript_tests.outcome }}
run: |
mkdir -p "$RUNNER_TEMP/sse-diagnostics"
printf '%s\n' "$STEP_RESULTS" > "$RUNNER_TEMP/sse-diagnostics/steps.txt"
printf 'commit=%s\njob=%s\n' "$GITHUB_SHA" "$GITHUB_JOB" > "$RUNNER_TEMP/sse-diagnostics/run.txt"
if command -v node >/dev/null; then
node --version > "$RUNNER_TEMP/sse-diagnostics/node.txt"
fi
if command -v npm >/dev/null && [ -d node_modules ]; then
npm ls --all --depth=1 > "$RUNNER_TEMP/sse-diagnostics/dependencies.txt" 2>&1 || true
fi
# Never upload config, authentication data, SQL, exports, or arbitrary logs.
# Refuse oversized or linked diagnostics instead of uploading them.
find "$RUNNER_TEMP/sse-diagnostics" -type l -delete
find "$RUNNER_TEMP/sse-diagnostics" -type f -size +2M -delete
- name: Upload diagnostic summary
id: upload_diagnostics
if: ${{ always() }}
continue-on-error: true
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: wp-compat-javascript-tests
path: |
${{ runner.temp }}/sse-diagnostics/run.txt
${{ runner.temp }}/sse-diagnostics/steps.txt
${{ runner.temp }}/sse-diagnostics/node.txt
${{ runner.temp }}/sse-diagnostics/dependencies.txt
${{ runner.temp }}/sse-diagnostics/javascript-tests.tap
retention-days: 7
if-no-files-found: warn
- name: Create issue on JavaScript test failure
id: create_issue_on_javascript_test_failure
if: ${{ failure() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.actor != 'dependabot[bot]' }}
uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2.9.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FAILURE_STAGE: ${{ steps.checkout_code.outcome == 'failure' && 'checkout_code' || steps.setup_php_8_3.outcome == 'failure' && 'setup_php_8_3' || steps.setup_node.outcome == 'failure' && 'setup_node' || steps.install_npm_dependencies.outcome == 'failure' && 'install_npm_dependencies' || steps.run_javascript_tests.outcome == 'failure' && 'run_javascript_tests' || 'unknown_or_canceled' }}
NODE_VERSION: '24'
RUN_ID: ${{ github.run_id }}
WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
filename: .github/ISSUE_TEMPLATE/javascript-test-failure.md
update_existing: false
lint:
name: Lint (Node 24)
runs-on: ubuntu-latest
# The job files an issue when it fails on main.
permissions:
contents: read
issues: write
timeout-minutes: 15
steps:
- name: Checkout code
id: checkout_code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node.js 24
id: setup_node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '24'
- name: Install npm dependencies
id: install_npm_dependencies
run: |
npm ci --no-audit --no-fund
npm ls @wordpress/eslint-plugin eslint markdownlint-cli2 stylelint stylelint-config-standard
- name: Install Python linters
id: install_python_linters
run: |
# A virtual environment holds the versions that .github/lint-requirements.txt pins.
python3 -m venv "$RUNNER_TEMP/lint-venv"
"$RUNNER_TEMP/lint-venv/bin/pip" install --disable-pip-version-check --quiet \
--requirement .github/lint-requirements.txt
echo "$RUNNER_TEMP/lint-venv/bin" >> "$GITHUB_PATH"
- name: Run yamllint
id: run_yamllint
run: |
# Lint the tracked files only, so installed dependencies are left out.
mapfile -d '' -t YAML_FILES < <(git ls-files -z -- '*.yml' '*.yaml')
# A run that found no files must not pass.
[[ ${#YAML_FILES[@]} -gt 0 ]]
# --strict makes a warning fail the job, as an error does.
yamllint --strict -c .yamllint.yml -- "${YAML_FILES[@]}"
- name: Run actionlint
id: run_actionlint
run: |
# ShellCheck and pyflakes are left out. With ShellCheck, actionlint can stall on the
# long steps of the compatibility workflow; Ruff and mypy check the Python scripts.
actionlint -shellcheck= -pyflakes= .github/workflows/*.yml
- name: Run zizmor
id: run_zizmor
# --offline: the audits that need the GitHub API are left out, so no token is used.
run: zizmor --offline --no-progress .github/workflows
- name: Run Ruff
id: run_ruff
run: |
ruff check .github/scripts
ruff format --check .github/scripts
- name: Run mypy
id: run_mypy
# .mypy.ini names the files and sets the strict mode.
run: mypy
- name: Run markdownlint
id: run_markdownlint
run: |
# .markdownlint-cli2.jsonc names the files; .markdownlint.json holds the rules.
status=0
npm run --silent lint:md > "$RUNNER_TEMP/markdownlint.txt" 2>&1 || status=$?
cat "$RUNNER_TEMP/markdownlint.txt"
[[ $status == 0 ]]
# A run that found no files must not pass.
grep -Eq '^Linting: [1-9][0-9]* file' "$RUNNER_TEMP/markdownlint.txt"
- name: Run ESLint
id: run_eslint
# ESLint fails by itself when the pattern matches no file.
run: npm run --silent lint:js
- name: Run Stylelint
id: run_stylelint
# Stylelint fails by itself when the pattern matches no file.
run: npm run --silent lint:css
- name: Preserve diagnostic summary
id: preserve_diagnostics
if: ${{ always() }}
continue-on-error: true
env:
STEP_RESULTS: |
checkout_code=${{ steps.checkout_code.outcome }}
setup_node=${{ steps.setup_node.outcome }}
install_npm_dependencies=${{ steps.install_npm_dependencies.outcome }}
install_python_linters=${{ steps.install_python_linters.outcome }}
run_yamllint=${{ steps.run_yamllint.outcome }}
run_actionlint=${{ steps.run_actionlint.outcome }}
run_zizmor=${{ steps.run_zizmor.outcome }}
run_ruff=${{ steps.run_ruff.outcome }}
run_mypy=${{ steps.run_mypy.outcome }}
run_markdownlint=${{ steps.run_markdownlint.outcome }}
run_eslint=${{ steps.run_eslint.outcome }}
run_stylelint=${{ steps.run_stylelint.outcome }}
run: |
mkdir -p "$RUNNER_TEMP/sse-diagnostics"
printf '%s\n' "$STEP_RESULTS" > "$RUNNER_TEMP/sse-diagnostics/steps.txt"
printf 'commit=%s\njob=%s\n' "$GITHUB_SHA" "$GITHUB_JOB" > "$RUNNER_TEMP/sse-diagnostics/run.txt"
if command -v node >/dev/null; then
node --version > "$RUNNER_TEMP/sse-diagnostics/node.txt"
fi
if command -v npm >/dev/null && [ -d node_modules ]; then
npm ls --all --depth=1 > "$RUNNER_TEMP/sse-diagnostics/dependencies.txt" 2>&1 || true
fi
if [ -x "$RUNNER_TEMP/lint-venv/bin/pip" ]; then
"$RUNNER_TEMP/lint-venv/bin/pip" freeze > "$RUNNER_TEMP/sse-diagnostics/python-dependencies.txt" 2>&1 || true
fi
# Never upload config, authentication data, SQL, exports, or arbitrary logs.
# Refuse oversized or linked diagnostics instead of uploading them.
find "$RUNNER_TEMP/sse-diagnostics" -type l -delete
find "$RUNNER_TEMP/sse-diagnostics" -type f -size +2M -delete
- name: Upload diagnostic summary
id: upload_diagnostics
if: ${{ always() }}
continue-on-error: true
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: wp-compat-lint
path: |
${{ runner.temp }}/sse-diagnostics/run.txt
${{ runner.temp }}/sse-diagnostics/steps.txt
${{ runner.temp }}/sse-diagnostics/node.txt
${{ runner.temp }}/sse-diagnostics/dependencies.txt
${{ runner.temp }}/sse-diagnostics/python-dependencies.txt
retention-days: 7
if-no-files-found: warn
- name: Create issue on lint failure
id: create_issue_on_lint_failure
if: ${{ failure() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.actor != 'dependabot[bot]' }}
uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2.9.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FAILURE_STAGE: ${{ steps.checkout_code.outcome == 'failure' && 'checkout_code' || steps.setup_node.outcome == 'failure' && 'setup_node' || steps.install_npm_dependencies.outcome == 'failure' && 'install_npm_dependencies' || steps.install_python_linters.outcome == 'failure' && 'install_python_linters' || steps.run_yamllint.outcome == 'failure' && 'run_yamllint' || steps.run_actionlint.outcome == 'failure' && 'run_actionlint' || steps.run_zizmor.outcome == 'failure' && 'run_zizmor' || steps.run_ruff.outcome == 'failure' && 'run_ruff' || steps.run_mypy.outcome == 'failure' && 'run_mypy' || steps.run_markdownlint.outcome == 'failure' && 'run_markdownlint' || steps.run_eslint.outcome == 'failure' && 'run_eslint' || steps.run_stylelint.outcome == 'failure' && 'run_stylelint' || 'unknown_or_canceled' }}
NODE_VERSION: '24'
RUN_ID: ${{ github.run_id }}
WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
filename: .github/ISSUE_TEMPLATE/lint-failure.md
update_existing: false
unit-coverage:
name: Unit Test Coverage (PHP 8.4)
runs-on: ubuntu-latest
# The job files an issue when it fails on main.
permissions:
contents: read
issues: write
timeout-minutes: 15
env:
# The unit suite must cover at least this percentage of the production lines. It is set a few
# points below the measured value, so it stops a real drop without failing on small changes.
MINIMUM_LINE_COVERAGE: '15'
steps:
- name: Checkout code
id: checkout_code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup PHP 8.4
id: setup_php_8_4
uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
with:
php-version: '8.4'
extensions: mysqli, curl, zip, intl, gd, mbstring, fileinfo, xml
coverage: pcov
tools: composer:v2
- name: Install Composer dependencies
id: install_composer_dependencies
uses: ramsey/composer-install@26d8a556604053a9612623447203a691f406fbe6 # v4
with:
dependency-versions: highest
composer-options: "--prefer-dist --no-progress"
- name: Run unit tests with coverage
id: run_unit_tests_with_coverage
run: |
mkdir -p "$RUNNER_TEMP/sse-diagnostics"
vendor/bin/phpunit --version
vendor/bin/phpunit --configuration phpunit.xml --testsuite unit --fail-on-empty-test-suite \
--coverage-text="$RUNNER_TEMP/sse-diagnostics/coverage.txt" \
--coverage-clover "$RUNNER_TEMP/sse-diagnostics/clover.xml"
cat "$RUNNER_TEMP/sse-diagnostics/coverage.txt"
- name: Check line coverage
id: check_line_coverage
run: |
php -r '
$metrics = simplexml_load_file($argv[1])->project->metrics;
$lines = (int) $metrics["statements"];
$covered = (int) $metrics["coveredstatements"];
if ($lines < 1) {
fwrite(STDERR, "::error::The coverage report lists no production lines.\n");
exit(1);
}
$percent = 100 * $covered / $lines;
printf("Line coverage: %.2f%% (%d of %d lines); minimum %s%%.\n", $percent, $covered, $lines, $argv[2]);
if ($percent < (float) $argv[2]) {
fwrite(STDERR, "::error::Unit test line coverage is below the minimum.\n");
exit(1);
}
' "$RUNNER_TEMP/sse-diagnostics/clover.xml" "$MINIMUM_LINE_COVERAGE"
- name: Preserve diagnostic summary
id: preserve_diagnostics
if: ${{ always() }}
continue-on-error: true
env:
STEP_RESULTS: |
checkout_code=${{ steps.checkout_code.outcome }}
setup_php_8_4=${{ steps.setup_php_8_4.outcome }}
install_composer_dependencies=${{ steps.install_composer_dependencies.outcome }}
run_unit_tests_with_coverage=${{ steps.run_unit_tests_with_coverage.outcome }}
check_line_coverage=${{ steps.check_line_coverage.outcome }}
run: |
mkdir -p "$RUNNER_TEMP/sse-diagnostics"
printf '%s\n' "$STEP_RESULTS" > "$RUNNER_TEMP/sse-diagnostics/steps.txt"
printf 'commit=%s\njob=%s\n' "$GITHUB_SHA" "$GITHUB_JOB" > "$RUNNER_TEMP/sse-diagnostics/run.txt"
if command -v php >/dev/null; then
php -r 'echo PHP_VERSION, PHP_EOL;' > "$RUNNER_TEMP/sse-diagnostics/php.txt"
fi
if command -v composer >/dev/null && [ -f vendor/composer/installed.json ]; then
php -r '$data = json_decode(file_get_contents("vendor/composer/installed.json"), true, 512, JSON_THROW_ON_ERROR); foreach ($data["packages"] ?? $data as $package) { echo $package["name"], " ", $package["version"], PHP_EOL; }' > "$RUNNER_TEMP/sse-diagnostics/dependencies.txt"
fi
# Never upload config, authentication data, SQL, exports, or arbitrary logs.
# Refuse oversized or linked diagnostics instead of uploading them.
find "$RUNNER_TEMP/sse-diagnostics" -type l -delete
find "$RUNNER_TEMP/sse-diagnostics" -type f -size +2M -delete
- name: Upload diagnostic summary
id: upload_diagnostics
if: ${{ always() }}
continue-on-error: true
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: wp-compat-unit-coverage
path: |
${{ runner.temp }}/sse-diagnostics/run.txt
${{ runner.temp }}/sse-diagnostics/steps.txt
${{ runner.temp }}/sse-diagnostics/php.txt
${{ runner.temp }}/sse-diagnostics/dependencies.txt
${{ runner.temp }}/sse-diagnostics/wordpress.txt
${{ runner.temp }}/sse-diagnostics/junit.xml
${{ runner.temp }}/sse-diagnostics/coverage.txt
${{ runner.temp }}/sse-diagnostics/clover.xml
retention-days: 7
if-no-files-found: warn
- name: Create issue on unit coverage failure
id: create_issue_on_unit_coverage_failure
if: ${{ failure() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.actor != 'dependabot[bot]' }}
uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2.9.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FAILURE_STAGE: ${{ steps.checkout_code.outcome == 'failure' && 'checkout_code' || steps.setup_php_8_4.outcome == 'failure' && 'setup_php_8_4' || steps.install_composer_dependencies.outcome == 'failure' && 'install_composer_dependencies' || steps.run_unit_tests_with_coverage.outcome == 'failure' && 'run_unit_tests_with_coverage' || steps.check_line_coverage.outcome == 'failure' && 'check_line_coverage' || 'unknown_or_canceled' }}
PHP_VERSION: '8.4'
RUN_ID: ${{ github.run_id }}
WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
filename: .github/ISSUE_TEMPLATE/unit-coverage-failure.md
update_existing: false
wp-version-test:
name: Test WordPress ${{ matrix.wp-version }} with PHP ${{ matrix.php-version }} (${{ matrix.dependency-versions }} deps)
runs-on: ubuntu-latest
# The job files an issue when it fails on main.
permissions:
contents: read
issues: write
timeout-minutes: 45
strategy:
matrix:
php-version: ['8.3', '8.4', '8.5']
wp-version: ['7.0', 'latest', 'nightly']
dependency-versions: ['highest']
include:
- php-version: '8.3'
wp-version: 'latest'
dependency-versions: 'lowest'
fail-fast: false
services:
mysql:
image: mysql:8.4
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: wordpress_test
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping -proot --silent" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- name: Checkout code
id: checkout_code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup PHP ${{ matrix.php-version }}
id: setup_php
uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
with:
php-version: ${{ matrix.php-version }}
extensions: mysqli, curl, zip, intl, gd, mbstring, fileinfo, xml
coverage: none
tools: composer:v2
- name: Use isolated Composer auth
id: use_isolated_composer_auth
run: |
# Avoid inheriting a masked or malformed GitHub OAuth token from Composer auth.
composer_home="${RUNNER_TEMP}/composer-home"
composer_cache="${RUNNER_TEMP}/composer-cache"
mkdir -p "$composer_home" "$composer_cache"
{
echo "COMPOSER_HOME=$composer_home"
echo "COMPOSER_CACHE_DIR=$composer_cache"
echo 'COMPOSER_AUTH={"github-oauth":{"github.com":"${{ github.token }}"}}'
} >> "$GITHUB_ENV"
- name: PHP syntax lint
id: php_syntax_lint
run: |
find . \
-path './vendor' -prune -o \
-path './tests' -prune -o \
-path './build' -prune -o \
-path './plugin-check-build' -prune -o \
-name '*.php' -print0 | xargs -0 -n1 php -l
- name: Install Subversion
id: install_subversion
timeout-minutes: 15
run: sudo apt-get update && sudo apt-get install -y subversion
- name: Resolve dependencies for this PHP version
id: resolve_dependencies_for_this_php_version
run: |
# composer.json resolves composer.lock for the PHP 8.3 floor (config.platform);
# this job updates the dependencies, so it resolves them for the PHP it runs.
composer config platform.php "$(php -r 'echo PHP_VERSION;')"
- name: Install Composer dependencies
id: install_composer_dependencies
uses: ramsey/composer-install@26d8a556604053a9612623447203a691f406fbe6 # v4
with:
dependency-versions: ${{ matrix.dependency-versions }}
composer-options: "--prefer-dist --no-progress"
- name: Run unit tests
id: run_unit_tests
run: |
# The tracked unit suite needs no WordPress install. It runs here, on the
# Composer-resolved PHPUnit and with the tracked phpunit.xml and
# tests/bootstrap.php, before the next steps pin PHPUnit 9.6 and generate
# the WordPress integration bootstrap and configuration in their place.
vendor/bin/phpunit --version
vendor/bin/phpunit --configuration phpunit.xml --testsuite unit --fail-on-empty-test-suite
- name: Pin PHPUnit for WordPress test suite
id: pin_phpunit_for_wordpress_test_suite
run: |
# The WordPress test library still calls PHPUnit APIs removed in PHPUnit 10+.
# Keep this generated integration-test job on PHPUnit 9.6 while the rest
# of the project can continue using newer Composer-resolved QA tooling.
if [ "${{ matrix.dependency-versions }}" = "lowest" ]; then
composer require --dev phpunit/phpunit:"^9.6" yoast/phpunit-polyfills:"^4.0" --with-all-dependencies --prefer-lowest --no-progress
else
composer require --dev phpunit/phpunit:"^9.6" yoast/phpunit-polyfills:"^4.0" --with-all-dependencies --no-progress
fi
- name: Verify PHPUnit dependency versions
id: verify_phpunit_dependency_versions
run: |
composer show phpunit/phpunit
composer show yoast/phpunit-polyfills
vendor/bin/phpunit --version | grep -E '^PHPUnit 9\.6\.'
- name: Prepare Database
id: prepare_database
run: |
mysql -u root --password=root --host=127.0.0.1 --port=3306 -e "DROP DATABASE IF EXISTS wordpress_test;"
mysqladmin -u root --password=root --host=127.0.0.1 --port=3306 --force create wordpress_test
- name: Create tests directory structure
id: create_tests_directory_structure
run: |
mkdir -p tests/bin
mkdir -p tests/bootstrap
- name: Create WP tests install script
id: create_wp_tests_install_script
run: |
cat > tests/bin/install-wp-tests.sh << 'EOF'
#!/usr/bin/env bash
set -euo pipefail
set +x
umask 077
if [ $# -lt 3 ]; then
echo "usage: $0 <db-name> <db-user> <db-pass> [db-host] [wp-version] [skip-database-creation]"
exit 1
fi
DB_NAME=$1
DB_USER=$2
DB_PASS=$3
DB_HOST=${4-localhost}
WP_VERSION=${5-latest}
SKIP_DB_CREATE=${6-false}
WP_TESTS_DIR=${WP_TESTS_DIR-/tmp/wordpress-tests-lib}
WP_CORE_DIR=${WP_CORE_DIR-/tmp/wordpress/}
# Only the isolated GitHub service database is in scope.
[[ $DB_NAME == wordpress_test && $DB_HOST == 127.0.0.1:3306 ]]
[[ $SKIP_DB_CREATE == true || $SKIP_DB_CREATE == false ]]
download() {
curl --fail --silent --show-error --location \
--proto '=https' --proto-redir '=https' \
--connect-timeout 20 --max-time 180 --retry 3 \
"$1" --output "$2"
test -s "$2"
}
if [[ $WP_VERSION == latest ]]; then
download https://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json
WP_VERSION=$(php -r '
$data = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR);
$versions = [];
foreach ($data["offers"] ?? [] as $offer) {
if (($offer["response"] ?? null) !== "upgrade") {
continue;
}
$version = $offer["current"] ?? null;
if (!is_string($version) || !preg_match("/\A[0-9]+\.[0-9]+(?:\.[0-9]+)?\z/", $version)) {
throw new RuntimeException("Invalid stable WordPress offer.");
}
$versions[$version] = true;
}
if (count($versions) !== 1) {
throw new RuntimeException("Expected exactly one stable WordPress release.");
}
echo array_key_first($versions);
' /tmp/wp-latest.json)
fi
if [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
WP_TESTS_TAG="tags/$WP_VERSION"
elif [[ $WP_VERSION == nightly || $WP_VERSION == trunk ]]; then
WP_TESTS_TAG=trunk
else
echo "Invalid WordPress version selector" >&2
exit 1
fi
WP_SOURCE_URL="https://develop.svn.wordpress.org/$WP_TESTS_TAG"
WP_REVISION=$(svn info --non-interactive --show-item revision "$WP_SOURCE_URL")
[[ $WP_REVISION =~ ^[0-9]+$ ]]
install_wp() {
# Do not silently reuse a partial install.
[[ ! -e $WP_CORE_DIR && ! -L $WP_CORE_DIR ]]
if [[ $WP_TESTS_TAG == trunk ]]; then
# Nightly core and test fixtures use one immutable SVN snapshot.
svn export --non-interactive --quiet -r "$WP_REVISION" \
"$WP_SOURCE_URL/src@$WP_REVISION" "$WP_CORE_DIR"
else
mkdir -p "$WP_CORE_DIR"
download "https://wordpress.org/wordpress-$WP_VERSION.tar.gz" /tmp/wordpress.tar.gz
tar --strip-components=1 -zxf /tmp/wordpress.tar.gz -C "$WP_CORE_DIR"
fi
# setup-php supplies native mysqli; never fetch a mutable third-party db.php.
php -r 'exit(extension_loaded("mysqli") ? 0 : 1);'
test ! -e "$WP_CORE_DIR/wp-content/db.php"
CORE_VERSION=$(php -r 'require $argv[1]; echo $wp_version;' "$WP_CORE_DIR/wp-includes/version.php")
SOURCE_VERSION=$(svn cat --non-interactive -r "$WP_REVISION" \
"$WP_SOURCE_URL/src/wp-includes/version.php@$WP_REVISION" |
php -r '$source = stream_get_contents(STDIN); if (!preg_match("/\\\$wp_version\s*=\s*\x27([^\x27]+)\x27/", $source, $match)) { exit(1); } echo $match[1];')
[[ ${CORE_VERSION%-src} == "${SOURCE_VERSION%-src}" ]]
if [[ $WP_TESTS_TAG != trunk ]]; then
[[ ${CORE_VERSION%-src} == "$WP_VERSION" ]]
fi
mkdir -p "$RUNNER_TEMP/sse-diagnostics"
printf 'core=%s\nsource=%s\nrevision=%s\n' "$CORE_VERSION" "$WP_TESTS_TAG" "$WP_REVISION" \
> "$RUNNER_TEMP/sse-diagnostics/wordpress.txt"
}
install_test_suite() {
[[ ! -e $WP_TESTS_DIR && ! -L $WP_TESTS_DIR ]]
mkdir -p "$WP_TESTS_DIR"
svn co --non-interactive --quiet -r "$WP_REVISION" \
"$WP_SOURCE_URL/tests/phpunit/includes@$WP_REVISION" "$WP_TESTS_DIR/includes"
svn co --non-interactive --quiet -r "$WP_REVISION" \
"$WP_SOURCE_URL/tests/phpunit/data@$WP_REVISION" "$WP_TESTS_DIR/data"
if [ ! -f "$WP_TESTS_DIR/wp-tests-config.php" ]; then
svn cat --non-interactive -r "$WP_REVISION" \
"$WP_SOURCE_URL/wp-tests-config-sample.php@$WP_REVISION" \
> "$WP_TESTS_DIR/wp-tests-config.php"
export WP_TESTS_DIR WP_CORE_DIR DB_NAME DB_USER DB_PASS DB_HOST
# Quote PHP literals safely; do not interpolate credentials through sed.
php -r '
$path = getenv("WP_TESTS_DIR") . "/wp-tests-config.php";
$config = file_get_contents($path);
$replacements = [
"dirname( __FILE__ ) . \x27/src/\x27" => rtrim(getenv("WP_CORE_DIR"), "/") . "/",
"\x27youremptytestdbnamehere\x27" => getenv("DB_NAME"),
"\x27yourusernamehere\x27" => getenv("DB_USER"),
"\x27yourpasswordhere\x27" => getenv("DB_PASS"),
"\x27localhost\x27" => getenv("DB_HOST"),
];
foreach ($replacements as $needle => $value) {
if (substr_count($config, $needle) !== 1) {
throw new RuntimeException("Unexpected WordPress test config template.");
}
$config = str_replace($needle, var_export($value, true), $config);
}
if (file_put_contents($path, $config) !== strlen($config)) {
throw new RuntimeException("Cannot write WordPress test config.");
}
'
fi
}
install_db() {
if [[ $SKIP_DB_CREATE == true ]]; then
return
fi
# Fallback is limited to this job's disposable service and never drops a DB.
MYSQL_PWD="$DB_PASS" mysql --user="$DB_USER" --host=127.0.0.1 --port=3306 \
-e 'CREATE DATABASE wordpress_test'
}
install_wp
install_test_suite
install_db
EOF
chmod +x tests/bin/install-wp-tests.sh
- name: Create Bootstrap File
id: create_bootstrap_file
run: |
mkdir -p tests
cat > tests/bootstrap.php << 'EOF'
<?php
/**
* PHPUnit bootstrap file for plugin tests.
*
* @package EngineScript_Site_Exporter
*/
define( 'WP_TESTS_PHPUNIT_POLYFILLS_PATH', dirname( __DIR__ ) . '/vendor/yoast/phpunit-polyfills' );
require_once '/tmp/wordpress-tests-lib/includes/functions.php';
function _manually_load_plugin() {
add_filter('widgets_init', function() {
return;
}, 0);
require dirname( dirname( __FILE__ ) ) . '/enginescript-site-exporter.php';
}
tests_add_filter( 'muplugins_loaded', '_manually_load_plugin' );
require '/tmp/wordpress-tests-lib/includes/bootstrap.php';
EOF
- name: Create Test File
id: create_test_file
run: |
# The generated suite lives in tests/integration so that the WordPress
# configuration below does not pick up the tracked tests/unit suite,
# which has its own bootstrap and has already run.
mkdir -p tests/integration
cat > tests/integration/EngineScriptSiteExporterTest.php << 'EOF'
<?php
/**
* Class EngineScriptSiteExporterTest
*
* @package EngineScript_Site_Exporter
*/
class EngineScriptSiteExporterTest extends WP_UnitTestCase {
public function tear_down() {
esse_clear_current_export_lease();
esse_clear_export_resource_budget();
$_GET = [];
$_POST = [];
$_REQUEST = [];
wp_set_current_user(0);
parent::tear_down();
}
public function test_export_capability_boundary() {
wp_set_current_user(0);
$this->assertFalse(esse_current_user_can_export_site());
$subscriber = self::factory()->user->create(['role' => 'subscriber']);
wp_set_current_user($subscriber);
$this->assertFalse(esse_current_user_can_export_site());
$admin = self::factory()->user->create(['role' => 'administrator']);
wp_set_current_user($admin);
$this->assertSame(!is_multisite(), esse_current_user_can_export_site());
}
public function test_lease_ownership_and_conflict() {
$lease = esse_acquire_export_lease();
$this->assertIsArray($lease);
try {
$conflict = esse_acquire_export_lease();
$this->assertWPError($conflict);
$this->assertSame('export_lease_conflict', $conflict->get_error_code());
$wrong = $lease;
$wrong['owner'] = wp_generate_uuid4();
$this->assertFalse(esse_release_export_lease($wrong));
$this->assertSame($lease, esse_get_stored_export_lease());
esse_set_current_export_lease($lease);
$this->assertTrue(esse_renew_current_export_lease(true));
} finally {
esse_release_export_lease(esse_get_current_export_lease() ?? $lease);
}
$this->assertNull(esse_get_stored_export_lease());
}
public function test_stale_owner_cannot_release_replacement() {
$old = esse_acquire_export_lease();
$this->assertIsArray($old);
$this->assertTrue(esse_release_export_lease($old));
$new = esse_acquire_export_lease();
$this->assertIsArray($new);
try {
$this->assertFalse(esse_release_export_lease($old));
$this->assertSame($new, esse_get_stored_export_lease());
} finally {
esse_release_export_lease($new);
}
}
public function test_logs_expire_without_new_append() {
update_option('sse_error_logs', 'malformed', false);
esse_prune_expired_stored_logs();
$this->assertSame([], get_option('sse_error_logs'));
esse_store_log_in_database('test record', 'error');
$logs = get_option('sse_error_logs');
$this->assertCount(1, $logs);
$logs[0]['time'] = time() - 8 * DAY_IN_SECONDS;
update_option('sse_error_logs', $logs, false);
$this->assertSame(1, esse_prune_expired_stored_logs());
$this->assertSame([], get_option('sse_error_logs'));
}
public function test_cleanup_schedule_is_idempotent() {
$path = '/tmp/sse-test-' . wp_generate_uuid4() . '/fixture.zip';
try {
esse_schedule_export_cleanup($path);
$first = wp_next_scheduled('sse_delete_export_file', [$path]);
$this->assertIsInt($first);
esse_schedule_export_cleanup($path);
$this->assertSame($first, wp_next_scheduled('sse_delete_export_file', [$path]));
$this->assertSame(1, wp_clear_scheduled_hook('sse_delete_export_file', [$path]));
$this->assertFalse(wp_next_scheduled('sse_delete_export_file', [$path]));
} finally {
wp_clear_scheduled_hook('sse_delete_export_file', [$path]);
}
}
public function test_cleanup_unschedule_removes_only_the_named_archive() {
$deleted = '/tmp/sse-test-' . wp_generate_uuid4() . '/deleted.zip';
$kept = '/tmp/sse-test-' . wp_generate_uuid4() . '/kept.zip';
try {
esse_schedule_export_cleanup($deleted);
esse_schedule_export_cleanup($kept);
$kept_time = wp_next_scheduled('sse_delete_export_file', [$kept]);
$this->assertIsInt(wp_next_scheduled('sse_delete_export_file', [$deleted]));
$this->assertIsInt($kept_time);
esse_unschedule_export_cleanup($deleted);
$this->assertFalse(wp_next_scheduled('sse_delete_export_file', [$deleted]));
$this->assertSame($kept_time, wp_next_scheduled('sse_delete_export_file', [$kept]));
// A path with no event is not an error.
esse_unschedule_export_cleanup($deleted);
$this->assertSame($kept_time, wp_next_scheduled('sse_delete_export_file', [$kept]));
} finally {
wp_clear_scheduled_hook('sse_delete_export_file', [$deleted]);
wp_clear_scheduled_hook('sse_delete_export_file', [$kept]);
}
}
public function test_stored_logs_are_bounded_and_redacted() {
delete_option('sse_error_logs');
for ($index = 0; $index < 25; ++$index) {
esse_store_log_in_database('record ' . $index . ' ' . ABSPATH . 'private.sql', 'ERROR!');
}
$logs = get_option('sse_error_logs');
$this->assertCount(20, $logs);
$this->assertStringStartsWith('record 5 ', $logs[0]['message']);
foreach ($logs as $log) {
$this->assertSame('error', $log['level']);
$this->assertStringNotContainsString(ABSPATH, $log['message']);
$this->assertStringNotContainsString('private.sql', $log['message']);
}
$this->assertLessThanOrEqual(1000, strlen(esse_sanitize_stored_log_message(str_repeat('x', 2000))));
}
public function test_private_directory_contract_rejects_legacy_and_traversal() {
$valid = esse_generate_private_export_directory_name();
$this->assertTrue(esse_validate_export_directory_name_format($valid));
foreach (['', '.', '..', '../' . $valid, $valid . '/child', $valid . '\\child', 'legacy-export'] as $invalid) {
$error = esse_validate_export_directory_name_format($invalid);
$this->assertWPError($error);
$this->assertSame('invalid_export_directory', $error->get_error_code());
}
}
public function test_direct_filesystem_global_boundary() {
require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php';
require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php';
$existed = array_key_exists('wp_filesystem', $GLOBALS);
$previous = $GLOBALS['wp_filesystem'] ?? null;
try {
foreach ([null, false, 'invalid', new stdClass()] as $invalid) {
$GLOBALS['wp_filesystem'] = $invalid;
$this->assertNull(esse_get_direct_global_filesystem());
}
$direct = new WP_Filesystem_Direct(null);
$GLOBALS['wp_filesystem'] = $direct;
$this->assertSame($direct, esse_get_direct_global_filesystem());
} finally {
if ($existed) {
$GLOBALS['wp_filesystem'] = $previous;
} else {
unset($GLOBALS['wp_filesystem']);
}
}
}
public function test_filepath_rejects_symlink_escape() {
$root = sys_get_temp_dir() . '/sse-test-' . wp_generate_uuid4();
$this->assertTrue(mkdir($root, 0700));
$this->assertTrue(mkdir($root . '/allowed', 0700));
$outside = $root . '/outside.zip';
$link = $root . '/allowed/escape.zip';
try {
$this->assertSame(7, file_put_contents($outside, 'fixture'));
$this->assertTrue(symlink($outside, $link));
$this->assertTrue(esse_validate_filepath($outside, $root));
$this->assertFalse(esse_validate_filepath($link, $root . '/allowed'));
$this->assertSame('fixture', file_get_contents($outside));
} finally {
if (is_link($link)) {
unlink($link);
}
if (is_file($outside)) {
unlink($outside);
}
rmdir($root . '/allowed');
rmdir($root);
}
}
public function test_plugin_loaded() {
$this->assertTrue(function_exists('esse_admin_menu'), 'Plugin was not loaded correctly');
}
public function test_cleanup_uses_directory_entry_fields() {
$root = sys_get_temp_dir() . '/sse-test-' . wp_generate_uuid4();
$zip = $root . '/site_enginescript_site_export_20260513_123456.zip';
$had_filesystem = array_key_exists('wp_filesystem', $GLOBALS);
$previous_filesystem = $GLOBALS['wp_filesystem'] ?? null;
$this->assertTrue(mkdir($root, 0700));
try {
$this->assertSame(7, file_put_contents($zip, 'fixture'));
$this->assertSame(7, file_put_contents($root . '/123', 'fixture'));
$this->assertSame(7, file_put_contents($root . '/unrelated.txt', 'fixture'));
$this->assertTrue(mkdir($root . '/nested.zip', 0700));
$this->assertSame([$zip], esse_get_export_files_from_private_directory($root));
$this->assertSame([], esse_get_export_files_from_private_directory($root . '/missing'));
// This helper independently accepts broad arrays; retain its guards.
$this->assertSame([], esse_get_export_files_from_directory_entry(['name' => null, 'type' => null], $root, ''));
} finally {
foreach ([$zip, $root . '/123', $root . '/unrelated.txt'] as $file) {
if (is_file($file)) {
unlink($file);
}
}
if (is_dir($root . '/nested.zip')) {
rmdir($root . '/nested.zip');
}
rmdir($root);
if ($had_filesystem) {
$GLOBALS['wp_filesystem'] = $previous_filesystem;
} else {
unset($GLOBALS['wp_filesystem']);
}
}
}
public function test_plugin_hooks_registered() {
$this->assertSame(10, has_action('admin_menu', 'esse_admin_menu'));
$this->assertSame(10, has_action('network_admin_menu', 'esse_network_admin_menu'));
$this->assertSame(10, has_action('sse_recover_expired_export', 'esse_recover_expired_export_handler'));
$this->assertSame(10, has_action('sse_export_housekeeping', 'esse_export_housekeeping_handler'));
$this->assertSame(10, has_action('admin_enqueue_scripts', 'esse_enqueue_admin_assets'));
$this->assertSame(10, has_action('admin_post_sse_export_site', 'esse_handle_export'));
$this->assertSame(10, has_action('admin_post_sse_secure_download', 'esse_handle_secure_download'));
$this->assertSame(10, has_action('admin_post_sse_delete_export', 'esse_handle_export_deletion'));
$this->assertSame(10, has_action('sse_delete_export_file', 'esse_delete_export_file_handler'));
$this->assertSame(10, has_action('sse_bulk_cleanup_exports', 'esse_bulk_cleanup_exports_handler'));
}
public function test_plugin_constants_defined() {
$this->assertTrue(defined('ESSE_VERSION'));
$this->assertTrue(defined('ESSE_ALLOWED_EXTENSIONS'));
$this->assertTrue(defined('ESSE_EXPORT_DIR_NAME'));
$this->assertTrue(defined('ESSE_FILTER_MAX_FILE_SIZE'));
$this->assertNotEmpty(ESSE_VERSION);
$this->assertSame(['zip'], ESSE_ALLOWED_EXTENSIONS);
$this->assertSame('enginescript-site-exporter-exports', ESSE_EXPORT_DIR_NAME);
$this->assertSame('sse_max_file_size_for_export', ESSE_FILTER_MAX_FILE_SIZE);
}
public function test_security_helper_allows_only_zip_extension() {
$this->assertTrue(esse_validate_file_extension('site_enginescript_site_export_20260513_123456.zip'));
$this->assertFalse(esse_validate_file_extension('site_enginescript_site_export_20260513_123456.php'));
$this->assertFalse(esse_validate_file_extension('site_enginescript_site_export_20260513_123456.sql'));
$this->assertFalse(esse_validate_file_extension('site_enginescript_site_export_20260513_123456.tar.gz'));
}
public function test_export_filename_format_validation() {
$this->assertTrue(esse_validate_filename_format('site_enginescript_site_export_20260513_123456.zip'));
$this->assertInstanceOf(WP_Error::class, esse_validate_filename_format('../site_enginescript_site_export_20260513_123456.zip'));
$this->assertInstanceOf(WP_Error::class, esse_validate_filename_format('site_enginescript_site_export_20260513_123456.php'));
$this->assertInstanceOf(WP_Error::class, esse_validate_filename_format('site_export.zip'));
}
public function test_filepath_validation_rejects_traversal() {
$base_dir = sys_get_temp_dir() . '/sse-test-' . wp_generate_uuid4();
$this->assertTrue(mkdir($base_dir, 0700));
$file = $base_dir . '/site_enginescript_site_export_20260513_123456.zip';
try {
$this->assertSame(7, file_put_contents($file, 'fixture'));
$this->assertTrue(esse_validate_filepath($file, $base_dir));
$this->assertFalse(esse_validate_filepath($base_dir . '/../' . basename($file), $base_dir));
// A file that does not exist is never valid, also inside the base.
$this->assertFalse(esse_validate_filepath($base_dir . '/site_enginescript_site_export_20260513_999999.zip', $base_dir));
} finally {
if (is_file($file)) {
unlink($file);
}
rmdir($base_dir);
}
}
public function test_wordpress_version_compatibility() {
global $wp_version;
$this->assertNotEmpty($wp_version, 'WordPress version should be available');
// Test that we're running on a supported WordPress version
$min_wp_version = '7.0';
$this->assertTrue(version_compare(preg_replace('/-src$/', '', $wp_version), $min_wp_version, '>='),
"WordPress version {$wp_version} should be >= {$min_wp_version}");
}
/**
* Signs in a user who may export on this kind of installation.
*/
private function sse_sign_in_exporter() {
$user_id = self::factory()->user->create(['role' => 'administrator']);
if (is_multisite()) {
grant_super_admin($user_id);
}
wp_set_current_user($user_id);
return $user_id;
}
/**
* Requires that a request handler stops through wp_die().
*/
private function sse_assert_refused(callable $request, $expected_message = '') {
try {
$request();
} catch (WPDieException $refusal) {
$this->assertStringContainsString($expected_message, $refusal->getMessage());
return;
}
$this->fail('The request was not refused.');
}
/**
* Removes a fixture tree without following links.
*/
private function sse_remove_fixture_tree($path) {
if (is_link($path) || is_file($path)) {
unlink($path);
return;
}
if (!is_dir($path)) {
return;
}
chmod($path, 0700);
foreach (array_diff(scandir($path), ['.', '..']) as $entry) {
$this->sse_remove_fixture_tree($path . '/' . $entry);
}
rmdir($path);
}
/**
* Lists every scheduled event that belongs to the plugin.
*/
private function sse_get_scheduled_plugin_hooks() {
$hooks = [];
foreach ((array) _get_cron_array() as $events) {
foreach (array_keys((array) $events) as $hook) {
if (str_starts_with((string) $hook, 'sse_')) {
$hooks[] = (string) $hook;
}
}
}
return $hooks;
}
public function test_tar_header_layout_and_checksum() {
$header = esse_tar_build_header('dir/file.txt', 5, 0104755, 1700000000, '0');
$this->assertSame(512, strlen($header));
$this->assertSame('dir/file.txt', rtrim(substr($header, 0, 100), "\0"));
// Only the nine permission bits are stored, and the owner and group are zero.
$this->assertSame("0000755\0", substr($header, 100, 8));
$this->assertSame("0000000\0", substr($header, 108, 8));
$this->assertSame("0000000\0", substr($header, 116, 8));
$this->assertSame("00000000005\0", substr($header, 124, 12));
$this->assertSame(sprintf("%011o\0", 1700000000), substr($header, 136, 12));
$this->assertSame('0', $header[156]);
$this->assertSame("ustar \0", substr($header, 257, 8));
$checksum = array_sum(unpack('C*', substr_replace($header, ' ', 148, 8)));
$this->assertSame(sprintf("%06o\0 ", $checksum), substr($header, 148, 8));
}
public function test_tar_size_field_padding_and_entry_lengths() {
$this->assertSame("77777777777\0", esse_tar_format_size_field(077777777777));
// From 8 GiB on, the size is stored in the GNU binary form.
$this->assertSame("\x80\0\0\0" . pack('J', 8589934592), esse_tar_format_size_field(8589934592));
$this->assertSame(12, strlen(esse_tar_format_size_field(PHP_INT_MAX)));
$this->assertSame('', esse_tar_get_padding(0));
$this->assertSame(511, strlen(esse_tar_get_padding(1)));
$this->assertSame('', esse_tar_get_padding(1024));
$this->assertSame(str_repeat("\0", 1024), esse_tar_get_end_of_archive());
// A path of up to 100 bytes needs one header; a longer one needs a long-name record first.
foreach ([100 => 512, 101 => 1536, 300 => 1536, 600 => 2048] as $length => $expected) {
$path = str_repeat('n', $length);
$this->assertSame($expected, strlen(esse_tar_build_entry_header($path, 0, 0644, 0, '0')));
$this->assertSame($expected, esse_tar_get_entry_bytes(0, $path));
}
$this->assertSame(512 + 1024, esse_tar_get_entry_bytes(513, 'f'));
$long_name = esse_tar_build_long_name_record(str_repeat('n', 101));
$this->assertSame('L', $long_name[156]);
$this->assertSame('././@LongLink', rtrim(substr($long_name, 0, 100), "\0"));
}
public function test_tar_output_is_read_by_gnu_tar() {
$root = sys_get_temp_dir() . '/sse-test-' . wp_generate_uuid4();
$entries = [
'd/' => null,
'd/empty.txt' => '',
str_repeat('e', 100) => 'exactly one hundred bytes',
'd/' . str_repeat('m', 99) => 'one byte over',
'd/' . str_repeat('L', 120) . '/' . str_repeat('L', 170) . '.txt' => str_repeat('x', 513),
"d/\u{00FC}n\u{00EF}-\u{2713}.txt" => 'not ASCII',
];
$archive = '';
foreach ($entries as $name => $content) {
if (null === $content) {
$archive .= esse_tar_build_entry_header($name, 0, 0755, 1700000000, '5');
continue;
}
$archive .= esse_tar_build_entry_header($name, strlen($content), 0640, 1700000000, '0')
. $content . esse_tar_get_padding(strlen($content));
}
$archive .= esse_tar_get_end_of_archive();
$this->assertTrue(mkdir($root . '/out', 0700, true));
try {
$this->assertSame(strlen($archive), file_put_contents($root . '/fixture.tar', $archive));
exec(
'tar --extract --preserve-permissions --file ' . escapeshellarg($root . '/fixture.tar')
. ' --directory ' . escapeshellarg($root . '/out') . ' 2>&1',
$output,
$status
);
$this->assertSame(0, $status, implode("\n", $output));
$this->assertSame([], $output);
foreach ($entries as $name => $content) {
if (null === $content) {
$this->assertDirectoryExists($root . '/out/' . $name);
continue;
}
$this->assertSame($content, file_get_contents($root . '/out/' . $name));
}
$this->assertSame(0640, fileperms($root . '/out/d/empty.txt') & 0777);
$this->assertSame(1700000000, filemtime($root . '/out/d/empty.txt'));
} finally {
$this->sse_remove_fixture_tree($root);
}
}
public function test_export_exclusion_rules() {
$excluded = ['.git', 'a/.git', 'a/.GIT', 'wp-content/.svn', '.hg', 'a/.DS_Store', '.htaccess',
'wp-content/.user.ini', 'wp-content/cache/page.html', 'wp-content/upgrade/a/b', 'wp-content/temp/c'];
foreach ($excluded as $path) {
$this->assertTrue(esse_should_exclude_file($path), $path);
}
// A file named "0", and names that only begin like an excluded one, are kept.
$kept = ['0', 'a/0', '.gitignore', 'a/.github', 'a/b.git', 'git', 'wp-config.php',
'wp-content/cache', 'wp-content/cachefoo/page.html', 'a/wp-content/cache/page.html'];
foreach ($kept as $path) {
$this->assertFalse(esse_should_exclude_file($path), $path);
}
}
public function test_files_archive_walk_contents_and_single_pass() {
$fixture = 'sse-walk-' . wp_generate_uuid4();
$source = ABSPATH . $fixture;
$out = sys_get_temp_dir() . '/sse-test-' . wp_generate_uuid4();
$long_name = str_repeat('p', 150) . '.txt';
$utf8_name = "\u{00FC}n\u{00EF}-\u{2713}.txt";
$export_paths = null;
$lease = esse_acquire_export_lease();
$this->assertIsArray($lease);
esse_set_current_export_lease($lease);
esse_initialize_export_resource_budget(0);
try {
foreach (['/kept', '/.git', '/locked'] as $directory) {
$this->assertTrue(mkdir($source . $directory, 0755, true));
}
$files = [
'/0' => 'zero',
'/empty.txt' => '',
'/kept/' . $long_name => str_repeat('y', 700),
'/' . $utf8_name => 'not ASCII',
'/.git/config' => 'left out',
'/.htaccess' => 'left out',
'/locked/inside.txt' => 'left out',
];
foreach ($files as $name => $content) {
$this->assertSame(strlen($content), file_put_contents($source . $name, $content));
}
$this->assertTrue(symlink($source . '/0', $source . '/link'));
$this->assertTrue(chmod($source . '/locked', 0000));
$export_paths = esse_setup_export_directories();
$this->assertIsArray($export_paths);
$archive = $export_paths['export_dir'] . '/files.tar.gz';
$this->assertTrue(esse_create_wordpress_files_archive($archive));
$this->assertSame(0600, fileperms($archive) & 0777);
$skipped = esse_get_skipped_export_entry_counts();
$this->assertSame(1, $skipped['links']);
$this->assertSame(1, $skipped['unreadable']);
$this->assertSame(0, $skipped['large'] + $skipped['special'] + $skipped['changed']);
exec(
'tar --list --verbose --gzip --quoting-style=literal --file ' . escapeshellarg($archive) . ' 2>&1',
$listing,
$status
);
$this->assertSame(0, $status, implode("\n", array_slice($listing, -5)));
$names = [];
$tar_bytes = strlen(esse_tar_get_end_of_archive());
$file_bytes = 0;
foreach ($listing as $line) {
$this->assertSame(1, preg_match('/^.{10}\s+\S+\s+(\d+)\s+\S+\s+\S+\s(.+)$/', $line, $match), $line);
$names[] = $match[2];
$file_bytes += (int) $match[1];
$tar_bytes += esse_tar_get_entry_bytes((int) $match[1], $match[2]);
}
$this->assertGreaterThan(1000, count($names));
$this->assertSame(count($names), count(array_unique($names)));
// Single pass: every entry is counted once, and the stream holds one
// header and one copy of the content per entry and nothing else.
$budget = esse_get_export_resource_budget();
$this->assertSame(count($names), $budget['entries']);
$this->assertSame($file_bytes, $budget['source_bytes']);
$stream = gzopen($archive, 'rb');
$this->assertNotFalse($stream);
$stream_bytes = 0;
while (!gzeof($stream)) {
$stream_bytes += strlen((string) gzread($stream, 1048576));
}
gzclose($stream);
$this->assertSame($tar_bytes, $stream_bytes);
$prefix = $fixture . '/';
$expected = [$prefix, $prefix . '0', $prefix . 'empty.txt', $prefix . 'kept/',
$prefix . 'kept/' . $long_name, $prefix . $utf8_name];
$archived = array_values(array_filter($names, static function ($name) use ($prefix) {
return str_starts_with($name, $prefix);
}));
sort($expected);
sort($archived);
$this->assertSame($expected, $archived);
$this->assertTrue(mkdir($out, 0700));
exec(
'tar --extract --gzip --file ' . escapeshellarg($archive) . ' --directory ' . escapeshellarg($out)
. ' ' . escapeshellarg($fixture) . ' 2>&1',
$messages,
$status
);
$this->assertSame(0, $status, implode("\n", $messages));
foreach (['/0', '/empty.txt', '/kept/' . $long_name, '/' . $utf8_name] as $name) {
$this->assertSame($files[$name], file_get_contents($out . '/' . $fixture . $name));
}
} finally {
$this->sse_remove_fixture_tree($source);
$this->sse_remove_fixture_tree($out);
if (is_array($export_paths)) {
esse_delete_directory_tree($export_paths['export_dir']);
}
esse_release_export_lease(esse_get_current_export_lease() ?? $lease);
}
}
public function test_per_file_size_limit_travels_with_the_request() {
$user_id = $this->sse_sign_in_exporter();
// An earlier version kept the limit in this transient; it no longer has any effect.
set_transient('sse_export_max_file_size_' . $user_id, 1, HOUR_IN_SECONDS);
$this->assertSame(0, esse_get_export_max_file_size());
esse_initialize_export_resource_budget(104857600);
$this->assertSame(104857600, esse_get_export_max_file_size());
$this->assertFalse(esse_is_over_export_file_size_limit(104857600, 'at-the-limit.bin'));
$this->assertTrue(esse_is_over_export_file_size_limit(104857601, 'over-the-limit.bin'));
$this->assertSame(1, esse_get_skipped_export_entry_counts()['large']);
esse_initialize_export_resource_budget(0);
$this->assertFalse(esse_is_over_export_file_size_limit(PHP_INT_MAX, 'no-limit.bin'));
$this->assertSame(0, esse_get_skipped_export_entry_counts()['large']);
}
public function test_lease_recovers_from_unusable_rows() {
foreach ([null, 'not serialized', 'O:8:"stdClass":0:{}', serialize(['owner' => 'incomplete'])] as $row) {
$this->assertNull(esse_decode_export_lease_row($row));
}
delete_option('sse_error_logs');
// A row that fails validation cannot belong to a running export, so it is removed.
$this->assertTrue(esse_add_export_lease_option(['owner' => 'incomplete']));
$invalid = esse_get_stored_export_lease();
$this->assertWPError($invalid);
$this->assertSame('export_lease_invalid', $invalid->get_error_code());
$lease = esse_acquire_export_lease();
$this->assertIsArray($lease);
$this->assertSame($lease, esse_get_stored_export_lease());
$records = get_option('sse_error_logs');
$this->assertCount(1, $records);
$this->assertSame('security', $records[0]['level']);
$this->assertTrue(esse_release_export_lease($lease));
// A valid lease that has expired is reclaimed.
$expired = $lease;
$expired['owner'] = wp_generate_uuid4();
$expired['started_at'] = time() - 7200;
$expired['heartbeat_at'] = time() - 7200;
$expired['expires_at'] = time() - 1;
$this->assertTrue(esse_add_export_lease_option($expired));
$this->assertSame($expired, esse_get_stored_export_lease());
$next = esse_acquire_export_lease();
$this->assertIsArray($next);
$this->assertNotSame($expired['owner'], $next['owner']);
$this->assertTrue(esse_release_export_lease($next));
$this->assertNull(esse_get_stored_export_lease());
}
public function test_lease_renewal_never_moves_the_heartbeat_backward() {
$lease = esse_acquire_export_lease();
$this->assertIsArray($lease);
try {
// The stored heartbeat is ahead of the clock, as after a backward clock step.
$ahead = $lease;
$ahead['heartbeat_at'] = time() + 600;
$ahead['expires_at'] = $ahead['heartbeat_at'] + 30;
$this->assertTrue(esse_compare_and_update_export_lease($lease, $ahead));
esse_set_current_export_lease($ahead);
$this->assertTrue(esse_renew_current_export_lease(true));
$renewed = esse_get_current_export_lease();
$this->assertSame($ahead['heartbeat_at'], $renewed['heartbeat_at']);
$this->assertSame($ahead['heartbeat_at'] + esse_get_export_lease_lifetime(), $renewed['expires_at']);
$this->assertSame($renewed, esse_get_stored_export_lease());
} finally {
esse_release_export_lease(esse_get_current_export_lease() ?? $lease);
}
}
public function test_site_identifier_always_fits_the_archive_name_pattern() {
$cases = [
'example.com' => 'example.com',
'WWW.Example.COM' => 'www.example.com',
'localhost:8080' => 'localhost-8080',
'my site!' => 'my-site',
'a..b' => 'a.b',
'.leading.and.trailing-' => 'leading.and.trailing',
'' => 'wordpress-site',
'...' => 'wordpress-site',
// Without the intl extension the two bytes of the letter become one hyphen.
"b\u{00FC}cher.example" => function_exists('idn_to_ascii') ? 'xn--bcher-kva.example' : 'b-cher.example',
];
$identifiers = [esse_build_export_site_identifier(str_repeat('a', 150) . '.example'), esse_get_export_site_identifier()];
$this->assertLessThanOrEqual(100, strlen($identifiers[0]));
foreach ($cases as $host => $expected) {
$identifiers[] = esse_build_export_site_identifier((string) $host);
$this->assertSame($expected, end($identifiers), (string) $host);
}
foreach ($identifiers as $identifier) {
$this->assertTrue(esse_validate_filename_format(esse_get_engine_script_archive_filename($identifier, '20260513_123456')), $identifier);
}
}
public function test_deactivation_and_uninstall_leave_nothing_behind() {
$export_paths = esse_setup_export_directories();
$this->assertIsArray($export_paths);
$base = esse_get_export_directory_path();
$zip = $export_paths['export_dir'] . '/site_enginescript_site_export_20260513_123456.zip';
$this->assertSame(7, file_put_contents($zip, 'fixture'));
$this->assertIsArray(esse_acquire_export_lease());
delete_option('sse_error_logs');
esse_store_log_in_database('kept until the plugin is deleted', 'error');
foreach (esse_get_scheduled_hook_names() as $hook) {
$this->assertTrue(wp_schedule_single_event(time() + 600, $hook, [$zip]));
}
$this->assertCount(4, array_unique($this->sse_get_scheduled_plugin_hooks()));
esse_deactivate_plugin();
clearstatcache();
$this->assertFileDoesNotExist($zip);
$this->assertDirectoryDoesNotExist($export_paths['export_dir']);
$this->assertNull(esse_get_stored_export_lease());
$this->assertSame([], $this->sse_get_scheduled_plugin_hooks());
// The records stay until the plugin is deleted.
$this->assertCount(1, get_option('sse_error_logs'));
esse_uninstall_plugin();
clearstatcache();
$this->assertFalse(get_option('sse_error_logs'));
$this->assertDirectoryDoesNotExist($base);
}
public function test_tree_deletion_does_not_follow_links() {
$root = sys_get_temp_dir() . '/sse-test-' . wp_generate_uuid4();
$base = $root . '/base';
$tree = $base . '/export-fixture';
$this->assertTrue(mkdir($tree . '/nested', 0700, true));
$this->assertTrue(mkdir($root . '/outside', 0700));
try {
$this->assertSame(4, file_put_contents($root . '/outside/keep.txt', 'keep'));
$this->assertSame(4, file_put_contents($tree . '/nested/gone.txt', 'gone'));
$this->assertTrue(symlink($root . '/outside', $tree . '/directory-link'));
$this->assertTrue(symlink($root . '/outside/keep.txt', $tree . '/nested/file-link'));
$this->assertTrue(symlink($root . '/outside', $base . '/linked-tree'));
$this->assertTrue(symlink($root . '/outside', $root . '/outside-link'));
// A path outside the base is refused, also when it is written through the base.
$this->assertFalse(esse_delete_directory_tree($root . '/outside', $base));
$this->assertFalse(esse_delete_directory_tree($base . '/../outside', $base));
// A link outside the base is left in place.
$this->assertFalse(esse_delete_directory_tree($root . '/outside-link', $base));
$this->assertTrue(is_link($root . '/outside-link'));
$this->assertTrue(esse_delete_directory_tree($tree, $base));
// A link in place of the directory is removed as a link.
$this->assertTrue(esse_delete_directory_tree($base . '/linked-tree', $base));
clearstatcache();
$this->assertDirectoryDoesNotExist($tree);
$this->assertFalse(is_link($base . '/linked-tree'));
$this->assertSame('keep', file_get_contents($root . '/outside/keep.txt'));
} finally {
$this->sse_remove_fixture_tree($root);
}
}
public function test_export_base_is_refused_inside_web_served_directories() {
$had_root = array_key_exists('DOCUMENT_ROOT', $_SERVER);
$previous_root = $_SERVER['DOCUMENT_ROOT'] ?? null;
$root = sys_get_temp_dir() . '/sse-test-' . wp_generate_uuid4();
$upload_dir = wp_get_upload_dir();
$this->assertTrue(wp_mkdir_p($upload_dir['basedir']));
$this->assertTrue(mkdir($root . '/public/inner', 0700, true));
$this->assertTrue(mkdir($root . '/private', 0700));
try {
$this->assertTrue(symlink($root . '/public/inner', $root . '/private/alias'));
$_SERVER['DOCUMENT_ROOT'] = $root . '/public';
$served = [ABSPATH . 'wp-admin', WP_CONTENT_DIR, $upload_dir['basedir'],
$root . '/public', $root . '/public/inner', $root . '/private/alias'];
foreach ($served as $path) {
$this->assertTrue(esse_is_path_web_served($path), $path);
}
$this->assertFalse(esse_is_path_web_served($root . '/private'));
// An empty document root, or the filesystem root, says nothing about what is served.
foreach (['', '/'] as $ignored_root) {
$_SERVER['DOCUMENT_ROOT'] = $ignored_root;
$this->assertFalse(esse_is_path_web_served($root . '/public'));
}
$_SERVER['DOCUMENT_ROOT'] = untrailingslashit(get_temp_dir());
$refused = esse_setup_export_directories();
$this->assertWPError($refused);
$this->assertSame('export_dir_public', $refused->get_error_code());
} finally {
if ($had_root) {
$_SERVER['DOCUMENT_ROOT'] = $previous_root;
} else {
unset($_SERVER['DOCUMENT_ROOT']);
}
$this->sse_remove_fixture_tree($root);
}
}
public function test_export_base_name_is_specific_to_the_installation() {
$base = esse_get_export_directory_path();
$this->assertSame(1, preg_match('#/' . preg_quote(ESSE_EXPORT_DIR_NAME, '#') . '-[a-f0-9]{16}$#', $base));
$this->assertNotSame(trailingslashit(get_temp_dir()) . ESSE_EXPORT_DIR_NAME, $base);
// Another installation has other secret salts, and so another directory.
$other_salt = static function () {
return 'the salt of another installation';
};
add_filter('salt', $other_salt);
$other_base = esse_get_export_directory_path();
remove_filter('salt', $other_salt);
$this->assertSame(1, preg_match('#/' . preg_quote(ESSE_EXPORT_DIR_NAME, '#') . '-[a-f0-9]{16}$#', $other_base));
$this->assertNotSame($base, $other_base);
$this->assertSame($base, esse_get_export_directory_path());
}
public function test_cleanup_stays_inside_the_installation_export_base() {
$base = esse_get_export_directory_path();
// The fixed name that versions before 2.1.1 used; other installations may share it.
$neighbour = trailingslashit(get_temp_dir()) . ESSE_EXPORT_DIR_NAME;
$stale = 'export-20200101_000000-' . str_repeat('a', 32);
$created = [];
foreach ([$base, $neighbour] as $directory) {
if (!is_dir($directory)) {
$created[] = $directory;
}
$this->assertTrue(wp_mkdir_p($directory . '/' . $stale));
$this->assertTrue(chmod($directory . '/' . $stale, 0700));
$this->assertSame(3, file_put_contents($directory . '/' . $stale . '/old.txt', 'old'));
$this->assertTrue(touch($directory . '/' . $stale, time() - DAY_IN_SECONDS));
}
try {
clearstatcache();
$this->assertSame(1, esse_cleanup_stale_export_directories());
esse_bulk_cleanup_exports_handler();
clearstatcache();
$this->assertDirectoryDoesNotExist($base . '/' . $stale);
$this->assertSame('old', file_get_contents($neighbour . '/' . $stale . '/old.txt'));
} finally {
$this->sse_remove_fixture_tree($base . '/' . $stale);
$this->sse_remove_fixture_tree($neighbour . '/' . $stale);
foreach ($created as $directory) {
$this->sse_remove_fixture_tree($directory);
}
}
}
public function test_export_request_is_refused_without_action_nonce_or_capability() {
delete_option('sse_error_logs');
// A request that is not the export form is ignored.
$this->assertFalse(esse_validate_export_request());
$_POST = ['action' => 'another_action'];
$this->assertFalse(esse_validate_export_request());
$_POST = ['action' => 'sse_export_site'];
$this->sse_assert_refused('esse_validate_export_request');
$_REQUEST['sse_export_nonce'] = 'invalid';
$this->sse_assert_refused('esse_validate_export_request');
wp_set_current_user(self::factory()->user->create(['role' => 'subscriber']));
$_REQUEST['sse_export_nonce'] = wp_create_nonce('sse_export_action');
$this->sse_assert_refused('esse_validate_export_request', 'You do not have permission to perform this action.');
$this->assertSame([], get_option('sse_error_logs', []));
$this->sse_sign_in_exporter();
$_REQUEST['sse_export_nonce'] = wp_create_nonce('sse_export_action');
$this->assertSame(0, esse_validate_export_request());
// Only a size that the form offers is accepted; anything else means no limit.
$sizes = ['104857600' => 104857600, '524288000' => 524288000, '1073741824' => 1073741824,
'0' => 0, '12345' => 0, '-1' => 0, '1e9' => 0, ' 104857600' => 104857600];
foreach ($sizes as $posted => $accepted) {
$_POST['sse_max_file_size'] = (string) $posted;
$this->assertSame($accepted, esse_validate_export_request(), (string) $posted);
}
$_POST['sse_max_file_size'] = ['104857600'];
$this->assertSame(0, esse_validate_export_request());
}
public function test_download_and_delete_requests_are_refused_before_any_file_access() {
delete_option('sse_error_logs');
$file = 'site_enginescript_site_export_20260513_123456.zip';
$directory = esse_generate_private_export_directory_name();
$download_action = 'sse_secure_download_' . $file . '_' . $directory;
$delete_action = 'sse_delete_export_' . $file . '_' . $directory;
$request = ['file' => $file, 'export_dir' => $directory];
// The download reads the query string and the deletion reads the form body, and nothing else.
$this->sse_assert_refused('esse_handle_secure_download', 'Invalid download request.');
$this->sse_assert_refused('esse_handle_export_deletion', 'Invalid deletion request.');
$_POST = $request;
$this->sse_assert_refused('esse_handle_secure_download', 'Invalid download request.');
$_POST = [];
$_GET = $request;
$this->sse_assert_refused('esse_handle_export_deletion', 'Invalid deletion request.');
$_POST = $request;
foreach ([null, 'invalid'] as $nonce) {
if (null !== $nonce) {
$_REQUEST['_wpnonce'] = $nonce;
}
$this->sse_assert_refused('esse_handle_secure_download');
$this->sse_assert_refused('esse_handle_export_deletion');
}
wp_set_current_user(self::factory()->user->create(['role' => 'subscriber']));
$_REQUEST['_wpnonce'] = wp_create_nonce($download_action);
$this->sse_assert_refused('esse_handle_secure_download', 'You do not have permission to download export files.');
$_REQUEST['_wpnonce'] = wp_create_nonce($delete_action);
$this->sse_assert_refused('esse_handle_export_deletion', 'You do not have permission to delete export files.');
// A refusal before or at the authorization checks stores no record.
$this->assertSame([], get_option('sse_error_logs', []));
$this->sse_sign_in_exporter();
// The nonce of one action does not authorize the other.
$_REQUEST['_wpnonce'] = wp_create_nonce($download_action);
$this->sse_assert_refused('esse_handle_export_deletion');
$this->assertSame([], get_option('sse_error_logs', []));
$this->sse_assert_refused('esse_handle_secure_download', 'This export has expired or was deleted.');
$_REQUEST['_wpnonce'] = wp_create_nonce($delete_action);
$this->sse_assert_refused('esse_handle_export_deletion', 'This export has expired or was deleted.');
$records = get_option('sse_error_logs');
$this->assertSame(['activity', 'activity'], wp_list_pluck($records, 'level'));
$this->assertStringStartsWith('Export download refused:', $records[0]['message']);
$this->assertStringStartsWith('Export deletion refused:', $records[1]['message']);
}
public function test_activity_and_error_records() {
$user_id = $this->sse_sign_in_exporter();
$failure = "Same failure\nforged line in " . ESSE_EXPORT_PRIVATE_DIR_PREFIX . '20260513_123456-' . str_repeat('a', 32);
// A record written by an earlier version carries a client address.
update_option('sse_error_logs', [[
'time' => time(), 'level' => 'error', 'message' => 'from an earlier version',
'user_id' => 0, 'ip' => '203.0.113.7',
]], false);
esse_record_activity('Export created: site.zip');
esse_record_activity('Export created: site.zip');
esse_log($failure, 'error');
esse_log($failure, 'error');
esse_log('Not stored', 'info');
esse_log('Not stored', 'warning');
$records = get_option('sse_error_logs');
// Activity is always stored; an identical error is stored once per hour.
$this->assertSame(['error', 'activity', 'activity', 'error'], wp_list_pluck($records, 'level'));
foreach ($records as $record) {
$this->assertSame(['time', 'level', 'message', 'user_id'], array_keys($record));
}
$this->assertSame($user_id, $records[1]['user_id']);
// The line break and the random directory name do not reach the record.
$this->assertSame('Same failure forged line in ' . ESSE_EXPORT_PRIVATE_DIR_PREFIX . '[private]', $records[3]['message']);
$records[3]['time'] -= HOUR_IN_SECONDS;
update_option('sse_error_logs', $records, false);
esse_log($failure, 'error');
$this->assertCount(5, get_option('sse_error_logs'));
}
}
EOF
- name: Create PHPUnit Config
id: create_phpunit_config
run: |
cat > phpunit.xml << 'EOF'
<?xml version="1.0"?>
<phpunit
bootstrap="tests/bootstrap.php"
backupGlobals="false"
colors="true"
failOnEmptyTestSuite="true"
failOnRisky="true"
failOnWarning="true"
>
<testsuites>
<testsuite name="EngineScript Site Exporter">
<directory suffix="Test.php">./tests/integration/</directory>
</testsuite>
</testsuites>
</phpunit>
EOF
- name: Setup WP Tests
id: setup_wp_tests
timeout-minutes: 15
run: |
bash tests/bin/install-wp-tests.sh wordpress_test root root 127.0.0.1:3306 ${{ matrix.wp-version }} true
- name: Run plugin test
id: run_plugin_test
run: |
mkdir -p "$RUNNER_TEMP/sse-diagnostics"
vendor/bin/phpunit --config phpunit.xml --log-junit "$RUNNER_TEMP/sse-diagnostics/junit.xml"
# Prove that the real generated config rejects an empty filtered suite.
if vendor/bin/phpunit --config phpunit.xml --do-not-cache-result \
--filter '__sse_intentionally_no_matching_test__' \
> "$RUNNER_TEMP/sse-empty-suite.txt" 2>&1; then
echo '::error::An empty test suite incorrectly succeeded.'
exit 1
else
status=$?
[[ $status == 1 ]]
grep -q 'No tests executed' "$RUNNER_TEMP/sse-empty-suite.txt"
fi
# This control must fail due to its assertion, not a setup/parse error.
cat > "$RUNNER_TEMP/SseIntentionalFailureTest.php" << 'EOF'
<?php
final class SseIntentionalFailureTest extends PHPUnit\Framework\TestCase {
public function test_intentional_failure(): void {
$this->assertTrue(false, 'SSE intentional assertion control');
}
}
EOF
if vendor/bin/phpunit --no-configuration --do-not-cache-result \
"$RUNNER_TEMP/SseIntentionalFailureTest.php" \
> "$RUNNER_TEMP/sse-failing-suite.txt" 2>&1; then
echo '::error::An intentionally failing assertion incorrectly succeeded.'
exit 1
else
status=$?
[[ $status == 1 ]]
grep -q 'SSE intentional assertion control' "$RUNNER_TEMP/sse-failing-suite.txt"
fi
echo 'Empty-suite and intentional-assertion controls failed as expected.'
- name: Preserve diagnostic summary
id: preserve_diagnostics
if: ${{ always() }}
continue-on-error: true
env:
STEP_RESULTS: |
checkout_code=${{ steps.checkout_code.outcome }}
setup_php=${{ steps.setup_php.outcome }}
use_isolated_composer_auth=${{ steps.use_isolated_composer_auth.outcome }}
php_syntax_lint=${{ steps.php_syntax_lint.outcome }}
install_subversion=${{ steps.install_subversion.outcome }}
install_composer_dependencies=${{ steps.install_composer_dependencies.outcome }}
run_unit_tests=${{ steps.run_unit_tests.outcome }}
pin_phpunit_for_wordpress_test_suite=${{ steps.pin_phpunit_for_wordpress_test_suite.outcome }}
verify_phpunit_dependency_versions=${{ steps.verify_phpunit_dependency_versions.outcome }}
prepare_database=${{ steps.prepare_database.outcome }}
create_tests_directory_structure=${{ steps.create_tests_directory_structure.outcome }}
create_wp_tests_install_script=${{ steps.create_wp_tests_install_script.outcome }}
create_bootstrap_file=${{ steps.create_bootstrap_file.outcome }}
create_test_file=${{ steps.create_test_file.outcome }}
create_phpunit_config=${{ steps.create_phpunit_config.outcome }}
setup_wp_tests=${{ steps.setup_wp_tests.outcome }}
run_plugin_test=${{ steps.run_plugin_test.outcome }}
run: |
mkdir -p "$RUNNER_TEMP/sse-diagnostics"
printf '%s\n' "$STEP_RESULTS" > "$RUNNER_TEMP/sse-diagnostics/steps.txt"
printf 'commit=%s\njob=%s\n' "$GITHUB_SHA" "$GITHUB_JOB" > "$RUNNER_TEMP/sse-diagnostics/run.txt"
if command -v php >/dev/null; then
php -r 'echo PHP_VERSION, PHP_EOL;' > "$RUNNER_TEMP/sse-diagnostics/php.txt"
fi
if command -v composer >/dev/null && [ -f vendor/composer/installed.json ]; then
php -r '$data = json_decode(file_get_contents("vendor/composer/installed.json"), true, 512, JSON_THROW_ON_ERROR); foreach ($data["packages"] ?? $data as $package) { echo $package["name"], " ", $package["version"], PHP_EOL; }' > "$RUNNER_TEMP/sse-diagnostics/dependencies.txt"
fi
# Never upload config, authentication data, SQL, exports, or arbitrary logs.
# Refuse oversized or linked diagnostics instead of uploading them.
find "$RUNNER_TEMP/sse-diagnostics" -type l -delete
find "$RUNNER_TEMP/sse-diagnostics" -type f -size +2M -delete
- name: Upload diagnostic summary
id: upload_diagnostics
if: ${{ always() }}
continue-on-error: true
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: wp-compat-${{ matrix.php-version }}-${{ matrix.wp-version }}-${{ matrix.dependency-versions }}
path: |
${{ runner.temp }}/sse-diagnostics/run.txt
${{ runner.temp }}/sse-diagnostics/steps.txt
${{ runner.temp }}/sse-diagnostics/php.txt
${{ runner.temp }}/sse-diagnostics/dependencies.txt
${{ runner.temp }}/sse-diagnostics/wordpress.txt
${{ runner.temp }}/sse-diagnostics/junit.xml
retention-days: 7
if-no-files-found: warn
- name: Report test status
id: report_test_status
if: ${{ always() }}
env:
JOB_STATUS: ${{ job.status }}
MATRIX_PHP_VERSION: ${{ matrix.php-version }}
MATRIX_WP_VERSION: ${{ matrix.wp-version }}
run: |
if [ "$JOB_STATUS" == 'success' ]; then
echo "✅ Tests passed successfully on PHP $MATRIX_PHP_VERSION with WordPress $MATRIX_WP_VERSION"
else
echo "❌ Tests failed on PHP $MATRIX_PHP_VERSION with WordPress $MATRIX_WP_VERSION"
fi
- name: Ensure repository files available after failure
id: ensure_repository_files_available_after_failure
if: ${{ failure() }}
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
- name: Create issue on test failure
id: create_issue_on_test_failure
if: ${{ failure() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.actor != 'dependabot[bot]' }}
uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2.9.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FAILURE_STAGE: ${{ steps.checkout_code.outcome == 'failure' && 'checkout_code' || steps.setup_php.outcome == 'failure' && 'setup_php' || steps.use_isolated_composer_auth.outcome == 'failure' && 'use_isolated_composer_auth' || steps.php_syntax_lint.outcome == 'failure' && 'php_syntax_lint' || steps.install_subversion.outcome == 'failure' && 'install_subversion' || steps.install_composer_dependencies.outcome == 'failure' && 'install_composer_dependencies' || steps.run_unit_tests.outcome == 'failure' && 'run_unit_tests' || steps.pin_phpunit_for_wordpress_test_suite.outcome == 'failure' && 'pin_phpunit_for_wordpress_test_suite' || steps.verify_phpunit_dependency_versions.outcome == 'failure' && 'verify_phpunit_dependency_versions' || steps.prepare_database.outcome == 'failure' && 'prepare_database' || steps.create_tests_directory_structure.outcome == 'failure' && 'create_tests_directory_structure' || steps.create_wp_tests_install_script.outcome == 'failure' && 'create_wp_tests_install_script' || steps.create_bootstrap_file.outcome == 'failure' && 'create_bootstrap_file' || steps.create_test_file.outcome == 'failure' && 'create_test_file' || steps.create_phpunit_config.outcome == 'failure' && 'create_phpunit_config' || steps.setup_wp_tests.outcome == 'failure' && 'setup_wp_tests' || steps.run_plugin_test.outcome == 'failure' && 'run_plugin_test' || steps.report_test_status.outcome == 'failure' && 'report_test_status' || steps.ensure_repository_files_available_after_failure.outcome == 'failure' && 'ensure_repository_files_available_after_failure' || 'unknown_or_canceled' }}
PHP_VERSION: ${{ matrix.php-version }}
WP_VERSION: ${{ matrix.wp-version }}
DEPENDENCY_VERSIONS: ${{ matrix.dependency-versions }}
RUN_ID: ${{ github.run_id }}
WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
filename: .github/ISSUE_TEMPLATE/wp-version-test-failure.md
update_existing: false