From d2d5e6b244c09a21f76d2c048257d6ef012f6378 Mon Sep 17 00:00:00 2001 From: Aydin Hassan Date: Tue, 27 Oct 2026 19:32:57 +0100 Subject: [PATCH 1/4] Warm the composer cache for reference solutions at build time ReferencePackages installs each reference solution's packages once, keeping or writing its composer.lock and filling the shared cache. The verify then installs them with COMPOSER_DISABLE_NETWORK, so it never downloads. composer.lock is now copied with the reference solution. --- app/config.php | 5 +++ src/Listener/PrepareSolutionListener.php | 13 ++++-- src/ReferencePackages.php | 53 ++++++++++++++++++++++++ src/Solution/DirectorySolution.php | 2 +- src/TestUtils/WorkshopExerciseTest.php | 10 +++++ test/ReferencePackagesTest.php | 39 +++++++++++++++++ 6 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 src/ReferencePackages.php create mode 100644 test/ReferencePackagesTest.php diff --git a/app/config.php b/app/config.php index 8125e652..5808440c 100644 --- a/app/config.php +++ b/app/config.php @@ -51,6 +51,7 @@ use PhpSchool\PhpWorkshop\Listener\InitialCodeListener; use PhpSchool\PhpWorkshop\Listener\OutputRunInfoListener; use PhpSchool\PhpWorkshop\Listener\PrepareSolutionListener; +use PhpSchool\PhpWorkshop\ReferencePackages; use PhpSchool\PhpWorkshop\Listener\RealPathListener; use PhpSchool\PhpWorkshop\Listener\RemoveStudentSymlinksListener; use PhpSchool\PhpWorkshop\Listener\SelfCheckListener; @@ -272,6 +273,10 @@ ); }, + ReferencePackages::class => function (ContainerInterface $c) { + return new ReferencePackages($c->get(ExerciseRepository::class), $c->get(Sandbox::class)); + }, + //Listeners InitialCodeListener::class => function (ContainerInterface $c) { return new InitialCodeListener($c->get('currentWorkingDirectory'), $c->get(LoggerInterface::class)); diff --git a/src/Listener/PrepareSolutionListener.php b/src/Listener/PrepareSolutionListener.php index 65d3a396..bb9f75c2 100644 --- a/src/Listener/PrepareSolutionListener.php +++ b/src/Listener/PrepareSolutionListener.php @@ -11,7 +11,7 @@ use PhpSchool\PhpWorkshop\Process\ProcessInput; /** - * Listener to install composer deps for an exercise solution + * Listener to install composer deps for an exercise solution, from the lock and cache written by ReferencePackages */ class PrepareSolutionListener { @@ -41,13 +41,20 @@ public function __invoke(ExerciseRunnerEvent $event): void //only install if vendor folder not available if (!file_exists(sprintf('%s/vendor', $event->getContext()->getReferenceExecutionDirectory()))) { $execution = $this->sandbox->create( - new ProcessInput('composer', ['install', '--no-interaction'], $event->getContext()->getReferenceExecutionDirectory(), [], []), + new ProcessInput( + 'composer', + ['install', '--no-interaction'], + $event->getContext()->getReferenceExecutionDirectory(), + //everything is in the cache already, so a miss fails rather than downloading + ['COMPOSER_DISABLE_NETWORK' => '1'], + [], + ), ); try { $execution->mustRun(); } catch (\Symfony\Component\Process\Exception\RuntimeException $e) { - throw new RuntimeException('Composer dependencies could not be installed', 0, $e); + throw new RuntimeException('Composer dependencies could not be installed, has ReferencePackages been run?', 0, $e); } } } diff --git a/src/ReferencePackages.php b/src/ReferencePackages.php new file mode 100644 index 00000000..85c0a00a --- /dev/null +++ b/src/ReferencePackages.php @@ -0,0 +1,53 @@ +filesystem = new Filesystem(); + } + + public function warm(): void + { + foreach ($this->exerciseRepository->findAll() as $exercise) { + if (!$exercise instanceof ProvidesSolution || !$exercise->getSolution()->hasComposerFile()) { + continue; + } + + $directory = $exercise->getSolution()->getBaseDirectory(); + + //an old vendor folder would mean nothing is downloaded, so nothing is cached + $this->filesystem->remove($directory . '/vendor'); + + //keeps a committed composer.lock, or writes one next to the solution. It is copied with it on each verify + $execution = $this->sandbox->create( + new ProcessInput('composer', ['install', '--no-interaction'], $directory, [], []), + ); + + try { + $execution->mustRun(); + } catch (\Symfony\Component\Process\Exception\RuntimeException $e) { + throw new RuntimeException(sprintf('Composer packages for "%s" could not be installed', $exercise->getName()), 0, $e); + } + + //verifies install from the cache, this copy is never used + $this->filesystem->remove($directory . '/vendor'); + } + } +} diff --git a/src/Solution/DirectorySolution.php b/src/Solution/DirectorySolution.php index 5f1425f3..f96576cb 100644 --- a/src/Solution/DirectorySolution.php +++ b/src/Solution/DirectorySolution.php @@ -88,7 +88,7 @@ public function __construct(string $directory, string $entryPoint, array $exclus */ public static function fromDirectory(string $directory, array $exclusions = [], $entryPoint = 'solution.php'): self { - return new self($directory, $entryPoint, array_merge($exclusions, ['composer.lock', 'vendor'])); + return new self($directory, $entryPoint, array_merge($exclusions, ['vendor'])); } /** diff --git a/src/TestUtils/WorkshopExerciseTest.php b/src/TestUtils/WorkshopExerciseTest.php index b21f2187..585418e3 100644 --- a/src/TestUtils/WorkshopExerciseTest.php +++ b/src/TestUtils/WorkshopExerciseTest.php @@ -14,6 +14,7 @@ use PhpSchool\PhpWorkshop\ExerciseCheck\ComposerExerciseCheck; use PhpSchool\PhpWorkshop\ExerciseDispatcher; use PhpSchool\PhpWorkshop\ExerciseRepository; +use PhpSchool\PhpWorkshop\ReferencePackages; use PhpSchool\PhpWorkshop\Result\Cgi\CgiResult; use PhpSchool\PhpWorkshop\Result\Cli\CliResult; use PhpSchool\PhpWorkshop\Result\Failure; @@ -41,6 +42,7 @@ abstract class WorkshopExerciseTest extends TestCase private Filesystem $filesystem; private string $executionDirectory; + private static bool $referencePackagesWarmed = false; public const SINGLE_FILE_SOLUTION = 'single-file-solution'; public const DIRECTORY_SOLUTION = 'directory-solution'; @@ -57,6 +59,14 @@ public function setUp(): void $this->container = $container; $this->filesystem = new Filesystem(); $this->executionDirectory = System::randomTempDir(); + + //what install/deploy does, once per test run + if (!self::$referencePackagesWarmed) { + /** @var ReferencePackages $referencePackages */ + $referencePackages = $this->container->get(ReferencePackages::class); + $referencePackages->warm(); + self::$referencePackagesWarmed = true; + } } public function tearDown(): void diff --git a/test/ReferencePackagesTest.php b/test/ReferencePackagesTest.php new file mode 100644 index 00000000..f0294ff7 --- /dev/null +++ b/test/ReferencePackagesTest.php @@ -0,0 +1,39 @@ + ['psr/log' => '^3.0']])); + + $withPackages = new CliExerciseImpl(); + $withPackages->setSolution(DirectorySolution::fromDirectory($directory)); + + (new ReferencePackages(new ExerciseRepository([$withPackages]), new HostSandbox()))->warm(); + + $this->assertFileExists($directory . '/composer.lock'); + $this->assertDirectoryDoesNotExist($directory . '/vendor'); + $this->assertContains('composer.lock', array_map( + fn($file) => $file->getRelativePath(), + DirectorySolution::fromDirectory($directory)->getFiles(), + )); + + (new Filesystem())->remove($directory); + } +} From a07e744d67cb5a1f3dffb8c3aaf24877285b9649 Mon Sep 17 00:00:00 2001 From: Aydin Hassan Date: Tue, 27 Oct 2026 19:59:00 +0100 Subject: [PATCH 2/4] Fall back to downloading reference packages when the cache misses A workshop can run without anything calling ReferencePackages, eg. from a clone, so the verify must still work then. --- src/Listener/PrepareSolutionListener.php | 47 +++++++++++++++--------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/Listener/PrepareSolutionListener.php b/src/Listener/PrepareSolutionListener.php index bb9f75c2..90df8463 100644 --- a/src/Listener/PrepareSolutionListener.php +++ b/src/Listener/PrepareSolutionListener.php @@ -9,9 +9,10 @@ use PhpSchool\PhpWorkshop\Exception\RuntimeException; use PhpSchool\PhpWorkshop\Process\Sandbox; use PhpSchool\PhpWorkshop\Process\ProcessInput; +use PhpSchool\PhpWorkshop\Process\SandboxExecution; /** - * Listener to install composer deps for an exercise solution, from the lock and cache written by ReferencePackages + * Listener to install composer deps for an exercise solution, offline when ReferencePackages has warmed the cache */ class PrepareSolutionListener { @@ -39,23 +40,33 @@ public function __invoke(ExerciseRunnerEvent $event): void //prepare composer deps //only install if vendor folder not available - if (!file_exists(sprintf('%s/vendor', $event->getContext()->getReferenceExecutionDirectory()))) { - $execution = $this->sandbox->create( - new ProcessInput( - 'composer', - ['install', '--no-interaction'], - $event->getContext()->getReferenceExecutionDirectory(), - //everything is in the cache already, so a miss fails rather than downloading - ['COMPOSER_DISABLE_NETWORK' => '1'], - [], - ), - ); - - try { - $execution->mustRun(); - } catch (\Symfony\Component\Process\Exception\RuntimeException $e) { - throw new RuntimeException('Composer dependencies could not be installed, has ReferencePackages been run?', 0, $e); - } + if (file_exists(sprintf('%s/vendor', $event->getContext()->getReferenceExecutionDirectory()))) { + return; + } + + //offline first, from the cache ReferencePackages warms. Without it, eg. a workshop run from a clone, download + $offline = $this->install($event, ['COMPOSER_DISABLE_NETWORK' => '1']); + $offline->start(); + $offline->wait(); + + if ($offline->isSuccessful()) { + return; + } + + try { + $this->install($event, [])->mustRun(); + } catch (\Symfony\Component\Process\Exception\RuntimeException $e) { + throw new RuntimeException('Composer dependencies could not be installed', 0, $e); } } + + /** + * @param array $env + */ + private function install(ExerciseRunnerEvent $event, array $env): SandboxExecution + { + return $this->sandbox->create( + new ProcessInput('composer', ['install', '--no-interaction'], $event->getContext()->getReferenceExecutionDirectory(), $env, []), + ); + } } From 0c65977845fdc075e92e671945796bc05f074eca Mon Sep 17 00:00:00 2001 From: Aydin Hassan Date: Tue, 27 Oct 2026 20:07:33 +0100 Subject: [PATCH 3/4] Add a warm-cache command for workshops to run on composer install --- app/config.php | 6 ++++++ src/Command/WarmCacheCommand.php | 22 ++++++++++++++++++++++ test/Command/WarmCacheCommandTest.php | 20 ++++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 src/Command/WarmCacheCommand.php create mode 100644 test/Command/WarmCacheCommandTest.php diff --git a/app/config.php b/app/config.php index 5808440c..41e140cd 100644 --- a/app/config.php +++ b/app/config.php @@ -30,6 +30,7 @@ use PhpSchool\PhpWorkshop\Command\PrintCommand; use PhpSchool\PhpWorkshop\Command\RunCommand; use PhpSchool\PhpWorkshop\Command\VerifyCommand; +use PhpSchool\PhpWorkshop\Command\WarmCacheCommand; use PhpSchool\PhpWorkshop\CommandDefinition; use PhpSchool\PhpWorkshop\CommandRouter; use PhpSchool\PhpWorkshop\Event\EventDispatcher; @@ -161,6 +162,7 @@ new CommandDefinition('verify', [], VerifyCommand::class), new CommandDefinition('run', [], RunCommand::class), new CommandDefinition('credits', [], CreditsCommand::class), + new CommandDefinition('warm-cache', [], WarmCacheCommand::class), ], 'menu', $c->get(EventDispatcher::class), @@ -222,6 +224,10 @@ }, //commands + WarmCacheCommand::class => function (ContainerInterface $c) { + return new WarmCacheCommand($c->get(ReferencePackages::class)); + }, + MenuCommand::class => function (ContainerInterface $c) { return new MenuCommand($c->get('menu')); }, diff --git a/src/Command/WarmCacheCommand.php b/src/Command/WarmCacheCommand.php new file mode 100644 index 00000000..c150dfd1 --- /dev/null +++ b/src/Command/WarmCacheCommand.php @@ -0,0 +1,22 @@ +referencePackages->warm(); + + return 0; + } +} diff --git a/test/Command/WarmCacheCommandTest.php b/test/Command/WarmCacheCommandTest.php new file mode 100644 index 00000000..de759fa6 --- /dev/null +++ b/test/Command/WarmCacheCommandTest.php @@ -0,0 +1,20 @@ +createMock(ReferencePackages::class); + $referencePackages->expects($this->once())->method('warm'); + + $this->assertSame(0, (new WarmCacheCommand($referencePackages))->__invoke()); + } +} From 4baa2d5ea065753fc90c7d7379ba0a841d82fc01 Mon Sep 17 00:00:00 2001 From: Aydin Hassan Date: Tue, 27 Oct 2026 20:23:30 +0100 Subject: [PATCH 4/4] Don't warm reference packages in WorkshopExerciseTest composer install/update warms them, and a verify downloads on a cache miss. --- src/TestUtils/WorkshopExerciseTest.php | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/TestUtils/WorkshopExerciseTest.php b/src/TestUtils/WorkshopExerciseTest.php index 585418e3..b21f2187 100644 --- a/src/TestUtils/WorkshopExerciseTest.php +++ b/src/TestUtils/WorkshopExerciseTest.php @@ -14,7 +14,6 @@ use PhpSchool\PhpWorkshop\ExerciseCheck\ComposerExerciseCheck; use PhpSchool\PhpWorkshop\ExerciseDispatcher; use PhpSchool\PhpWorkshop\ExerciseRepository; -use PhpSchool\PhpWorkshop\ReferencePackages; use PhpSchool\PhpWorkshop\Result\Cgi\CgiResult; use PhpSchool\PhpWorkshop\Result\Cli\CliResult; use PhpSchool\PhpWorkshop\Result\Failure; @@ -42,7 +41,6 @@ abstract class WorkshopExerciseTest extends TestCase private Filesystem $filesystem; private string $executionDirectory; - private static bool $referencePackagesWarmed = false; public const SINGLE_FILE_SOLUTION = 'single-file-solution'; public const DIRECTORY_SOLUTION = 'directory-solution'; @@ -59,14 +57,6 @@ public function setUp(): void $this->container = $container; $this->filesystem = new Filesystem(); $this->executionDirectory = System::randomTempDir(); - - //what install/deploy does, once per test run - if (!self::$referencePackagesWarmed) { - /** @var ReferencePackages $referencePackages */ - $referencePackages = $this->container->get(ReferencePackages::class); - $referencePackages->warm(); - self::$referencePackagesWarmed = true; - } } public function tearDown(): void