diff --git a/.github/actions/php/setup-composer/composer-without-plugins.php b/.github/actions/php/setup-composer/composer-without-plugins.php new file mode 100644 index 000000000..2f18f2780 --- /dev/null +++ b/.github/actions/php/setup-composer/composer-without-plugins.php @@ -0,0 +1,30 @@ +#!/usr/bin/env php +> "$GITHUB_ENV" + echo "${composer_shim_directory}" >> "$GITHUB_PATH" + - name: Run dependency health check env: COMPOSER_ROOT_VERSION: ${{ env.TESTS_ROOT_VERSION }} @@ -178,11 +269,18 @@ jobs: - 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 @@ -203,25 +301,90 @@ jobs: - Dependency health result: `${{ needs.dependency-health.result }}` publish_required_statuses: - if: ${{ always() && inputs.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 pending required test statuses + - 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: | - php -r "foreach (json_decode(getenv('TEST_MATRIX'), true, 512, JSON_THROW_ON_ERROR)['php-version'] as \$version) { echo \$version, PHP_EOL; }" | while IFS= read -r php_version; do + 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="pending" \ + -f state="${state}" \ -f context="Run Tests (${php_version})" \ - -f description="Workflow-dispatched PHPUnit job is pending." \ + -f description="PHP ${php_version} matrix job result: ${conclusion}." \ -f target_url="${TARGET_URL}" done diff --git a/AGENTS.md b/AGENTS.md index daadcb6b9..8f6ab441c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,7 +161,10 @@ Release and publishing behavior is driven primarily through `wiki-preview.yml`, `wiki-maintenance.yml`, `auto-assign.yml`, and `label-sync.yml`, with reusable local workflow building blocks grouped under `.github/actions/` and packaged consumer workflow wrappers living under -`resources/github-actions/`. Packaged skills live under `.agents/skills/` +`resources/github-actions/`. Contract-specific templates in +`resources/github-actions-optional/` require explicit adoption and are not +installed by `dev-tools:sync`; read each companion guide before copying one +into a consumer repository. Packaged skills live under `.agents/skills/` alongside mirrored project-agent prompts under `.agents/agents/`. **Package Details:** @@ -198,6 +201,7 @@ composer dev-tools - `.github/workflows/`: CI and release automation truth, especially `tests.yml`, `reports.yml`, `review.yml`, `wiki.yml`, `wiki-preview.yml`, `wiki-maintenance.yml`, `changelog.yml`, `auto-assign.yml`, and `label-sync.yml` - `.github/actions/`: shared workflow building blocks for `php`, `project-board`, `github-pages`, `review`, `summary`, `wiki`, `changelog`, and `label-sync` - `resources/github-actions/`: consumer-facing workflow wrappers synchronized by `dev-tools:sync` +- [resources/github-actions-optional/test-statuses.md](resources/github-actions-optional/test-statuses.md): explicit opt-in guide for Dependabot status aliases; verify source workflow identity, PHP matrix and protection contexts before adopting the checkout-free template - `.github/pull_request_template.md`: expected PR structure and reviewer checklist - `src/Sync/`: shared packaged-directory synchronization primitives used by `skills` and `agents` - `.agents/skills/`: packaged procedural skills shipped to consumer repositories diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a186b23..149d65d75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Keep required per-version statuses current across normal and Dependabot reruns through checkout-free lifecycle publishers using verified GitHub run and job metadata. +- Keep Composer audit and nested dependency-health checks plugin-free after CI installs without plugins, so consumer allowlists do not block vulnerability or dependency analysis. +- Isolate completed per-version status publication from repository-controlled test jobs, preserve access to private GitHub job metadata, and disable persisted checkout credentials in the reusable test workflow. - Restore tests, reports, and dependency checks after ECS and Rector API changes, and replace the abandoned rector/jack dependency checker with Rector Swiss Knife. ## [1.25.6] - 2026-05-22 diff --git a/docs/advanced/branch-protection-and-bot-commits.rst b/docs/advanced/branch-protection-and-bot-commits.rst index 767132818..d54dbe015 100644 --- a/docs/advanced/branch-protection-and-bot-commits.rst +++ b/docs/advanced/branch-protection-and-bot-commits.rst @@ -109,12 +109,62 @@ a parent-repository pointer update, it explicitly dispatches ``tests.yml`` for the pull request head branch so the newest bot-authored commit receives the required ``Run Tests`` matrix checks. Because manually dispatched workflow check runs are not always treated as pull-request required checks, that dispatched -test run first publishes pending commit statuses for the resolved PHP matrix and -then lets each matrix job publish its own final status. The status contexts use -the same required-check names, such as ``Run Tests (8.3)``, ``Run Tests (8.4)``, -and ``Run Tests (8.5)``. Test workflow concurrency cancels older in-progress -runs for the same pull request so the newest commit owns the required check -contexts. +test run enables two separate status publishers. After PHP version resolution, +the checkout-free pending publisher validates the matrix and the current run +attempt, then marks every configured required context pending. The test matrix +waits for this job. Disabling mirroring or skipping publication for Dependabot +still permits tests to run; a failed pending publisher blocks the matrix. + +The final publisher requires successful pending publication and a matrix that +actually finished with success or failure. It does not run for skipped or +cancelled matrices, so a publication failure cannot make it select earlier +successful jobs. Neither publisher checks out repository files or executes +consumer code. The final publisher reads GitHub job attempts for that exact +run, selects the newest attempt for each PHP version, and requires that result +to be completed before publishing it under its required-check name, such as +``Run Tests (8.3)``, ``Run Tests (8.4)``, and ``Run Tests (8.5)``. + +When only failed jobs are rerun, successful versions can remain in an earlier +attempt. The publisher retains those completed results and replaces only the +versions that have a newer attempt. An incomplete or ambiguous newest attempt +fails publication rather than falling back to an older successful result. + +Full reruns execute the pending phase again before the new matrix starts. A +missing, ambiguous, incomplete or wrong-run result makes final publication fail +before posting terminal statuses. When investigating a blocked check, inspect +both isolated publishers and the corresponding matrix job. Tests never publish +statuses from their own code-executing jobs. Test workflow concurrency cancels +older in-progress runs for the same pull request. + +Pending publication is not instantaneous or atomic. Earlier statuses may remain +visible while the run is scheduled or PHP versions are resolved. An API failure +before the first pending POST leaves old statuses unchanged; failure between +POSTs can leave only some contexts updated. Rerunning an individual successful +test job can retain its successful pending-job ancestor instead of executing +that ancestor again. Prefer the exact native qualified GitHub Actions checks +when deliberately migrating a consumer's branch protection policy. + +Jobs that execute checked-out code have read-only contents access and no status +write permission; all checkouts disable credential persistence. Only the +isolated pending and final publishers can write commit statuses. Both are +skipped for Dependabot. Consumers requiring unqualified aliases for Dependabot +pushes can explicitly install the optional default-branch lifecycle bridge +described in :doc:`../usage/github-actions`. It is outside normal +``dev-tools:sync`` workflow installation and requires verification of the +workflow name/path, caller job prefix, configured PHP versions and protection +contexts. Its metadata-backed pending and terminal phases reject stale +attempts; a verified failed or cancelled attempt that did not reach the matrix +must not publish earlier successful results. + +Status mirroring is opt-in and defaults to disabled. The isolated publishers +have ``actions: read`` to access run and job metadata in public or private +repositories and ``statuses: write`` to submit pending and completed results. +Reusable-workflow callers +must include both permissions in their maximum permission set, even when +mirroring is disabled: GitHub checks the reusable workflow's permission ceiling +before evaluating its individual job conditions. The packaged test wrapper +declares this maximum; jobs that execute consumer code explicitly reduce both +Actions and status permissions to ``none``. The predictable-conflict workflow MAY also refresh pull request branches when the only conflicts are ``.github/wiki`` pointer drift and/or ``CHANGELOG.md`` @@ -155,10 +205,13 @@ The reusable workflows default to read-only repository access and grant write permissions at the job level when generated content must be pushed or pull requests must be updated. -``tests.yml`` needs ``contents: read`` because it checks out code, installs -dependencies, and runs PHPUnit. It also declares ``statuses: write`` so -workflow-dispatched test runs can mirror required matrix contexts onto -bot-authored wiki pointer commits. +The code-executing jobs in ``tests.yml`` need only ``contents: read`` to check +out code, install dependencies and run PHPUnit. Its separate pending and final +publishers have ``actions: read`` and ``statuses: write`` so workflow-dispatched +test runs can mirror each required matrix context onto bot-authored wiki pointer +commits. The publishers do not check out code or run consumer scripts. The +optional Dependabot lifecycle bridge grants the same two scopes only to its +isolated status job; it does not add write permission to consumer tests. ``reports.yml`` keeps ``contents: write`` on jobs that publish or clean ``gh-pages`` content. The pull request preview comment runs as a separate job diff --git a/docs/usage/github-actions.rst b/docs/usage/github-actions.rst index ad6a2d998..6ae24571c 100644 --- a/docs/usage/github-actions.rst +++ b/docs/usage/github-actions.rst @@ -6,7 +6,7 @@ FastForward DevTools provides a set of reusable GitHub Actions workflows that au Workflow Layers --------------- -The automation model now has three layers: +The automation model separates shared implementations from consumer triggers: * **Local composite and JavaScript actions** in ``.github/actions/`` inside this repository. These contain the reusable implementation details for PHP @@ -45,6 +45,13 @@ Example of an inherited workflow: This approach ensures that all libraries in the ecosystem benefit from infrastructure updates without requiring manual changes to every repository. +First-party wrappers intentionally follow the reviewed ``@main`` workflow +contract so centrally deployed fixes reach consumers. This is an explicit +shared-infrastructure update policy, distinct from pinning third-party +actions. A workflow SHA alone would not freeze the existing action-source +checkout, which also follows DevTools ``main``; a fully immutable migration +would need to version both surfaces together. + The packaged wrappers currently include: * ``tests.yml`` @@ -57,6 +64,10 @@ The packaged wrappers currently include: * ``auto-assign.yml`` * ``label-sync.yml`` +The optional ``resources/github-actions-optional/test-statuses.yml`` template +is kept outside this synchronized directory. It requires an explicit copy and +repository-specific verification; ``dev-tools:sync`` does not install it. + For the protected-branch-safe preview and publish model, see :doc:`../advanced/branch-protection-and-bot-commits`. @@ -65,6 +76,98 @@ local Composer dependency. The shared ``setup-composer`` action prefers the consumer ``vendor/bin/dev-tools`` when it exists and otherwise exposes a ``dev-tools`` wrapper backed by the checked-out ``.dev-tools-actions`` source. +Dependabot Required Test Statuses +--------------------------------- + +The optional standalone ``test-statuses.yml`` workflow supplies commit-status +aliases for consumers whose branch protection requires unqualified names such +as ``Run Tests (8.3)``. The current first-party rollout targets twelve audited +consumer repositories with PHP 8.3, 8.4 and 8.5; this template does not infer an +arbitrary consumer's matrix or protection policy. Consumers protecting the +native qualified GitHub Actions checks do not need these aliases. + +To opt in, copy +``resources/github-actions-optional/test-statuses.yml`` from a reviewed +DevTools checkout or installed package into the consumer repository as +``.github/workflows/test-statuses.yml``. Review these contracts before merging +the copied file: + +* The ``workflow_run.workflows`` name and the PHP metadata check must match the + source workflow name, currently ``Fast Forward Test Suite``. +* The source workflow path must match the API path check, currently + ``.github/workflows/tests.yml``. +* The caller job name must match all job-name checks. The template expects + ``tests / Run Tests ()`` and control jobs prefixed with ``tests /``. +* ``EXPECTED_PHP_VERSIONS`` must list every expected version as JSON strings, + currently ``["8.3","8.4","8.5"]``. Align those versions and the resulting + ``Run Tests ()`` contexts with branch protection. + +A different caller prefix or matrix needs a reviewed adjustment to the copied +template. An additional observed test version, missing expected version, +ambiguous job or invalid attempt makes final publication fail closed; the +publisher must not silently mirror only a subset of the matrix. + +After the file reaches the default branch, same-repository Dependabot ``push`` +runs trigger it through ``workflow_run`` requested, in-progress and completed +events. Active attempts receive pending statuses, including reruns; terminal +attempts receive the actual per-version outcomes. A delayed start event reads +the current Run API state instead of overwriting completed results with pending. + +The publisher does not check out source, install dependencies, retrieve +artifacts or caches, or run caller code. Its own job alone receives +``actions: read`` and ``statuses: write``. It verifies the source +repository, SHA, workflow ID/path/name, actor, event and attempt through +the Run API and rechecks that snapshot before writing. It validates the +configured matrix before publishing terminal results. Partial retries retain +completed results for unaffected versions and select the newest attempt for +rerun versions. A full rerun that fails or is cancelled before the matrix runs +must not reuse earlier successes: verified failed control jobs or a failed +source attempt produce failure statuses for blocked versions. Stale events and +superseded runs stop publication. + +The target is the source push's verified ``head_sha``, never the +publisher's ``github.sha``, which points to the default branch. +Pull-request runs are deliberately excluded because they can test a +different merge commit; Dependabot's push run supplies this bridge. +Fork pull requests are outside this workflow's scope. + +This bridge becomes active only after its file is merged into the default +branch; merely adding the template to a pull request does not activate its +lifecycle events. Verify a real Dependabot push and its required contexts after +deployment. API reads and status writes are separate operations: scheduling, +API availability and a state transition between requests prevent an atomic or +instantaneous update of every context. + +Ordinary Required Test Statuses +------------------------------- + +For ordinary opt-in runs, the reusable test workflow has a separate +checkout-free pending publisher after PHP version resolution. It verifies the +current run attempt and complete matrix, then marks all configured contexts +pending before the test matrix can start. Opt-out and Dependabot skips still +permit tests to run; Dependabot uses the optional lifecycle bridge described +above when its protection policy needs aliases. + +If pending publication fails, the matrix is blocked and the final publisher +does not run. The final publisher requires successful pending publication and +an actual success or failure matrix outcome; skipped or cancelled matrices +cannot cause it to republish older successful jobs. Final results come from +GitHub job metadata for that exact run, selecting the newest attempt for each +version and requiring it to be completed. A failed-only retry can retain results from +unaffected versions, while incomplete or ambiguous newest results stop +publication. + +Full reruns reexecute the ordered pending publisher. Rerunning only an +individual successful job may retain its successful ancestors and therefore +does not guarantee another pending publication. There is also a scheduling and +PHP-resolution gap before the pending job runs. A failed API read before its +first POST leaves existing statuses unchanged, and later API failures may leave +only some contexts updated. Commit-status mirroring does not provide an atomic +replacement of earlier results. Prefer native qualified checks when migrating +a repository's protection policy. See +:doc:`../advanced/branch-protection-and-bot-commits` for the permission ceiling +and bot-authored commit flow. + Fast Forward Reports -------------------- diff --git a/resources/github-actions-optional/test-statuses.md b/resources/github-actions-optional/test-statuses.md new file mode 100644 index 000000000..101e2ab66 --- /dev/null +++ b/resources/github-actions-optional/test-statuses.md @@ -0,0 +1,108 @@ +# Dependabot Test Statuses + +The adjacent [test-statuses.yml](test-statuses.yml) template mirrors required +commit statuses for same-repository Dependabot push runs. Use it when branch +protection expects unqualified contexts such as `Run Tests (8.3)` while a +reusable test workflow produces native names such as `tests / Run Tests (8.3)`. +Repositories protecting the native qualified checks do not need this bridge. + +This is an optional template. `dev-tools:sync` copies workflows from +`resources/github-actions/`; it does not install this directory. Adoption, +configuration and deployment require an explicit reviewed consumer change. + +## Preconditions and configuration + +The first-party rollout targets twelve audited consumers with PHP 8.3, 8.4 and +8.5. Before copying the template elsewhere, verify these contracts: + +- The source workflow runs on `push`, including Dependabot branches. Its + `name` matches both `workflow_run.workflows` and the PHP metadata guard: + `Fast Forward Test Suite` by default. +- Its path matches the metadata guard: `.github/workflows/tests.yml`. +- Its caller job is named `tests`. All matrix and control-job name checks + expect that prefix, including `tests / Run Tests ()` and + `tests / Resolve PHP Version`. A different prefix requires updating every + corresponding guard, not only the workflow trigger. +- `EXPECTED_PHP_VERSIONS` is a JSON array of strings listing the complete + matrix, initially `["8.3","8.4","8.5"]`. The resulting unqualified contexts + must match branch protection. Additional observed versions, missing + expected versions and ambiguous results stop final publication rather than + silently mirroring a subset. +- The publishing job can obtain `actions: read` and `statuses: write` through + its own `GITHUB_TOKEN`. These permissions belong only to the isolated bridge + job; do not add write permissions to code-executing test jobs. +- The runner provides PHP and the GitHub CLI. The template uses `ubuntu-latest` + and invokes only its fixed inline PHP code and `gh api`; it does not need + Composer dependencies, a repository checkout or a personal token. + +## Explicit installation + +Copy the reviewed template into the consumer's `.github/workflows/` directory +as `test-statuses.yml`. For a consumer with an installed DevTools package: + +```bash +mkdir -p .github/workflows +cp vendor/fast-forward/dev-tools/resources/github-actions-optional/test-statuses.yml \ + .github/workflows/test-statuses.yml +``` + +Workflow-only consumers can copy the same file from a reviewed DevTools +checkout. Inspect the configured names, version list and permissions, run +`actionlint .github/workflows/test-statuses.yml`, and open a consumer PR. +The lifecycle listener becomes active only after its file reaches the default +branch. A passing template PR does not prove that listener has run. + +## Lifecycle and side effects + +`workflow_run` observes requested, in-progress and completed events for the +configured test workflow. Only same-repository Dependabot `push` runs qualify; +pull-request runs, fork pull requests and other actors are outside its scope. +Pull-request runs may test a merge commit, so the bridge uses the source +push's verified `head_sha`, never its own default-branch `github.sha`. + +The bridge verifies the source run's identity, repository, SHA, workflow +name/path/ID, actor, event and attempt through GitHub metadata. It skips +superseded runs and attempts and rechecks the source snapshot before each +write. Active attempts receive `pending` statuses. A delayed start event reads +the current state instead of downgrading a completed attempt to pending. + +Terminal publication validates the entire configured matrix before writing. +Each version uses its newest attempt, which must have a completed result; +failed-only retries retain completed results for unaffected versions. A full +rerun that fails or is cancelled before its matrix executes must not revive +earlier successes. Verified failed control jobs or a failed source attempt +produce failure statuses for blocked versions. Invalid or ambiguous metadata +stops publication without inventing successful results. + +The only writes are commit statuses on the verified source SHA, with links to +its test run. No source, artifacts, caches or caller-produced result files are +downloaded, and no consumer code executes with the writing token. + +Scheduling and API access are not instantaneous. Separate status POSTs are +not atomic: an API failure can leave existing statuses unchanged or only some +contexts updated, and source state can change between requests. Inspect the +bridge result and retry the appropriate lifecycle execution after the cause is +resolved. Do not treat a missing status or API error as a successful test. + +## Verification and rollback + +After deployment, verify a real Dependabot push against the consumer's exact +SHA. Confirm pending contexts while the source attempt is active and the +correct per-version terminal states and target URLs afterward. Check failed +and partial retries, a cancelled or blocked matrix, and stale-event handling. +For template changes, use isolated PHP fixtures with a fake GitHub CLI to +exercise the actual run block, then lint the copied YAML and compare it with +the optional canonical source. No test should write to a real repository or +reuse the operator's real home directory. + +Restore the previous reviewed consumer workflow if an adoption fails. Before +removing the bridge, verify that protection requires the actual native checks +or that another trusted publisher still supplies every required alias. Do not +remove required checks or create artificial successes to make a merge pass. +The reusable workflow's ordinary pending job runs again on full reruns; an +individual successful-job rerun can retain successful ancestors and therefore +does not guarantee another ordinary pending publication. + +See the [workflow guide](../../docs/usage/github-actions.rst) and +[branch-protection guide](../../docs/advanced/branch-protection-and-bot-commits.rst) +for the separate ordinary-run publishers and caller permission ceiling. diff --git a/resources/github-actions-optional/test-statuses.yml b/resources/github-actions-optional/test-statuses.yml new file mode 100644 index 000000000..ccb6214ca --- /dev/null +++ b/resources/github-actions-optional/test-statuses.yml @@ -0,0 +1,288 @@ +name: Dependabot Test Statuses + +on: + workflow_run: + workflows: ["Fast Forward Test Suite"] + types: [requested, in_progress, completed] + +permissions: {} + +concurrency: + group: dependabot-test-statuses-${{ github.event.workflow_run.head_sha }} + cancel-in-progress: false + +jobs: + publish: + if: >- + github.event.workflow_run.event == 'push' && + github.event.workflow_run.actor.login == 'dependabot[bot]' && + github.event.workflow_run.head_repository.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: read + statuses: write + steps: + - name: Mirror verified Dependabot test results + shell: bash + env: + GH_TOKEN: ${{ github.token }} + SOURCE_RUN_ID: ${{ github.event.workflow_run.id }} + SOURCE_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} + SOURCE_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + SOURCE_EVENT_ACTION: ${{ github.event.action }} + EXPECTED_PHP_VERSIONS: '["8.3","8.4","8.5"]' + run: | + php <<'PHP' + 'full_name', 'head_repository' => 'full_name', 'actor' => 'login'] as $field => $key) { + if (($current[$field][$key] ?? null) !== ($snapshot[$field][$key] ?? null)) { + echo "The source run identity changed before publication; no further statuses published.\n"; + return false; + } + } + return true; + } + + $repository = getenv('GITHUB_REPOSITORY'); + $runId = getenv('SOURCE_RUN_ID'); + $attempt = getenv('SOURCE_RUN_ATTEMPT'); + $headSha = getenv('SOURCE_HEAD_SHA'); + if (!is_string($repository) || preg_match('~\A[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+\z~', $repository) !== 1 + || !is_string($runId) || preg_match('/\A[1-9][0-9]*\z/', $runId) !== 1 + || !is_string($attempt) || preg_match('/\A[1-9][0-9]*\z/', $attempt) !== 1 + || !is_string($headSha) || preg_match('/\A[0-9a-f]{40}\z/', $headSha) !== 1) { + throw new RuntimeException('Invalid completion event identity.'); + } + $eventAction = getenv('SOURCE_EVENT_ACTION'); + if (!is_string($eventAction) || !in_array($eventAction, ['requested', 'in_progress', 'completed'], true)) { + throw new RuntimeException('Invalid workflow lifecycle event.'); + } + $versionList = getenv('EXPECTED_PHP_VERSIONS'); + if (!is_string($versionList) || $versionList === '') { + throw new RuntimeException('A PHP version list is required.'); + } + $versions = json_decode($versionList, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($versions) || $versions === [] || !array_is_list($versions)) { + throw new RuntimeException('A non-empty PHP version list is required.'); + } + foreach ($versions as $version) { + if (!is_string($version) || preg_match('/\A[0-9]+\.[0-9]+\z/', $version) !== 1) { + throw new RuntimeException('Invalid PHP version.'); + } + } + if (count($versions) !== count(array_unique($versions))) { + throw new RuntimeException('Duplicate PHP version.'); + } + + $run = githubApi(["repos/$repository/actions/runs/$runId"]); + if ((string) ($run['id'] ?? '') !== $runId + || ($run['repository']['full_name'] ?? null) !== $repository + || ($run['head_repository']['full_name'] ?? null) !== $repository + || ($run['head_sha'] ?? null) !== $headSha + || ($run['event'] ?? null) !== 'push' + || ($run['actor']['login'] ?? null) !== 'dependabot[bot]' + || ($run['name'] ?? null) !== 'Fast Forward Test Suite' + || explode('@', $run['path'] ?? '')[0] !== '.github/workflows/tests.yml' + || !is_int($run['workflow_id'] ?? null) || $run['workflow_id'] < 1 + || !is_int($run['run_number'] ?? null) || $run['run_number'] < 1 + || !is_int($run['run_attempt'] ?? null) || $run['run_attempt'] < 1) { + throw new RuntimeException('The API run does not match the expected test workflow.'); + } + if ((string) $run['run_attempt'] !== $attempt) { + echo "A newer attempt supersedes this completion; no statuses published.\n"; + exit(0); + } + if (!in_array($run['status'] ?? null, ['queued', 'in_progress', 'requested', 'waiting', 'pending', 'completed'], true)) { + throw new RuntimeException('Unsupported workflow run status.'); + } + if ($eventAction === 'completed' && $run['status'] !== 'completed') { + echo "The completed event no longer matches the current attempt state; no statuses published.\n"; + exit(0); + } + + $workflowId = $run['workflow_id']; + $pages = githubApi(['--paginate', '--slurp', "repos/$repository/actions/workflows/$workflowId/runs?head_sha=$headSha&event=push&per_page=100"]); + $matchingRuns = []; + foreach ($pages as $page) { + if (!is_array($page['workflow_runs'] ?? null)) { + throw new RuntimeException('Malformed workflow run list.'); + } + foreach ($page['workflow_runs'] as $candidate) { + if (($candidate['head_sha'] ?? null) === $headSha && ($candidate['event'] ?? null) === 'push' + && ($candidate['workflow_id'] ?? null) === $workflowId) { + if (!is_int($candidate['run_number'] ?? null) || $candidate['run_number'] < 1 + || !is_int($candidate['id'] ?? null) || $candidate['id'] < 1) { + throw new RuntimeException('Invalid matching run identity.'); + } + $matchingRuns[] = $candidate; + } + } + } + if ($matchingRuns === []) { + throw new RuntimeException('The source run is absent from the workflow run list.'); + } + $latestNumber = max(array_column($matchingRuns, 'run_number')); + $latestRuns = array_values(array_filter($matchingRuns, static fn (array $candidate): bool => $candidate['run_number'] === $latestNumber)); + if (count($latestRuns) !== 1) { + throw new RuntimeException('Ambiguous newest workflow run.'); + } + if ((string) $latestRuns[0]['id'] !== $runId) { + echo "A newer run supersedes this completion; no statuses published.\n"; + exit(0); + } + + $targetUrl = getenv('GITHUB_SERVER_URL') . "/$repository/actions/runs/$runId"; + if ($run['status'] !== 'completed') { + foreach ($versions as $version) { + if (!runIsCurrent($repository, $runId, $run)) { + exit(0); + } + githubApi(['--method', 'POST', "repos/$repository/statuses/$headSha", + '-f', 'state=pending', + '-f', "context=Run Tests ($version)", + '-f', "description=PHP $version matrix job is pending.", + '-f', "target_url=$targetUrl"]); + } + exit(0); + } + + $pages = githubApi(['--paginate', '--slurp', "repos/$repository/actions/runs/$runId/jobs?filter=all&per_page=100"]); + $jobs = []; + foreach ($pages as $page) { + if (!is_array($page['jobs'] ?? null)) { + throw new RuntimeException('Malformed workflow job list.'); + } + $jobs = array_merge($jobs, $page['jobs']); + } + $sourceFailed = in_array($run['conclusion'] ?? null, + ['failure', 'cancelled', 'timed_out', 'action_required', 'startup_failure', 'stale'], true); + $requireCurrentAttempt = false; + $blockedReason = null; + $currentJobsObserved = false; + $currentControlJobs = []; + foreach ($jobs as $job) { + if (!is_array($job)) { + throw new RuntimeException('Invalid workflow job record.'); + } + $jobName = $job['name'] ?? null; + if (($job['run_attempt'] ?? null) === $run['run_attempt'] + && (string) ($job['run_id'] ?? '') === $runId + && (in_array($jobName, ['tests / Resolve PHP Version', 'tests / Run Tests', 'tests / Dependency Health'], true) + || (is_string($jobName) && preg_match('/\Atests \/ Run Tests \([^)]+\)\z/', $jobName) === 1))) { + $currentJobsObserved = true; + } + if (is_string($jobName) && preg_match('/\Atests \/ Run Tests \(([^)]+)\)\z/', $jobName, $lane) === 1 + && !in_array($lane[1], $versions, true)) { + throw new RuntimeException('Observed PHP matrix differs from the explicitly configured version list.'); + } + if (!in_array($jobName, ['tests / Resolve PHP Version', 'tests / Run Tests'], true)) { + continue; + } + if ((string) ($job['run_id'] ?? '') !== $runId + || !is_int($job['run_attempt'] ?? null) || $job['run_attempt'] < 1 + || $job['run_attempt'] > $run['run_attempt']) { + throw new RuntimeException('Invalid test control job attempt.'); + } + if ($job['run_attempt'] !== $run['run_attempt']) { + continue; + } + if (isset($currentControlJobs[$jobName])) { + throw new RuntimeException('Ambiguous current test control job.'); + } + $currentControlJobs[$jobName] = true; + if (($job['status'] ?? null) !== 'completed' + || !is_string($job['conclusion'] ?? null) + || preg_match('/\A[a-z_]+\z/', $job['conclusion']) !== 1) { + throw new RuntimeException('Incomplete test control job result.'); + } + if ($jobName === 'tests / Resolve PHP Version') { + $requireCurrentAttempt = true; + } + if ($job['conclusion'] !== 'success') { + $blockedReason = 'test_control_' . $job['conclusion']; + } + } + if ($sourceFailed && !$currentJobsObserved) { + $blockedReason = 'source_' . $run['conclusion']; + } + $results = []; + foreach ($versions as $version) { + $expectedName = "tests / Run Tests ($version)"; + $matches = array_values(array_filter($jobs, static fn (array $job): bool => ($job['name'] ?? null) === $expectedName)); + if ($matches === []) { + if ($sourceFailed || $blockedReason !== null) { + $results[] = [$version, $blockedReason ?? 'source_' . $run['conclusion']]; + continue; + } + throw new RuntimeException("Missing test job for PHP $version."); + } + foreach ($matches as $job) { + if ((string) ($job['run_id'] ?? '') !== $runId + || !is_int($job['run_attempt'] ?? null) || $job['run_attempt'] < 1 + || $job['run_attempt'] > $run['run_attempt']) { + throw new RuntimeException("Invalid test job attempt for PHP $version."); + } + } + $latestAttempt = max(array_column($matches, 'run_attempt')); + $latest = array_values(array_filter($matches, static fn (array $job): bool => $job['run_attempt'] === $latestAttempt)); + if (count($latest) !== 1 || ($latest[0]['status'] ?? null) !== 'completed' + || !is_string($latest[0]['conclusion'] ?? null) + || preg_match('/\A[a-z_]+\z/', $latest[0]['conclusion']) !== 1) { + throw new RuntimeException("Incomplete or ambiguous test result for PHP $version."); + } + if ($blockedReason !== null) { + $results[] = [$version, $blockedReason]; + } elseif ($requireCurrentAttempt && $latestAttempt !== $run['run_attempt']) { + if (!$sourceFailed) { + throw new RuntimeException("Missing current-attempt test job for PHP $version."); + } + $results[] = [$version, 'source_' . $run['conclusion']]; + } else { + $results[] = [$version, $latest[0]['conclusion']]; + } + } + + // Validate every version before the first write. No checkout, artifacts, caches or caller-produced results. + foreach ($results as [$version, $conclusion]) { + if (!runIsCurrent($repository, $runId, $run)) { + exit(0); + } + githubApi(['--method', 'POST', "repos/$repository/statuses/$headSha", + '-f', 'state=' . ($conclusion === 'success' ? 'success' : 'failure'), + '-f', "context=Run Tests ($version)", + '-f', "description=PHP $version matrix job result: $conclusion.", + '-f', "target_url=$targetUrl"]); + } + PHP diff --git a/resources/github-actions/tests.yml b/resources/github-actions/tests.yml index e070da263..e0d51d55f 100644 --- a/resources/github-actions/tests.yml +++ b/resources/github-actions/tests.yml @@ -21,6 +21,7 @@ on: default: false permissions: + actions: read contents: read statuses: write