From 59e78da71811b17536746e8b73a705bd3a46dc74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 7 Oct 2026 19:20:10 -0300 Subject: [PATCH 1/9] fix(ci): keep Composer checks plugin-free --- .../composer-without-plugins.php | 30 +++++++++++++++++++ .github/workflows/tests.yml | 11 ++++++- CHANGELOG.md | 1 + 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 .github/actions/php/setup-composer/composer-without-plugins.php 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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a186b23..efbc9fb20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- 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. - 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 From 4f7f8f079551ea74f4b41434f74a8d00572e5e47 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:22:45 +0000 Subject: [PATCH 2/9] Update wiki submodule pointer for PR #362 --- .github/wiki | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/wiki b/.github/wiki index eed2e691d..df4904389 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit eed2e691dcca1be57c48c58427d4ed2cca8aeb4f +Subproject commit df4904389de0d7cac75713d946113a35376caf43 From 5a40a4c89e5926d919716dc479be79ec00f18e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 7 Oct 2026 19:42:11 -0300 Subject: [PATCH 3/9] fix(ci): isolate status publishing from test code --- .github/workflows/tests.yml | 88 ++++++++++++++++++++++++------------- CHANGELOG.md | 1 + 2 files changed, 58 insertions(+), 31 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c31626c3b..6b2776632 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -42,7 +42,7 @@ on: permissions: contents: read - statuses: write + statuses: none concurrency: group: ${{ github.event_name == 'pull_request' && format('tests-pr-{0}', github.event.pull_request.number) || format('tests-{0}', github.ref) }} @@ -55,6 +55,9 @@ jobs: resolve_php: name: Resolve PHP Version runs-on: ubuntu-latest + permissions: + contents: read + statuses: none outputs: php-version: ${{ steps.resolve.outputs.php-version }} php-version-source: ${{ steps.resolve.outputs.php-version-source }} @@ -62,9 +65,12 @@ jobs: 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 @@ -79,6 +85,9 @@ jobs: needs: resolve_php name: Run Tests runs-on: ubuntu-latest + permissions: + contents: read + statuses: none strategy: fail-fast: false matrix: ${{ fromJson(needs.resolve_php.outputs.test-matrix) }} @@ -86,9 +95,12 @@ jobs: 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 @@ -118,42 +130,23 @@ jobs: COMPOSER_ROOT_VERSION: ${{ env.TESTS_ROOT_VERSION }} run: dev-tools tests --coverage=.dev-tools/coverage --min-coverage=${{ steps.minimum-coverage.outputs.value }} - - name: Publish required test status - if: ${{ always() && inputs.publish-required-statuses }} - env: - GH_TOKEN: ${{ github.token }} - TARGET_SHA: ${{ github.sha }} - TARGET_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - TEST_CONTEXT: Run Tests (${{ matrix.php-version }}) - TEST_RESULT: ${{ job.status }} - run: | - if [ "${TEST_RESULT}" = "success" ]; then - state="success" - description="Workflow-dispatched PHPUnit job passed." - else - state="failure" - description="Workflow-dispatched PHPUnit job result: ${TEST_RESULT}." - fi - - gh api \ - --method POST \ - "repos/${GITHUB_REPOSITORY}/statuses/${TARGET_SHA}" \ - -f state="${state}" \ - -f context="${TEST_CONTEXT}" \ - -f description="${description}" \ - -f target_url="${TARGET_URL}" - dependency-health: needs: resolve_php name: Dependency Health runs-on: ubuntu-latest + permissions: + contents: read + statuses: 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 @@ -187,11 +180,17 @@ jobs: - tests - dependency-health runs-on: ubuntu-latest + permissions: + contents: read + statuses: 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 @@ -212,25 +211,52 @@ 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]' }} name: Publish Required Test Statuses needs: - resolve_php + - tests runs-on: ubuntu-latest + permissions: + 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 }} + TEST_RESULT: ${{ needs.tests.result }} 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 + if [ "${TEST_RESULT}" = "success" ]; then + state="success" + description="All PHPUnit matrix jobs passed." + else + state="failure" + description="Aggregated PHPUnit matrix result: ${TEST_RESULT}." + fi + + # 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."); + } + } + echo implode(PHP_EOL, $versions), PHP_EOL; + ' | while IFS= read -r php_version; 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="${description}" \ -f target_url="${TARGET_URL}" done diff --git a/CHANGELOG.md b/CHANGELOG.md index efbc9fb20..43972a930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - 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 required-status publication from repository-controlled test jobs 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 From df1552c59e8a7f861f35345e0f0e3fa05e036692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 7 Oct 2026 20:04:53 -0300 Subject: [PATCH 4/9] fix(ci): publish results from each PHP matrix job --- .github/workflows/tests.yml | 54 ++++++++++++++++++++++++++++--------- CHANGELOG.md | 2 +- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6b2776632..37e09ddcd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ on: type: string default: '-1' publish-required-statuses: - description: Mirror required test matrix checks as commit statuses for workflow-dispatched runs. + description: Mirror per-version checks from public workflow job metadata using an isolated publisher. required: false type: boolean default: false @@ -31,7 +31,7 @@ on: type: string default: '-1' publish-required-statuses: - description: Mirror required test matrix checks as commit statuses for workflow-dispatched runs. + description: Mirror per-version checks from public workflow job metadata using an isolated publisher. required: false type: boolean default: false @@ -218,6 +218,8 @@ jobs: - tests runs-on: ubuntu-latest permissions: + # Public job metadata needs no additional token scope. Private mirroring + # requires actions: read here and in the caller permissions maximum. statuses: write steps: - name: Publish completed required test statuses @@ -227,15 +229,10 @@ jobs: TARGET_SHA: ${{ github.sha }} TARGET_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} TEST_MATRIX: ${{ needs.resolve_php.outputs.test-matrix }} - TEST_RESULT: ${{ needs.tests.result }} run: | - if [ "${TEST_RESULT}" = "success" ]; then - state="success" - description="All PHPUnit matrix jobs passed." - else - state="failure" - description="Aggregated PHPUnit matrix result: ${TEST_RESULT}." - fi + export TEST_JOBS_FILE="${RUNNER_TEMP}/required-test-jobs.json" + gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs?filter=latest&per_page=100" > "$TEST_JOBS_FILE" # PHP evaluates these variables; Bash must leave them literal. # shellcheck disable=SC2016 @@ -250,13 +247,44 @@ jobs: throw new RuntimeException("Unsupported PHP test matrix entry."); } } - echo implode(PHP_EOL, $versions), PHP_EOL; - ' | while IFS= read -r php_version; do + 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 (count($matches) !== 1) { + throw new RuntimeException("Missing or ambiguous workflow job for " . $version); + } + $job = $matches[0]; + if ((string) ($job["run_id"] ?? "") !== getenv("GITHUB_RUN_ID") + || ($job["status"] ?? null) !== "completed" + || !is_string($job["conclusion"] ?? null) || $job["conclusion"] === "" + || preg_match("/\A[a-z_]+\z/", $job["conclusion"]) !== 1) { + throw new RuntimeException("Incomplete or mismatched 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="${description}" \ + -f description="PHP ${php_version} matrix job result: ${conclusion}." \ -f target_url="${TARGET_URL}" done diff --git a/CHANGELOG.md b/CHANGELOG.md index 43972a930..04c4938a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - 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 required-status publication from repository-controlled test jobs and disable persisted checkout credentials in the reusable test workflow. +- Isolate required-status publication from repository-controlled test jobs, preserve per-version results from 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 From ede3b44e8d06acd6924275540f52cca44768d6fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 7 Oct 2026 20:18:55 -0300 Subject: [PATCH 5/9] fix(ci): preserve private status mirroring and document publication --- .github/workflows/tests.yml | 12 ++++-- CHANGELOG.md | 2 +- .../branch-protection-and-bot-commits.rst | 43 ++++++++++++++----- resources/github-actions/tests.yml | 1 + 4 files changed, 43 insertions(+), 15 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 37e09ddcd..fd5fe9beb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ on: type: string default: '-1' publish-required-statuses: - description: Mirror per-version checks from public workflow job metadata using an isolated publisher. + description: Mirror per-version checks from workflow job metadata using an isolated publisher. required: false type: boolean default: false @@ -31,7 +31,7 @@ on: type: string default: '-1' publish-required-statuses: - description: Mirror per-version checks from public workflow job metadata using an isolated publisher. + description: Mirror per-version checks from workflow job metadata using an isolated publisher. required: false type: boolean default: false @@ -43,6 +43,7 @@ on: 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) }} @@ -58,6 +59,7 @@ jobs: permissions: contents: read statuses: none + actions: none outputs: php-version: ${{ steps.resolve.outputs.php-version }} php-version-source: ${{ steps.resolve.outputs.php-version-source }} @@ -88,6 +90,7 @@ jobs: permissions: contents: read statuses: none + actions: none strategy: fail-fast: false matrix: ${{ fromJson(needs.resolve_php.outputs.test-matrix) }} @@ -137,6 +140,7 @@ jobs: 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: @@ -183,6 +187,7 @@ jobs: permissions: contents: read statuses: none + actions: none steps: - uses: actions/checkout@v7 with: @@ -218,8 +223,7 @@ jobs: - tests runs-on: ubuntu-latest permissions: - # Public job metadata needs no additional token scope. Private mirroring - # requires actions: read here and in the caller permissions maximum. + actions: read statuses: write steps: - name: Publish completed required test statuses diff --git a/CHANGELOG.md b/CHANGELOG.md index 04c4938a1..3c1399bcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - 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 required-status publication from repository-controlled test jobs, preserve per-version results from GitHub job metadata, and disable persisted checkout credentials in the reusable test workflow. +- 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..3b5fb77de 100644 --- a/docs/advanced/branch-protection-and-bot-commits.rst +++ b/docs/advanced/branch-protection-and-bot-commits.rst @@ -109,12 +109,34 @@ 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 a separate status publisher after the complete test matrix +finishes. This publisher does not check out repository files or execute consumer +code. It reads the latest GitHub job metadata for that exact run and publishes +each PHP version's own completed result under its required-check name, such as +``Run Tests (8.3)``, ``Run Tests (8.4)``, and ``Run Tests (8.5)``. + +The workflow does not publish pending commit statuses. Required contexts can +therefore remain absent or awaiting a result while the matrix runs. A missing, +ambiguous, incomplete or wrong-run job result makes the publisher fail before +posting any statuses. When investigating a blocked check, inspect the isolated +publisher's result and the corresponding matrix job rather than expecting an +early pending status or publication from the test job itself. Test workflow +concurrency cancels older in-progress runs for the same pull request so the +newest commit owns the required check contexts. + +Jobs that execute checked-out code have read-only contents access and no status +write permission; all checkouts disable credential persistence. Only the +isolated publisher can write commit statuses, and publication is skipped for +Dependabot. + +Status mirroring is opt-in and defaults to disabled. The isolated publisher has +``actions: read`` to access job metadata in public or private repositories and +``statuses: write`` to submit the 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 +177,11 @@ 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 status publisher +has ``actions: read`` and ``statuses: write`` so workflow-dispatched test runs +can mirror each required matrix context onto bot-authored wiki pointer commits. +The publisher does not check out code or run consumer scripts. ``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/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 From e7a8b77f3567dc9f6241caacd1e2e6d81cb6a6c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 7 Oct 2026 20:36:53 -0300 Subject: [PATCH 6/9] fix(ci): retain successful legs across partial retries --- .github/workflows/tests.yml | 24 +++++++++++++------ .../branch-protection-and-bot-commits.rst | 10 ++++++-- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fd5fe9beb..3a0cac021 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -236,7 +236,7 @@ jobs: 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=latest&per_page=100" > "$TEST_JOBS_FILE" + "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 @@ -269,15 +269,25 @@ jobs: return is_string($job["name"] ?? null) && ($job["name"] === $name || str_ends_with($job["name"], " / " . $name)); })); - if (count($matches) !== 1) { - throw new RuntimeException("Missing or ambiguous workflow job for " . $version); + if ($matches === []) { + throw new RuntimeException("Missing workflow job for " . $version); } - $job = $matches[0]; - if ((string) ($job["run_id"] ?? "") !== getenv("GITHUB_RUN_ID") - || ($job["status"] ?? null) !== "completed" + 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 or mismatched workflow job for " . $version); + throw new RuntimeException("Incomplete latest workflow job for " . $version); } $results[] = $version . "\t" . ($job["conclusion"] === "success" ? "success" : "failure") . "\t" . $job["conclusion"]; diff --git a/docs/advanced/branch-protection-and-bot-commits.rst b/docs/advanced/branch-protection-and-bot-commits.rst index 3b5fb77de..078712422 100644 --- a/docs/advanced/branch-protection-and-bot-commits.rst +++ b/docs/advanced/branch-protection-and-bot-commits.rst @@ -111,10 +111,16 @@ required ``Run Tests`` matrix checks. Because manually dispatched workflow check runs are not always treated as pull-request required checks, that dispatched test run enables a separate status publisher after the complete test matrix finishes. This publisher does not check out repository files or execute consumer -code. It reads the latest GitHub job metadata for that exact run and publishes -each PHP version's own completed result under its required-check name, such as +code. It reads all GitHub job attempts for that exact run, selects the newest +attempt for each PHP version, and publishes that version's completed result +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. + The workflow does not publish pending commit statuses. Required contexts can therefore remain absent or awaiting a result while the matrix runs. A missing, ambiguous, incomplete or wrong-run job result makes the publisher fail before From 7f3a0562e56c963ef5a51b6e14cd96ac0b26a167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 8 Oct 2026 13:47:17 -0300 Subject: [PATCH 7/9] ci: mirror verified Dependabot push test results --- CHANGELOG.md | 1 + docs/usage/github-actions.rst | 30 ++++ resources/github-actions/test-statuses.yml | 172 +++++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 resources/github-actions/test-statuses.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c1399bcc..61e47f6b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Restore required per-version statuses for same-repository Dependabot pushes through a checkout-free completion workflow 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. diff --git a/docs/usage/github-actions.rst b/docs/usage/github-actions.rst index ad6a2d998..f053b2b29 100644 --- a/docs/usage/github-actions.rst +++ b/docs/usage/github-actions.rst @@ -48,6 +48,7 @@ This approach ensures that all libraries in the ecosystem benefit from infrastru The packaged wrappers currently include: * ``tests.yml`` +* ``test-statuses.yml`` for protected Dependabot push results * ``reports.yml`` * ``review.yml`` * ``changelog.yml`` @@ -65,6 +66,35 @@ 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 standalone packaged ``test-statuses.yml`` workflow mirrors the three +``Run Tests (8.3)``, ``Run Tests (8.4)``, and ``Run Tests (8.5)`` +contexts required by consumers that still protect the unqualified names. +It runs from the default branch after a same-repository Dependabot +``push`` completes the ``Fast Forward Test Suite`` workflow. + +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, then validates all three jobs before publishing their actual +conclusions. Partial retries retain the latest attempt for each version; +stale completion events and superseded runs publish nothing. + +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. Copy it only to consumers with this workflow name, job prefix +``tests / Run Tests (...)`` and three-version matrix, or explicitly +adjust and verify those contracts. Consumers protecting the native +qualified GitHub Actions checks do not need the additional aliases. + Fast Forward Reports -------------------- diff --git a/resources/github-actions/test-statuses.yml b/resources/github-actions/test-statuses.yml new file mode 100644 index 000000000..845e2968c --- /dev/null +++ b/resources/github-actions/test-statuses.yml @@ -0,0 +1,172 @@ +name: Dependabot Test Statuses + +on: + workflow_run: + workflows: ["Fast Forward Test Suite"] + types: [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 }} + EXPECTED_PHP_VERSIONS: '["8.3","8.4","8.5"]' + run: | + php <<'PHP' + $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); + } + + $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']); + } + $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 === []) { + 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."); + } + $results[] = [$version, $latest[0]['conclusion']]; + } + + // Validate every version before the first write. No checkout, artifacts, caches or caller-produced results. + $targetUrl = getenv('GITHUB_SERVER_URL') . "/$repository/actions/runs/$runId"; + foreach ($results as [$version, $conclusion]) { + 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 From ed57e6a805a3657a7dfd1461ed0b6ac583d287a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 8 Oct 2026 14:05:55 -0300 Subject: [PATCH 8/9] ci: reset protected test statuses safely on reruns --- .github/workflows/tests.yml | 90 +++++++++++++++++++++- CHANGELOG.md | 2 +- docs/usage/github-actions.rst | 30 +++++++- resources/github-actions/test-statuses.yml | 35 ++++++++- 4 files changed, 146 insertions(+), 11 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3a0cac021..03e69403d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -83,8 +83,93 @@ jobs: id: resolve uses: ./.dev-tools-actions/.github/actions/php/resolve-version - tests: + 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' + Date: Thu, 8 Oct 2026 14:30:59 -0300 Subject: [PATCH 9/9] ci: make status bridge opt-in and fail closed on aborted reruns --- AGENTS.md | 6 +- .../branch-protection-and-bot-commits.rst | 70 ++++++++---- docs/usage/github-actions.rst | 105 ++++++++++++----- .../github-actions-optional/test-statuses.md | 108 ++++++++++++++++++ .../test-statuses.yml | 91 ++++++++++++++- 5 files changed, 328 insertions(+), 52 deletions(-) create mode 100644 resources/github-actions-optional/test-statuses.md rename resources/{github-actions => github-actions-optional}/test-statuses.yml (67%) 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/docs/advanced/branch-protection-and-bot-commits.rst b/docs/advanced/branch-protection-and-bot-commits.rst index 078712422..d54dbe015 100644 --- a/docs/advanced/branch-protection-and-bot-commits.rst +++ b/docs/advanced/branch-protection-and-bot-commits.rst @@ -109,11 +109,19 @@ 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 enables a separate status publisher after the complete test matrix -finishes. This publisher does not check out repository files or execute consumer -code. It reads all GitHub job attempts for that exact run, selects the newest -attempt for each PHP version, and publishes that version's completed result -under its required-check name, such as +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 @@ -121,23 +129,37 @@ 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. -The workflow does not publish pending commit statuses. Required contexts can -therefore remain absent or awaiting a result while the matrix runs. A missing, -ambiguous, incomplete or wrong-run job result makes the publisher fail before -posting any statuses. When investigating a blocked check, inspect the isolated -publisher's result and the corresponding matrix job rather than expecting an -early pending status or publication from the test job itself. Test workflow -concurrency cancels older in-progress runs for the same pull request so the -newest commit owns the required check contexts. +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 publisher can write commit statuses, and publication is skipped for -Dependabot. - -Status mirroring is opt-in and defaults to disabled. The isolated publisher has -``actions: read`` to access job metadata in public or private repositories and -``statuses: write`` to submit the completed results. Reusable-workflow callers +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 @@ -184,10 +206,12 @@ permissions at the job level when generated content must be pushed or pull requests must be updated. The code-executing jobs in ``tests.yml`` need only ``contents: read`` to check -out code, install dependencies and run PHPUnit. Its separate status publisher -has ``actions: read`` and ``statuses: write`` so workflow-dispatched test runs -can mirror each required matrix context onto bot-authored wiki pointer commits. -The publisher does not check out code or run consumer scripts. +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 3de784c9f..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 @@ -55,7 +55,6 @@ would need to version both surfaces together. The packaged wrappers currently include: * ``tests.yml`` -* ``test-statuses.yml`` for protected Dependabot push results * ``reports.yml`` * ``review.yml`` * ``changelog.yml`` @@ -65,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`. @@ -76,23 +79,51 @@ consumer ``vendor/bin/dev-tools`` when it exists and otherwise exposes a Dependabot Required Test Statuses --------------------------------- -The standalone packaged ``test-statuses.yml`` workflow mirrors the three -``Run Tests (8.3)``, ``Run Tests (8.4)``, and ``Run Tests (8.5)`` -contexts required by consumers that still protect the unqualified names. -It runs from the default branch when a same-repository Dependabot -``push`` requests, starts or completes the ``Fast Forward Test Suite`` -workflow. Active attempts receive pending statuses, including reruns; -completed attempts receive the actual per-version conclusions. +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, then validates all three jobs before publishing their actual -conclusions. Partial retries retain the latest attempt for each version; -stale completion events and superseded runs publish nothing. A delayed -start event reads the current Run API state and cannot overwrite a completed -result with pending. +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. @@ -101,21 +132,41 @@ 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. Copy it only to consumers with this workflow name, job prefix -``tests / Run Tests (...)`` and three-version matrix, or explicitly -adjust and verify those contracts. Consumers protecting the native -qualified GitHub Actions checks do not need the additional aliases. +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. The test matrix waits for pending -publication; opt-out and Dependabot skips still permit tests to run. -If pending publication fails, the matrix and final publisher cannot reuse -earlier successful job results. The final publisher runs only after an -actual success/failure matrix outcome. Full reruns reexecute this ordered -publisher; rerunning only an individual successful job may retain successful -ancestors, so instantaneous protection before scheduling or API access is -not guaranteed by commit-status mirroring. Prefer native qualified checks -when migrating a repository's protection policy. +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/test-statuses.yml b/resources/github-actions-optional/test-statuses.yml similarity index 67% rename from resources/github-actions/test-statuses.yml rename to resources/github-actions-optional/test-statuses.yml index 508961486..ccb6214ca 100644 --- a/resources/github-actions/test-statuses.yml +++ b/resources/github-actions-optional/test-statuses.yml @@ -56,6 +56,25 @@ jobs: return $data; } + function runIsCurrent(string $repository, string $runId, array $snapshot): bool + { + $current = githubApi(["repos/$repository/actions/runs/$runId"]); + foreach (['id', 'head_sha', 'event', 'name', 'path', + 'workflow_id', 'run_number', 'run_attempt', 'status', 'conclusion'] as $field) { + if (($current[$field] ?? null) !== ($snapshot[$field] ?? null)) { + echo "The source run changed before publication; no further statuses published.\n"; + return false; + } + } + foreach (['repository' => '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'); @@ -147,6 +166,9 @@ jobs: $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)", @@ -164,11 +186,66 @@ jobs: } $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) { @@ -185,11 +262,23 @@ jobs: || preg_match('/\A[a-z_]+\z/', $latest[0]['conclusion']) !== 1) { throw new RuntimeException("Incomplete or ambiguous test result for PHP $version."); } - $results[] = [$version, $latest[0]['conclusion']]; + 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)",