Author: Shivam Mathur (shivammathur)
Date: 2025-09-26T01:34:52+05:30
Commit:
https://github.com/php/web-downloads/commit/66fe29e20afc67287231bbdf6a8629ae13c3d4c1
Raw diff:
https://github.com/php/web-downloads/commit/66fe29e20afc67287231bbdf6a8629ae13c3d4c1.diff
Add support to delete and promote series files to stable
Changed paths:
A .github/workflows/series-delete.yml
A .github/workflows/series-stability.yml
A src/Console/Command/SeriesDeleteCommand.php
A src/Console/Command/SeriesStabilityCommand.php
A src/Http/Controllers/SeriesDeleteController.php
A src/Http/Controllers/SeriesStabilityController.php
A tests/Console/Command/SeriesDeleteCommandTest.php
A tests/Console/Command/SeriesStabilityCommandTest.php
A tests/Http/Controllers/SeriesDeleteControllerTest.php
A tests/Http/Controllers/SeriesStabilityControllerTest.php
M API.md
M routes.php
Diff:
diff --git a/.github/workflows/series-delete.yml
b/.github/workflows/series-delete.yml
new file mode 100644
index 0000000..1d3b605
--- /dev/null
+++ b/.github/workflows/series-delete.yml
@@ -0,0 +1,23 @@
+name: Delete series files
+run-name: Series delete ${{ inputs.php_version }}
+on:
+ workflow_dispatch:
+ inputs:
+ php_version:
+ description: 'PHP Version'
+ required: true
+ vs_version:
+ description: 'VS Version'
+ required: true
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ environment: downloads.php.net
+ steps:
+ - name: Run
+ run: |
+ curl \
+ --request POST \
+ --location https://downloads.php.net/api/series-delete \
+ --header 'Authorization: Bearer ${{ secrets.AUTH_TOKEN }}' \
+ --data '{ "php_version": "${{ inputs.php_version }}", "vs_version":
"${{ inputs.vs_version }}" }'
diff --git a/.github/workflows/series-stability.yml
b/.github/workflows/series-stability.yml
new file mode 100644
index 0000000..cfc41c6
--- /dev/null
+++ b/.github/workflows/series-stability.yml
@@ -0,0 +1,23 @@
+name: Promote series files to stable
+run-name: Series stability ${{ inputs.php_version }}
+on:
+ workflow_dispatch:
+ inputs:
+ php_version:
+ description: 'PHP Version'
+ required: true
+ vs_version:
+ description: 'VS Version'
+ required: true
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ environment: downloads.php.net
+ steps:
+ - name: Run
+ run: |
+ curl \
+ --request POST \
+ --location https://downloads.php.net/api/series-stability \
+ --header 'Authorization: Bearer ${{ secrets.AUTH_TOKEN }}' \
+ --data '{ "php_version": "${{ inputs.php_version }}", "vs_version":
"${{ inputs.vs_version }}" }'
diff --git a/API.md b/API.md
index 0bdcbb8..1bc0735 100644
--- a/API.md
+++ b/API.md
@@ -144,3 +144,53 @@ curl -i -X POST \
}' \
https://downloads.php.net/api/series-init
```
+
+---
+
+### POST /api/series-delete
+
+- Auth: Required
+- Purpose: Queue deletion of existing series files.
+- Request body (JSON):
+ - `php_version` (string, required): Matches `^(\d+\.\d+|master)$`.
+ - `vs_version` (string, required): Matches `^v[c|s]\d{2}$`.
+- Success: `200 OK`, empty body.
+- Errors: `400` with validation details if input is invalid.
+
+Example
+
+```bash
+curl -i -X POST \
+ -H "Authorization: Bearer $AUTH_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "php_version": "8.3",
+ "vs_version": "vs16"
+ }' \
+ https://downloads.php.net/api/series-delete
+```
+
+---
+
+### POST /api/series-stability
+
+- Auth: Required
+- Purpose: Promote the staging series files to stable.
+- Request body (JSON):
+ - `php_version` (string, required): Matches `^(\d+\.\d+|master)$`.
+ - `vs_version` (string, required): Matches `^v[c|s]\d{2}$`.
+- Success: `200 OK`, empty body.
+- Errors: `400` with validation details if input is invalid.
+
+Example
+
+```bash
+curl -i -X POST \
+ -H "Authorization: Bearer $AUTH_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "php_version": "8.3",
+ "vs_version": "vs16"
+ }' \
+ https://downloads.php.net/api/series-stability
+```
diff --git a/routes.php b/routes.php
index d8a5471..fb590a3 100644
--- a/routes.php
+++ b/routes.php
@@ -3,7 +3,9 @@
use App\Http\Controllers\IndexController;
use App\Http\Controllers\PeclController;
use App\Http\Controllers\PhpController;
+use App\Http\Controllers\SeriesDeleteController;
use App\Http\Controllers\SeriesInitController;
+use App\Http\Controllers\SeriesStabilityController;
use App\Http\Controllers\WinlibsController;
use App\Router;
@@ -13,4 +15,6 @@
$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->registerRoute('/api/series-delete', 'POST',
SeriesDeleteController::class, true);
+$router->registerRoute('/api/series-stability', 'POST',
SeriesStabilityController::class, true);
$router->handleRequest();
diff --git a/src/Console/Command/SeriesDeleteCommand.php
b/src/Console/Command/SeriesDeleteCommand.php
new file mode 100644
index 0000000..d032dfb
--- /dev/null
+++ b/src/Console/Command/SeriesDeleteCommand.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace App\Console\Command;
+
+use App\Console\Command;
+use Exception;
+
+class SeriesDeleteCommand extends Command
+{
+ protected string $signature = 'series:delete --base-directory=
--builds-directory=';
+ protected string $description = 'Delete 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-delete-*.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->deleteSeriesFiles($php_version, $vs_version);
+ unlink($filepath);
+ unlink($filepath . '.lock');
+ }
+ return Command::SUCCESS;
+ } catch (Exception $e) {
+ echo $e->getMessage();
+ return Command::FAILURE;
+ }
+ }
+
+ /**
+ * @throws Exception
+ */
+ private function deleteSeriesFiles(
+ string $php_version,
+ string $vs_version,
+ ): 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) {
+ $filePath = $baseDirectory . '/packages-' . $php_version . '-'
. $vs_version . '-' . $arch . '-' . $stability . '.txt';
+ if(file_exists($filePath)) {
+ unlink($filePath);
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Console/Command/SeriesStabilityCommand.php
b/src/Console/Command/SeriesStabilityCommand.php
new file mode 100644
index 0000000..362cbca
--- /dev/null
+++ b/src/Console/Command/SeriesStabilityCommand.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace App\Console\Command;
+
+use App\Console\Command;
+use Exception;
+
+class SeriesStabilityCommand extends Command
+{
+ protected string $signature = 'series:stability --base-directory=
--builds-directory=';
+ protected string $description = 'Promote staging series files to stable
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-stability-*.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->promoteSeriesFiles($php_version, $vs_version);
+ unlink($filepath);
+ unlink($filepath . '.lock');
+ }
+ return Command::SUCCESS;
+ } catch (Exception $e) {
+ echo $e->getMessage();
+ return Command::FAILURE;
+ }
+ }
+
+ /**
+ * @throws Exception
+ */
+ private function promoteSeriesFiles(
+ string $php_version,
+ string $vs_version,
+ ): void
+ {
+ $baseDirectory = $this->baseDirectory . "/php-sdk/deps/series";
+
+ if (!is_dir($baseDirectory)) {
+ mkdir($baseDirectory, 0755, true);
+ }
+ foreach(['x86', 'x64'] as $arch) {
+ $sourceFile = $baseDirectory . '/packages-' . $php_version . '-' .
$vs_version . '-' . $arch . '-staging.txt';
+ $destinationFile = $baseDirectory . '/packages-' . $php_version .
'-' . $vs_version . '-' . $arch . '-stable.txt';
+ if(!file_exists($sourceFile)) {
+ throw new Exception($sourceFile . ' does not exist');
+ }
+ copy($sourceFile, $destinationFile);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Http/Controllers/SeriesDeleteController.php
b/src/Http/Controllers/SeriesDeleteController.php
new file mode 100644
index 0000000..0f54c0a
--- /dev/null
+++ b/src/Http/Controllers/SeriesDeleteController.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\BaseController;
+use App\Validator;
+
+class SeriesDeleteController 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}$/',
+ ]);
+
+ $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-delete-' . $hash . '.json';
+ file_put_contents($file, json_encode($data));
+ }
+}
\ No newline at end of file
diff --git a/src/Http/Controllers/SeriesStabilityController.php
b/src/Http/Controllers/SeriesStabilityController.php
new file mode 100644
index 0000000..9b56840
--- /dev/null
+++ b/src/Http/Controllers/SeriesStabilityController.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\BaseController;
+use App\Validator;
+
+class SeriesStabilityController 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}$/',
+ ]);
+
+ $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-stability-' . $hash . '.json';
+ file_put_contents($file, json_encode($data));
+ }
+}
\ No newline at end of file
diff --git a/tests/Console/Command/SeriesDeleteCommandTest.php
b/tests/Console/Command/SeriesDeleteCommandTest.php
new file mode 100644
index 0000000..fbca7f2
--- /dev/null
+++ b/tests/Console/Command/SeriesDeleteCommandTest.php
@@ -0,0 +1,230 @@
+<?php
+
+namespace Console\Command;
+
+use App\Console\Command\SeriesDeleteCommand;
+use App\Helpers\Helpers;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+
+class SeriesDeleteCommandTest extends TestCase
+{
+ private string $baseDirectory;
+ private string $buildsDirectory;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->baseDirectory = sys_get_temp_dir() . '/series_delete_base_' .
uniqid();
+ $this->buildsDirectory = sys_get_temp_dir() . '/series_delete_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 versionProvider(): array
+ {
+ return [
+ ['8.3', 'vs17'],
+ ['8.2', 'vs16'],
+ ];
+ }
+
+ public function testReturnsSuccessWhenNoSeriesDir(): void
+ {
+ $command = new SeriesDeleteCommand();
+ $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 SeriesDeleteCommand();
+ $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 SeriesDeleteCommand();
+ $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('versionProvider')]
+ public function testDeletesAllSeriesFilesAndCleansUpTask(string
$phpVersion, string $vsVersion): void
+ {
+ $seriesBase = $this->baseDirectory . '/php-sdk/deps/series';
+ mkdir($seriesBase, 0755, true);
+
+ $paths = [
+ "$seriesBase/packages-$phpVersion-$vsVersion-x86-stable.txt",
+ "$seriesBase/packages-$phpVersion-$vsVersion-x86-staging.txt",
+ "$seriesBase/packages-$phpVersion-$vsVersion-x64-stable.txt",
+ "$seriesBase/packages-$phpVersion-$vsVersion-x64-staging.txt",
+ ];
+ foreach ($paths as $p) {
+ file_put_contents($p, "dummy");
+ }
+
+ $seriesDir = $this->buildsDirectory . '/series';
+ mkdir($seriesDir, 0755, true);
+ $jsonPath = $seriesDir . '/series-delete-task1.json';
+ file_put_contents($jsonPath, json_encode([
+ 'php_version' => $phpVersion,
+ 'vs_version' => $vsVersion,
+ ]));
+
+ clearstatcache(true);
+
+ $command = new SeriesDeleteCommand();
+ $command->setOption('base-directory', $this->baseDirectory);
+ $command->setOption('builds-directory', $this->buildsDirectory);
+
+ $result = $command->handle();
+ $this->assertSame(0, $result, 'Command should return success.');
+
+ foreach ($paths as $p) {
+ $this->assertFileDoesNotExist($p);
+ }
+
+ $this->assertFileDoesNotExist($jsonPath);
+ $this->assertFileDoesNotExist($jsonPath . '.lock');
+ }
+
+ #[DataProvider('versionProvider')]
+ public function testIdempotentWhenSomeFilesMissing(string $phpVersion,
string $vsVersion): void
+ {
+ $seriesBase = $this->baseDirectory . '/php-sdk/deps/series';
+ mkdir($seriesBase, 0755, true);
+
+ $existing = [
+ "$seriesBase/packages-$phpVersion-$vsVersion-x86-stable.txt",
+ "$seriesBase/packages-$phpVersion-$vsVersion-x64-staging.txt",
+ ];
+ foreach ($existing as $p) {
+ file_put_contents($p, "exists");
+ }
+ $missing = [
+ "$seriesBase/packages-$phpVersion-$vsVersion-x86-staging.txt",
+ "$seriesBase/packages-$phpVersion-$vsVersion-x64-stable.txt",
+ ];
+
+ $seriesDir = $this->buildsDirectory . '/series';
+ mkdir($seriesDir, 0755, true);
+ $jsonPath = $seriesDir . '/series-delete-task2.json';
+ file_put_contents($jsonPath, json_encode([
+ 'php_version' => $phpVersion,
+ 'vs_version' => $vsVersion,
+ ]));
+
+ clearstatcache(true);
+
+ $command = new SeriesDeleteCommand();
+ $command->setOption('base-directory', $this->baseDirectory);
+ $command->setOption('builds-directory', $this->buildsDirectory);
+
+ $result = $command->handle();
+ $this->assertSame(0, $result);
+
+ foreach ($existing as $p) {
+ $this->assertFileDoesNotExist($p);
+ }
+ foreach ($missing as $p) {
+ $this->assertFileDoesNotExist($p);
+ }
+
+ $this->assertFileDoesNotExist($jsonPath);
+ $this->assertFileDoesNotExist($jsonPath . '.lock');
+ }
+
+ public function testSkipsLockedJsonFile(): void
+ {
+ $phpVersion = '8.3';
+ $vsVersion = 'vs17';
+
+ $seriesBase = $this->baseDirectory . '/php-sdk/deps/series';
+ mkdir($seriesBase, 0755, true);
+
+ $target = "$seriesBase/packages-$phpVersion-$vsVersion-x86-stable.txt";
+ file_put_contents($target, 'keep');
+
+ $seriesDir = $this->buildsDirectory . '/series';
+ mkdir($seriesDir, 0755, true);
+ $jsonPath = $seriesDir . '/series-delete-locked.json';
+ file_put_contents($jsonPath, json_encode([
+ 'php_version' => $phpVersion,
+ 'vs_version' => $vsVersion,
+ ]));
+ touch($jsonPath . '.lock');
+
+ clearstatcache(true);
+
+ $command = new SeriesDeleteCommand();
+ $command->setOption('base-directory', $this->baseDirectory);
+ $command->setOption('builds-directory', $this->buildsDirectory);
+
+ $result = $command->handle();
+ $this->assertSame(0, $result);
+
+ $this->assertFileExists($jsonPath);
+
+ $this->assertFileExists($target);
+ }
+
+ public function testHandlesCorruptJson(): void
+ {
+ $phpVersion = '8.3';
+ $vsVersion = 'vs17';
+
+ $seriesBase = $this->baseDirectory . '/php-sdk/deps/series';
+ mkdir($seriesBase, 0755, true);
+
+ $target = "$seriesBase/packages-$phpVersion-$vsVersion-x86-stable.txt";
+ file_put_contents($target, 'data');
+
+ $seriesDir = $this->buildsDirectory . '/series';
+ mkdir($seriesDir, 0755, true);
+ $jsonPath = $seriesDir . '/series-delete-bad.json';
+ file_put_contents($jsonPath, '{corrupt json');
+
+ $command = new SeriesDeleteCommand();
+ $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, 'Corrupt JSON should return FAILURE.');
+ $this->assertStringContainsString('Syntax error', $output);
+
+ $this->assertFileExists($jsonPath);
+ $this->assertFileExists($jsonPath . '.lock');
+
+ $this->assertFileExists($target);
+ }
+}
diff --git a/tests/Console/Command/SeriesStabilityCommandTest.php
b/tests/Console/Command/SeriesStabilityCommandTest.php
new file mode 100644
index 0000000..cf186a6
--- /dev/null
+++ b/tests/Console/Command/SeriesStabilityCommandTest.php
@@ -0,0 +1,201 @@
+<?php
+
+namespace Console\Command;
+
+use App\Console\Command\SeriesStabilityCommand;
+use App\Helpers\Helpers;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+
+class SeriesStabilityCommandTest extends TestCase
+{
+ private string $baseDirectory;
+ private string $buildsDirectory;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->baseDirectory = sys_get_temp_dir() .
'/series_stability_base_' . uniqid();
+ $this->buildsDirectory = sys_get_temp_dir() .
'/series_stability_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 versionProvider(): array
+ {
+ return [
+ ['8.3', 'vs17'],
+ ['8.2', 'vs16'],
+ ];
+ }
+
+ public function testReturnsSuccessWhenNoSeriesDir(): void
+ {
+ $command = new SeriesStabilityCommand();
+ $command->setOption('base-directory', $this->baseDirectory);
+ $command->setOption('builds-directory', $this->buildsDirectory);
+
+ $result = $command->handle();
+
+ $this->assertSame(0, $result);
+ }
+
+ public function testMissingBaseDirectory(): void
+ {
+ $command = new SeriesStabilityCommand();
+ $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 SeriesStabilityCommand();
+ $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('versionProvider')]
+ public function testPromotesStagingToStableAndCleansTask(string
$phpVersion, string $vsVersion): void
+ {
+ $seriesBase = $this->baseDirectory . '/php-sdk/deps/series';
+ mkdir($seriesBase, 0755, true);
+
+ $srcX86 =
"$seriesBase/packages-$phpVersion-$vsVersion-x86-staging.txt";
+ $srcX64 =
"$seriesBase/packages-$phpVersion-$vsVersion-x64-staging.txt";
+ file_put_contents($srcX86, "x86-content");
+ file_put_contents($srcX64, "x64-content");
+
+ $seriesDir = $this->buildsDirectory . '/series';
+ mkdir($seriesDir, 0755, true);
+ $jsonPath = $seriesDir . '/series-stability-task1.json';
+ file_put_contents($jsonPath, json_encode([
+ 'php_version' => $phpVersion,
+ 'vs_version' => $vsVersion,
+ ]));
+
+ $command = new SeriesStabilityCommand();
+ $command->setOption('base-directory', $this->baseDirectory);
+ $command->setOption('builds-directory', $this->buildsDirectory);
+
+ $result = $command->handle();
+ $this->assertSame(0, $result);
+
+ $dstX86 = "$seriesBase/packages-$phpVersion-$vsVersion-x86-stable.txt";
+ $dstX64 = "$seriesBase/packages-$phpVersion-$vsVersion-x64-stable.txt";
+ $this->assertFileExists($dstX86);
+ $this->assertFileExists($dstX64);
+ $this->assertSame("x86-content", file_get_contents($dstX86));
+ $this->assertSame("x64-content", file_get_contents($dstX64));
+
+ $this->assertFileDoesNotExist($jsonPath);
+ $this->assertFileDoesNotExist($jsonPath . '.lock');
+ }
+
+ public function testSkipsLockedJsonFile(): void
+ {
+ $phpVersion = '8.3';
+ $vsVersion = 'vs17';
+
+ $seriesBase = $this->baseDirectory . '/php-sdk/deps/series';
+ mkdir($seriesBase, 0755, true);
+
file_put_contents("$seriesBase/packages-$phpVersion-$vsVersion-x86-staging.txt",
"x86");
+
file_put_contents("$seriesBase/packages-$phpVersion-$vsVersion-x64-staging.txt",
"x64");
+
+ $seriesDir = $this->buildsDirectory . '/series';
+ mkdir($seriesDir, 0755, true);
+ $jsonPath = $seriesDir . '/series-stability-locked.json';
+ file_put_contents($jsonPath, json_encode([
+ 'php_version' => $phpVersion,
+ 'vs_version' => $vsVersion,
+ ]));
+ touch($jsonPath . '.lock');
+
+ $command = new SeriesStabilityCommand();
+ $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-$phpVersion-$vsVersion-x86-stable.txt");
+
$this->assertFileDoesNotExist("$seriesBase/packages-$phpVersion-$vsVersion-x64-stable.txt");
+ }
+
+ public function testHandlesCorruptJson(): void
+ {
+ $seriesDir = $this->buildsDirectory . '/series';
+ mkdir($seriesDir, 0755, true);
+ $jsonPath = $seriesDir . '/series-stability-bad.json';
+ file_put_contents($jsonPath, '{corrupt json');
+
+ $command = new SeriesStabilityCommand();
+ $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');
+ }
+
+ public function testFailsWhenAnySourceStagingFileIsMissing(): void
+ {
+ $phpVersion = '8.3';
+ $vsVersion = 'vs17';
+
+ $seriesBase = $this->baseDirectory . '/php-sdk/deps/series';
+ mkdir($seriesBase, 0755, true);
+
file_put_contents("$seriesBase/packages-$phpVersion-$vsVersion-x86-staging.txt",
"x86");
+
+ $seriesDir = $this->buildsDirectory . '/series';
+ mkdir($seriesDir, 0755, true);
+ $jsonPath = $seriesDir . '/series-stability-task.json';
+ file_put_contents($jsonPath, json_encode([
+ 'php_version' => $phpVersion,
+ 'vs_version' => $vsVersion,
+ ]));
+
+ $command = new SeriesStabilityCommand();
+ $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-$phpVersion-$vsVersion-x64-staging.txt
does not exist", $output);
+
+ $this->assertFileExists($jsonPath);
+ $this->assertFileExists($jsonPath . '.lock');
+
$this->assertFileExists("$seriesBase/packages-$phpVersion-$vsVersion-x86-staging.txt");
+
$this->assertFileExists("$seriesBase/packages-$phpVersion-$vsVersion-x86-stable.txt");
+
$this->assertFileDoesNotExist("$seriesBase/packages-$phpVersion-$vsVersion-x64-stable.txt");
+ }
+}
diff --git a/tests/Http/Controllers/SeriesDeleteControllerTest.php
b/tests/Http/Controllers/SeriesDeleteControllerTest.php
new file mode 100644
index 0000000..23fe8cb
--- /dev/null
+++ b/tests/Http/Controllers/SeriesDeleteControllerTest.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Http\Controllers;
+
+use App\Http\Controllers\SeriesDeleteController;
+use PHPUnit\Framework\TestCase;
+
+class MockSeriesDeleteController extends SeriesDeleteController {
+ 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 SeriesDeleteControllerTest extends TestCase {
+ public function testHandleWithValidData() {
+ $data = json_encode(["key" => "value"]);
+ $tempFile = tempnam(sys_get_temp_dir(), 'phpunit');
+ file_put_contents($tempFile, $data);
+ $controller = new MockSeriesDeleteController($tempFile);
+ $this->expectOutputString("Executed");
+ $controller->handle();
+ unlink($tempFile);
+ }
+}
diff --git a/tests/Http/Controllers/SeriesStabilityControllerTest.php
b/tests/Http/Controllers/SeriesStabilityControllerTest.php
new file mode 100644
index 0000000..45e69ca
--- /dev/null
+++ b/tests/Http/Controllers/SeriesStabilityControllerTest.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Http\Controllers;
+
+use App\Http\Controllers\SeriesInitController;
+use App\Http\Controllers\SeriesStabilityController;
+use PHPUnit\Framework\TestCase;
+
+class MockSeriesStabilityController extends SeriesStabilityController {
+ 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 SeriesStabilityControllerTest extends TestCase {
+ public function testHandleWithValidData() {
+ $data = json_encode(["key" => "value"]);
+ $tempFile = tempnam(sys_get_temp_dir(), 'phpunit');
+ file_put_contents($tempFile, $data);
+ $controller = new MockSeriesStabilityController($tempFile);
+ $this->expectOutputString("Executed");
+ $controller->handle();
+ unlink($tempFile);
+ }
+}