From 80e7a3111ab7e096c04b4ab820a63bbe658e52b7 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 9 Sep 2026 11:05:12 -0400 Subject: [PATCH 1/2] Fix composer.json filename lookup crashing Composer script events Composer registers the class autoloader for script events but never runs the autoload.files entries, so Laravel's helper functions don't exist. Env::get() evaluates value($default) when the variable is missing, which fataled every composer update/require via the pre-update-cmd hook. Co-Authored-By: Claude Opus 5 (1M context) --- src/Console/Composer/Json.php | 8 +++-- tests/Composer/ComposerJsonTest.php | 53 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/Console/Composer/Json.php b/src/Console/Composer/Json.php index 0a06574505f..701937dcb8b 100644 --- a/src/Console/Composer/Json.php +++ b/src/Console/Composer/Json.php @@ -2,7 +2,6 @@ namespace Statamic\Console\Composer; -use Illuminate\Support\Env; use Statamic\Facades\File; use Statamic\Facades\Path; use Statamic\Support\Arr; @@ -11,7 +10,12 @@ class Json { public static function filename(): string { - return trim((string) Env::get('COMPOSER')) ?: 'composer.json'; + // Read the env var using vanilla PHP so that this can be run in a Composer hook, where + // Composer registers the class autoloader but never runs the `autoload.files` entries. + // That means Laravel's helper functions don't exist, and `Env::get()` relies on `value()`. + $filename = $_ENV['COMPOSER'] ?? $_SERVER['COMPOSER'] ?? getenv('COMPOSER'); + + return trim((string) $filename) ?: 'composer.json'; } public static function path(): string diff --git a/tests/Composer/ComposerJsonTest.php b/tests/Composer/ComposerJsonTest.php index 6a7888f5122..c444f564c1c 100644 --- a/tests/Composer/ComposerJsonTest.php +++ b/tests/Composer/ComposerJsonTest.php @@ -3,9 +3,11 @@ namespace Tests\Composer; use Illuminate\Filesystem\Filesystem; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Console\Composer\Json; use Statamic\Console\Composer\Scripts; +use Symfony\Component\Process\Process; use Tests\TestCase; class ComposerJsonTest extends TestCase @@ -77,6 +79,57 @@ public function it_reads_the_composer_json_named_by_the_composer_env_var() $this->assertFalse(Json::isMissingPreUpdateCmd()); } + #[Test] + #[DataProvider('composerScriptContextProvider')] + public function it_resolves_the_filenames_in_a_composer_script_context($composerEnv, $expected) + { + $this->assertEquals($expected, $this->runInComposerScriptContext($composerEnv)); + } + + public static function composerScriptContextProvider() + { + return [ + 'without the env var' => [false, "composer.json\ncomposer.lock\n"], + 'with the env var' => ['composer.testing.json', "composer.testing.json\ncomposer.testing.lock\n"], + ]; + } + + /** + * When Composer runs a script event, it registers the class autoloader but never runs the + * `autoload.files` entries, so none of Laravel's helper functions exist. Replicate that + * in a subprocess to ensure we don't reach for anything that depends on them. + */ + private function runInComposerScriptContext($composerEnv) + { + $script = <<<'EOT' +require 'vendor/composer/ClassLoader.php'; + +$loader = new Composer\Autoload\ClassLoader; + +foreach (require 'vendor/composer/autoload_psr4.php' as $namespace => $paths) { + $loader->setPsr4($namespace, $paths); +} + +$loader->addClassMap(require 'vendor/composer/autoload_classmap.php'); +$loader->register(); + +echo Statamic\Console\Composer\Json::filename()."\n"; +echo Statamic\Console\Composer\Lock::filename()."\n"; +EOT; + + $process = new Process( + ['php', '-r', $script], + realpath(__DIR__.'/../..'), + ['COMPOSER' => $composerEnv], + ); + + $process->run(); + + $this->assertTrue($process->isSuccessful(), $process->getErrorOutput()); + + return $process->getOutput(); + } + #[Test] public function it_adds_pre_update_cmd_to_the_composer_json_named_by_the_composer_env_var() { From 5ef622aebd5870e080164d59d7c481a4de982b7a Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 9 Sep 2026 11:28:06 -0400 Subject: [PATCH 2/2] Cover the whole pre-update-cmd chain in the hook context test Exercising Lock::backup() rather than the filename methods means anything later added anywhere the hook reaches is covered, not just the leaf. Co-Authored-By: Claude Opus 5 (1M context) --- tests/Composer/ComposerJsonTest.php | 53 ------------------ tests/Composer/ComposerLockBackupTest.php | 67 +++++++++++++++++++++++ 2 files changed, 67 insertions(+), 53 deletions(-) diff --git a/tests/Composer/ComposerJsonTest.php b/tests/Composer/ComposerJsonTest.php index c444f564c1c..6a7888f5122 100644 --- a/tests/Composer/ComposerJsonTest.php +++ b/tests/Composer/ComposerJsonTest.php @@ -3,11 +3,9 @@ namespace Tests\Composer; use Illuminate\Filesystem\Filesystem; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Console\Composer\Json; use Statamic\Console\Composer\Scripts; -use Symfony\Component\Process\Process; use Tests\TestCase; class ComposerJsonTest extends TestCase @@ -79,57 +77,6 @@ public function it_reads_the_composer_json_named_by_the_composer_env_var() $this->assertFalse(Json::isMissingPreUpdateCmd()); } - #[Test] - #[DataProvider('composerScriptContextProvider')] - public function it_resolves_the_filenames_in_a_composer_script_context($composerEnv, $expected) - { - $this->assertEquals($expected, $this->runInComposerScriptContext($composerEnv)); - } - - public static function composerScriptContextProvider() - { - return [ - 'without the env var' => [false, "composer.json\ncomposer.lock\n"], - 'with the env var' => ['composer.testing.json', "composer.testing.json\ncomposer.testing.lock\n"], - ]; - } - - /** - * When Composer runs a script event, it registers the class autoloader but never runs the - * `autoload.files` entries, so none of Laravel's helper functions exist. Replicate that - * in a subprocess to ensure we don't reach for anything that depends on them. - */ - private function runInComposerScriptContext($composerEnv) - { - $script = <<<'EOT' -require 'vendor/composer/ClassLoader.php'; - -$loader = new Composer\Autoload\ClassLoader; - -foreach (require 'vendor/composer/autoload_psr4.php' as $namespace => $paths) { - $loader->setPsr4($namespace, $paths); -} - -$loader->addClassMap(require 'vendor/composer/autoload_classmap.php'); -$loader->register(); - -echo Statamic\Console\Composer\Json::filename()."\n"; -echo Statamic\Console\Composer\Lock::filename()."\n"; -EOT; - - $process = new Process( - ['php', '-r', $script], - realpath(__DIR__.'/../..'), - ['COMPOSER' => $composerEnv], - ); - - $process->run(); - - $this->assertTrue($process->isSuccessful(), $process->getErrorOutput()); - - return $process->getOutput(); - } - #[Test] public function it_adds_pre_update_cmd_to_the_composer_json_named_by_the_composer_env_var() { diff --git a/tests/Composer/ComposerLockBackupTest.php b/tests/Composer/ComposerLockBackupTest.php index 28d509fd362..64d3a41eeab 100644 --- a/tests/Composer/ComposerLockBackupTest.php +++ b/tests/Composer/ComposerLockBackupTest.php @@ -2,8 +2,10 @@ namespace Tests\Composer; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Console\Composer\Lock; +use Symfony\Component\Process\Process; /** * Test that we can backup a composer lock file using vanilla PHP so that it can be run in a Composer hook. @@ -15,6 +17,7 @@ class ComposerLockBackupTest extends \PHPUnit\Framework\TestCase protected $customLockPath = './custom/composer.lock'; protected $backupLockPath = './storage/statamic/updater/composer.lock.bak'; protected $customBackupLockPath = './custom/storage/statamic/updater/composer.lock.bak'; + protected $tempDir; public function setUp(): void { @@ -26,6 +29,7 @@ public function setUp(): void public function tearDown(): void { $this->removeLockFiles(); + $this->removeTempDir(); unset($_ENV['COMPOSER']); @@ -86,6 +90,69 @@ public function it_can_backup_lock_file_from_custom_location() $this->assertEquals($content, file_get_contents($this->customBackupLockPath)); } + #[Test] + #[DataProvider('composerScriptContextProvider')] + public function it_can_backup_the_lock_file_in_a_composer_script_context($composerEnv, $expected) + { + $dir = $this->makeTempDir(); + + file_put_contents($dir.'/composer.lock', 'default lock file content'); + file_put_contents($dir.'/composer.testing.lock', 'env lock file content'); + + // Composer builds a class autoloader for script events but never runs the `autoload.files` + // entries, so none of Laravel's or Statamic's helper functions exist. Replicate that here + // to ensure nothing reachable from the hook depends on them. + $script = str_replace('{{ vendor }}', realpath(__DIR__.'/../../vendor'), <<<'EOT' +require '{{ vendor }}/composer/ClassLoader.php'; + +$loader = new Composer\Autoload\ClassLoader; + +foreach (require '{{ vendor }}/composer/autoload_psr4.php' as $namespace => $paths) { + $loader->setPsr4($namespace, $paths); +} + +$loader->addClassMap(require '{{ vendor }}/composer/autoload_classmap.php'); +$loader->register(); + +Statamic\Console\Composer\Lock::backup(); +EOT); + + $process = new Process(['php', '-r', $script], $dir, ['COMPOSER' => $composerEnv]); + + $process->run(); + + $this->assertTrue($process->isSuccessful(), $process->getErrorOutput()); + $this->assertEquals($expected, file_get_contents($dir.'/storage/statamic/updater/composer.lock.bak')); + } + + public static function composerScriptContextProvider() + { + return [ + 'without the env var' => [false, 'default lock file content'], + 'with the env var' => ['composer.testing.json', 'env lock file content'], + ]; + } + + private function makeTempDir() + { + mkdir($dir = sys_get_temp_dir().'/statamic-composer-hook-'.bin2hex(random_bytes(6))); + + return $this->tempDir = $dir; + } + + private function removeTempDir($dir = null) + { + if (! ($dir ??= $this->tempDir) || ! is_dir($dir)) { + return; + } + + foreach (array_diff(scandir($dir), ['.', '..']) as $item) { + is_dir($path = $dir.'/'.$item) ? $this->removeTempDir($path) : unlink($path); + } + + rmdir($dir); + } + private function removeLockFiles() { $files = [