Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changelog/pages-propagation-budget.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions .github/actions/github-pages/verify-deployment/action.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand Down
58 changes: 37 additions & 21 deletions .github/actions/github-pages/verify-deployment/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 title=${title}::${label} was not verified: 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 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
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
}

Expand All @@ -45,6 +61,6 @@ while IFS='|' read -r relative_path label; do
fi

check_url "${base_url}${relative_path}" "${label}"
done <<EOF
done <<EOF_CHECKS
${INPUT_CHECKS}
EOF
EOF_CHECKS
2 changes: 1 addition & 1 deletion docs/usage/github-actions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ The ``reports.yml`` workflow is responsible for generating technical documentati

**Behavior:**
* **Main Branch**: Runs all checks and deploys the final reports to the root of the ``gh-pages`` branch.
* Runs a post-deploy health check against the published reports index and coverage URLs with retry/backoff to account for Pages propagation.
* Runs a post-deploy health check against the published reports index and coverage URLs with retry/backoff to account for Pages propagation. The legacy health-check action defaults to 12 attempts at 10-second intervals, with a shared 180-second budget across URLs and a 10-second transfer limit (5-second connection limit). Attempts and delay accept integers from 1 to 30; exhaustion remains a failing check, not successful deployment.
* Resolves the workflow PHP version from ``composer.lock`` or ``composer.json`` before installing dependencies.
* Removes ``.dev-tools/cache`` from the publish directory before deployment so repository-local tool caches never leak into GitHub Pages output.
* Appends a run summary with the published docs, coverage, and metrics URLs plus deployment verification status.
Expand Down
145 changes: 145 additions & 0 deletions tests/GitHubActions/PagesDeploymentActionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<?php

declare(strict_types=1);

/**
* Fast Forward Development Tools for PHP projects.
*
* This file is part of fast-forward/dev-tools project.
*
* @author Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
* @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<string, array{string, int, string, string, int, int}> */
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('::error title=Deployment failed::Coverage was not verified: deployment health checks exceeded the 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::assertMatchesRegularExpression('/--max-time [1-6] /', $calls[6]);
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<string>} */
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);
}
}
}
Loading