From f9e130d2fa5749e6614ff5b03268022d74f2d6c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 21 Oct 2026 15:49:24 -0300 Subject: [PATCH 1/4] fix(ci): bound Pages propagation health checks --- .changelog/pages-propagation-budget.md | 6 + .../github-pages/verify-deployment/action.yml | 6 +- .../github-pages/verify-deployment/run.sh | 58 ++++--- docs/usage/github-actions.rst | 2 +- .../PagesDeploymentActionTest.php | 145 ++++++++++++++++++ 5 files changed, 192 insertions(+), 25 deletions(-) create mode 100644 .changelog/pages-propagation-budget.md create mode 100644 tests/GitHubActions/PagesDeploymentActionTest.php diff --git a/.changelog/pages-propagation-budget.md b/.changelog/pages-propagation-budget.md new file mode 100644 index 0000000000..84a8ab5e03 --- /dev/null +++ b/.changelog/pages-propagation-budget.md @@ -0,0 +1,6 @@ +--- +category: fixed +type: patch +--- + +Allow Pages health checks to tolerate delayed propagation while bounding all URL checks to 180 seconds, limiting transfers and preserving failures for missing or broken deployments. diff --git a/.github/actions/github-pages/verify-deployment/action.yml b/.github/actions/github-pages/verify-deployment/action.yml index 227f109141..54ed7d050a 100644 --- a/.github/actions/github-pages/verify-deployment/action.yml +++ b/.github/actions/github-pages/verify-deployment/action.yml @@ -1,5 +1,5 @@ name: Verify GitHub Pages Deployment -description: Health-check a set of GitHub Pages URLs with retry/backoff. +description: Health-check a set of GitHub Pages URLs with bounded propagation polling (180 seconds total). inputs: base-url: @@ -14,11 +14,11 @@ inputs: attempts: description: Number of retry attempts. required: false - default: '6' + default: '12' delay: description: Delay in seconds between retries. required: false - default: '5' + default: '10' runs: using: composite diff --git a/.github/actions/github-pages/verify-deployment/run.sh b/.github/actions/github-pages/verify-deployment/run.sh index 9823c99075..79e14cbc1d 100755 --- a/.github/actions/github-pages/verify-deployment/run.sh +++ b/.github/actions/github-pages/verify-deployment/run.sh @@ -6,36 +6,52 @@ title="${INPUT_TITLE}" attempts="${INPUT_ATTEMPTS}" delay="${INPUT_DELAY}" -check_url() { - url="$1" - label="$2" - - for attempt in $(seq 1 "${attempts}"); do - echo "Checking ${label}: ${url} (attempt ${attempt}/${attempts})" +if [[ ! "$attempts" =~ ^([1-9]|[12][0-9]|30)$ ]] || [[ ! "$delay" =~ ^([1-9]|[12][0-9]|30)$ ]]; then + echo '::error::Deployment check attempts and delay must be integers between 1 and 30.' + exit 1 +fi - http_status="$(curl --silent --show-error --output /dev/null --location --write-out '%{http_code}' "${url}" 2>/tmp/curl-error.log || true)" +# Share one bounded budget across every URL, including transfers and waits. +deadline=$((SECONDS + 180)) - if [ "${http_status}" -ge 200 ] && [ "${http_status}" -lt 400 ]; then - echo "${label} is reachable (${http_status})." +check_url() { + local url="$1" label="$2" attempt remaining transfer_timeout wait_time + local http_status='000' curl_status=0 - return 0 + for ((attempt = 1; attempt <= attempts; attempt++)); do + remaining=$((deadline - SECONDS)) + if ((remaining <= 0)); then + echo '::error::Deployment health checks exceeded the 180-second total budget.' + return 1 fi + transfer_timeout=$((remaining < 10 ? remaining : 10)) + echo "Checking ${label} (attempt ${attempt}/${attempts})" - curl_error="$(cat /tmp/curl-error.log)" + curl_status=0 + http_status="$(curl --silent --output /dev/null --location --connect-timeout 5 --max-time "$transfer_timeout" --write-out '%{http_code}' "$url" 2>/dev/null)" || curl_status=$? - if [ -n "${curl_error}" ]; then - echo "Attempt ${attempt} failed for ${label}: ${url} (curl error: ${curl_error})" - else - echo "Attempt ${attempt} failed for ${label}: ${url} (HTTP ${http_status})" + if ((SECONDS >= deadline)); then + echo '::error::Deployment health checks exceeded the 180-second total budget.' + return 1 + fi + if ((curl_status == 0)) && [[ "$http_status" =~ ^[23][0-9][0-9]$ ]]; then + echo "${label} is reachable (${http_status})." + return 0 fi - if [ "${attempt}" -lt "${attempts}" ]; then - sleep "${delay}" + # Never print curl stderr or malformed response values (which may contain secrets). + [[ "$http_status" =~ ^[0-9]{3}$ ]] || http_status='000' + echo "Attempt ${attempt} failed (HTTP ${http_status}, curl exit ${curl_status})." + if ((attempt < attempts)); then + remaining=$((deadline - SECONDS)) + wait_time=$((remaining < delay ? remaining : delay)) + if ((wait_time > 0)); then + sleep "$wait_time" + fi fi done - echo "::error title=${title}::${label} is not reachable at ${url}. Last HTTP status: ${http_status}${curl_error:+; curl error: ${curl_error}}" - + echo "::error title=${title}::${label} is not reachable. Last HTTP status: ${http_status}; curl exit: ${curl_status}." return 1 } @@ -45,6 +61,6 @@ while IFS='|' read -r relative_path label; do fi check_url "${base_url}${relative_path}" "${label}" -done < + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\GitHubActions; + +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use function Safe\chmod; +use function Safe\fclose; +use function Safe\file_get_contents; +use function Safe\file_put_contents; +use function Safe\mkdir; +use function Safe\proc_open; +use function Safe\rmdir; +use function Safe\stream_get_contents; +use function Safe\unlink; + +#[CoversNothing] +final class PagesDeploymentActionTest extends TestCase +{ + /** @return iterable */ + public static function responses(): iterable + { + yield 'late propagation' => ['404 404 404 404 404 404 200', 0, '12', '10', 0, 7]; + yield 'immediate success' => ['200', 0, '12', '10', 0, 1]; + yield 'missing deployment' => ['404', 0, '12', '10', 1, 12]; + yield 'broken deployment' => ['500', 0, '12', '10', 1, 12]; + yield 'transfer timeout despite status' => ['200', 28, '12', '10', 1, 12]; + yield 'malformed response' => ['TOKEN_MARKER', 0, '12', '10', 1, 12]; + yield 'total time budget' => ['404', 0, '30', '30', 1, 6]; + yield 'zero attempts' => ['200', 0, '0', '10', 1, 0]; + yield 'unbounded attempts' => ['200', 0, '31', '10', 1, 0]; + yield 'invalid delay' => ['200', 0, '12', 'TOKEN_MARKER', 1, 0]; + yield 'zero delay' => ['200', 0, '12', '0', 1, 0]; + yield 'unbounded delay' => ['200', 0, '12', '31', 1, 0]; + } + + #[Test] + #[DataProvider('responses')] + public function deploymentCheckWillPreserveBoundedFailureSemantics(string $responses, int $curlExit, string $attempts, string $delay, int $expectedExit, int $expectedCalls): void + { + [$exit, $output, $calls] = $this->runAction($responses, $curlExit, $attempts, $delay); + self::assertSame($expectedExit, $exit, $output); + self::assertCount($expectedCalls, $calls); + self::assertStringNotContainsString('TOKEN_MARKER', $output); + foreach ($calls as $call) { + self::assertStringContainsString('--connect-timeout 5 --max-time ', $call); + } + } + + #[Test] + public function deploymentCheckWillVerifyEveryConfiguredPath(): void + { + [$exit, $output, $calls] = $this->runAction('200', 0, '12', '10', "/|Index\n/coverage/|Coverage"); + self::assertSame(0, $exit, $output); + self::assertCount(2, $calls); + self::assertStringContainsString('https://example.test/coverage/', $calls[1]); + } + + #[Test] + public function deploymentChecksWillShareOneBudgetAcrossPaths(): void + { + [$exit, $output, $calls] = $this->runAction('404 200 404', 0, '30', '30', "/|Index\n/coverage/|Coverage"); + self::assertSame(1, $exit); + self::assertCount(7, $calls); + self::assertStringContainsString('180-second total budget', $output); + } + + #[Test] + public function finalTransferWillBeLimitedToTheRemainingBudget(): void + { + [$exit, $output, $calls] = $this->runAction('404', 0, '30', '29'); + self::assertSame(1, $exit); + self::assertCount(7, $calls); + self::assertStringContainsString('--max-time 6 ', $calls[6]); + self::assertStringContainsString('180-second total budget', $output); + } + + /** @return array{int, string, list} */ + private function runAction(string $responses, int $curlExit, string $attempts, string $delay, string $checks = '/|Index'): array + { + $directory = sys_get_temp_dir() . '/pages-check-' . bin2hex(random_bytes(8)); + mkdir($directory); + try { + file_put_contents($directory . '/curl', <<<'BASH' +#!/usr/bin/env bash +set -eu +printf '%s\n' "$*" >> "$FIXTURE/calls" +count=$(wc -l < "$FIXTURE/calls") +read -r -a responses <<< "$RESPONSES" +index=$((count-1)) +if ((index >= ${#responses[@]})); then index=$((${#responses[@]}-1)); fi +printf '%s' "${responses[$index]}" +echo TOKEN_MARKER >&2 +exit "$CURL_EXIT" +BASH); + chmod($directory . '/curl', 0o700); + file_put_contents($directory . '/calls', ''); + $script = __DIR__ . '/../../.github/actions/github-pages/verify-deployment/run.sh'; + $command = 'sleep() { SECONDS=$((SECONDS + $1)); }; source ' . escapeshellarg($script); + $process = proc_open('bash -c ' . escapeshellarg($command), [['pipe', 'r'], ['pipe', 'w'], ['redirect', 1]], $pipes, null, array_merge(getenv(), [ + 'PATH' => $directory . ':' . getenv('PATH'), + 'FIXTURE' => $directory, + 'RESPONSES' => $responses, + 'CURL_EXIT' => (string) $curlExit, + 'INPUT_BASE_URL' => 'https://example.test', + 'INPUT_TITLE' => 'Deployment failed', + 'INPUT_CHECKS' => $checks, + 'INPUT_ATTEMPTS' => $attempts, + 'INPUT_DELAY' => $delay, + ])); + self::assertIsResource($process); + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + fclose($pipes[1]); + $exit = proc_close($process); + $calls = trim(file_get_contents($directory . '/calls')); + + return [$exit, $output, $calls === '' ? [] : explode("\n", $calls)]; + } finally { + foreach (['curl', 'calls'] as $file) { + if (is_file($directory . '/' . $file)) { unlink($directory . '/' . $file); } + } + rmdir($directory); + } + } +} From 6cdef07eed69367e21c7f42391e4dfe924579855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 21 Oct 2026 15:52:34 -0300 Subject: [PATCH 2/4] test: allow elapsed time in remaining transfer budget --- tests/GitHubActions/PagesDeploymentActionTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/GitHubActions/PagesDeploymentActionTest.php b/tests/GitHubActions/PagesDeploymentActionTest.php index a81ee079af..f834d7fbe1 100644 --- a/tests/GitHubActions/PagesDeploymentActionTest.php +++ b/tests/GitHubActions/PagesDeploymentActionTest.php @@ -90,7 +90,7 @@ public function finalTransferWillBeLimitedToTheRemainingBudget(): void [$exit, $output, $calls] = $this->runAction('404', 0, '30', '29'); self::assertSame(1, $exit); self::assertCount(7, $calls); - self::assertStringContainsString('--max-time 6 ', $calls[6]); + self::assertMatchesRegularExpression('/--max-time [1-6] /', $calls[6]); self::assertStringContainsString('180-second total budget', $output); } From 96e0040375302ffa2da2b6da4278f28a814746ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 21 Oct 2026 17:54:53 -0300 Subject: [PATCH 3/4] fix(ci): identify deployment checks on budget expiry --- .github/actions/github-pages/verify-deployment/run.sh | 4 ++-- tests/GitHubActions/PagesDeploymentActionTest.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) mode change 100755 => 100644 .github/actions/github-pages/verify-deployment/run.sh diff --git a/.github/actions/github-pages/verify-deployment/run.sh b/.github/actions/github-pages/verify-deployment/run.sh old mode 100755 new mode 100644 index 79e14cbc1d..b5b81d80f7 --- a/.github/actions/github-pages/verify-deployment/run.sh +++ b/.github/actions/github-pages/verify-deployment/run.sh @@ -21,7 +21,7 @@ check_url() { for ((attempt = 1; attempt <= attempts; attempt++)); do remaining=$((deadline - SECONDS)) if ((remaining <= 0)); then - echo '::error::Deployment health checks exceeded the 180-second total budget.' + echo "::error title=${title}::${label} was not verified: deployment health checks exceeded the 180-second total budget." return 1 fi transfer_timeout=$((remaining < 10 ? remaining : 10)) @@ -31,7 +31,7 @@ check_url() { http_status="$(curl --silent --output /dev/null --location --connect-timeout 5 --max-time "$transfer_timeout" --write-out '%{http_code}' "$url" 2>/dev/null)" || curl_status=$? if ((SECONDS >= deadline)); then - echo '::error::Deployment health checks exceeded the 180-second total budget.' + echo "::error title=${title}::${label} was not verified: deployment health checks exceeded the 180-second total budget." return 1 fi if ((curl_status == 0)) && [[ "$http_status" =~ ^[23][0-9][0-9]$ ]]; then diff --git a/tests/GitHubActions/PagesDeploymentActionTest.php b/tests/GitHubActions/PagesDeploymentActionTest.php index f834d7fbe1..2c81156ec2 100644 --- a/tests/GitHubActions/PagesDeploymentActionTest.php +++ b/tests/GitHubActions/PagesDeploymentActionTest.php @@ -81,7 +81,7 @@ public function deploymentChecksWillShareOneBudgetAcrossPaths(): void [$exit, $output, $calls] = $this->runAction('404 200 404', 0, '30', '30', "/|Index\n/coverage/|Coverage"); self::assertSame(1, $exit); self::assertCount(7, $calls); - self::assertStringContainsString('180-second total budget', $output); + self::assertStringContainsString('::error title=Deployment failed::Coverage was not verified: deployment health checks exceeded the 180-second total budget.', $output); } #[Test] @@ -91,7 +91,7 @@ public function finalTransferWillBeLimitedToTheRemainingBudget(): void self::assertSame(1, $exit); self::assertCount(7, $calls); self::assertMatchesRegularExpression('/--max-time [1-6] /', $calls[6]); - self::assertStringContainsString('180-second total budget', $output); + self::assertStringContainsString('::error title=Deployment failed::Index was not verified: deployment health checks exceeded the 180-second total budget.', $output); } /** @return array{int, string, list} */ From c37a8a69c43d26ba30da9ae6f7f34030ff74177a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 21 Oct 2026 18:00:16 -0300 Subject: [PATCH 4/4] fix(ci): preserve deployment script executable mode --- .github/actions/github-pages/verify-deployment/run.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 .github/actions/github-pages/verify-deployment/run.sh diff --git a/.github/actions/github-pages/verify-deployment/run.sh b/.github/actions/github-pages/verify-deployment/run.sh old mode 100644 new mode 100755