name: Run PHPUnit Tests on: workflow_call: inputs: min-coverage: description: Minimum line coverage percentage enforced by dev-tools tests. required: false type: number default: 80 max-outdated: description: Maximum number of outdated packages allowed by the dependencies command. required: false type: string default: '-1' publish-required-statuses: description: Mirror per-version checks from workflow job metadata using an isolated publisher. required: false type: boolean default: false workflow_dispatch: inputs: min-coverage: description: Minimum line coverage percentage enforced by dev-tools tests. required: false type: number default: 80 max-outdated: description: Maximum number of outdated packages allowed by the dependencies command. required: false type: string default: '-1' publish-required-statuses: description: Mirror per-version checks from workflow job metadata using an isolated publisher. required: false type: boolean default: false pull_request: types: [opened, synchronize, reopened] push: branches: [ "main" ] permissions: contents: read statuses: none actions: none concurrency: group: ${{ github.event_name == 'pull_request' && format('tests-pr-{0}', github.event.pull_request.number) || format('tests-{0}', github.ref) }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: FORCE_COLOR: '1' jobs: resolve_php: name: Resolve PHP Version runs-on: ubuntu-latest permissions: contents: read statuses: none actions: none outputs: php-version: ${{ steps.resolve.outputs.php-version }} php-version-source: ${{ steps.resolve.outputs.php-version-source }} test-matrix: ${{ steps.resolve.outputs.test-matrix }} steps: - uses: actions/checkout@v7 with: persist-credentials: false - name: Checkout dev-tools workflow action source uses: actions/checkout@v7 with: persist-credentials: false repository: php-fast-forward/dev-tools ref: ${{ github.repository == 'php-fast-forward/dev-tools' && (github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha) || 'main' }} path: .dev-tools-actions sparse-checkout: | .github/actions - name: Resolve workflow PHP version id: resolve uses: ./.dev-tools-actions/.github/actions/php/resolve-version publish_pending_statuses: if: ${{ !cancelled() && needs.resolve_php.result == 'success' && inputs.publish-required-statuses && github.actor != 'dependabot[bot]' }} name: Publish Pending Test Statuses needs: resolve_php runs-on: ubuntu-latest permissions: actions: read statuses: write steps: - name: Mark the current test attempt as pending shell: bash env: GH_TOKEN: ${{ github.token }} TARGET_SHA: ${{ github.sha }} TARGET_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} TEST_MATRIX: ${{ needs.resolve_php.outputs.test-matrix }} run: | php <<'PHP' > "$GITHUB_OUTPUT" env: INPUT_MIN_COVERAGE: ${{ inputs.min-coverage }} - name: Run PHPUnit tests env: COMPOSER_ROOT_VERSION: ${{ env.TESTS_ROOT_VERSION }} run: dev-tools tests --coverage=.dev-tools/coverage --min-coverage=${{ steps.minimum-coverage.outputs.value }} dependency-health: needs: resolve_php name: Dependency Health runs-on: ubuntu-latest permissions: contents: read statuses: none actions: none env: TESTS_ROOT_VERSION: ${{ github.event_name == 'pull_request' && format('dev-{0}', github.event.pull_request.head.ref) || 'dev-main' }} steps: - uses: actions/checkout@v7 with: persist-credentials: false - name: Checkout dev-tools workflow action source uses: actions/checkout@v7 with: persist-credentials: false repository: php-fast-forward/dev-tools ref: ${{ github.repository == 'php-fast-forward/dev-tools' && (github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha) || 'main' }} path: .dev-tools-actions - name: Setup PHP and install dependencies uses: ./.dev-tools-actions/.github/actions/php/setup-composer with: php-version: ${{ needs.resolve_php.outputs.php-version }} root-version: ${{ env.TESTS_ROOT_VERSION }} install-options: --prefer-dist --no-progress --no-interaction --no-plugins --no-scripts - name: Keep nested Composer checks plugin-free run: | composer_original_binary="$(command -v composer)" composer_shim_directory="${RUNNER_TEMP}/composer-without-plugins" mkdir -p "${composer_shim_directory}" install -m 0755 .dev-tools-actions/.github/actions/php/setup-composer/composer-without-plugins.php "${composer_shim_directory}/composer" echo "FAST_FORWARD_CI_COMPOSER_BINARY=${composer_original_binary}" >> "$GITHUB_ENV" echo "${composer_shim_directory}" >> "$GITHUB_PATH" - name: Run dependency health check env: COMPOSER_ROOT_VERSION: ${{ env.TESTS_ROOT_VERSION }} run: dev-tools dependencies --max-outdated=${{ inputs.max-outdated || -1 }} summarize: if: ${{ always() }} name: Summarize Test Workflow needs: - resolve_php - tests - dependency-health runs-on: ubuntu-latest permissions: contents: read statuses: none actions: none steps: - uses: actions/checkout@v7 with: persist-credentials: false - name: Checkout dev-tools workflow action source uses: actions/checkout@v7 with: persist-credentials: false repository: php-fast-forward/dev-tools ref: ${{ github.repository == 'php-fast-forward/dev-tools' && (github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha) || 'main' }} path: .dev-tools-actions sparse-checkout: | .github/actions - uses: ./.dev-tools-actions/.github/actions/summary/write with: markdown: | ## Tests Workflow Summary - Workflow PHP version: `${{ needs.resolve_php.outputs.php-version }}` - PHP version source: `${{ needs.resolve_php.outputs.php-version-source }}` - Test matrix: `${{ needs.resolve_php.outputs.test-matrix }}` - Minimum coverage threshold: `${{ inputs.min-coverage || 80 }}` - Dependency health `max-outdated`: `${{ inputs.max-outdated || -1 }}` - Tests job result: `${{ needs.tests.result }}` - Dependency health result: `${{ needs.dependency-health.result }}` publish_required_statuses: if: ${{ always() && inputs.publish-required-statuses && github.actor != 'dependabot[bot]' && needs.publish_pending_statuses.result == 'success' && (needs.tests.result == 'success' || needs.tests.result == 'failure') }} name: Publish Required Test Statuses needs: - resolve_php - publish_pending_statuses - tests runs-on: ubuntu-latest permissions: actions: read statuses: write steps: - name: Publish completed required test statuses shell: bash env: GH_TOKEN: ${{ github.token }} TARGET_SHA: ${{ github.sha }} TARGET_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} TEST_MATRIX: ${{ needs.resolve_php.outputs.test-matrix }} run: | export TEST_JOBS_FILE="${RUNNER_TEMP}/required-test-jobs.json" gh api --paginate --slurp \ "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs?filter=all&per_page=100" > "$TEST_JOBS_FILE" # PHP evaluates these variables; Bash must leave them literal. # shellcheck disable=SC2016 php -r ' $matrix = json_decode(getenv("TEST_MATRIX"), true, 512, JSON_THROW_ON_ERROR); $versions = $matrix["php-version"] ?? null; if (!is_array($versions) || $versions === []) { throw new RuntimeException("A non-empty PHP test matrix is required."); } foreach ($versions as $version) { if (!is_string($version) || preg_match("/\A[0-9]+\.[0-9]+\z/", $version) !== 1) { throw new RuntimeException("Unsupported PHP test matrix entry."); } } if (count($versions) !== count(array_unique($versions))) { throw new RuntimeException("Duplicate PHP test matrix entry."); } $pages = json_decode(file_get_contents(getenv("TEST_JOBS_FILE")), true, 512, JSON_THROW_ON_ERROR); $jobs = []; foreach ($pages as $page) { if (!is_array($page["jobs"] ?? null)) { throw new RuntimeException("Malformed workflow jobs response."); } $jobs = array_merge($jobs, $page["jobs"]); } $results = []; foreach ($versions as $version) { $name = "Run Tests (" . $version . ")"; $matches = array_values(array_filter($jobs, static function ($job) use ($name) { return is_string($job["name"] ?? null) && ($job["name"] === $name || str_ends_with($job["name"], " / " . $name)); })); if ($matches === []) { throw new RuntimeException("Missing workflow job for " . $version); } foreach ($matches as $candidate) { if ((string) ($candidate["run_id"] ?? "") !== getenv("GITHUB_RUN_ID") || !is_int($candidate["run_attempt"] ?? null) || $candidate["run_attempt"] < 1) { throw new RuntimeException("Invalid workflow job attempt for " . $version); } } $latestAttempt = max(array_column($matches, "run_attempt")); $latest = array_values(array_filter($matches, static fn ($job) => $job["run_attempt"] === $latestAttempt)); if (count($latest) !== 1) { throw new RuntimeException("Ambiguous latest workflow job for " . $version); } $job = $latest[0]; if (($job["status"] ?? null) !== "completed" || !is_string($job["conclusion"] ?? null) || $job["conclusion"] === "" || preg_match("/\A[a-z_]+\z/", $job["conclusion"]) !== 1) { throw new RuntimeException("Incomplete latest workflow job for " . $version); } $results[] = $version . "\t" . ($job["conclusion"] === "success" ? "success" : "failure") . "\t" . $job["conclusion"]; } echo implode(PHP_EOL, $results), PHP_EOL; ' | while IFS=$'\t' read -r php_version state conclusion; do gh api \ --method POST \ "repos/${GITHUB_REPOSITORY}/statuses/${TARGET_SHA}" \ -f state="${state}" \ -f context="Run Tests (${php_version})" \ -f description="PHP ${php_version} matrix job result: ${conclusion}." \ -f target_url="${TARGET_URL}" done