diff --git a/.gitattributes b/.gitattributes index b9d9594..b5d14c8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ /.* export-ignore +/bin/ export-ignore /tests/ export-ignore /tools/ export-ignore diff --git a/.github/workflows/prune-actions-caches.yml b/.github/workflows/prune-actions-caches.yml new file mode 100644 index 0000000..8d04376 --- /dev/null +++ b/.github/workflows/prune-actions-caches.yml @@ -0,0 +1,19 @@ +name: Prune GitHub Actions Caches + +on: + pull_request: + types: + - closed + +jobs: + prune: + runs-on: ubuntu-24.04 + + steps: + - name: Run prune-cache script + run: | + bin/prune-cache $REPO --pr-branch $BRANCH + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.event.pull_request.number }} diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 08255b4..f864243 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -29,12 +29,15 @@ $finder = Finder::create() ->files() ->in([ + __DIR__.'/.github', + __DIR__.'/bin', __DIR__.'/src', __DIR__.'/tests', __DIR__.'/tools', ]) ->append([ __FILE__, + 'bin/prune-cache', ]) ; diff --git a/bin/prune-cache b/bin/prune-cache new file mode 100755 index 0000000..53d6391 --- /dev/null +++ b/bin/prune-cache @@ -0,0 +1,15 @@ +#!/usr/bin/env php + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +require __DIR__.'/prune-cache.php'; diff --git a/bin/prune-cache.php b/bin/prune-cache.php new file mode 100644 index 0000000..43cc9f0 --- /dev/null +++ b/bin/prune-cache.php @@ -0,0 +1,207 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +// ============================================================================ +// This script flushes the GitHub Actions caches used by closed/merged PRs. +// It works by querying the REST API endpoints for GitHub Actions cache. +// +// @see https://docs.github.com/en/rest/actions/cache?apiVersion=2022-11-28#about-the-cache-in-github-actions +// ============================================================================ + +if ($argc < 2) { + echo "\033[106;30m[USAGE]\033[0m \033[32mphp\033[0m \033[33m.github/prune-cache.php\033[0m [--pr-branch=BRANCH] [--schedule]\n"; + + exit(1); +} + +if ((bool) getenv('GITHUB_ACTIONS') && getenv('GH_TOKEN') === false) { + echo "\033[97;41m[ERROR]\033[0m When running in GitHub Actions, please pass the \033[32mGH_TOKEN\033[0m environment variable.\n"; + + exit(1); +} + +$arguments = $argv; +array_shift($arguments); + +$repository = ''; +$branch = 0; +$onSchedule = false; +$parseOption = true; + +foreach ($arguments as $index => $argument) { + if ('--' === $argument) { + $parseOption = false; + + continue; + } + + if (str_starts_with($argument, '--') && $parseOption) { + if (str_starts_with($argument, '--pr-branch=')) { + $branch = (int) substr($argument, 13); + + continue; + } + + if ('--pr-branch' === $argument) { + $branch = (int) $arguments[$index + 1]; + + continue; + } + + if ('--schedule' === $argument) { + $onSchedule = true; + } + + continue; + } + + if (0 === $index) { + $repository = $argument; + + continue; + } +} + +$activeCacheUsageCommand = [ + 'gh api', + '-H "Accept: application/vnd.github+json"', + '-H "X-GitHub-Api-Version: 2022-11-28"', + sprintf('/repos/%s/actions/cache/usage', $repository), + '2>/dev/null', +]; +$cacheUsageOutput = (array) json_decode((string) shell_exec(implode(' ', $activeCacheUsageCommand)), true, flags: JSON_THROW_ON_ERROR); + +if ( + isset($cacheUsageOutput['status'], $cacheUsageOutput['message']) + && is_string($cacheUsageOutput['status']) + && is_string($cacheUsageOutput['message']) + && '200' !== $cacheUsageOutput['status'] +) { + echo sprintf( + "\033[97;41m[ERROR]\033[0m %s (HTTP %d)\n", + $cacheUsageOutput['message'], + $cacheUsageOutput['status'], + ); + + exit(1); +} + +assert(isset($cacheUsageOutput['active_caches_count'], $cacheUsageOutput['active_caches_size_in_bytes'])); +$activeCachesCount = (int) $cacheUsageOutput['active_caches_count']; +$activeCachesSize = (float) $cacheUsageOutput['active_caches_size_in_bytes']; + +echo sprintf( + << implode(' ', [ + 'gh api', + '-X GET', + '-H "Accept: application/vnd.github+json"', + '-H "X-GitHub-Api-Version: 2022-11-28"', + '-F per_page=100', + sprintf('-F page=%d', $page), + $branch > 0 ? sprintf('-F ref=refs/pull/%d/merge', $branch) : '', + sprintf('/repos/%s/actions/caches', $repository), + '2>/dev/null', +]); +$cachesDeleteCommand = static fn(string $key, string $ref): string => implode(' ', [ + 'gh api', + '-X DELETE', + '-H "Accept: application/vnd.github+json"', + '-H "X-GitHub-Api-Version: 2022-11-28"', + sprintf('-F ref=%s', $ref), + sprintf('/repos/%s/actions/caches?key=%s', $repository, $key), + '2>/dev/null', +]); + +/** + * @var array{ + * total_count: int, + * actions_caches: list + * } $caches + */ +$caches = json_decode((string) shell_exec($cachesListCommand()), true, flags: JSON_THROW_ON_ERROR); +$counter = 0; +$roundTrips = $caches['total_count'] > 100 ? (int) ceil($caches['total_count'] / 100) : 1; + +for ($page = 2; $page < $roundTrips; ++$page) { + /** + * @var array{ + * total_count: int, + * actions_caches: list + * } $output + */ + $output = json_decode((string) shell_exec($cachesListCommand($page)), true, flags: JSON_THROW_ON_ERROR); + $caches['actions_caches'] = array_merge($caches['actions_caches'], $output['actions_caches']); +} + +foreach ($caches['actions_caches'] as $cache) { + if (preg_match('#refs/pull/\d+/merge#', $cache['ref']) !== 1) { + continue; + } + + $exitCode = 0; + $result = []; + $message = sprintf( + "Deleting cache \033[33m%s\033[0m (\033[31m%s MB\033[0m) on ref \033[32m%s\033[0m...\n", + substr($cache['key'], 0, 50), + number_format($cache['size_in_bytes'] / 1_000_000, 2), + $cache['ref'], + ); + + echo $message; + exec($cachesDeleteCommand($cache['key'], $cache['ref']), $result, $exitCode); + + if (0 === $exitCode) { + echo "\033[1A"; + echo sprintf("\033[%dC", mb_strlen($message) - 27); + echo "\033[32mDone\033[0m"; + echo "\033[1B"; + echo "\033[0G"; + ++$counter; + } +} + +echo sprintf( + "\nDeleted \033[32m%d caches\033[0m from %s.\n", + $counter, + $branch > 0 ? sprintf('PR #%d branch', $branch) : ($onSchedule ? 'merged PR branches' : 'all branches'), +); diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 6b79b9f..53a04b7 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -7,6 +7,8 @@ parameters: level: 9 tmpDir: build/phpstan paths: + - .github + - bin - src - tests - tools @@ -21,8 +23,6 @@ parameters: checkTooWideReturnTypesInProtectedAndPublicMethods: true checkUninitializedProperties: true checkBenevolentUnionTypes: true - reportPossiblyNonexistentGeneralArrayOffset: true - reportPossiblyNonexistentConstantArrayOffset: true reportAlwaysTrueInLastCondition: true reportAnyTypeWideningInVarTag: true checkMissingCallableSignature: true