[web-downloads] main: Add API to init series files (#4)
| From: | Shivam Mathur via GitHub | Date: | Wed, 24 Sep 2025 14:13:57 +0000 |
| Subject: | [web-downloads] main: Add API to init series files (#4) | ||
| Groups: | php.webmaster | ||
| Request: | Send a blank email to php-webmaster+get-33219@lists.php.net to get a copy of this message | ||
Author: Shivam Mathur (shivammathur)
Committer: GitHub (web-flow)
Pusher: shivammathur
Date: 2025-09-24T19:43:54+05:30
Commit: https://github.com/php/web-downloads/commit/840e683a6dd3b85f71604134b2f4346368ff4e20
Raw diff: https://github.com/php/web-downloads/commit/840e683a6dd3b85f71604134b2f4346368ff4e20.diff
Add API to init series files (#4)
Changed paths:
A src/Console/Command/SeriesInitCommand.php
A src/Http/Controllers/SeriesInitController.php
A tests/Console/Command/SeriesInitCommandTest.php
A tests/Http/Controllers/SeriesInitControllerTest.php
M routes.php
Diff:
diff --git a/routes.php b/routes.php
index 7491aa7..d8a5471 100644
--- a/routes.php
+++ b/routes.php
@@ -3,6 +3,7 @@
use App\Http\Controllers\IndexController;
use App\Http\Controllers\PeclController;
use App\Http\Controllers\PhpController;
+use App\Http\Controllers\SeriesInitController;
use App\Http\Controllers\WinlibsController;
use App\Router;
@@ -11,4 +12,5 @@
$router->registerRoute('/api/pecl', 'POST', PeclController::class, true);
$router->registerRoute('/api/winlibs', 'POST', WinlibsController::class,
true);
$router->registerRoute('/api/php', 'POST', PhpController::class, true);
+$router->registerRoute('/api/series-init', 'POST',
SeriesInitController::class, true);
$router->handleRequest();
diff --git a/src/Console/Command/SeriesInitCommand.php b/src/Console/Command/SeriesInitCommand.php
new file mode 100644
index 0000000..6d6405d
--- /dev/null
+++ b/src/Console/Command/SeriesInitCommand.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace App\Console\Command;
+
+use App\Console\Command;
+use Exception;
+
+class SeriesInitCommand extends Command
+{
+ protected string $signature = 'series:init --base-directory= --builds-directory=';
+ protected string $description = 'Initialize series files for libraries';
+
+ 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');
+ }
+
+ $series_directory = $buildsDirectory . '/series';
+ if(!is_dir($series_directory)) {
+ return Command::SUCCESS;
+ }
+
+ $files = glob($series_directory . '/series-init-*.json');
+
+ // We lock the files we are working on
+ // so that we don't process them again if the command is run again
+ $filteredFiles = [];
+ foreach ($files as $filepath) {
+ $lockFile = $filepath . '.lock';
+ if (!file_exists($lockFile)) {
+ touch($lockFile);
+ $filteredFiles[] = $filepath;
+ }
+ }
+
+ foreach ($filteredFiles as $filepath) {
+ $data = json_decode(file_get_contents($filepath), true, 512, JSON_THROW_ON_ERROR);
+ extract($data);
+ $this->initSeriesFiles($series, $series_vs, $target_vs);
+ unlink($filepath);
+ unlink($filepath . '.lock');
+ }
+ return Command::SUCCESS;
+ } catch (Exception $e) {
+ echo $e->getMessage();
+ return Command::FAILURE;
+ }
+ }
+
+ /**
+ * @throws Exception
+ */
+ private function initSeriesFiles(
+ string $series,
+ string $series_vs,
+ string $target_vs
+ ): void
+ {
+ $baseDirectory = $this->baseDirectory . "/php-sdk/deps/series";
+
+ if (!is_dir($baseDirectory)) {
+ mkdir($baseDirectory, 0755, true);
+ }
+ foreach(['x86', 'x64'] as $arch) {
+ foreach(['stable', 'staging'] as $stability) {
+ $sourceSeries = 'packages-master-' . $series_vs . '-' . $arch .
'-' . $stability . '.txt';
+ if(!file_exists($baseDirectory . '/' . $sourceSeries)) {
+ throw new Exception("$baseDirectory/$sourceSeries not found");
+ }
+ $destinationFileName = 'packages-' . $series . '-' . $target_vs
. '-' . $arch . '-' . $stability . '.txt';
+ copy($baseDirectory . '/' . $sourceSeries, $baseDirectory . '/'
. $destinationFileName);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Http/Controllers/SeriesInitController.php
b/src/Http/Controllers/SeriesInitController.php
new file mode 100644
index 0000000..eeab09b
--- /dev/null
+++ b/src/Http/Controllers/SeriesInitController.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\BaseController;
+use App\Validator;
+
+class SeriesInitController extends BaseController
+{
+ protected function validate(array $data): bool
+ {
+ $validator = new Validator([
+ 'series' => 'required|string',
+ 'series_vs' => 'required|string|regex:/^v[c|s]\d{2}$/',
+ 'target_vs' => 'required|string|regex:/^v[c|s]\d{2}$/',
+ 'token' => 'required|string',
+ ]);
+
+ $validator->validate($data);
+
+ $valid = $validator->isValid();
+
+ if (!$valid) {
+ http_response_code(400);
+ echo 'Invalid request: ' . $validator;
+ }
+
+ return $valid;
+ }
+
+ protected function execute(array $data): void
+ {
+ extract($data);
+ $directory = getenv('BUILDS_DIRECTORY') . '/series';
+ $hash = hash('sha256', $series) . strtotime('now');
+ $file = $directory . '/series-init-' . $hash . '.json';
+ file_put_contents($file, json_encode($data));
+ }
+}
\ No newline at end of file
diff --git a/tests/Console/Command/SeriesInitCommandTest.php
b/tests/Console/Command/SeriesInitCommandTest.php
new file mode 100644
index 0000000..8b39e8f
--- /dev/null
+++ b/tests/Console/Command/SeriesInitCommandTest.php
@@ -0,0 +1,238 @@
+<?php
+
+namespace Console\Command;
+
+use App\Console\Command\SeriesInitCommand;
+use App\Helpers\Helpers;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+
+class SeriesInitCommandTest extends TestCase
+{
+ private string $baseDirectory;
+ private string $buildsDirectory;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->baseDirectory = sys_get_temp_dir() . '/series_init_base_' . uniqid();
+ $this->buildsDirectory = sys_get_temp_dir() . '/series_init_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 static function caseProvider(): array
+ {
+ return [
+ ['8.3', 'vs17', 'vs17'],
+ ['8.2', 'vs16', 'vs16'],
+ ];
+ }
+
+ public function testReturnsSuccessWhenNoSeriesDir(): void
+ {
+ $command = new SeriesInitCommand();
+ $command->setOption('base-directory', $this->baseDirectory);
+ $command->setOption('builds-directory', $this->buildsDirectory);
+
+ $result = $command->handle();
+
+ $this->assertSame(0, $result, 'Should return success when there is no builds/series
directory.');
+ }
+
+ public function testMissingBaseDirectory(): void
+ {
+ $command = new SeriesInitCommand();
+ $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 SeriesInitCommand();
+ $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);
+ }
+
+ #[DataProvider('caseProvider')]
+ public function testProcessesJsonCopiesSeriesFilesAndCleansUp(string $series, string
$series_vs, string $target_vs): void
+ {
+ $seriesBase = $this->baseDirectory . '/php-sdk/deps/series';
+ mkdir($seriesBase, 0755, true);
+
+ $sourceNames = [
+ "packages-master-$series_vs-x86-stable.txt",
+ "packages-master-$series_vs-x86-staging.txt",
+ "packages-master-$series_vs-x64-stable.txt",
+ "packages-master-$series_vs-x64-staging.txt",
+ ];
+ foreach ($sourceNames as $name) {
+ file_put_contents($seriesBase . '/' . $name, "content-of-$name");
+ }
+
+ $buildSeriesDir = $this->buildsDirectory . '/series';
+ mkdir($buildSeriesDir, 0755, true);
+ $jsonPath = $buildSeriesDir . '/series-init-task1.json';
+ file_put_contents($jsonPath, json_encode([
+ 'series' => $series,
+ 'series_vs' => $series_vs,
+ 'target_vs' => $target_vs,
+ ]));
+
+ $command = new SeriesInitCommand();
+ $command->setOption('base-directory', $this->baseDirectory);
+ $command->setOption('builds-directory', $this->buildsDirectory);
+
+ $result = $command->handle();
+ $this->assertSame(0, $result, 'Command should return success.');
+
+ $destinations = [
+ "packages-$series-$target_vs-x86-stable.txt",
+ "packages-$series-$target_vs-x86-staging.txt",
+ "packages-$series-$target_vs-x64-stable.txt",
+ "packages-$series-$target_vs-x64-staging.txt",
+ ];
+
+ foreach ($destinations as $dest) {
+ $destPath = $seriesBase . '/' . $dest;
+ $this->assertFileExists($destPath);
+ $srcName = str_replace("packages-$series-$target_vs",
"packages-master-$series_vs", $dest);
+ $this->assertSame(
+ "content-of-$srcName",
+ file_get_contents($destPath),
+ "Destination $dest should have the same content as $srcName"
+ );
+ }
+
+ $this->assertFileDoesNotExist($jsonPath);
+ $this->assertFileDoesNotExist($jsonPath . '.lock');
+ }
+
+ public function testSkipsLockedJsonFile(): void
+ {
+ $series = '8.3';
+ $series_vs = 'vs17';
+ $target_vs = 'vs17';
+
+ $seriesBase = $this->baseDirectory . '/php-sdk/deps/series';
+ mkdir($seriesBase, 0755, true);
+ foreach ([
+ "packages-master-$series_vs-x86-stable.txt",
+ "packages-master-$series_vs-x86-staging.txt",
+ "packages-master-$series_vs-x64-stable.txt",
+ "packages-master-$series_vs-x64-staging.txt",
+ ] as $name) {
+ file_put_contents($seriesBase . '/' . $name, "ok");
+ }
+
+ $buildSeriesDir = $this->buildsDirectory . '/series';
+ mkdir($buildSeriesDir, 0755, true);
+ $jsonPath = $buildSeriesDir . '/series-init-locked.json';
+ file_put_contents($jsonPath, json_encode([
+ 'series' => $series,
+ 'series_vs' => $series_vs,
+ 'target_vs' => $target_vs,
+ ]));
+ touch($jsonPath . '.lock');
+
+ $command = new SeriesInitCommand();
+ $command->setOption('base-directory', $this->baseDirectory);
+ $command->setOption('builds-directory', $this->buildsDirectory);
+
+ $result = $command->handle();
+ $this->assertSame(0, $result);
+
+ $this->assertFileExists($jsonPath);
+ $this->assertFileExists($jsonPath . '.lock');
+
+ $this->assertFileDoesNotExist($seriesBase .
"/packages-$series-$target_vs-x86-stable.txt");
+ $this->assertFileDoesNotExist($seriesBase .
"/packages-$series-$target_vs-x86-staging.txt");
+ $this->assertFileDoesNotExist($seriesBase .
"/packages-$series-$target_vs-x64-stable.txt");
+ $this->assertFileDoesNotExist($seriesBase .
"/packages-$series-$target_vs-x64-staging.txt");
+ }
+
+ public function testHandlesCorruptJson(): void
+ {
+ $seriesDir = $this->buildsDirectory . '/series';
+ mkdir($seriesDir, 0755, true);
+ $jsonPath = $seriesDir . '/series-init-bad.json';
+ file_put_contents($jsonPath, '{corrupt json');
+
+ $command = new SeriesInitCommand();
+ $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($jsonPath);
+ $this->assertFileExists($jsonPath . '.lock', 'Lock is created before
processing and should remain after failure.');
+ }
+
+ public function testFailsWhenAnySourceSeriesFileIsMissing(): void
+ {
+ $series = '8.3';
+ $series_vs = 'vs17';
+ $target_vs = 'vs17';
+
+ $seriesBase = $this->baseDirectory . '/php-sdk/deps/series';
+ mkdir($seriesBase, 0755, true);
+
+ file_put_contents($seriesBase . "/packages-master-$series_vs-x86-stable.txt",
'ok');
+
+ $seriesDir = $this->buildsDirectory . '/series';
+ mkdir($seriesDir, 0755, true);
+ $jsonPath = $seriesDir . '/series-init-task.json';
+ file_put_contents($jsonPath, json_encode([
+ 'series' => $series,
+ 'series_vs' => $series_vs,
+ 'target_vs' => $target_vs,
+ ]));
+
+ $command = new SeriesInitCommand();
+ $command->setOption('base-directory', $this->baseDirectory);
+ $command->setOption('builds-directory', $this->buildsDirectory);
+
+ ob_start();
+ $result = $command->handle();
+ $output = trim(ob_get_clean());
+
+ $this->assertSame(1, $result);
+ $this->assertStringContainsString(
+ "$seriesBase/packages-master-$series_vs-x86-staging.txt not found",
+ $output
+ );
+
+ $this->assertFileExists($seriesBase .
"/packages-$series-$target_vs-x86-stable.txt");
+
+ $this->assertFileDoesNotExist($seriesBase .
"/packages-$series-$target_vs-x86-staging.txt");
+ $this->assertFileDoesNotExist($seriesBase .
"/packages-$series-$target_vs-x64-stable.txt");
+ $this->assertFileDoesNotExist($seriesBase .
"/packages-$series-$target_vs-x64-staging.txt");
+
+ $this->assertFileExists($jsonPath);
+ $this->assertFileExists($jsonPath . '.lock');
+ }
+}
diff --git a/tests/Http/Controllers/SeriesInitControllerTest.php
b/tests/Http/Controllers/SeriesInitControllerTest.php
new file mode 100644
index 0000000..1aa7db3
--- /dev/null
+++ b/tests/Http/Controllers/SeriesInitControllerTest.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Http\Controllers;
+
+use App\Http\Controllers\SeriesInitController;
+use PHPUnit\Framework\TestCase;
+
+class MockSeriesInitController extends SeriesInitController {
+ protected function validate(array $data): bool {
+ return isset($data['key']);
+ }
+
+ protected function execute(array $data): void {
+ echo "Executed";
+ }
+
+ public function handle(): void
+ {
+ $data = json_decode(file_get_contents($this->inputPath), true);
+
+ if ($this->validate($data)) {
+ $this->execute($data);
+ }
+ }
+}
+
+class SeriesInitControllerTest extends TestCase {
+ public function testHandleWithValidData() {
+ $data = json_encode(["key" => "value"]);
+ $tempFile = tempnam(sys_get_temp_dir(), 'phpunit');
+ file_put_contents($tempFile, $data);
+ $controller = new MockSeriesInitController($tempFile);
+ $this->expectOutputString("Executed");
+ $controller->handle();
+ unlink($tempFile);
+ }
+}