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