[PHP-WEBMASTER] [web-downloads] support-build-number: Support numbered PHP builds

Author: Shivam Mathur (shivammathur)
Date: 2026-10-26T04:14:39+05:30

Commit: Support numbered PHP builds · php/web-downloads@d0d2685 · GitHub
Raw diff: https://github.com/php/web-downloads/commit/d0d268577bd3c28cb4ab6b4fbceaa9e1555f7ff7.diff

Support numbered PHP builds

Changed paths:
  M src/Actions/GetListing.php
  M src/Console/Command/PhpCommand.php
  M tests/Actions/GetListingTest.php
  M tests/Console/Command/PhpCommandTest.php

Diff:

diff --git a/src/Actions/GetListing.php b/src/Actions/GetListing.php
index 5194809..5e6648b 100644
--- a/src/Actions/GetListing.php
+++ b/src/Actions/GetListing.php
@@ -22,8 +22,12 @@ public function handle(string $directory): array
             $parts = $this->parseFileName(basename($file));
             $key = ($parts['nts'] ? 'nts-' : 'ts-') . $parts['vc'] . '-' . $parts['arch'];
             $version_short = $parts['version_short'];
- if (!isset($releases[$version_short]['version'])) {
- $releases[$version_short]['version'] = $parts['version'];
+ $currentVersion = $releases[$version_short]['version'] ?? null;
+ if ($currentVersion !== null && version_compare($parts['version'], $currentVersion) < 0) {
+ continue;
+ }
+ if ($currentVersion === null || version_compare($parts['version'], $currentVersion) > 0) {
+ $releases[$version_short] = ['version' => $parts['version']];
             }
             $releases[$version_short][$key]['mtime'] = $mtime;
             $releases[$version_short][$key]['zip'] = [
@@ -104,7 +108,11 @@ public function parseFileName($fileName): array
         $fileName = str_replace(['-Win32', '.zip'], ['', ''], $fileName);

         $parts = explode('-', $fileName);
- if (is_numeric($parts[2]) || $parts[2] == 'dev') {
+ if ($parts[2] === 'dev' && isset($parts[3]) && ctype_digit($parts[3])) {
+ $parts[2] .= '-' . $parts[3];
+ array_splice($parts, 3, 1);
+ }
+ if (is_numeric($parts[2]) || str_starts_with($parts[2], 'dev')) {
             $version = $parts[1] . '-' . $parts[2];
             $nts = $parts[3] == 'nts' ? 'nts' : false;
             if ($nts) {
diff --git a/src/Console/Command/PhpCommand.php b/src/Console/Command/PhpCommand.php
index da2da67..c6543c2 100644
--- a/src/Console/Command/PhpCommand.php
+++ b/src/Console/Command/PhpCommand.php
@@ -86,8 +86,10 @@ public function handle(): int
                 $this->updateReleasesJson->handle($releases, $destinationDirectory);
                 if ($destinationDirectory === $this->baseDirectory . '/releases') {
                     $this->updateLatestBuilds($releases, $destinationDirectory);
- $parts = explode('.', $version);
- $this->promoteSeriesFiles($parts[0] . '.' . $parts[1]);
+ $series = implode('.', array_slice(explode('.', $version), 0, 2));
+ if (($releases[$series]['version'] ?? null) === $version) {
+ $this->promoteSeriesFiles($series);
+ }
                 }

                 Helpers::rmdirr($tempDirectory);
@@ -113,7 +115,7 @@ public function handle(): int
     private function getDestinationDirectory(string $tempDirectory): string
     {
         $version = $this->getBuildVersion($tempDirectory);
- return $this->baseDirectory . (preg_match('/^\d+\.\d+\.\d+$/', $version) ? '/releases' : '/qa');
+ return $this->baseDirectory . (preg_match('/^\d+\.\d+\.\d+(?:-\d+)?$/', $version) ? '/releases' : '/qa');
     }

     /**
@@ -143,7 +145,7 @@ private function moveBuild(string $tempDirectory, string $destinationDirectory):
     {
         $files = glob($tempDirectory . '/*');
         if ($files) {
- $version = $this->getFileVersion($files[0]);
+ $version = $this->getBuildVersion($tempDirectory);
             foreach ($files as $file) {
                 $fileName = basename($file);
                 $destination = $destinationDirectory . '/' . $fileName;
@@ -160,6 +162,12 @@ private function copyBuildsToArchive(string $directory, string $version): void
     {
         $version_short = substr($version, 0, 3);
         $files = glob($directory . '/php*' . $version_short . '*.zip');
+ foreach ($files as $file) {
+ $fileVersion = $this->getFileVersion($file);
+ if (version_compare($fileVersion, $version) > 0) {
+ $version = $fileVersion;
+ }
+ }
         if(!is_dir($directory . '/archives')) {
             mkdir($directory . '/archives', 0755, true);
         }
@@ -190,8 +198,8 @@ private function getFileVersion(string $file): string
             $pattern = '/php-/';
         }
         $file = preg_replace($pattern, '', $file);
- $parts = explode('-', (string) $file);
- return str_replace('.zip', '', $parts[0]);
+ preg_match('/^(\d+\.\d+\.\d+(?:(?:alpha|beta|RC)\d+|-dev)?(?:-\d+)?)/i', (string) $file, $matches);
+ return $matches[1] ?? '';
     }

     /**
diff --git a/tests/Actions/GetListingTest.php b/tests/Actions/GetListingTest.php
index 29ed332..8a63056 100644
--- a/tests/Actions/GetListingTest.php
+++ b/tests/Actions/GetListingTest.php
@@ -80,6 +80,26 @@ public static function parseFileNameProvider(): array
                     'ts' => false,
                 ],
             ],
+ 'numbered nts' => [
+ 'php-8.5.12-1-nts-Win32-vs17-x64.zip',
+ ['version' => '8.5.12-1', 'version_short' => '8.5', 'nts' => 'nts', 'vc' => 'vs17', 'arch' => 'x64', 'ts' => false],
+ ],
+ 'numbered ts' => [
+ 'php-8.5.12-10-Win32-vs17-x86.zip',
+ ['version' => '8.5.12-10', 'version_short' => '8.5', 'nts' => false, 'vc' => 'vs17', 'arch' => 'x86', 'ts' => false],
+ ],
+ 'numbered dev' => [
+ 'php-8.6.0-dev-2-nts-Win32-vs18-arm64.zip',
+ ['version' => '8.6.0-dev-2', 'version_short' => '8.6', 'nts' => 'nts', 'vc' => 'vs18', 'arch' => 'arm64', 'ts' => false],
+ ],
+ 'numbered RC' => [
+ 'php-8.6.0RC1-2-Win32-vs18-x64.zip',
+ ['version' => '8.6.0RC1-2', 'version_short' => '8.6', 'nts' => false, 'vc' => 'vs18', 'arch' => 'x64', 'ts' => false],
+ ],
+ 'latest alias' => [
+ 'php-8.5-nts-Win32-vs17-x64-latest.zip',
+ ['version' => '8.5', 'version_short' => '8.5', 'nts' => 'nts', 'vc' => 'vs17', 'arch' => 'x64', 'ts' => false],
+ ],
         ];
     }

@@ -207,4 +227,29 @@ public function testHandleWithMatchingFiles(): void
         $this->assertEquals($expectedSha256, $installerInfo['sha256']);
         $this->assertFileExists($this->tempDir . '/sha256sum.txt');
     }
+
+ public function testListingSelectsOneRevisionAcrossEveryPackage(): void
+ {
+ foreach (['8.5.12', '8.5.12-1', '8.5.12-2', '8.5.12-10'] as $version) {
+ foreach ([
+ "php-$version-nts-Win32-vs17-x64.zip",
+ "php-debug-pack-$version-nts-Win32-vs17-x64.zip",
+ "php-devel-pack-$version-nts-Win32-vs17-x64.zip",
+ "php-$version-src.zip",
+ "php-test-pack-$version.zip",
+ ] as $file) {
+ file_put_contents($this->tempDir . '/' . $file, $file);
+ }
+ }
+ // An older architecture must not leak into metadata for the selected revision.
+ file_put_contents($this->tempDir . '/php-8.5.12-nts-Win32-vs17-x86.zip', 'older architecture');
+ $release = $this->getListing->handle($this->tempDir)['8.5'];
+ $this->assertSame('8.5.12-10', $release['version']);
+ $this->assertArrayNotHasKey('nts-vs17-x86', $release);
+ foreach (['zip', 'debug_pack', 'devel_pack'] as $type) {
+ $this->assertStringContainsString('8.5.12-10-', $release['nts-vs17-x64'][$type]['path']);
+ }
+ $this->assertSame('php-8.5.12-10-src.zip', $release['source']['path']);
+ $this->assertSame('php-test-pack-8.5.12-10.zip', $release['test_pack']['path']);
+ }
}
diff --git a/tests/Console/Command/PhpCommandTest.php b/tests/Console/Command/PhpCommandTest.php
index d0b490b..218da51 100644
--- a/tests/Console/Command/PhpCommandTest.php
+++ b/tests/Console/Command/PhpCommandTest.php
@@ -83,6 +83,12 @@ public static function buildsProvider(): array
                 'php-test-pack-8.4.0-dev.zip',
             ]]
         ];
+ foreach ($builds as [$files]) {
+ $builds[] = [array_map(
+ static fn (string $file): string => str_replace(['8.4.1', '8.4.0-dev'], ['8.4.1-1', '8.4.0-dev-2'], $file),
+ $files
+ )];
+ }
         foreach ($builds as [$files]) {
             $builds[] = [array_map(
                 static fn (string $file): string => str_replace('-x64', '-arm64', $file),
@@ -103,7 +109,7 @@ private function stageBuilds(array $phpZips, $zipPath): void
                 } else {
                     $innerZip = new ZipArchive();
                     if ($innerZip->open($zipFilePath, ZipArchive::CREATE) === TRUE) {
- $innerZip->addFromString("test_file.php", "<?php echo 'Hello, world!'; ?>");
+ $innerZip->addFromString('build.txt', $zipFileName);
                         $innerZip->close();
                     }
                 }
@@ -130,6 +136,9 @@ public function testCommandHandlesSuccessfulExecution(array $phpZips): void

         $expectedDestination = $this->baseDirectory . (str_contains($phpZips[0], '-dev-') ? '/qa' : '/releases');
         $this->assertDirectoryExists($expectedDestination, "Destination directory should exist.");
+ foreach ($phpZips as $file) {
+ $this->assertFileExists($expectedDestination . '/' . $file);
+ }
         foreach (array_filter($phpZips, static fn (string $file): bool => str_ends_with($file, '.json')) as $sidecar) {
             $this->assertFileExists($expectedDestination . '/' . $sidecar);
             $this->assertFileExists($expectedDestination . '/archives/' . $sidecar);
@@ -253,4 +262,56 @@ public function testQaReleaseDoesNotPromoteSeriesFiles(): void
         $this->assertFileDoesNotExist($this->baseDirectory . '/php-sdk/deps/series/packages-8.4-vs17-x64-stable.txt');
         $this->assertFileDoesNotExist($this->baseDirectory . '/php-sdk/deps/series/packages-8.4-vs17-arm64-stable.txt');
     }
+
+ public function testRebuildsArchiveOlderNumbersAndNeverDowngradeLatest(): void
+ {
+ $command = new PhpCommand(new GetListing(), new UpdateReleasesJson());
+ $command->options = ['base-directory' => $this->baseDirectory, 'builds-directory' => $this->buildsDirectory];
+ $files = self::buildsProvider()[0][0];
+ $releaseDirectory = $this->baseDirectory . '/releases';
+ $latestVersion = '8.4.1';
+ foreach (['8.4.1', '8.4.1-1', '8.4.1-2', '8.4.1-10', '8.4.1-3', '8.4.2', '8.4.1-11'] as $version) {
+ $builds = array_map(static fn (string $file): string => str_replace('8.4.1', $version, $file), $files);
+ $this->stageBuilds($builds, $this->buildsDirectory . '/php/rebuild.zip');
+ $this->assertSame(0, $command->handle());
+ if (version_compare($version, $latestVersion) > 0) {
+ $latestVersion = $version;
+ }
+ $releases = json_decode(file_get_contents($releaseDirectory . '/releases.json'), true, flags: JSON_THROW_ON_ERROR);
+ $this->assertSame($latestVersion, $releases['8.4']['version']);
+ foreach ($builds as $file) {
+ $this->assertFileExists($releaseDirectory . '/archives/' . $file);
+ if ($version !== $latestVersion) {
+ $this->assertFileDoesNotExist($releaseDirectory . '/' . $file);
+ }
+ }
+ foreach ($files as $original) {
+ $current = str_replace('8.4.1', $latestVersion, $original);
+ $this->assertFileExists($releaseDirectory . '/' . $current);
+ if (str_ends_with($original, '.zip')) {
+ $latest = str_replace(['8.4.1', '.zip'], ['8.4', '-latest.zip'], $original);
+ $this->assertFileEquals($releaseDirectory . '/' . $current, $releaseDirectory . '/latest/' . $latest);
+ }
+ }
+ array_walk_recursive($releases, function ($value, $key) use ($latestVersion, $releaseDirectory): void {
+ if ($key === 'path') {
+ $this->assertStringContainsString($latestVersion, $value);
+ $this->assertFileExists($releaseDirectory . '/' . $value);
+ }
+ });
+ foreach (['nts', 'ts'] as $ts) {
+ foreach (['x86', 'x64'] as $arch) {
+ foreach (['zip', 'debug_pack', 'devel_pack'] as $type) {
+ $entry = $releases['8.4']["$ts-vs17-$arch"][$type];
+ $this->assertSame(hash_file('sha256', $releaseDirectory . '/' . $entry['path']), $entry['sha256']);
+ }
+ }
+ }
+ }
+ foreach (['8.4.1', '8.4.1-1', '8.4.1-2', '8.4.1-10'] as $old) {
+ foreach ($files as $file) {
+ $this->assertFileDoesNotExist($releaseDirectory . '/' . str_replace('8.4.1', $old, $file));
+ }
+ }
+ }
}