From b1ed1784892f611e0b9f9006ef2ad40824850282 Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Tue, 6 Oct 2026 15:28:14 +0200 Subject: [PATCH 1/5] Only write the GitHub Actions cache when the builder can export a cache --- CHANGELOG.md | 6 ++++ doc/tasks.md | 5 +++ src/functions.php | 38 ++++++++++++++++++++++- tests/Unit/CacheExportTest.php | 56 ++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/CacheExportTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 018cd5d..7705ac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixed + +* Builds on the `docker` buildx driver no longer fail on GitHub Actions: the GitHub Actions cache is only written when the builder can export a cache. + ## 0.9.0 - 2026-10-06 ### Added diff --git a/doc/tasks.md b/doc/tasks.md index bb0429a..c4bda67 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -272,6 +272,11 @@ What a pull request writes stays with it: its next runs reuse it, the default branch never sees it. The layers it shares with the default branch are not stored twice. +Builds only write it when the builder can export a cache: the `docker` +driver, the default one of the runners, cannot without the containerd image +store, and they then only read it. `docker/setup-buildx-action` sets up a +builder that can, which `castor docker:push` needs anyway. + Buildx reaches that cache with variables Actions only hands to JavaScript actions, so every job that builds has to expose them first, reading the cache needing them as much as writing it: diff --git a/src/functions.php b/src/functions.php index 4980df3..b32680b 100644 --- a/src/functions.php +++ b/src/functions.php @@ -9,6 +9,8 @@ use Castor\Context; use Castor\Descriptor\TaskDescriptor; use Castor\Docker\Attribute\AsDockerComposeBuilder; +use Castor\Docker\Doctor\HostSystemProbe; +use Castor\Docker\Doctor\SystemProbe; use Castor\Docker\Event\DockerComposeBuilderEvent; use Castor\Docker\Event\DockerComposeWriteEvent; use Castor\Docker\Event\RegisterServiceEvent; @@ -1549,7 +1551,8 @@ function generate_compose_file(Context $c, array $services): void // After the listeners, so a domain routed by one of them is resolvable too. add_project_extra_hosts($c, $composeBuilder); - $compose = dispatch(new DockerComposeWriteEvent($c, $composeBuilder->toArray()))->compose; + $compose = drop_unsupported_cache_export($composeBuilder->toArray(), new HostSystemProbe($c)); + $compose = dispatch(new DockerComposeWriteEvent($c, $compose))->compose; file_put_contents( $c->workingDirectory . '/compose.generated.yaml', @@ -1557,6 +1560,39 @@ function generate_compose_file(Context $c, array $services): void ); } +/** + * The "docker" driver exports no cache without the containerd image store, and + * fails the whole build on a cache_to instead of skipping it. + * + * @param array $compose + * + * @return array + */ +function drop_unsupported_cache_export(array $compose, SystemProbe $probe): array +{ + $services = $compose['services'] ?? []; + $exporting = array_filter($services, static fn(array $service): bool => isset($service['build']['cache_to'])); + + if (!$exporting || builder_exports_cache($probe)) { + return $compose; + } + + foreach (array_keys($exporting) as $name) { + unset($compose['services'][$name]['build']['cache_to']); + } + + return $compose; +} + +function builder_exports_cache(SystemProbe $probe): bool +{ + return match ($probe->buildxDriver()) { + null => false, + 'docker' => true === ($probe->dockerInfo()['containerdStore'] ?? null), + default => true, + }; +} + /** * @return array */ diff --git a/tests/Unit/CacheExportTest.php b/tests/Unit/CacheExportTest.php new file mode 100644 index 0000000..bd567cd --- /dev/null +++ b/tests/Unit/CacheExportTest.php @@ -0,0 +1,56 @@ + ['app' => ['build' => [ + 'cache_from' => ['type=gha,scope=app'], + 'cache_to' => ['type=gha,scope=app,mode=max,ignore-error=true'], + ]]]]; + + public function testTheDockerDriverDropsTheCacheExport(): void + { + $probe = new FakeSystemProbe(); + + static::assertSame( + ['services' => ['app' => ['build' => ['cache_from' => ['type=gha,scope=app']]]]], + drop_unsupported_cache_export(self::COMPOSE, $probe), + ); + } + + public function testTheDockerDriverOnTheContainerdStoreKeepsIt(): void + { + $probe = new FakeSystemProbe(); + $probe->dockerInfo = ['containerdStore' => true] + (array) $probe->dockerInfo; + + static::assertSame(self::COMPOSE, drop_unsupported_cache_export(self::COMPOSE, $probe)); + } + + public function testAContainerBuilderKeepsIt(): void + { + $probe = new FakeSystemProbe(); + $probe->buildxDriver = 'docker-container'; + + static::assertSame(self::COMPOSE, drop_unsupported_cache_export(self::COMPOSE, $probe)); + } + + public function testWithoutBuildxItIsDropped(): void + { + $probe = new FakeSystemProbe(); + $probe->buildxDriver = null; + + static::assertArrayNotHasKey('cache_to', drop_unsupported_cache_export(self::COMPOSE, $probe)['services']['app']['build']); + } +} From b01103b1807574df6db8a4031fc8cd6d5158c758 Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Tue, 6 Oct 2026 15:39:57 +0200 Subject: [PATCH 2/5] Drop the GitHub Actions cache a builder cannot read, and let the builds skip writing their cache --- CHANGELOG.md | 6 +- doc/configuration.md | 2 + doc/tasks.md | 21 +++++-- phpstan.neon | 1 + src/functions.php | 69 ++++++++++++++++++++-- tests/Unit/BuildCacheExportTest.php | 91 +++++++++++++++++++++++++++++ tests/Unit/CacheExportTest.php | 35 +++++++++-- 7 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 tests/Unit/BuildCacheExportTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7705ac6..770338c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,13 @@ ## Unreleased +### Added + +* [`build_cache_export`](tasks.md#on-github-actions) context variable and `CASTOR_DOCKER_BUILD_CACHE_EXPORT`: the builds read the caches but write none, e.g. outside of the default branch. + ### Fixed -* Builds on the `docker` buildx driver no longer fail on GitHub Actions: the GitHub Actions cache is only written when the builder can export a cache. +* Builds on the `docker` buildx driver no longer fail on GitHub Actions: the GitHub Actions cache is only used when the builder can export a cache, the registry one otherwise. ## 0.9.0 - 2026-10-06 diff --git a/doc/configuration.md b/doc/configuration.md index 027ff1f..de5a7d5 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -33,6 +33,7 @@ function default_context(): Context | `resolve_domains_via_host` | Whether the containers resolve the project's own public domains, `true` by default — see [below](#resolving-your-own-domains-from-a-container) | | `docker_profiles` | The compose profiles every task activates by default, `['default']` otherwise | | `router_autostart` | Whether `docker:up` starts the global router and `docker:stop` stops it, `true` by default — see [below](#starting-and-stopping-the-router-with-your-projects) | +| `build_cache_export` | Whether the builds write their cache, `true` by default — see [on GitHub Actions](tasks.md#on-github-actions) | | `worktree` | The git worktree this checkout is, detected on boot — see [git worktrees](going-further/worktrees.md) | | `worktree_isolation` | Whether a git worktree runs a stack of its own, `true` by default | | `worktree_directory` | Where `worktree:create` checks a worktree out | @@ -174,4 +175,5 @@ And it reads these from your own environment: | Variable | Role | |----------|------| | `CASTOR_DOCKER_ROUTER_AUTOSTART` | Turns the router autostart on or off for a single command, over the `router_autostart` context variable | +| `CASTOR_DOCKER_BUILD_CACHE_EXPORT` | Turns the export of the build caches on or off, over the `build_cache_export` context variable | | `DOCKER_SOCKET_PATH` | The Docker socket the router watches, before `DOCKER_HOST` and `/var/run/docker.sock` — see [the socket it watches](services/router.md#the-docker-socket-it-watches) | diff --git a/doc/tasks.md b/doc/tasks.md index c4bda67..74278ab 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -272,10 +272,11 @@ What a pull request writes stays with it: its next runs reuse it, the default branch never sees it. The layers it shares with the default branch are not stored twice. -Builds only write it when the builder can export a cache: the `docker` -driver, the default one of the runners, cannot without the containerd image -store, and they then only read it. `docker/setup-buildx-action` sets up a -builder that can, which `castor docker:push` needs anyway. +Builds only use it when the builder can export a cache: the `docker` driver, +the default one of the runners, can neither write nor read it without the +containerd image store, and they then use the registry cache alone. +`docker/setup-buildx-action` sets up a builder that can, which +`castor docker:push` needs anyway. Buildx reaches that cache with variables Actions only hands to JavaScript actions, so every job that builds has to expose them first, reading the cache @@ -296,6 +297,18 @@ Without them buildx skips the cache silently, and the build only uses the registry. A matrix building several variants of the same service has to give each one its own cache name, or the jobs overwrite each other's cache. +Exporting a cache can take longer than the build it saves. To write it from +the default branch only, the pull requests reading it, turn the export off +everywhere else with the `build_cache_export` context variable or +`CASTOR_DOCKER_BUILD_CACHE_EXPORT`, which wins over it: + +```yaml +env: + CASTOR_DOCKER_BUILD_CACHE_EXPORT: ${{ github.ref == 'refs/heads/main' }} +``` + +`castor docker:push` is not affected: it writes the caches it is asked to. + #### Publishing to ghcr.io GitHub attaches a package to a repository in two cases only: a push from a diff --git a/phpstan.neon b/phpstan.neon index 4154bd7..e2fa838 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -13,6 +13,7 @@ parameters: docker_profiles?: string[], resolve_domains_via_host?: bool, router_autostart?: bool, + build_cache_export?: bool, worktree?: ?string, worktree_isolation?: bool, worktree_directory?: string, diff --git a/src/functions.php b/src/functions.php index b32680b..27bf7a2 100644 --- a/src/functions.php +++ b/src/functions.php @@ -1551,7 +1551,13 @@ function generate_compose_file(Context $c, array $services): void // After the listeners, so a domain routed by one of them is resolvable too. add_project_extra_hosts($c, $composeBuilder); - $compose = drop_unsupported_cache_export($composeBuilder->toArray(), new HostSystemProbe($c)); + $compose = $composeBuilder->toArray(); + + if (!is_build_cache_export_enabled($c)) { + $compose = drop_cache_export($compose); + } + + $compose = drop_unsupported_cache_export($compose, new HostSystemProbe($c)); $compose = dispatch(new DockerComposeWriteEvent($c, $compose))->compose; file_put_contents( @@ -1560,9 +1566,47 @@ function generate_compose_file(Context $c, array $services): void ); } +/** + * Off, the builds still read the build caches but write none: a pull request + * then reuses the cache of the default branch without filling the GitHub + * Actions quota with layers only it would use. The environment variable wins + * over the context, so a CI workflow can decide per branch. + */ +function is_build_cache_export_enabled(?Context $c = null): bool +{ + $env = $_SERVER['CASTOR_DOCKER_BUILD_CACHE_EXPORT'] ?? null; + + if (\is_string($env) && '' !== $env) { + $value = filter_var($env, \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE); + + if (null !== $value) { + return $value; + } + } + + $data = ($c ?? context())->data['build_cache_export'] ?? null; + + return \is_bool($data) ? $data : true; +} + +/** + * @param array $compose + * + * @return array + */ +function drop_cache_export(array $compose): array +{ + foreach (array_keys($compose['services'] ?? []) as $name) { + unset($compose['services'][$name]['build']['cache_to']); + } + + return $compose; +} + /** * The "docker" driver exports no cache without the containerd image store, and - * fails the whole build on a cache_to instead of skipping it. + * fails the whole build on a cache_to instead of skipping it. It cannot import + * a GitHub Actions cache either: every build would log an error for it. * * @param array $compose * @@ -1571,14 +1615,27 @@ function generate_compose_file(Context $c, array $services): void function drop_unsupported_cache_export(array $compose, SystemProbe $probe): array { $services = $compose['services'] ?? []; - $exporting = array_filter($services, static fn(array $service): bool => isset($service['build']['cache_to'])); + $usingGha = array_filter($services, static fn(array $service): bool => isset($service['build']['cache_to']) + || array_filter($service['build']['cache_from'] ?? [], static fn(string $cache): bool => str_contains($cache, 'type=gha'))); - if (!$exporting || builder_exports_cache($probe)) { + if (!$usingGha || builder_exports_cache($probe)) { return $compose; } - foreach (array_keys($exporting) as $name) { - unset($compose['services'][$name]['build']['cache_to']); + foreach (array_keys($usingGha) as $name) { + $build = &$compose['services'][$name]['build']; + unset($build['cache_to']); + + $build['cache_from'] = array_values(array_filter( + $build['cache_from'] ?? [], + static fn(string $cache): bool => !str_contains($cache, 'type=gha'), + )); + + if (!$build['cache_from']) { + unset($build['cache_from']); + } + + unset($build); } return $compose; diff --git a/tests/Unit/BuildCacheExportTest.php b/tests/Unit/BuildCacheExportTest.php new file mode 100644 index 0000000..c992dc9 --- /dev/null +++ b/tests/Unit/BuildCacheExportTest.php @@ -0,0 +1,91 @@ +backup = $_SERVER[self::VARIABLE] ?? null; + + unset($_SERVER[self::VARIABLE]); + } + + protected function tearDown(): void + { + if (null === $this->backup) { + unset($_SERVER[self::VARIABLE]); + + return; + } + + $_SERVER[self::VARIABLE] = $this->backup; + } + + private function context(?bool $export): Context + { + /** @var array{build_cache_export?: bool} $data */ + $data = null === $export ? [] : ['build_cache_export' => $export]; + + return new Context($data); + } + + public function testItIsOnByDefault(): void + { + static::assertTrue(is_build_cache_export_enabled($this->context(null))); + } + + public function testTheContextTurnsItOff(): void + { + static::assertFalse(is_build_cache_export_enabled($this->context(false))); + static::assertTrue(is_build_cache_export_enabled($this->context(true))); + } + + /** + * What a workflow sets from the branch it runs on. + */ + public function testTheEnvironmentVariableWinsOverTheContext(): void + { + $_SERVER[self::VARIABLE] = 'false'; + static::assertFalse(is_build_cache_export_enabled($this->context(true))); + + $_SERVER[self::VARIABLE] = 'true'; + static::assertTrue(is_build_cache_export_enabled($this->context(false))); + } + + public function testAnUnreadableValueFallsBackToTheContext(): void + { + $_SERVER[self::VARIABLE] = 'maybe'; + + static::assertFalse(is_build_cache_export_enabled($this->context(false))); + } + + public function testOffTheBuildsStillReadTheCaches(): void + { + $compose = ['services' => ['app' => ['build' => [ + 'cache_from' => ['type=registry,ref=/app:cache', 'type=gha,scope=app'], + 'cache_to' => ['type=gha,scope=app,mode=max,ignore-error=true'], + ]], 'db' => ['image' => 'mariadb']]]; + + static::assertSame( + ['services' => ['app' => ['build' => ['cache_from' => ['type=registry,ref=/app:cache', 'type=gha,scope=app']]], 'db' => ['image' => 'mariadb']]], + drop_cache_export($compose), + ); + } +} diff --git a/tests/Unit/CacheExportTest.php b/tests/Unit/CacheExportTest.php index bd567cd..3a58047 100644 --- a/tests/Unit/CacheExportTest.php +++ b/tests/Unit/CacheExportTest.php @@ -11,25 +11,52 @@ /** * On the "docker" driver without the containerd image store, a cache_to fails * the whole build: the GitHub Actions one added on CI broke every project - * building with the default builder of the runners. + * building with the default builder of the runners. Its GitHub Actions + * cache_from logs an "unknown cache importer" error on every build. */ final class CacheExportTest extends TestCase { private const COMPOSE = ['services' => ['app' => ['build' => [ - 'cache_from' => ['type=gha,scope=app'], + 'cache_from' => ['type=registry,ref=/app:cache', 'type=gha,scope=app'], 'cache_to' => ['type=gha,scope=app,mode=max,ignore-error=true'], ]]]]; - public function testTheDockerDriverDropsTheCacheExport(): void + public function testTheDockerDriverOnlyKeepsTheRegistryCache(): void { $probe = new FakeSystemProbe(); static::assertSame( - ['services' => ['app' => ['build' => ['cache_from' => ['type=gha,scope=app']]]]], + ['services' => ['app' => ['build' => ['cache_from' => ['type=registry,ref=/app:cache']]]]], drop_unsupported_cache_export(self::COMPOSE, $probe), ); } + /** + * With the export turned off, nothing is written but the import error would + * still be logged. + */ + public function testTheDockerDriverDropsAGitHubActionsCacheItOnlyReads(): void + { + $compose = ['services' => ['app' => ['build' => [ + 'cache_from' => ['type=registry,ref=/app:cache', 'type=gha,scope=app'], + ]]]]; + + static::assertSame( + ['services' => ['app' => ['build' => ['cache_from' => ['type=registry,ref=/app:cache']]]]], + drop_unsupported_cache_export($compose, new FakeSystemProbe()), + ); + } + + public function testAServiceLeftWithoutCacheHasNoCacheFrom(): void + { + $compose = ['services' => ['app' => ['build' => [ + 'cache_from' => ['type=gha,scope=app'], + 'cache_to' => ['type=gha,scope=app,mode=max,ignore-error=true'], + ]]]]; + + static::assertSame(['services' => ['app' => ['build' => []]]], drop_unsupported_cache_export($compose, new FakeSystemProbe())); + } + public function testTheDockerDriverOnTheContainerdStoreKeepsIt(): void { $probe = new FakeSystemProbe(); From ce4502a47ddfba0bee232ba82bc288f1eea040e9 Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Tue, 6 Oct 2026 16:12:27 +0200 Subject: [PATCH 3/5] Build each service on its own, in parallel, so services sharing a stage keep their build cache --- CHANGELOG.md | 1 + doc/tasks.md | 6 +++ src/tasks.php | 136 +++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 120 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 770338c..dde1dcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +* `castor docker:build` and `castor docker:push` build each service on its own, in parallel: in a single bake session BuildKit randomly lost the build cache of all but one of the services sharing a stage ([moby/buildkit#6418](https://github.com/moby/buildkit/issues/6418)). * Builds on the `docker` buildx driver no longer fail on GitHub Actions: the GitHub Actions cache is only used when the builder can export a cache, the registry one otherwise. ## 0.9.0 - 2026-10-06 diff --git a/doc/tasks.md b/doc/tasks.md index 74278ab..88e4b69 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -58,6 +58,12 @@ castor docker:build app # a single service castor docker:build --profiles builder # restrict to a profile ``` +Each service builds on its own, all of them in parallel, and its log is +printed once it is done. Built together, the services sharing a stage — the +`php-base` of an application and of its builder — randomly lost the build +cache of all but one of them ([moby/buildkit#6418](https://github.com/moby/buildkit/issues/6418)). +`castor docker:push` builds its targets the same way. + Alias: `castor build`. ### `castor docker:up` diff --git a/src/tasks.php b/src/tasks.php index bb6af59..ddc6459 100644 --- a/src/tasks.php +++ b/src/tasks.php @@ -19,6 +19,7 @@ use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Helper\TableSeparator; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Terminal; use Symfony\Component\Process\Exception\ExceptionInterface; use Symfony\Component\Process\Process; @@ -27,6 +28,7 @@ use function Castor\capture; use function Castor\context; use function Castor\io; +use function Castor\parallel; use function Castor\variable; use function Castor\run; @@ -56,16 +58,88 @@ function build( $command[] = "{$key}={$value}"; } - if ($service) { - $command[] = $service; - } - if (!$profiles) { $profiles = get_default_profiles(); $profiles[] = 'builder'; } - docker_compose($command, profiles: $profiles); + $services = $service ? [$service] : get_buildable_services($profiles); + + if (\count($services) < 2) { + docker_compose([...$command, ...$services], profiles: $profiles); + + return; + } + + build_separately($command, $services, $profiles); +} + +/** + * One build per service, run in parallel: in a single session BuildKit merges + * the stages services share, then races the caches of those services over them + * and randomly loses the steps of all but one (moby/buildkit#6418). + * + * @param list $command + * @param list $services + * @param list $profiles + */ +function build_separately(array $command, array $services, array $profiles): void +{ + $c = context()->withQuiet()->withAllowFailure()->withTty(false)->withPty(false); + + $processes = parallel(...array_map( + static fn(string $service): \Closure => static fn(): Process => docker_compose([...$command, $service], $c, $profiles, 'plain'), + $services, + )); + + report_separate_builds($services, $processes); +} + +/** + * @param list $services + * @param array $processes + */ +function report_separate_builds(array $services, array $processes): void +{ + $failed = []; + + foreach ($services as $i => $service) { + $process = $processes[$i]; + + if (!$process instanceof Process) { + $failed[] = $service; + + continue; + } + + io()->section($service); + io()->write($process->getOutput() . $process->getErrorOutput(), false, OutputInterface::OUTPUT_RAW); + + if (!$process->isSuccessful()) { + $failed[] = $service; + } + } + + if ($failed) { + throw new \RuntimeException(\sprintf('The build of %s failed.', implode(', ', $failed))); + } +} + +/** + * @param list $profiles + * + * @return list + */ +function get_buildable_services(array $profiles): array +{ + $config = json_decode( + docker_compose(['config', '--format', 'json'], context()->withQuiet(), profiles: $profiles)->getOutput(), + true, + ); + + $services = \is_array($config['services'] ?? null) ? $config['services'] : []; + + return array_map('strval', array_keys(array_filter($services, static fn(mixed $config): bool => \is_array($config) && isset($config['build'])))); } /** @@ -730,16 +804,19 @@ function push( // apply: bake sees every service that has a "build". $command = ['docker', 'buildx', 'bake', '-f', $c->workingDirectory . '/compose.yaml']; + $options = []; + foreach ($targets as $service => $cacheEntries) { $images = []; + $options[$service] = []; foreach ($cacheEntries as $cacheTo) { $cacheRef = get_cache_reference($cacheTo); - $command[] = '--set'; + $options[$service][] = '--set'; // A GitHub Actions cache only backs the registry one up: its // outage must not keep the images from being pushed. - $command[] = \sprintf('%s.cache-to=%s,mode=max%s', $service, $cacheTo, str_contains($cacheTo, 'type=gha') ? ',ignore-error=true' : ''); + $options[$service][] = \sprintf('%s.cache-to=%s,mode=max%s', $service, $cacheTo, str_contains($cacheTo, 'type=gha') ? ',ignore-error=true' : ''); // "type=gha" or "type=local" names no repository to publish an // image to. @@ -763,32 +840,26 @@ function push( } foreach ($images as $image) { - $command[] = '--set'; - $command[] = \sprintf('%s.tags=%s', $service, $image); + $options[$service][] = '--set'; + $options[$service][] = \sprintf('%s.tags=%s', $service, $image); } // Per target, rather than a global "--push": the services whose cache // is not a registry one must not be pushed anywhere. - $command[] = '--set'; - $command[] = \sprintf('%s.output=type=registry', $service); + $options[$service][] = '--set'; + $options[$service][] = \sprintf('%s.output=type=registry', $service); if (null !== $source) { - $command[] = '--set'; - $command[] = \sprintf('%s.labels.org.opencontainers.image.source=%s', $service, $source); + $options[$service][] = '--set'; + $options[$service][] = \sprintf('%s.labels.org.opencontainers.image.source=%s', $service, $source); } if (null !== $revision) { - $command[] = '--set'; - $command[] = \sprintf('%s.labels.org.opencontainers.image.revision=%s', $service, $revision); + $options[$service][] = '--set'; + $options[$service][] = \sprintf('%s.labels.org.opencontainers.image.revision=%s', $service, $revision); } } - if ($dryRun) { - $command[] = '--print'; - } - - // Naming the targets keeps out the services that build without a cache: - // bake's default group is every buildable service of the project. - run([...$command, ...array_keys($targets)], context: $c->withEnvironment([ + $c = $c->withEnvironment([ // bake does not go through docker_compose(), so the variables the // generated compose file interpolates have to be given to it here. 'COMPOSE_PROJECT_NAME' => get_project_name($c), @@ -797,7 +868,26 @@ function push( // The build contexts live in the plugin, outside of the project // directory, which bake asks to confirm on every run otherwise. 'BUILDX_BAKE_ENTITLEMENTS_FS' => '0', - ])); + ]); + + // Naming the targets keeps out the services that build without a cache: + // bake's default group is every buildable service of the project. + if ($dryRun) { + run([...$command, ...array_merge(...array_values($options)), '--print', ...array_keys($targets)], context: $c); + + return; + } + + // One bake per target, for the reason build_separately() gives. + $quiet = $c->withQuiet()->withAllowFailure()->withTty(false)->withPty(false); + $services = array_keys($targets); + + $processes = parallel(...array_map( + static fn(string $service): \Closure => static fn(): Process => run([...$command, ...$options[$service], '--progress', 'plain', $service], context: $quiet), + $services, + )); + + report_separate_builds($services, $processes); } /** From b9912389ca09fb878b6222caa0cc5a27de60ef5e Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Tue, 6 Oct 2026 16:41:55 +0200 Subject: [PATCH 4/5] Drop the automatic GitHub Actions cache, and make the build of each service on its own an option --- CHANGELOG.md | 7 +- doc/configuration.md | 3 +- doc/tasks.md | 66 ++++------------- phpstan.neon | 2 +- src/Service/Builder/BuildBuilder.php | 18 ----- src/functions.php | 95 +------------------------ src/tasks.php | 25 +++++-- tests/Integration/DockerPushTest.php | 29 -------- tests/Unit/BuildCacheExportTest.php | 91 ----------------------- tests/Unit/Builder/BuildBuilderTest.php | 33 +++------ tests/Unit/CacheExportTest.php | 83 --------------------- 11 files changed, 45 insertions(+), 407 deletions(-) delete mode 100644 tests/Unit/BuildCacheExportTest.php delete mode 100644 tests/Unit/CacheExportTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index dde1dcf..069e65f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,11 @@ ### Added -* [`build_cache_export`](tasks.md#on-github-actions) context variable and `CASTOR_DOCKER_BUILD_CACHE_EXPORT`: the builds read the caches but write none, e.g. outside of the default branch. +* [`docker:build --parallel`](tasks.md#castor-dockerbuild) and the `build_parallel` context variable: each service builds on its own, in parallel, so services sharing a stage keep their build cache ([moby/buildkit#6418](https://github.com/moby/buildkit/issues/6418)). `docker:push` follows them too. -### Fixed +### Removed -* `castor docker:build` and `castor docker:push` build each service on its own, in parallel: in a single bake session BuildKit randomly lost the build cache of all but one of the services sharing a stage ([moby/buildkit#6418](https://github.com/moby/buildkit/issues/6418)). -* Builds on the `docker` buildx driver no longer fail on GitHub Actions: the GitHub Actions cache is only used when the builder can export a cache, the registry one otherwise. +* The GitHub Actions cache 0.9.0 added to every build on GitHub Actions. The registry cache misses it made up for come from a BuildKit race between caches describing the same stages, which a second cache only makes worse, and the `docker` driver of the runners failed the build exporting it. A service still takes any cache given to its `cacheFrom()`. ## 0.9.0 - 2026-10-06 diff --git a/doc/configuration.md b/doc/configuration.md index de5a7d5..18abe46 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -33,7 +33,7 @@ function default_context(): Context | `resolve_domains_via_host` | Whether the containers resolve the project's own public domains, `true` by default — see [below](#resolving-your-own-domains-from-a-container) | | `docker_profiles` | The compose profiles every task activates by default, `['default']` otherwise | | `router_autostart` | Whether `docker:up` starts the global router and `docker:stop` stops it, `true` by default — see [below](#starting-and-stopping-the-router-with-your-projects) | -| `build_cache_export` | Whether the builds write their cache, `true` by default — see [on GitHub Actions](tasks.md#on-github-actions) | +| `build_parallel` | Whether the services are built on their own, in parallel, so services sharing a stage keep their build cache, `false` by default — see [`docker:build`](tasks.md#castor-dockerbuild) | | `worktree` | The git worktree this checkout is, detected on boot — see [git worktrees](going-further/worktrees.md) | | `worktree_isolation` | Whether a git worktree runs a stack of its own, `true` by default | | `worktree_directory` | Where `worktree:create` checks a worktree out | @@ -175,5 +175,4 @@ And it reads these from your own environment: | Variable | Role | |----------|------| | `CASTOR_DOCKER_ROUTER_AUTOSTART` | Turns the router autostart on or off for a single command, over the `router_autostart` context variable | -| `CASTOR_DOCKER_BUILD_CACHE_EXPORT` | Turns the export of the build caches on or off, over the `build_cache_export` context variable | | `DOCKER_SOCKET_PATH` | The Docker socket the router watches, before `DOCKER_HOST` and `/var/run/docker.sock` — see [the socket it watches](services/router.md#the-docker-socket-it-watches) | diff --git a/doc/tasks.md b/doc/tasks.md index 88e4b69..b84e134 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -58,11 +58,18 @@ castor docker:build app # a single service castor docker:build --profiles builder # restrict to a profile ``` -Each service builds on its own, all of them in parallel, and its log is -printed once it is done. Built together, the services sharing a stage — the -`php-base` of an application and of its builder — randomly lost the build -cache of all but one of them ([moby/buildkit#6418](https://github.com/moby/buildkit/issues/6418)). -`castor docker:push` builds its targets the same way. +`--parallel` builds each service on its own, all of them in parallel, and +prints the log of each one once it is done. Built together, services sharing a +stage — an application and its builder sharing `php-base` — race their build +caches, and BuildKit randomly rebuilds the steps of all but one of them +([moby/buildkit#6418](https://github.com/moby/buildkit/issues/6418)). Turn it on +for a whole project with the `build_parallel` context variable: `castor docker:up +--build` and `castor docker:push` follow it too, `--no-parallel` turns it off +for a single command. + +```bash +castor docker:build --parallel +``` Alias: `castor build`. @@ -266,55 +273,6 @@ castor docker:push --tag "$(git rev-parse --short HEAD)" castor docker:push --dry-run ``` -#### On GitHub Actions - -When `GITHUB_ACTIONS` is set, every registry cache gets a -[GitHub Actions cache](https://docs.docker.com/build/cache/backends/gha/) next -to it, scoped by the cache name: every build reads both and writes the GitHub -one, `castor docker:push` writes both. BuildKit sometimes misses layers of a -registry cache without a word, the second one makes up for it. - -What a pull request writes stays with it: its next runs reuse it, the default -branch never sees it. The layers it shares with the default branch are not -stored twice. - -Builds only use it when the builder can export a cache: the `docker` driver, -the default one of the runners, can neither write nor read it without the -containerd image store, and they then use the registry cache alone. -`docker/setup-buildx-action` sets up a builder that can, which -`castor docker:push` needs anyway. - -Buildx reaches that cache with variables Actions only hands to JavaScript -actions, so every job that builds has to expose them first, reading the cache -needing them as much as writing it: - -```yaml -- uses: actions/github-script@v9 - with: - script: | - core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN) - core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL) - core.exportVariable('ACTIONS_CACHE_SERVICE_V2', process.env.ACTIONS_CACHE_SERVICE_V2 || 'true') - -- run: castor docker:build -``` - -Without them buildx skips the cache silently, and the build only uses the -registry. A matrix building several variants of the same service has to give -each one its own cache name, or the jobs overwrite each other's cache. - -Exporting a cache can take longer than the build it saves. To write it from -the default branch only, the pull requests reading it, turn the export off -everywhere else with the `build_cache_export` context variable or -`CASTOR_DOCKER_BUILD_CACHE_EXPORT`, which wins over it: - -```yaml -env: - CASTOR_DOCKER_BUILD_CACHE_EXPORT: ${{ github.ref == 'refs/heads/main' }} -``` - -`castor docker:push` is not affected: it writes the caches it is asked to. - #### Publishing to ghcr.io GitHub attaches a package to a repository in two cases only: a push from a diff --git a/phpstan.neon b/phpstan.neon index e2fa838..2c296c6 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -13,7 +13,7 @@ parameters: docker_profiles?: string[], resolve_domains_via_host?: bool, router_autostart?: bool, - build_cache_export?: bool, + build_parallel?: bool, worktree?: ?string, worktree_isolation?: bool, worktree_directory?: string, diff --git a/src/Service/Builder/BuildBuilder.php b/src/Service/Builder/BuildBuilder.php index 38b4f2b..68c31fd 100644 --- a/src/Service/Builder/BuildBuilder.php +++ b/src/Service/Builder/BuildBuilder.php @@ -26,8 +26,6 @@ final class BuildBuilder private array $additionalContexts = []; /** @var array */ private array $cacheFrom = []; - /** @var array */ - private array $cacheTo = []; public function __construct(private readonly ServiceBuilder $serviceBuilder) {} @@ -40,7 +38,6 @@ public function clone(ServiceBuilder $serviceBuilder): self $new->args = $this->args; $new->additionalContexts = $this->additionalContexts; $new->cacheFrom = $this->cacheFrom; - $new->cacheTo = $this->cacheTo; return $new; } @@ -101,7 +98,6 @@ public function cacheFrom(string $image): self public function noCacheFrom(): self { $this->cacheFrom = []; - $this->cacheTo = []; return $this; } @@ -109,16 +105,6 @@ public function noCacheFrom(): self public function withRegistryCache(string $image): self { $this->cacheFrom = ['type=registry,ref=${REGISTRY:-}/' . $image . ':cache']; - $this->cacheTo = []; - - // BuildKit silently misses layers of a registry cache on CI - // (jolicode/docker-starter#430), the GitHub Actions one backs it up. - // Every build writes it: GitHub keeps what a pull request writes to - // that pull request, whose next runs reuse it. - if ('true' === getenv('GITHUB_ACTIONS')) { - $this->cacheFrom[] = 'type=gha,scope=' . $image; - $this->cacheTo = ['type=gha,scope=' . $image . ',mode=max,ignore-error=true']; - } return $this; } @@ -159,10 +145,6 @@ public function toArray(): array $build['cache_from'] = $this->cacheFrom; } - if (!empty($this->cacheTo)) { - $build['cache_to'] = $this->cacheTo; - } - return $build; } } diff --git a/src/functions.php b/src/functions.php index 27bf7a2..4980df3 100644 --- a/src/functions.php +++ b/src/functions.php @@ -9,8 +9,6 @@ use Castor\Context; use Castor\Descriptor\TaskDescriptor; use Castor\Docker\Attribute\AsDockerComposeBuilder; -use Castor\Docker\Doctor\HostSystemProbe; -use Castor\Docker\Doctor\SystemProbe; use Castor\Docker\Event\DockerComposeBuilderEvent; use Castor\Docker\Event\DockerComposeWriteEvent; use Castor\Docker\Event\RegisterServiceEvent; @@ -1551,14 +1549,7 @@ function generate_compose_file(Context $c, array $services): void // After the listeners, so a domain routed by one of them is resolvable too. add_project_extra_hosts($c, $composeBuilder); - $compose = $composeBuilder->toArray(); - - if (!is_build_cache_export_enabled($c)) { - $compose = drop_cache_export($compose); - } - - $compose = drop_unsupported_cache_export($compose, new HostSystemProbe($c)); - $compose = dispatch(new DockerComposeWriteEvent($c, $compose))->compose; + $compose = dispatch(new DockerComposeWriteEvent($c, $composeBuilder->toArray()))->compose; file_put_contents( $c->workingDirectory . '/compose.generated.yaml', @@ -1566,90 +1557,6 @@ function generate_compose_file(Context $c, array $services): void ); } -/** - * Off, the builds still read the build caches but write none: a pull request - * then reuses the cache of the default branch without filling the GitHub - * Actions quota with layers only it would use. The environment variable wins - * over the context, so a CI workflow can decide per branch. - */ -function is_build_cache_export_enabled(?Context $c = null): bool -{ - $env = $_SERVER['CASTOR_DOCKER_BUILD_CACHE_EXPORT'] ?? null; - - if (\is_string($env) && '' !== $env) { - $value = filter_var($env, \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE); - - if (null !== $value) { - return $value; - } - } - - $data = ($c ?? context())->data['build_cache_export'] ?? null; - - return \is_bool($data) ? $data : true; -} - -/** - * @param array $compose - * - * @return array - */ -function drop_cache_export(array $compose): array -{ - foreach (array_keys($compose['services'] ?? []) as $name) { - unset($compose['services'][$name]['build']['cache_to']); - } - - return $compose; -} - -/** - * The "docker" driver exports no cache without the containerd image store, and - * fails the whole build on a cache_to instead of skipping it. It cannot import - * a GitHub Actions cache either: every build would log an error for it. - * - * @param array $compose - * - * @return array - */ -function drop_unsupported_cache_export(array $compose, SystemProbe $probe): array -{ - $services = $compose['services'] ?? []; - $usingGha = array_filter($services, static fn(array $service): bool => isset($service['build']['cache_to']) - || array_filter($service['build']['cache_from'] ?? [], static fn(string $cache): bool => str_contains($cache, 'type=gha'))); - - if (!$usingGha || builder_exports_cache($probe)) { - return $compose; - } - - foreach (array_keys($usingGha) as $name) { - $build = &$compose['services'][$name]['build']; - unset($build['cache_to']); - - $build['cache_from'] = array_values(array_filter( - $build['cache_from'] ?? [], - static fn(string $cache): bool => !str_contains($cache, 'type=gha'), - )); - - if (!$build['cache_from']) { - unset($build['cache_from']); - } - - unset($build); - } - - return $compose; -} - -function builder_exports_cache(SystemProbe $probe): bool -{ - return match ($probe->buildxDriver()) { - null => false, - 'docker' => true === ($probe->dockerInfo()['containerdStore'] ?? null), - default => true, - }; -} - /** * @return array */ diff --git a/src/tasks.php b/src/tasks.php index ddc6459..9a9ccdc 100644 --- a/src/tasks.php +++ b/src/tasks.php @@ -8,6 +8,7 @@ use Castor\Attribute\AsOption; use Castor\Attribute\AsRawTokens; use Castor\Attribute\AsTask; +use Castor\Context; use Castor\Console\Output\VerbosityLevel; use Castor\Docker\Doctor\Doctor; use Castor\Docker\Doctor\HostSystemProbe; @@ -41,6 +42,8 @@ function build( ?string $service = null, #[AsOption(mode: InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED)] array $profiles = [], + #[AsOption(description: 'Build each service on its own, in parallel, so services sharing a stage keep their build cache', mode: InputOption::VALUE_NONE | InputOption::VALUE_NEGATABLE)] + ?bool $parallel = null, ): void { io()->title('Building infrastructure'); @@ -63,10 +66,10 @@ function build( $profiles[] = 'builder'; } - $services = $service ? [$service] : get_buildable_services($profiles); + $services = $service || !is_build_parallel($parallel) ? [] : get_buildable_services($profiles); if (\count($services) < 2) { - docker_compose([...$command, ...$services], profiles: $profiles); + docker_compose($service ? [...$command, $service] : $command, profiles: $profiles); return; } @@ -74,6 +77,14 @@ function build( build_separately($command, $services, $profiles); } +/** + * Off by default: the logs of the services only show once each one is built. + */ +function is_build_parallel(?bool $option = null, ?Context $c = null): bool +{ + return $option ?? (($c ?? context())->data['build_parallel'] ?? false); +} + /** * One build per service, run in parallel: in a single session BuildKit merges * the stages services share, then races the caches of those services over them @@ -763,6 +774,8 @@ function push( bool $dryRun = false, #[AsOption(description: 'The tag the images are published under')] string $tag = 'latest', + #[AsOption(description: 'Build each target on its own, in parallel, so targets sharing a stage keep their build cache', mode: InputOption::VALUE_NONE | InputOption::VALUE_NEGATABLE)] + ?bool $parallel = null, ): void { $registry = variable('registry'); @@ -814,9 +827,7 @@ function push( $cacheRef = get_cache_reference($cacheTo); $options[$service][] = '--set'; - // A GitHub Actions cache only backs the registry one up: its - // outage must not keep the images from being pushed. - $options[$service][] = \sprintf('%s.cache-to=%s,mode=max%s', $service, $cacheTo, str_contains($cacheTo, 'type=gha') ? ',ignore-error=true' : ''); + $options[$service][] = \sprintf('%s.cache-to=%s,mode=max', $service, $cacheTo); // "type=gha" or "type=local" names no repository to publish an // image to. @@ -872,8 +883,8 @@ function push( // Naming the targets keeps out the services that build without a cache: // bake's default group is every buildable service of the project. - if ($dryRun) { - run([...$command, ...array_merge(...array_values($options)), '--print', ...array_keys($targets)], context: $c); + if ($dryRun || !is_build_parallel($parallel)) { + run([...$command, ...array_merge(...array_values($options)), ...($dryRun ? ['--print'] : []), ...array_keys($targets)], context: $c); return; } diff --git a/tests/Integration/DockerPushTest.php b/tests/Integration/DockerPushTest.php index 63a4958..192f45c 100644 --- a/tests/Integration/DockerPushTest.php +++ b/tests/Integration/DockerPushTest.php @@ -74,35 +74,6 @@ public function testEveryCacheOfAServiceIsPushed(): void static::assertSame(['registry.invalid/ns/mirrored:latest', 'registry.invalid/ns/mirror:latest'], $target['tags']); } - /** - * The GitHub Actions cache comes on top of the registry one, which the - * images still go to. The cache_to every build writes the GitHub one with - * gives way to the one exporting both. - */ - public function testOnGithubActionsTheCacheIsAlsoPushedToGithub(): void - { - $castor = $this->castorOrSkip(); - - $push = $this->castor($castor, ['docker:push', '--dry-run'], [ - 'CASTOR_DOCKER_TEST_REGISTRY' => 'registry.invalid/ns', - 'GITHUB_ACTIONS' => 'true', - ]); - - static::assertTrue($push->isSuccessful(), "castor docker:push --dry-run failed:\n" . $push->getOutput() . $push->getErrorOutput()); - - $target = json_decode($push->getOutput(), true)['target']['cached']; - - static::assertSame([ - ['ref' => 'registry.invalid/ns/cached:cache', 'type' => 'registry'], - ['scope' => 'cached', 'type' => 'gha'], - ], $target['cache-from']); - static::assertSame([ - ['mode' => 'max', 'ref' => 'registry.invalid/ns/cached:cache', 'type' => 'registry'], - ['ignore-error' => 'true', 'mode' => 'max', 'scope' => 'cached', 'type' => 'gha'], - ], $target['cache-to']); - static::assertSame(['registry.invalid/ns/cached:latest'], $target['tags']); - } - /** * The cache alone carries no label, so a registry reading one to attach a * package to a repository — ghcr.io — would file every push under nobody. diff --git a/tests/Unit/BuildCacheExportTest.php b/tests/Unit/BuildCacheExportTest.php deleted file mode 100644 index c992dc9..0000000 --- a/tests/Unit/BuildCacheExportTest.php +++ /dev/null @@ -1,91 +0,0 @@ -backup = $_SERVER[self::VARIABLE] ?? null; - - unset($_SERVER[self::VARIABLE]); - } - - protected function tearDown(): void - { - if (null === $this->backup) { - unset($_SERVER[self::VARIABLE]); - - return; - } - - $_SERVER[self::VARIABLE] = $this->backup; - } - - private function context(?bool $export): Context - { - /** @var array{build_cache_export?: bool} $data */ - $data = null === $export ? [] : ['build_cache_export' => $export]; - - return new Context($data); - } - - public function testItIsOnByDefault(): void - { - static::assertTrue(is_build_cache_export_enabled($this->context(null))); - } - - public function testTheContextTurnsItOff(): void - { - static::assertFalse(is_build_cache_export_enabled($this->context(false))); - static::assertTrue(is_build_cache_export_enabled($this->context(true))); - } - - /** - * What a workflow sets from the branch it runs on. - */ - public function testTheEnvironmentVariableWinsOverTheContext(): void - { - $_SERVER[self::VARIABLE] = 'false'; - static::assertFalse(is_build_cache_export_enabled($this->context(true))); - - $_SERVER[self::VARIABLE] = 'true'; - static::assertTrue(is_build_cache_export_enabled($this->context(false))); - } - - public function testAnUnreadableValueFallsBackToTheContext(): void - { - $_SERVER[self::VARIABLE] = 'maybe'; - - static::assertFalse(is_build_cache_export_enabled($this->context(false))); - } - - public function testOffTheBuildsStillReadTheCaches(): void - { - $compose = ['services' => ['app' => ['build' => [ - 'cache_from' => ['type=registry,ref=/app:cache', 'type=gha,scope=app'], - 'cache_to' => ['type=gha,scope=app,mode=max,ignore-error=true'], - ]], 'db' => ['image' => 'mariadb']]]; - - static::assertSame( - ['services' => ['app' => ['build' => ['cache_from' => ['type=registry,ref=/app:cache', 'type=gha,scope=app']]], 'db' => ['image' => 'mariadb']]], - drop_cache_export($compose), - ); - } -} diff --git a/tests/Unit/Builder/BuildBuilderTest.php b/tests/Unit/Builder/BuildBuilderTest.php index fa282db..4fd298d 100644 --- a/tests/Unit/Builder/BuildBuilderTest.php +++ b/tests/Unit/Builder/BuildBuilderTest.php @@ -14,35 +14,20 @@ protected function tearDown(): void putenv('GITHUB_ACTIONS='); } - public function testARegistryCacheIsTheOnlyCacheOutsideOfGithubActions(): void - { - static::assertSame( - ['cache_from' => ['type=registry,ref=${REGISTRY:-}/app:cache']], - $this->buildWithRegistryCache(), - ); - } - - public function testOnGithubActionsEveryBuildReadsAndWritesTheGithubCache(): void - { - putenv('GITHUB_ACTIONS=true'); - - static::assertSame( - [ - 'cache_from' => ['type=registry,ref=${REGISTRY:-}/app:cache', 'type=gha,scope=app'], - 'cache_to' => ['type=gha,scope=app,mode=max,ignore-error=true'], - ], - $this->buildWithRegistryCache(), - ); - } - /** - * @return array + * A second cache describing the same stages makes BuildKit race them and + * lose steps (moby/buildkit#6418): GitHub Actions gets no cache of its own. */ - private function buildWithRegistryCache(): array + public function testTheRegistryCacheIsTheOnlyCacheEvenOnGithubActions(): void { + putenv('GITHUB_ACTIONS=true'); + $builder = new ComposeBuilder(); $builder->service('app')->build()->withRegistryCache('app'); - return $builder->toArray()['services']['app']['build']; + static::assertSame( + ['cache_from' => ['type=registry,ref=${REGISTRY:-}/app:cache']], + $builder->toArray()['services']['app']['build'], + ); } } diff --git a/tests/Unit/CacheExportTest.php b/tests/Unit/CacheExportTest.php deleted file mode 100644 index 3a58047..0000000 --- a/tests/Unit/CacheExportTest.php +++ /dev/null @@ -1,83 +0,0 @@ - ['app' => ['build' => [ - 'cache_from' => ['type=registry,ref=/app:cache', 'type=gha,scope=app'], - 'cache_to' => ['type=gha,scope=app,mode=max,ignore-error=true'], - ]]]]; - - public function testTheDockerDriverOnlyKeepsTheRegistryCache(): void - { - $probe = new FakeSystemProbe(); - - static::assertSame( - ['services' => ['app' => ['build' => ['cache_from' => ['type=registry,ref=/app:cache']]]]], - drop_unsupported_cache_export(self::COMPOSE, $probe), - ); - } - - /** - * With the export turned off, nothing is written but the import error would - * still be logged. - */ - public function testTheDockerDriverDropsAGitHubActionsCacheItOnlyReads(): void - { - $compose = ['services' => ['app' => ['build' => [ - 'cache_from' => ['type=registry,ref=/app:cache', 'type=gha,scope=app'], - ]]]]; - - static::assertSame( - ['services' => ['app' => ['build' => ['cache_from' => ['type=registry,ref=/app:cache']]]]], - drop_unsupported_cache_export($compose, new FakeSystemProbe()), - ); - } - - public function testAServiceLeftWithoutCacheHasNoCacheFrom(): void - { - $compose = ['services' => ['app' => ['build' => [ - 'cache_from' => ['type=gha,scope=app'], - 'cache_to' => ['type=gha,scope=app,mode=max,ignore-error=true'], - ]]]]; - - static::assertSame(['services' => ['app' => ['build' => []]]], drop_unsupported_cache_export($compose, new FakeSystemProbe())); - } - - public function testTheDockerDriverOnTheContainerdStoreKeepsIt(): void - { - $probe = new FakeSystemProbe(); - $probe->dockerInfo = ['containerdStore' => true] + (array) $probe->dockerInfo; - - static::assertSame(self::COMPOSE, drop_unsupported_cache_export(self::COMPOSE, $probe)); - } - - public function testAContainerBuilderKeepsIt(): void - { - $probe = new FakeSystemProbe(); - $probe->buildxDriver = 'docker-container'; - - static::assertSame(self::COMPOSE, drop_unsupported_cache_export(self::COMPOSE, $probe)); - } - - public function testWithoutBuildxItIsDropped(): void - { - $probe = new FakeSystemProbe(); - $probe->buildxDriver = null; - - static::assertArrayNotHasKey('cache_to', drop_unsupported_cache_export(self::COMPOSE, $probe)['services']['app']['build']); - } -} From f266e07bc187939dbdd7486821e1d28db8b1f28b Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Tue, 6 Oct 2026 16:47:20 +0200 Subject: [PATCH 5/5] Stop pinning GITHUB_ACTIONS in the tests, nothing reads it anymore --- phpunit.xml.dist | 5 ----- 1 file changed, 5 deletions(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 7123d00..ab06bc6 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -6,11 +6,6 @@ cacheDirectory=".phpunit.cache" failOnRisky="true" > - - - - - tests/Unit