Repository navigation
Bump the dev-dependencies group with 3 updates #306
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 |