From 78fe68688a178d98d73b7d824125ccca3c526a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 7 Oct 2026 20:32:08 -0300 Subject: [PATCH 1/5] ci: permit isolated status publishing to read job results --- .github/workflows/tests.yml | 1 + CHANGELOG.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bff4e9e..6f26c08 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,6 +21,7 @@ on: default: false permissions: + actions: read contents: read statuses: write diff --git a/CHANGELOG.md b/CHANGELOG.md index f14b3c2..6c785bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Replace the mascot banner with contextual Dash artwork for typed enum cases and align README and documentation references. (#5) +### Fixed + +- Allow the isolated CI status publisher to read workflow job outcomes without granting write permissions to test jobs. + ## [0.1.0] - 2026-04-24 ### Added From 0d3be94b6e945ac4409b46f3009b6656e6374941 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:33:03 +0000 Subject: [PATCH 2/5] Update wiki submodule pointer for PR #6 --- .github/wiki | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/wiki b/.github/wiki index 44f2a6a..e5558a0 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit 44f2a6acd0fcce008914765e250b2fb56ec83189 +Subproject commit e5558a0801a211037754a06b4ce68fc61331cbfb From a3fc663afa786a4865297a7decaf245de63be24d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 8 Oct 2026 13:49:32 -0300 Subject: [PATCH 3/5] ci: mirror verified Dependabot push test results --- .github/workflows/test-statuses.yml | 172 ++++++++++++++++++++++++++++ CHANGELOG.md | 1 + 2 files changed, 173 insertions(+) create mode 100644 .github/workflows/test-statuses.yml diff --git a/.github/workflows/test-statuses.yml b/.github/workflows/test-statuses.yml new file mode 100644 index 0000000..845e296 --- /dev/null +++ b/.github/workflows/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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c785bf..9f090a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Mirror verified Dependabot push test outcomes through a separate completion workflow so required statuses are available with the bot's restricted token. - Allow the isolated CI status publisher to read workflow job outcomes without granting write permissions to test jobs. ## [0.1.0] - 2026-04-24 From 38a1ff727225e282ae658de62ab4580f93e432a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 8 Oct 2026 14:07:43 -0300 Subject: [PATCH 4/5] ci: reset Dependabot statuses across reruns --- .github/workflows/test-statuses.yml | 35 +++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-statuses.yml b/.github/workflows/test-statuses.yml index 845e296..5089614 100644 --- a/.github/workflows/test-statuses.yml +++ b/.github/workflows/test-statuses.yml @@ -3,7 +3,7 @@ name: Dependabot Test Statuses on: workflow_run: workflows: ["Fast Forward Test Suite"] - types: [completed] + types: [requested, in_progress, completed] permissions: {} @@ -30,6 +30,7 @@ jobs: 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' @@ -65,7 +66,15 @@ jobs: || !is_string($headSha) || preg_match('/\A[0-9a-f]{40}\z/', $headSha) !== 1) { throw new RuntimeException('Invalid completion event identity.'); } - $versions = json_decode(getenv('EXPECTED_PHP_VERSIONS'), true, 512, JSON_THROW_ON_ERROR); + $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.'); } @@ -92,10 +101,17 @@ jobs: || !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 || ($run['status'] ?? null) !== 'completed') { + 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"]); @@ -128,6 +144,18 @@ jobs: exit(0); } + $targetUrl = getenv('GITHUB_SERVER_URL') . "/$repository/actions/runs/$runId"; + if ($run['status'] !== 'completed') { + foreach ($versions as $version) { + 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) { @@ -161,7 +189,6 @@ jobs: } // 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'), From 621f39681f7a9439f7438f6ce49a88c815b9abe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 8 Oct 2026 14:32:44 -0300 Subject: [PATCH 5/5] ci: finalize aborted test runs without stale successes --- .github/workflows/test-statuses.yml | 91 ++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-statuses.yml b/.github/workflows/test-statuses.yml index 5089614..ccb6214 100644 --- a/.github/workflows/test-statuses.yml +++ b/.github/workflows/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)",