Author: Shivam Mathur (shivammathur)
Date: 2025-11-19T06:43:38+05:30

Commit: 
https://github.com/php/web-downloads/commit/64cfaa7e1d78f5db8de21b67c74e63493800de16
Raw diff: 
https://github.com/php/web-downloads/commit/64cfaa7e1d78f5db8de21b67c74e63493800de16.diff

Add api to update series files

Changed paths:
  A  src/Console/Command/SeriesUpdateCommand.php
  A  src/Http/Controllers/SeriesUpdateController.php
  A  tests/Console/Command/SeriesUpdateCommandTest.php
  A  tests/Http/Controllers/SeriesUpdateControllerTest.php
  M  API.md
  M  routes.php


Diff:

diff --git a/API.md b/API.md
index 9240bff..2933363 100644
--- a/API.md
+++ b/API.md
@@ -221,6 +221,57 @@ curl -i -X POST \
 
 ---
 
+### POST /api/series-update
+
+- Auth: Required
+- Purpose: Queue an update to a library entry in a series packages file, or 
remove it entirely.
+- Request body (JSON):
+    - `php_version` (string, required): Matches `^(\d+\.\d+|master)$`.
+    - `vs_version` (string, required): Matches `^v[c|s]\d{2}$`.
+    - `stability` (string, required): Either `stable` or `staging`.
+    - `library` (string, required): Library identifier to update/remove.
+    - `ref` (string, required but may be empty): When non-empty, 
updates/creates entries named `<library>-<ref>-<vs_version>-<arch>.zip` for 
both `x86` and `x64`; when empty, removes the library from both files if 
present.
+- Success: `200 OK`, empty body.
+- Errors:
+    - `400` with validation details if the payload is invalid.
+    - `500` if `BUILDS_DIRECTORY` is not configured on the server.
+
+Example (update)
+
+```bash
+curl -i -X POST \
+    -H "Authorization: Bearer $AUTH_TOKEN" \
+    -H "Content-Type: application/json" \
+    -d '{
+            "php_version": "8.2",
+            "vs_version": "vs16",
+            "arch": "x64",
+            "stability": "stable",
+            "library": "libxml2",
+            "ref": "2.9.15"
+        }' \
+    https://downloads.php.net/api/series-update
+```
+
+Example (remove)
+
+```bash
+curl -i -X POST \
+    -H "Authorization: Bearer $AUTH_TOKEN" \
+    -H "Content-Type: application/json" \
+    -d '{
+            "php_version": "8.2",
+            "vs_version": "vs16",
+            "arch": "x64",
+            "stability": "stable",
+            "library": "libxml2",
+            "ref": ""
+        }' \
+    https://downloads.php.net/api/series-update
+```
+
+---
+
 ### POST /api/series-stability
 
 - Auth: Required
diff --git a/routes.php b/routes.php
index 46c959d..b2813b8 100644
--- a/routes.php
+++ b/routes.php
@@ -9,6 +9,7 @@
 use App\Http\Controllers\SeriesDeleteController;
 use App\Http\Controllers\SeriesInitController;
 use App\Http\Controllers\SeriesStabilityController;
+use App\Http\Controllers\SeriesUpdateController;
 use App\Http\Controllers\WinlibsController;
 use App\Router;
 
@@ -21,5 +22,6 @@
 $router->registerRoute('/api/php', 'POST', PhpController::class, true);
 $router->registerRoute('/api/series-init', 'POST', 
SeriesInitController::class, true);
 $router->registerRoute('/api/series-delete', 'POST', 
SeriesDeleteController::class, true);
+$router->registerRoute('/api/series-update', 'POST', 
SeriesUpdateController::class, true);
 $router->registerRoute('/api/series-stability', 'POST', 
SeriesStabilityController::class, true);
 $router->handleRequest();
diff --git a/src/Console/Command/SeriesUpdateCommand.php 
b/src/Console/Command/SeriesUpdateCommand.php
new file mode 100644
index 0000000..f50362f
--- /dev/null
+++ b/src/Console/Command/SeriesUpdateCommand.php
@@ -0,0 +1,142 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Console\Command;
+
+use App\Console\Command;
+use Exception;
+
+class SeriesUpdateCommand extends Command
+{
+    protected string $signature = 'series:update --base-directory= 
--builds-directory=';
+    protected string $description = 'Update or remove libraries in series 
package files';
+
+    protected ?string $baseDirectory = null;
+
+    public function handle(): int
+    {
+        try {
+            $this->baseDirectory = $this->getOption('base-directory');
+            if (!$this->baseDirectory) {
+                throw new Exception('Base directory is required');
+            }
+
+            $buildsDirectory = $this->getOption('builds-directory');
+            if (!$buildsDirectory) {
+                throw new Exception('Build directory is required');
+            }
+
+            $seriesDirectory = $buildsDirectory . '/series';
+            if (!is_dir($seriesDirectory)) {
+                return Command::SUCCESS;
+            }
+
+            $tasks = glob($seriesDirectory . '/series-update-*.json');
+            $pendingTasks = [];
+
+            foreach ($tasks as $taskFile) {
+                $lockFile = $taskFile . '.lock';
+                if (!file_exists($lockFile)) {
+                    touch($lockFile);
+                    $pendingTasks[] = $taskFile;
+                }
+            }
+
+            foreach ($pendingTasks as $taskFile) {
+                $data = $this->decodeTask($taskFile);
+
+                $this->updateSeriesFiles(
+                    $data['php_version'],
+                    $data['vs_version'],
+                    $data['stability'],
+                    $data['library'],
+                    $data['ref']
+                );
+
+                unlink($taskFile);
+                unlink($taskFile . '.lock');
+            }
+
+            return Command::SUCCESS;
+        } catch (Exception $e) {
+            echo $e->getMessage();
+            return Command::FAILURE;
+        }
+    }
+
+    private function decodeTask(string $taskFile): array
+    {
+        $data = json_decode(file_get_contents($taskFile), true, 512, 
JSON_THROW_ON_ERROR);
+
+        $required = ['php_version', 'vs_version', 'stability', 'library', 
'ref'];
+        foreach ($required as $field) {
+            if (!array_key_exists($field, $data)) {
+                throw new Exception("Missing field: $field");
+            }
+        }
+
+        if (!is_string($data['ref'])) {
+            throw new Exception('Invalid field: ref');
+        }
+
+        return $data;
+    }
+
+    private function updateSeriesFiles(
+        string $phpVersion,
+        string $vsVersion,
+        string $stability,
+        string $library,
+        string $ref
+    ): void {
+        $seriesDirectory = $this->baseDirectory . '/php-sdk/deps/series';
+        if (!is_dir($seriesDirectory)) {
+            mkdir($seriesDirectory, 0755, true);
+        }
+
+        $arches = ['x86', 'x64'];
+
+        foreach ($arches as $arch) {
+            $filePath = $seriesDirectory . 
"/packages-$phpVersion-$vsVersion-$arch-$stability.txt";
+
+            $lines = [];
+            if (file_exists($filePath)) {
+                $lines = file($filePath, FILE_IGNORE_NEW_LINES);
+            }
+
+            $refValue = trim($ref);
+            $package = $refValue === '' ? null : sprintf('%s-%s-%s-%s.zip', 
$library, $refValue, $vsVersion, $arch);
+
+            $replaced = false;
+            foreach ($lines as $index => $line) {
+                if (str_starts_with($line, $library . '-')) {
+                    if ($package === null) {
+                        unset($lines[$index]);
+                    } elseif (!$replaced) {
+                        $lines[$index] = $package;
+                        $replaced = true;
+                    } else {
+                        unset($lines[$index]);
+                    }
+                }
+            }
+
+            $lines = array_values($lines);
+
+            if ($package !== null && !$replaced) {
+                $lines[] = $package;
+            }
+
+            if (empty($lines)) {
+                if (file_exists($filePath)) {
+                    unlink($filePath);
+                }
+                continue;
+            }
+
+            $tmpFile = $filePath . '.tmp';
+            file_put_contents($tmpFile, implode("\n", $lines), LOCK_EX);
+            rename($tmpFile, $filePath);
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/Http/Controllers/SeriesUpdateController.php 
b/src/Http/Controllers/SeriesUpdateController.php
new file mode 100644
index 0000000..f36d44e
--- /dev/null
+++ b/src/Http/Controllers/SeriesUpdateController.php
@@ -0,0 +1,59 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Http\Controllers;
+
+use App\Http\BaseController;
+use App\Validator;
+
+class SeriesUpdateController extends BaseController
+{
+    protected function validate(array $data): bool
+    {
+        $validator = new Validator([
+            'php_version' => 'required|string|regex:/^(?:\d+\.\d+|master)$/',
+            'vs_version' => 'required|string|regex:/^v[c|s]\d{2}$/',
+            'stability' => 'required|string|regex:/^(stable|staging)$/',
+            'library' => 'required|string',
+            'ref' => 'string',
+        ]);
+
+        $validator->validate($data);
+
+        if (!$validator->isValid()) {
+            http_response_code(400);
+            echo 'Invalid request: ' . $validator;
+            return false;
+        }
+
+        return true;
+    }
+
+    protected function execute(array $data): void
+    {
+        $directory = rtrim((string) getenv('BUILDS_DIRECTORY'), '/');
+        if ($directory === '') {
+            http_response_code(500);
+            echo 'Invalid server configuration: BUILDS_DIRECTORY is not set.';
+            return;
+        }
+
+        $seriesDirectory = $directory . '/series';
+        if (!is_dir($seriesDirectory)) {
+            mkdir($seriesDirectory, 0755, true);
+        }
+
+        $payload = [
+            'php_version' => $data['php_version'],
+            'vs_version' => $data['vs_version'],
+            'stability' => $data['stability'],
+            'library' => $data['library'],
+            'ref' => $data['ref'],
+        ];
+
+        $hash = hash('sha256', $data['php_version'] . $data['vs_version'] . 
$data['library']) . microtime(true);
+        $file = $seriesDirectory . '/series-update-' . $hash . '.json';
+
+        file_put_contents($file, json_encode($payload));
+    }
+}
\ No newline at end of file
diff --git a/tests/Console/Command/SeriesUpdateCommandTest.php 
b/tests/Console/Command/SeriesUpdateCommandTest.php
new file mode 100644
index 0000000..30bafca
--- /dev/null
+++ b/tests/Console/Command/SeriesUpdateCommandTest.php
@@ -0,0 +1,258 @@
+<?php
+declare(strict_types=1);
+
+namespace Console\Command;
+
+use App\Console\Command\SeriesUpdateCommand;
+use App\Helpers\Helpers;
+use PHPUnit\Framework\TestCase;
+
+class SeriesUpdateCommandTest extends TestCase
+{
+    private string $baseDirectory;
+    private string $buildsDirectory;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->baseDirectory = sys_get_temp_dir() . '/series_update_base_' . 
uniqid();
+        $this->buildsDirectory = sys_get_temp_dir() . '/series_update_builds_' 
. uniqid();
+
+        mkdir($this->baseDirectory, 0755, true);
+        mkdir($this->buildsDirectory, 0755, true);
+    }
+
+    protected function tearDown(): void
+    {
+        (new Helpers)->rmdirr($this->baseDirectory);
+        (new Helpers)->rmdirr($this->buildsDirectory);
+        parent::tearDown();
+    }
+
+    public function testMissingBaseDirectory(): void
+    {
+        $command = new SeriesUpdateCommand();
+        $command->setOption('builds-directory', $this->buildsDirectory);
+
+        ob_start();
+        $result = $command->handle();
+        $output = trim(ob_get_clean());
+
+        $this->assertSame(1, $result);
+        $this->assertSame('Base directory is required', $output);
+    }
+
+    public function testMissingBuildsDirectory(): void
+    {
+        $command = new SeriesUpdateCommand();
+        $command->setOption('base-directory', $this->baseDirectory);
+
+        ob_start();
+        $result = $command->handle();
+        $output = trim(ob_get_clean());
+
+        $this->assertSame(1, $result);
+        $this->assertSame('Build directory is required', $output);
+    }
+
+    public function testReturnsSuccessWhenNoSeriesDirectory(): void
+    {
+        $command = new SeriesUpdateCommand();
+        $command->setOption('base-directory', $this->baseDirectory);
+        $command->setOption('builds-directory', $this->buildsDirectory);
+
+        $result = $command->handle();
+
+        $this->assertSame(0, $result);
+    }
+
+    public function testUpdatesExistingLibraryEntry(): void
+    {
+        $seriesDirectory = $this->baseDirectory . '/php-sdk/deps/series';
+        mkdir($seriesDirectory, 0755, true);
+
+        $filePathX64 = $seriesDirectory . '/packages-8.2-vs16-x64-stable.txt';
+        $filePathX86 = $seriesDirectory . '/packages-8.2-vs16-x86-stable.txt';
+
+        file_put_contents($filePathX64, implode("\n", [
+            'libxml2-2.9.14-vs16-x64.zip',
+            'openssl-1.1.1w-vs16-x64.zip',
+        ]));
+
+        file_put_contents($filePathX86, implode("\n", [
+            'zlib-1.2.13-vs16-x86.zip',
+            'libxml2-2.9.14-vs16-x86.zip',
+        ]));
+
+        $this->createTask([
+            'php_version' => '8.2',
+            'vs_version' => 'vs16',
+            'stability' => 'stable',
+            'library' => 'libxml2',
+            'ref' => '2.9.15',
+        ]);
+
+        $command = new SeriesUpdateCommand();
+        $command->setOption('base-directory', $this->baseDirectory);
+        $command->setOption('builds-directory', $this->buildsDirectory);
+
+        $result = $command->handle();
+
+        $this->assertSame(0, $result);
+
+        $x64Lines = file($filePathX64, FILE_IGNORE_NEW_LINES);
+        $this->assertSame([
+            'libxml2-2.9.15-vs16-x64.zip',
+            'openssl-1.1.1w-vs16-x64.zip',
+        ], $x64Lines);
+
+        $x86Lines = file($filePathX86, FILE_IGNORE_NEW_LINES);
+        $this->assertSame([
+            'zlib-1.2.13-vs16-x86.zip',
+            'libxml2-2.9.15-vs16-x86.zip',
+        ], $x86Lines);
+
+        $this->assertEmpty(glob($this->buildsDirectory . 
'/series/series-update-*.json'));
+    }
+
+    public function testRemovesLibraryWhenNoPackageProvided(): void
+    {
+        $seriesDirectory = $this->baseDirectory . '/php-sdk/deps/series';
+        mkdir($seriesDirectory, 0755, true);
+
+        $filePathX86 = $seriesDirectory . '/packages-8.1-vs17-x86-stable.txt';
+        $filePathX64 = $seriesDirectory . '/packages-8.1-vs17-x64-stable.txt';
+
+        file_put_contents($filePathX86, implode("\n", [
+            'curl-7.88.0-vs17-x86.zip',
+            'libzip-1.9.1-vs17-x86.zip',
+        ]));
+
+        file_put_contents($filePathX64, implode("\n", [
+            'curl-7.88.0-vs17-x64.zip',
+            'libzip-1.9.1-vs17-x64.zip',
+        ]));
+
+        $this->createTask([
+            'php_version' => '8.1',
+            'vs_version' => 'vs17',
+            'stability' => 'stable',
+            'library' => 'libzip',
+            'ref' => '',
+        ]);
+
+        $command = new SeriesUpdateCommand();
+        $command->setOption('base-directory', $this->baseDirectory);
+        $command->setOption('builds-directory', $this->buildsDirectory);
+
+        $result = $command->handle();
+
+        $this->assertSame(0, $result);
+
+        $x86Lines = file($filePathX86, FILE_IGNORE_NEW_LINES);
+        $this->assertSame(['curl-7.88.0-vs17-x86.zip'], $x86Lines);
+
+        $x64Lines = file($filePathX64, FILE_IGNORE_NEW_LINES);
+        $this->assertSame(['curl-7.88.0-vs17-x64.zip'], $x64Lines);
+    }
+
+    public function testCreatesSeriesFileWhenMissing(): void
+    {
+        $this->createTask([
+            'php_version' => '8.3',
+            'vs_version' => 'vs17',
+            'stability' => 'staging',
+            'library' => 'libpq',
+            'ref' => '16.0.0',
+        ]);
+
+        $command = new SeriesUpdateCommand();
+        $command->setOption('base-directory', $this->baseDirectory);
+        $command->setOption('builds-directory', $this->buildsDirectory);
+
+        $result = $command->handle();
+
+        $this->assertSame(0, $result);
+
+        $filePathX64 = $this->baseDirectory . 
'/php-sdk/deps/series/packages-8.3-vs17-x64-staging.txt';
+        $filePathX86 = $this->baseDirectory . 
'/php-sdk/deps/series/packages-8.3-vs17-x86-staging.txt';
+
+        $this->assertFileExists($filePathX64);
+        $this->assertFileExists($filePathX86);
+
+        $this->assertSame([
+            'libpq-16.0.0-vs17-x64.zip',
+        ], file($filePathX64, FILE_IGNORE_NEW_LINES));
+
+        $this->assertSame([
+            'libpq-16.0.0-vs17-x86.zip',
+        ], file($filePathX86, FILE_IGNORE_NEW_LINES));
+    }
+
+    public function testSkipsLockedTask(): void
+    {
+        $seriesDirectory = $this->baseDirectory . '/php-sdk/deps/series';
+        mkdir($seriesDirectory, 0755, true);
+
+        $filePathX64 = $seriesDirectory . '/packages-8.0-vs16-x64-stable.txt';
+        $filePathX86 = $seriesDirectory . '/packages-8.0-vs16-x86-stable.txt';
+        file_put_contents($filePathX64, 'sqlite-3.45.0-vs16-x64.zip');
+        file_put_contents($filePathX86, 'sqlite-3.45.0-vs16-x86.zip');
+
+        $taskFile = $this->createTask([
+            'php_version' => '8.0',
+            'vs_version' => 'vs16',
+            'stability' => 'stable',
+            'library' => 'sqlite',
+            'ref' => '3.46.0',
+        ]);
+        touch($taskFile . '.lock');
+
+        $command = new SeriesUpdateCommand();
+        $command->setOption('base-directory', $this->baseDirectory);
+        $command->setOption('builds-directory', $this->buildsDirectory);
+
+        $result = $command->handle();
+
+        $this->assertSame(0, $result);
+
+        $this->assertSame('sqlite-3.45.0-vs16-x64.zip', 
trim(file_get_contents($filePathX64)));
+        $this->assertSame('sqlite-3.45.0-vs16-x86.zip', 
trim(file_get_contents($filePathX86)));
+        $this->assertFileExists($taskFile);
+    }
+
+    public function testHandlesCorruptJson(): void
+    {
+        $seriesDir = $this->buildsDirectory . '/series';
+        mkdir($seriesDir, 0755, true);
+
+        $taskFile = $seriesDir . '/series-update-corrupt.json';
+        file_put_contents($taskFile, '{corrupt json');
+
+        $command = new SeriesUpdateCommand();
+        $command->setOption('base-directory', $this->baseDirectory);
+        $command->setOption('builds-directory', $this->buildsDirectory);
+
+        ob_start();
+        $result = $command->handle();
+        $output = ob_get_clean();
+
+        $this->assertSame(1, $result);
+        $this->assertStringContainsString('Syntax error', $output);
+        $this->assertFileExists($taskFile);
+        $this->assertFileExists($taskFile . '.lock');
+    }
+
+    private function createTask(array $data): string
+    {
+        $seriesDir = $this->buildsDirectory . '/series';
+        if (!is_dir($seriesDir)) {
+            mkdir($seriesDir, 0755, true);
+        }
+
+        $taskFile = $seriesDir . '/series-update-' . uniqid() . '.json';
+        file_put_contents($taskFile, json_encode($data));
+
+        return $taskFile;
+    }
+}
\ No newline at end of file
diff --git a/tests/Http/Controllers/SeriesUpdateControllerTest.php 
b/tests/Http/Controllers/SeriesUpdateControllerTest.php
new file mode 100644
index 0000000..56e6672
--- /dev/null
+++ b/tests/Http/Controllers/SeriesUpdateControllerTest.php
@@ -0,0 +1,106 @@
+<?php
+declare(strict_types=1);
+
+namespace Http\Controllers;
+
+use App\Helpers\Helpers;
+use App\Http\Controllers\SeriesUpdateController;
+use PHPUnit\Framework\TestCase;
+
+class SeriesUpdateControllerTest extends TestCase
+{
+    private string $buildsDirectory;
+    private ?string $originalBuildsDirectory;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->buildsDirectory = sys_get_temp_dir() . 
'/series_update_controller_' . uniqid();
+        mkdir($this->buildsDirectory, 0755, true);
+
+        $this->originalBuildsDirectory = getenv('BUILDS_DIRECTORY') ?: null;
+        putenv('BUILDS_DIRECTORY=' . $this->buildsDirectory);
+    }
+
+    protected function tearDown(): void
+    {
+        putenv($this->originalBuildsDirectory === null ? 'BUILDS_DIRECTORY' : 
'BUILDS_DIRECTORY=' . $this->originalBuildsDirectory);
+        (new Helpers)->rmdirr($this->buildsDirectory);
+        parent::tearDown();
+    }
+
+    public function testEnqueuesUpdateTask(): void
+    {
+        $payload = [
+            'php_version' => '8.2',
+            'vs_version' => 'vs16',
+            'stability' => 'stable',
+            'library' => 'zlib',
+            'ref' => '1.2.13',
+        ];
+
+        $inputPath = $this->createInputFile($payload);
+
+        $controller = new SeriesUpdateController($inputPath);
+        $controller->handle();
+        unlink($inputPath);
+
+        $taskFiles = glob($this->buildsDirectory . 
'/series/series-update-*.json');
+        $this->assertNotEmpty($taskFiles);
+
+        $taskData = json_decode(file_get_contents($taskFiles[0]), true, 512, 
JSON_THROW_ON_ERROR);
+        $this->assertSame($payload, $taskData);
+    }
+
+    public function testEnqueuesDeleteTaskWithoutPackage(): void
+    {
+        $payload = [
+            'php_version' => '8.1',
+            'vs_version' => 'vs17',
+            'stability' => 'staging',
+            'library' => 'libcurl',
+            'ref' => '',
+        ];
+
+        $inputPath = $this->createInputFile($payload);
+
+        $controller = new SeriesUpdateController($inputPath);
+        $controller->handle();
+        unlink($inputPath);
+
+        $taskFiles = glob($this->buildsDirectory . 
'/series/series-update-*.json');
+        $this->assertNotEmpty($taskFiles);
+        $taskData = json_decode(file_get_contents($taskFiles[0]), true, 512, 
JSON_THROW_ON_ERROR);
+
+        $this->assertSame($payload, $taskData);
+    }
+
+    public function testRejectsInvalidPackageName(): void
+    {
+        $payload = [
+            'php_version' => '8.0',
+            'vs_version' => 'vs16',
+            'stability' => 'stable',
+            'library' => 'openssl',
+            // ref missing entirely
+        ];
+
+        $inputPath = $this->createInputFile($payload);
+        $controller = new SeriesUpdateController($inputPath);
+
+        ob_start();
+        $controller->handle();
+        $output = ob_get_clean();
+        unlink($inputPath);
+
+        $this->assertStringContainsString('The ref field must be a string.', 
$output);
+        $this->assertEmpty(glob($this->buildsDirectory . 
'/series/series-update-*.json'));
+    }
+
+    private function createInputFile(array $data): string
+    {
+        $path = tempnam(sys_get_temp_dir(), 'series-update-input-');
+        file_put_contents($path, json_encode($data));
+        return $path;
+    }
+}
\ No newline at end of file

Reply via email to