From 46745ae90d9455324d3d55426826f2c694271ea8 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 30 Sep 2026 21:03:20 +0100 Subject: [PATCH 01/10] 739: add testLatestReleaseMetadataSelectsTheExecutableAsset --- .../Update/FetchPieReleaseFromGitHubTest.php | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/unit/SelfManage/Update/FetchPieReleaseFromGitHubTest.php b/test/unit/SelfManage/Update/FetchPieReleaseFromGitHubTest.php index a4b8e2a2..a0202903 100644 --- a/test/unit/SelfManage/Update/FetchPieReleaseFromGitHubTest.php +++ b/test/unit/SelfManage/Update/FetchPieReleaseFromGitHubTest.php @@ -6,6 +6,7 @@ use Composer\Util\Http\Response; use Composer\Util\HttpDownloader; +use Php\Pie\SelfManage\Update\Channel; use Php\Pie\SelfManage\Update\FetchPieReleaseFromGitHub; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -88,4 +89,32 @@ public static function invalidBranchProvider(): array 'almost-main' => ['main-fix'], ]; } + + public function testLatestReleaseMetadataSelectsTheExecutableAsset(): void + { + $response = $this->createMock(Response::class); + $response->method('decodeJson')->willReturn([ + [ + 'tag_name' => '1.5.1', + 'assets' => [ + ['name' => 'pie.phar', 'browser_download_url' => 'https://github.com/php/pie/releases/download/1.5.1/pie.phar'], + ['name' => 'pie-Linux-ARM64', 'browser_download_url' => 'https://github.com/php/pie/releases/download/1.5.1/pie-Linux-ARM64'], + ['name' => 'pie-Linux-X64', 'browser_download_url' => 'https://github.com/php/pie/releases/download/1.5.1/pie-Linux-X64'], + ['name' => 'pie-macOS-ARM64', 'browser_download_url' => 'https://github.com/php/pie/releases/download/1.5.1/pie-macOS-ARM64'], + ['name' => 'pie-Windows-X64.exe', 'browser_download_url' => 'https://github.com/php/pie/releases/download/1.5.1/pie-Windows-X64.exe'], + ], + ], + ]); + + $this->httpDownloader->expects(self::once()) + ->method('get') + ->with('https://api.github.com/repos/php/pie/releases') + ->willReturn($response); + + $release = (new FetchPieReleaseFromGitHub('https://api.github.com', $this->httpDownloader, 'pie-Linux-X64')) + ->latestReleaseMetadata(Channel::Stable); + + self::assertSame('1.5.1', $release->tag); + self::assertSame('https://github.com/php/pie/releases/download/1.5.1/pie-Linux-X64', $release->downloadUrl); + } } From f9b6a88ee44d1ab3f2023a6d5796ee1eadca0333 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 30 Sep 2026 21:04:11 +0100 Subject: [PATCH 02/10] 739: add PieAssetName to determine what the asset name should be --- src/SelfManage/Update/PieAssetName.php | 50 +++++++++++++++ .../SelfManage/Update/PieAssetNameTest.php | 64 +++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 src/SelfManage/Update/PieAssetName.php create mode 100644 test/unit/SelfManage/Update/PieAssetNameTest.php diff --git a/src/SelfManage/Update/PieAssetName.php b/src/SelfManage/Update/PieAssetName.php new file mode 100644 index 00000000..9a083982 --- /dev/null +++ b/src/SelfManage/Update/PieAssetName.php @@ -0,0 +1,50 @@ + self::LinuxX64, + ['Linux', Architecture::arm64] => self::LinuxArm64, + ['Darwin', Architecture::arm64] => self::MacOsArm64, + ['Windows', Architecture::x86_64] => self::WindowsX64, + default => throw new RuntimeException(sprintf( + 'There is no PIE executable available for %s %s', + $osFamily, + $architecture->name, + )), + }; + } +} diff --git a/test/unit/SelfManage/Update/PieAssetNameTest.php b/test/unit/SelfManage/Update/PieAssetNameTest.php new file mode 100644 index 00000000..b01585ef --- /dev/null +++ b/test/unit/SelfManage/Update/PieAssetNameTest.php @@ -0,0 +1,64 @@ + */ + public static function supportedExecutablePlatformsProvider(): array + { + return [ + 'Linux x86_64' => ['Linux', Architecture::x86_64, PieAssetName::LinuxX64], + 'Linux arm64' => ['Linux', Architecture::arm64, PieAssetName::LinuxArm64], + 'macOS arm64' => ['Darwin', Architecture::arm64, PieAssetName::MacOsArm64], + 'Windows x86_64' => ['Windows', Architecture::x86_64, PieAssetName::WindowsX64], + ]; + } + + #[DataProvider('supportedExecutablePlatformsProvider')] + public function testForExecutable(string $osFamily, Architecture $architecture, PieAssetName $expectedAssetName): void + { + self::assertSame($expectedAssetName, self::invokeForExecutable($osFamily, $architecture)); + } + + /** @return array */ + public static function unsupportedExecutablePlatformsProvider(): array + { + return [ + 'macOS x86_64' => ['Darwin', Architecture::x86_64], + 'Windows arm64' => ['Windows', Architecture::arm64], + 'Linux x86' => ['Linux', Architecture::x86], + 'BSD x86_64' => ['BSD', Architecture::x86_64], + ]; + } + + #[DataProvider('unsupportedExecutablePlatformsProvider')] + public function testForExecutableOnUnsupportedPlatform(string $osFamily, Architecture $architecture): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('There is no PIE executable available for ' . $osFamily . ' ' . $architecture->name); + + self::invokeForExecutable($osFamily, $architecture); + } + + private static function invokeForExecutable(string $osFamily, Architecture $architecture): mixed + { + return (new ReflectionMethod(PieAssetName::class, 'forExecutable'))->invoke(null, $osFamily, $architecture); + } +} From c751e43445ed738c90dfe0375b1e74170502557b Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 30 Sep 2026 21:16:20 +0100 Subject: [PATCH 03/10] 739: search release assets for the appropriate PieAssetName --- src/Command/SelfUpdateCommand.php | 2 ++ src/Command/SelfVerifyCommand.php | 2 ++ .../Update/FetchPieReleaseFromGitHub.php | 20 +++++++++---------- .../Update/FetchPieReleaseFromGitHubTest.php | 5 +++-- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/Command/SelfUpdateCommand.php b/src/Command/SelfUpdateCommand.php index d4498b39..667fcbc3 100644 --- a/src/Command/SelfUpdateCommand.php +++ b/src/Command/SelfUpdateCommand.php @@ -15,6 +15,7 @@ use Php\Pie\SelfManage\Update\Channel; use Php\Pie\SelfManage\Update\FetchPieReleaseFromGitHub; use Php\Pie\SelfManage\Update\IsBrewInstallation; +use Php\Pie\SelfManage\Update\PieAssetName; use Php\Pie\SelfManage\Update\ReleaseIsNewer; use Php\Pie\SelfManage\Update\ReleaseMetadata; use Php\Pie\SelfManage\Verify\RecoverFromFailedVerification; @@ -133,6 +134,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $this->quieterConsoleIo, $composer->getConfig(), $this->githubApiBaseUrl, + PieAssetName::forRunningPie(), ); $verifyPiePhar = VerifyPieReleaseUsingAttestation::factory( $fetchLatestPieRelease, diff --git a/src/Command/SelfVerifyCommand.php b/src/Command/SelfVerifyCommand.php index ced96742..3bb83b4f 100644 --- a/src/Command/SelfVerifyCommand.php +++ b/src/Command/SelfVerifyCommand.php @@ -12,6 +12,7 @@ use Php\Pie\File\BinaryFile; use Php\Pie\File\FullPathToSelf; use Php\Pie\SelfManage\Update\FetchPieReleaseFromGitHub; +use Php\Pie\SelfManage\Update\PieAssetName; use Php\Pie\SelfManage\Update\ReleaseMetadata; use Php\Pie\SelfManage\Verify\VerifyPieReleaseUsingAttestation; use Php\Pie\Util\Emoji; @@ -89,6 +90,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $this->quieterConsoleIo, $composer->getConfig(), $this->githubApiBaseUrl, + PieAssetName::forRunningPie(), ); $latestRelease = new ReleaseMetadata($expectedVersion, 'blah'); diff --git a/src/SelfManage/Update/FetchPieReleaseFromGitHub.php b/src/SelfManage/Update/FetchPieReleaseFromGitHub.php index d1046201..784600f0 100644 --- a/src/SelfManage/Update/FetchPieReleaseFromGitHub.php +++ b/src/SelfManage/Update/FetchPieReleaseFromGitHub.php @@ -27,19 +27,19 @@ /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ final class FetchPieReleaseFromGitHub implements FetchPieRelease { - private const PIE_PHAR_NAME = 'pie.phar'; private const PIE_REPO_URL = '/repos/php/pie'; private const PIE_RELEASES_URL = '/repos/php/pie/releases'; public function __construct( private readonly string $githubApiBaseUrl, private readonly HttpDownloader $httpDownloader, + private readonly PieAssetName $assetName, ) { } - public static function factory(QuieterConsoleIO $io, Config $config, string $githubApiBaseUrl): self + public static function factory(QuieterConsoleIO $io, Config $config, string $githubApiBaseUrl, PieAssetName $assetName): self { - return new self($githubApiBaseUrl, new HttpDownloader($io, $config)); + return new self($githubApiBaseUrl, new HttpDownloader($io, $config), $assetName); } public function trunkBranch(): string @@ -94,14 +94,14 @@ public function latestReleaseMetadata(Channel $updateChannel): ReleaseMetadata $releases = array_filter( array_map( - static function (array $releaseResponse): ReleaseMetadata|null { + function (array $releaseResponse): ReleaseMetadata|null { Assert::keyExists($releaseResponse, 'tag_name'); Assert::stringNotEmpty($releaseResponse['tag_name']); Assert::keyExists($releaseResponse, 'assets'); Assert::isList($releaseResponse['assets']); Assert::allIsArray($releaseResponse['assets']); - $assetsNamedPiePhar = array_filter( + $assetsWithExpectedName = array_filter( array_map( static function (array $asset): array { Assert::keyExists($asset, 'name'); @@ -113,20 +113,18 @@ static function (array $asset): array { }, $releaseResponse['assets'], ), - static function (array $asset): bool { - return $asset['name'] === self::PIE_PHAR_NAME; - }, + fn (array $asset): bool => $asset['name'] === $this->assetName->value, ); - if (! count($assetsNamedPiePhar)) { + if (! count($assetsWithExpectedName)) { return null; } - $firstAssetNamedPiePhar = reset($assetsNamedPiePhar); + $firstAssetWithExpectedName = reset($assetsWithExpectedName); return new ReleaseMetadata( $releaseResponse['tag_name'], - $firstAssetNamedPiePhar['browser_download_url'], + $firstAssetWithExpectedName['browser_download_url'], ); }, array_filter( diff --git a/test/unit/SelfManage/Update/FetchPieReleaseFromGitHubTest.php b/test/unit/SelfManage/Update/FetchPieReleaseFromGitHubTest.php index a0202903..8ab5cc1b 100644 --- a/test/unit/SelfManage/Update/FetchPieReleaseFromGitHubTest.php +++ b/test/unit/SelfManage/Update/FetchPieReleaseFromGitHubTest.php @@ -8,6 +8,7 @@ use Composer\Util\HttpDownloader; use Php\Pie\SelfManage\Update\Channel; use Php\Pie\SelfManage\Update\FetchPieReleaseFromGitHub; +use Php\Pie\SelfManage\Update\PieAssetName; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; @@ -27,7 +28,7 @@ final class FetchPieReleaseFromGitHubTest extends TestCase protected function setUp(): void { $this->httpDownloader = $this->createMock(HttpDownloader::class); - $this->fetcher = new FetchPieReleaseFromGitHub('https://api.github.com', $this->httpDownloader); + $this->fetcher = new FetchPieReleaseFromGitHub('https://api.github.com', $this->httpDownloader, PieAssetName::Phar); } #[DataProvider('validBranchProvider')] @@ -111,7 +112,7 @@ public function testLatestReleaseMetadataSelectsTheExecutableAsset(): void ->with('https://api.github.com/repos/php/pie/releases') ->willReturn($response); - $release = (new FetchPieReleaseFromGitHub('https://api.github.com', $this->httpDownloader, 'pie-Linux-X64')) + $release = (new FetchPieReleaseFromGitHub('https://api.github.com', $this->httpDownloader, PieAssetName::LinuxX64)) ->latestReleaseMetadata(Channel::Stable); self::assertSame('1.5.1', $release->tag); From 06b7f24753d6ab80c7817d4cd3f065cd832e9e2a Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 30 Sep 2026 21:27:57 +0100 Subject: [PATCH 04/10] 739: handle nightly release asset name --- src/Command/SelfUpdateCommand.php | 6 +---- src/SelfManage/Update/FetchPieRelease.php | 2 ++ .../Update/FetchPieReleaseFromGitHub.php | 9 ++++++++ src/SelfManage/Update/PieAssetName.php | 19 +++++++++++----- .../Update/FetchPieReleaseFromGitHubTest.php | 22 +++++++++++++++++++ .../SelfManage/Update/PieAssetNameTest.php | 19 ++++++++++++++++ 6 files changed, 67 insertions(+), 10 deletions(-) diff --git a/src/Command/SelfUpdateCommand.php b/src/Command/SelfUpdateCommand.php index 667fcbc3..2151fabc 100644 --- a/src/Command/SelfUpdateCommand.php +++ b/src/Command/SelfUpdateCommand.php @@ -17,7 +17,6 @@ use Php\Pie\SelfManage\Update\IsBrewInstallation; use Php\Pie\SelfManage\Update\PieAssetName; use Php\Pie\SelfManage\Update\ReleaseIsNewer; -use Php\Pie\SelfManage\Update\ReleaseMetadata; use Php\Pie\SelfManage\Verify\RecoverFromFailedVerification; use Php\Pie\SelfManage\Verify\VerifyPieReleaseUsingAttestation; use Php\Pie\Settings; @@ -144,10 +143,7 @@ public function execute(InputInterface $input, OutputInterface $output): int ); if ($updateChannel === Channel::Nightly) { - $latestRelease = new ReleaseMetadata( - 'nightly', - 'https://php.github.io/pie/pie-nightly.phar', - ); + $latestRelease = $fetchLatestPieRelease->nightlyReleaseMetadata(); $this->io->write('Downloading the latest nightly release.'); } else { diff --git a/src/SelfManage/Update/FetchPieRelease.php b/src/SelfManage/Update/FetchPieRelease.php index 5456ad65..cd123daa 100644 --- a/src/SelfManage/Update/FetchPieRelease.php +++ b/src/SelfManage/Update/FetchPieRelease.php @@ -11,6 +11,8 @@ interface FetchPieRelease { public function latestReleaseMetadata(Channel $updateChannel): ReleaseMetadata; + public function nightlyReleaseMetadata(): ReleaseMetadata; + /** @return non-empty-string */ public function trunkBranch(): string; diff --git a/src/SelfManage/Update/FetchPieReleaseFromGitHub.php b/src/SelfManage/Update/FetchPieReleaseFromGitHub.php index 784600f0..a3c9599e 100644 --- a/src/SelfManage/Update/FetchPieReleaseFromGitHub.php +++ b/src/SelfManage/Update/FetchPieReleaseFromGitHub.php @@ -29,6 +29,7 @@ final class FetchPieReleaseFromGitHub implements FetchPieRelease { private const PIE_REPO_URL = '/repos/php/pie'; private const PIE_RELEASES_URL = '/repos/php/pie/releases'; + private const NIGHTLY_BASE_URL = 'https://php.github.io/pie/'; public function __construct( private readonly string $githubApiBaseUrl, @@ -153,6 +154,14 @@ static function (ReleaseMetadata|null $releaseMetadata) use ($updateChannel): bo return $first; } + public function nightlyReleaseMetadata(): ReleaseMetadata + { + return new ReleaseMetadata( + 'nightly', + self::NIGHTLY_BASE_URL . $this->assetName->nightly()->value, + ); + } + public function downloadContent(ReleaseMetadata $releaseMetadata): BinaryFile { $pharContent = $this->httpDownloader->get( diff --git a/src/SelfManage/Update/PieAssetName.php b/src/SelfManage/Update/PieAssetName.php index 9a083982..760589b6 100644 --- a/src/SelfManage/Update/PieAssetName.php +++ b/src/SelfManage/Update/PieAssetName.php @@ -16,11 +16,12 @@ /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ enum PieAssetName: string { - case Phar = 'pie.phar'; - case LinuxX64 = 'pie-Linux-X64'; - case LinuxArm64 = 'pie-Linux-ARM64'; - case MacOsArm64 = 'pie-macOS-ARM64'; - case WindowsX64 = 'pie-Windows-X64.exe'; + case Phar = 'pie.phar'; + case PharNightly = 'pie-nightly.phar'; + case LinuxX64 = 'pie-Linux-X64'; + case LinuxArm64 = 'pie-Linux-ARM64'; + case MacOsArm64 = 'pie-macOS-ARM64'; + case WindowsX64 = 'pie-Windows-X64.exe'; public static function forRunningPie(): self { @@ -33,6 +34,14 @@ public static function forRunningPie(): self return self::forExecutable(PHP_OS_FAMILY, Architecture::parseArchitecture($machineType !== '' ? $machineType : 'unknown')); } + public function nightly(): self + { + return match ($this) { + self::Phar, self::PharNightly => self::PharNightly, + default => $this, + }; + } + private static function forExecutable(string $osFamily, Architecture $architecture): self { return match ([$osFamily, $architecture]) { diff --git a/test/unit/SelfManage/Update/FetchPieReleaseFromGitHubTest.php b/test/unit/SelfManage/Update/FetchPieReleaseFromGitHubTest.php index 8ab5cc1b..3c26f819 100644 --- a/test/unit/SelfManage/Update/FetchPieReleaseFromGitHubTest.php +++ b/test/unit/SelfManage/Update/FetchPieReleaseFromGitHubTest.php @@ -118,4 +118,26 @@ public function testLatestReleaseMetadataSelectsTheExecutableAsset(): void self::assertSame('1.5.1', $release->tag); self::assertSame('https://github.com/php/pie/releases/download/1.5.1/pie-Linux-X64', $release->downloadUrl); } + + /** @return array */ + public static function nightlyDownloadUrlProvider(): array + { + return [ + 'PHAR' => [PieAssetName::Phar, 'https://php.github.io/pie/pie-nightly.phar'], + 'Linux x86_64' => [PieAssetName::LinuxX64, 'https://php.github.io/pie/pie-Linux-X64'], + 'Linux arm64' => [PieAssetName::LinuxArm64, 'https://php.github.io/pie/pie-Linux-ARM64'], + 'macOS arm64' => [PieAssetName::MacOsArm64, 'https://php.github.io/pie/pie-macOS-ARM64'], + 'Windows x86_64' => [PieAssetName::WindowsX64, 'https://php.github.io/pie/pie-Windows-X64.exe'], + ]; + } + + #[DataProvider('nightlyDownloadUrlProvider')] + public function testNightlyReleaseMetadata(PieAssetName $assetName, string $expectedDownloadUrl): void + { + $release = (new FetchPieReleaseFromGitHub('https://api.github.com', $this->httpDownloader, $assetName)) + ->nightlyReleaseMetadata(); + + self::assertSame('nightly', $release->tag); + self::assertSame($expectedDownloadUrl, $release->downloadUrl); + } } diff --git a/test/unit/SelfManage/Update/PieAssetNameTest.php b/test/unit/SelfManage/Update/PieAssetNameTest.php index b01585ef..327506ff 100644 --- a/test/unit/SelfManage/Update/PieAssetNameTest.php +++ b/test/unit/SelfManage/Update/PieAssetNameTest.php @@ -57,6 +57,25 @@ public function testForExecutableOnUnsupportedPlatform(string $osFamily, Archite self::invokeForExecutable($osFamily, $architecture); } + /** @return array */ + public static function nightlyProvider(): array + { + return [ + 'PHAR' => [PieAssetName::Phar, PieAssetName::PharNightly], + 'PHAR nightly' => [PieAssetName::PharNightly, PieAssetName::PharNightly], + 'Linux x86_64' => [PieAssetName::LinuxX64, PieAssetName::LinuxX64], + 'Linux arm64' => [PieAssetName::LinuxArm64, PieAssetName::LinuxArm64], + 'macOS arm64' => [PieAssetName::MacOsArm64, PieAssetName::MacOsArm64], + 'Windows x86_64' => [PieAssetName::WindowsX64, PieAssetName::WindowsX64], + ]; + } + + #[DataProvider('nightlyProvider')] + public function testNightly(PieAssetName $assetName, PieAssetName $expectedNightlyAssetName): void + { + self::assertSame($expectedNightlyAssetName, $assetName->nightly()); + } + private static function invokeForExecutable(string $osFamily, Architecture $architecture): mixed { return (new ReflectionMethod(PieAssetName::class, 'forExecutable'))->invoke(null, $osFamily, $architecture); From 96f8c96f7bc47128cc126bd9c62220a140d5c412 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Tue, 13 Oct 2026 19:25:25 +0100 Subject: [PATCH 05/10] 739: add ReplaceRunningExecutable helper --- src/File/ReplaceRunningExecutable.php | 112 +++++++++++++++ src/File/Sudo.php | 1 + ...ReplaceRunningExecutableNonWindowsTest.php | 135 ++++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 src/File/ReplaceRunningExecutable.php create mode 100644 test/integration/File/ReplaceRunningExecutableNonWindowsTest.php diff --git a/src/File/ReplaceRunningExecutable.php b/src/File/ReplaceRunningExecutable.php new file mode 100644 index 00000000..9da2edb9 --- /dev/null +++ b/src/File/ReplaceRunningExecutable.php @@ -0,0 +1,112 @@ +path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_test_replace_running_executable_', true); + mkdir($this->path); + $this->executable = $this->path . DIRECTORY_SEPARATOR . 'pie'; + copy('/bin/sleep', $this->executable); + chmod($this->executable, 0755); + + $this->runningExecutable = new Process([$this->executable, '30']); + $this->runningExecutable->start(); + $this->waitUntilRunning(); + } + + public function tearDown(): void + { + $this->runningExecutable->stop(0); + chmod($this->path, 0755); + + if (is_string($this->originalComposerNoInteraction)) { + ComposerPlatform::putEnv('COMPOSER_NO_INTERACTION', $this->originalComposerNoInteraction); + } + + (new Filesystem())->remove($this->path); + } + + public function testRunningExecutableCanBeReplaced(): void + { + ReplaceRunningExecutable::contents($this->executable, 'the new executable'); + + self::assertSame('the new executable', file_get_contents($this->executable)); + self::assertTrue(is_executable($this->executable)); + } + + public function testRunningExecutableInUnwritableDirectoryCanBeReplaced(): void + { + $this->skipUnlessSudoIsNeededAndAvailable(); + chmod($this->path, 0555); + + ReplaceRunningExecutable::contents($this->executable, 'the new executable'); + + self::assertSame('the new executable', file_get_contents($this->executable)); + self::assertTrue(is_executable($this->executable)); + } + + public function testRootOwnedRunningExecutableInUnwritableDirectoryCanBeReplaced(): void + { + $this->skipUnlessSudoIsNeededAndAvailable(); + chmod($this->executable, 0555); + (new Process([Sudo::find(), 'chown', '0:0', $this->executable]))->mustRun(); + chmod($this->path, 0555); + + ReplaceRunningExecutable::contents($this->executable, 'the new executable'); + + self::assertSame('the new executable', file_get_contents($this->executable)); + self::assertTrue(is_executable($this->executable)); + self::assertSame(0, fileowner($this->executable)); + self::assertSame(0, filegroup($this->executable)); + } + + private function skipUnlessSudoIsNeededAndAvailable(): void + { + if (TargetPlatform::isRunningAsRoot()) { + self::markTestSkipped('Running as root, so file permissions do not apply'); + } + + $this->originalComposerNoInteraction = ComposerPlatform::getEnv('COMPOSER_NO_INTERACTION'); + ComposerPlatform::clearEnv('COMPOSER_NO_INTERACTION'); + + try { + Sudo::find(); + } catch (SudoNotFoundOnSystem | SudoRequiresInteractiveTerminal) { + self::markTestSkipped('Cannot test without sudo in an interactive terminal'); + } + } + + private function waitUntilRunning(): void + { + $procExe = '/proc/' . $this->runningExecutable->getPid() . '/exe'; + $timeout = microtime(true) + 5; + + while (microtime(true) < $timeout) { + if (file_exists($procExe) && readlink($procExe) === $this->executable) { + return; + } + + usleep(10_000); + } + + self::fail('Executable did not start in time'); + } +} From 07cfb8dac623febcc83868d191dd73346fbd7899 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Tue, 13 Oct 2026 19:44:17 +0100 Subject: [PATCH 06/10] 739: handle Windows move running exe out the way --- src/File/ReplaceRunningExecutable.php | 30 ++++++++ .../ReplaceRunningExecutableWindowsTest.php | 77 +++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 test/integration/File/ReplaceRunningExecutableWindowsTest.php diff --git a/src/File/ReplaceRunningExecutable.php b/src/File/ReplaceRunningExecutable.php index 9da2edb9..3faa6fd2 100644 --- a/src/File/ReplaceRunningExecutable.php +++ b/src/File/ReplaceRunningExecutable.php @@ -4,6 +4,7 @@ namespace Php\Pie\File; +use Composer\Util\Platform; use Php\Pie\Util\Process; use Safe\Exceptions\FilesystemException; use Symfony\Component\Process\Exception\ProcessFailedException; @@ -31,6 +32,12 @@ public static function contents(string $filename, string $content): void { $directory = dirname($filename); + if (is_writable($directory) && Platform::isWindows() && file_exists($filename)) { + self::replaceByRenamingAside($directory, $filename, $content); + + return; + } + if (is_writable($directory)) { self::replaceWithoutSudo($directory, $filename, $content); @@ -53,6 +60,29 @@ private static function replaceWithoutSudo(string $directory, string $filename, } } + private static function replaceByRenamingAside(string $directory, string $filename, string $content): void + { + $tempFilename = self::writeTempFile($directory, $filename, $content); + $oldFilename = $filename . '.old'; + + try { + rename($filename, $oldFilename); + } catch (FilesystemException $e) { + unlink($tempFilename); + + throw FailedToWriteFile::fromFilePutContentError($filename, $e); + } + + try { + rename($tempFilename, $filename); + } catch (FilesystemException $e) { + rename($oldFilename, $filename); + unlink($tempFilename); + + throw FailedToWriteFile::fromFilePutContentError($filename, $e); + } + } + private static function replaceWithSudo(string $directory, string $filename, string $content): void { try { diff --git a/test/integration/File/ReplaceRunningExecutableWindowsTest.php b/test/integration/File/ReplaceRunningExecutableWindowsTest.php new file mode 100644 index 00000000..ff245809 --- /dev/null +++ b/test/integration/File/ReplaceRunningExecutableWindowsTest.php @@ -0,0 +1,77 @@ +path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_test_replace_running_executable_', true); + mkdir($this->path); + $this->executable = $this->path . DIRECTORY_SEPARATOR . 'pie.exe'; + copy('C:\\Windows\\System32\\PING.EXE', $this->executable); + + $this->runningExecutable = new Process([$this->executable, '-n', '30', '127.0.0.1']); + $this->runningExecutable->start(); + $this->waitUntilRunning(); + } + + public function tearDown(): void + { + $this->runningExecutable->stop(0); + (new Filesystem())->remove($this->path); + } + + public function testRunningExecutableCanBeReplaced(): void + { + ReplaceRunningExecutable::contents($this->executable, 'the new executable'); + + self::assertSame('the new executable', file_get_contents($this->executable)); + } + + private function waitUntilRunning(): void + { + $timeout = microtime(true) + 5; + + while (microtime(true) < $timeout) { + try { + fclose(fopen($this->executable, 'r+')); + } catch (FilesystemException) { + // Windows locks the file of a running executable against writes + return; + } + + usleep(10_000); + } + + self::fail('Executable did not start in time'); + } +} From ea7618884bda10015b3057fc883075ed9f61d285 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Tue, 13 Oct 2026 20:10:33 +0100 Subject: [PATCH 07/10] 739: wire in self update for executable builds --- src/Command/SelfUpdateCommand.php | 14 ++++++++++---- src/Platform.php | 3 ++- .../Verify/RecoverFromFailedVerification.php | 4 ++-- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/Command/SelfUpdateCommand.php b/src/Command/SelfUpdateCommand.php index 2151fabc..1f32d450 100644 --- a/src/Command/SelfUpdateCommand.php +++ b/src/Command/SelfUpdateCommand.php @@ -10,6 +10,7 @@ use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\QuieterConsoleIO; use Php\Pie\File\FullPathToSelf; +use Php\Pie\File\ReplaceRunningExecutable; use Php\Pie\File\SudoFilePut; use Php\Pie\Platform; use Php\Pie\SelfManage\Update\Channel; @@ -86,7 +87,7 @@ public function configure(): void public function execute(InputInterface $input, OutputInterface $output): int { - if (! PieVersion::isPharBuild() || Platform::isRunningStaticPhp()) { + if (! PieVersion::isPharBuild()) { $this->io->writeError('Aborting! You are not running a PHAR, cannot self-update.'); return Command::FAILURE; @@ -199,7 +200,7 @@ public function execute(InputInterface $input, OutputInterface $output): int try { $pharContents = file_get_contents($pharFilename->filePath); } catch (FilesystemException) { - $this->io->writeError(sprintf('%s Failed to read the downloaded PHAR file %s', Emoji::CROSS, $pharFilename->filePath)); + $this->io->writeError(sprintf('%s Failed to read the downloaded PIE release file %s', Emoji::CROSS, $pharFilename->filePath)); unlink($pharFilename->filePath); return Command::FAILURE; @@ -208,7 +209,7 @@ public function execute(InputInterface $input, OutputInterface $output): int try { $pharFilename->verifyContent($pharContents); } catch (Throwable) { - $this->io->writeError(sprintf('%s PHAR contents changed after verification; aborting self-update', Emoji::CROSS)); + $this->io->writeError(sprintf('%s Downloaded PIE release contents changed after verification; aborting self-update', Emoji::CROSS)); unlink($pharFilename->filePath); return Command::FAILURE; @@ -218,7 +219,12 @@ public function execute(InputInterface $input, OutputInterface $output): int sprintf('Writing new version to %s', $fullPathToSelf), verbosity: IOInterface::VERBOSE, ); - SudoFilePut::contents($fullPathToSelf, $pharContents); + if (Platform::isRunningStaticPhp()) { + ReplaceRunningExecutable::contents($fullPathToSelf, $pharContents); + } else { + SudoFilePut::contents($fullPathToSelf, $pharContents); + } + unlink($pharFilename->filePath); $this->io->write(sprintf( diff --git a/src/Platform.php b/src/Platform.php index e2a3f778..31a73604 100644 --- a/src/Platform.php +++ b/src/Platform.php @@ -22,6 +22,7 @@ use const DIRECTORY_SEPARATOR; use const PHP_BUILD_PROVIDER; +use const PHP_SAPI; use const STDIN; /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ @@ -135,6 +136,6 @@ public static function getPieJsonFilename(TargetPlatform $targetPlatform): strin public static function isRunningStaticPhp(): bool { - return defined('PHP_BUILD_PROVIDER') && str_contains(PHP_BUILD_PROVIDER, 'static-php-cli'); + return PHP_SAPI === 'micro' || (defined('PHP_BUILD_PROVIDER') && str_contains(PHP_BUILD_PROVIDER, 'static-php-cli')); } } diff --git a/src/SelfManage/Verify/RecoverFromFailedVerification.php b/src/SelfManage/Verify/RecoverFromFailedVerification.php index 6daef44e..b444c598 100644 --- a/src/SelfManage/Verify/RecoverFromFailedVerification.php +++ b/src/SelfManage/Verify/RecoverFromFailedVerification.php @@ -22,12 +22,12 @@ final class RecoverFromFailedVerification public function __invoke(IOInterface $io, ReleaseMetadata $releaseMetadata, Throwable $verificationFailure): bool { $io->writeError(sprintf( - '%s Failed to verify the pie.phar release %s: %s', + '%s Failed to verify the PIE release %s: %s', Emoji::CROSS, $releaseMetadata->tag, $verificationFailure->getMessage(), )); - $io->writeError('This means I could not verify that the PHAR we tried to update to was authentic.'); + $io->writeError('This means I could not verify that the PIE release we tried to update to was authentic.'); $io->writeError(sprintf( 'You can manually download release %s yourself from: %s', $releaseMetadata->tag, From 0dba02bac705186f193e0b467b6c4603bae10c28 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Tue, 13 Oct 2026 20:17:42 +0100 Subject: [PATCH 08/10] 739: update docs for self update for executables --- docs/usage.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index b4ef8973..0af7c039 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -110,10 +110,7 @@ The PIE is built using [Static PHP](https://static-php.dev/), which builds a self-contained PHP executable with the extensions that PIE needs to run, and bundles the PHAR as a single distributable executable. Please keep in mind that this is **experimental**, and we do not recommend this for production use for -the time being. Please also note there are some limitations: - - - [php/pie#460](https://github.com/php/pie/discussions/460) - all the binary - versions have the `pie self-update` feature disabled for now. +the time being. If you find the binary releases useful, please leave feedback or upvote on the relevant discussions, so we can gauge interest in improving this functionality. @@ -128,6 +125,7 @@ stable releases can be downloaded from these links: | Linux | amd64 / x86_64 | https://github.com/php/pie/releases/latest/download/pie-Linux-X64 | | OS X | ARM 64 / aarch64 | https://github.com/php/pie/releases/latest/download/pie-macOS-ARM64 | | Linux | ARM 64 / aarch64 | https://github.com/php/pie/releases/latest/download/pie-Linux-ARM64 | +| Windows | amd64 / x86_64 | https://github.com/php/pie/releases/latest/download/pie-Windows-X64.exe | The "nightly" versions of these can be found here: @@ -136,6 +134,7 @@ The "nightly" versions of these can be found here: | Linux | amd64 / x86_64 | https://php.github.io/pie/pie-Linux-X64 | | OS X | ARM 64 / aarch64 | https://php.github.io/pie/pie-macOS-ARM64 | | Linux | ARM 64 / aarch64 | https://php.github.io/pie/pie-Linux-ARM64 | +| Windows | amd64 / x86_64 | https://php.github.io/pie/pie-Windows-X64.exe | We *highly* recommend you verify the file came from the PHP GitHub repository before running it, for example: From 23dbc5779c5dc0701cb0ba56bf3b15c9a1d35d81 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Tue, 13 Oct 2026 20:47:00 +0100 Subject: [PATCH 09/10] 739: limit setup to using Composer only not TargetPhp for self-update / self-verify --- src/Command/CommandHelper.php | 11 +++++++++++ src/Command/SelfUpdateCommand.php | 23 +++++++---------------- src/Command/SelfVerifyCommand.php | 23 +++++++---------------- 3 files changed, 25 insertions(+), 32 deletions(-) diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index 92720bfa..98887354 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -122,6 +122,17 @@ public static function configurePhpConfigOptions(Command $command): void ); } + public static function warnPhpConfigOptionsHaveNoEffect(InputInterface $input, IOInterface $io): void + { + foreach ([self::OPTION_WITH_PHP_CONFIG, self::OPTION_WITH_PHP_PATH, self::OPTION_WITH_PHPIZE_PATH] as $option) { + if (! $input->hasOption($option) || $input->getOption($option) === null) { + continue; + } + + $io->writeError(sprintf('The --%s option is now deprecated and has no effect for this command.', $option)); + } + } + public static function configureBuildToolsCheckOptions(Command $command): void { $command->addOption( diff --git a/src/Command/SelfUpdateCommand.php b/src/Command/SelfUpdateCommand.php index 1f32d450..c3cbe321 100644 --- a/src/Command/SelfUpdateCommand.php +++ b/src/Command/SelfUpdateCommand.php @@ -4,10 +4,8 @@ namespace Php\Pie\Command; +use Composer\Factory; use Composer\IO\IOInterface; -use Composer\IO\NullIO; -use Php\Pie\ComposerIntegration\PieComposerFactory; -use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\QuieterConsoleIO; use Php\Pie\File\FullPathToSelf; use Php\Pie\File\ReplaceRunningExecutable; @@ -23,7 +21,6 @@ use Php\Pie\Settings; use Php\Pie\Util\Emoji; use Php\Pie\Util\PieVersion; -use Psr\Container\ContainerInterface; use Safe\Exceptions\FilesystemException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -54,7 +51,6 @@ public function __construct( private readonly string $githubApiBaseUrl, private readonly IOInterface $io, private readonly QuieterConsoleIO $quieterConsoleIo, - private readonly ContainerInterface $container, private readonly FullPathToSelf $fullPathToSelf, ) { parent::__construct(); @@ -87,6 +83,8 @@ public function configure(): void public function execute(InputInterface $input, OutputInterface $output): int { + CommandHelper::warnPhpConfigOptionsHaveNoEffect($input, $this->io); + if (! PieVersion::isPharBuild()) { $this->io->writeError('Aborting! You are not running a PHAR, cannot self-update.'); @@ -118,28 +116,21 @@ public function execute(InputInterface $input, OutputInterface $output): int $this->io->write(sprintf('Updating using the %s channel.', $updateChannel->value)); - $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $this->io); - CommandHelper::applyNoCacheOptionIfSet($input, $this->io); - $composer = PieComposerFactory::createPieComposer( - $this->container, - PieComposerRequest::noOperation( - new NullIO(), - $targetPlatform, - ), - ); + $composerConfig = Factory::createConfig($this->quieterConsoleIo); + $this->quieterConsoleIo->loadConfiguration($composerConfig); $fetchLatestPieRelease = FetchPieReleaseFromGitHub::factory( $this->quieterConsoleIo, - $composer->getConfig(), + $composerConfig, $this->githubApiBaseUrl, PieAssetName::forRunningPie(), ); $verifyPiePhar = VerifyPieReleaseUsingAttestation::factory( $fetchLatestPieRelease, $this->quieterConsoleIo, - $composer->getConfig(), + $composerConfig, $this->githubApiBaseUrl, ); diff --git a/src/Command/SelfVerifyCommand.php b/src/Command/SelfVerifyCommand.php index 3bb83b4f..a080e4dc 100644 --- a/src/Command/SelfVerifyCommand.php +++ b/src/Command/SelfVerifyCommand.php @@ -4,10 +4,8 @@ namespace Php\Pie\Command; +use Composer\Factory; use Composer\IO\IOInterface; -use Composer\IO\NullIO; -use Php\Pie\ComposerIntegration\PieComposerFactory; -use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\QuieterConsoleIO; use Php\Pie\File\BinaryFile; use Php\Pie\File\FullPathToSelf; @@ -17,7 +15,6 @@ use Php\Pie\SelfManage\Verify\VerifyPieReleaseUsingAttestation; use Php\Pie\Util\Emoji; use Php\Pie\Util\PieVersion; -use Psr\Container\ContainerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -42,7 +39,6 @@ public function __construct( private readonly FullPathToSelf $fullPathToSelf, private readonly IOInterface $io, private readonly QuieterConsoleIO $quieterConsoleIo, - private readonly ContainerInterface $container, ) { parent::__construct(); } @@ -61,6 +57,8 @@ public function configure(): void public function execute(InputInterface $input, OutputInterface $output): int { + CommandHelper::warnPhpConfigOptionsHaveNoEffect($input, $this->io); + if (! PieVersion::isPharBuild()) { $this->io->writeError('Aborting! You are not running a PHAR, cannot self-verify.'); @@ -74,21 +72,14 @@ public function execute(InputInterface $input, OutputInterface $output): int $this->io->write(sprintf('No version specified, verifying against the version this PHAR claims to be (%s).', $expectedVersion)); } - $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $this->io); - CommandHelper::applyNoCacheOptionIfSet($input, $this->io); - $composer = PieComposerFactory::createPieComposer( - $this->container, - PieComposerRequest::noOperation( - new NullIO(), - $targetPlatform, - ), - ); + $composerConfig = Factory::createConfig($this->quieterConsoleIo); + $this->quieterConsoleIo->loadConfiguration($composerConfig); $fetchLatestPieRelease = FetchPieReleaseFromGitHub::factory( $this->quieterConsoleIo, - $composer->getConfig(), + $composerConfig, $this->githubApiBaseUrl, PieAssetName::forRunningPie(), ); @@ -98,7 +89,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $verifyPiePhar = VerifyPieReleaseUsingAttestation::factory( $fetchLatestPieRelease, $this->quieterConsoleIo, - $composer->getConfig(), + $composerConfig, $this->githubApiBaseUrl, ); From 86256945a28f17603ec99e02243e5b974c28d5ae Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Tue, 13 Oct 2026 20:59:04 +0100 Subject: [PATCH 10/10] 739: split PHP version echo --- src/Command/CommandHelper.php | 17 ++++++++++++----- src/Command/SelfUpdateCommand.php | 1 + src/Command/SelfVerifyCommand.php | 2 ++ 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index 98887354..dc6944ff 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -122,6 +122,17 @@ public static function configurePhpConfigOptions(Command $command): void ); } + public static function writeRunningPhpVersion(IOInterface $io): void + { + if (PiePlatform::isRunningStaticPhp()) { + $io->write(sprintf('You are running a PIE Static PHP %s build', PHP_VERSION)); + + return; + } + + $io->write(sprintf('You are running PHP %s', PHP_VERSION)); + } + public static function warnPhpConfigOptionsHaveNoEffect(InputInterface $input, IOInterface $io): void { foreach ([self::OPTION_WITH_PHP_CONFIG, self::OPTION_WITH_PHP_PATH, self::OPTION_WITH_PHPIZE_PATH] as $option) { @@ -292,11 +303,7 @@ public static function determineTargetPlatformFromInputs(InputInterface $input, $targetPlatform = TargetPlatform::fromPhpBinaryPath($phpBinaryPath, $makeParallelJobs, $phpizePath); - if (PiePlatform::isRunningStaticPhp()) { - $io->write(sprintf('You are running a PIE Static PHP %s build', PHP_VERSION)); - } else { - $io->write(sprintf('You are running PHP %s', PHP_VERSION)); - } + self::writeRunningPhpVersion($io); $io->write(sprintf( 'Target PHP installation: %s %s%s, on %s %s (from %s)', diff --git a/src/Command/SelfUpdateCommand.php b/src/Command/SelfUpdateCommand.php index c3cbe321..060e6a3e 100644 --- a/src/Command/SelfUpdateCommand.php +++ b/src/Command/SelfUpdateCommand.php @@ -115,6 +115,7 @@ public function execute(InputInterface $input, OutputInterface $output): int } $this->io->write(sprintf('Updating using the %s channel.', $updateChannel->value)); + CommandHelper::writeRunningPhpVersion($this->io); CommandHelper::applyNoCacheOptionIfSet($input, $this->io); diff --git a/src/Command/SelfVerifyCommand.php b/src/Command/SelfVerifyCommand.php index a080e4dc..f5285149 100644 --- a/src/Command/SelfVerifyCommand.php +++ b/src/Command/SelfVerifyCommand.php @@ -72,6 +72,8 @@ public function execute(InputInterface $input, OutputInterface $output): int $this->io->write(sprintf('No version specified, verifying against the version this PHAR claims to be (%s).', $expectedVersion)); } + CommandHelper::writeRunningPhpVersion($this->io); + CommandHelper::applyNoCacheOptionIfSet($input, $this->io); $composerConfig = Factory::createConfig($this->quieterConsoleIo);