Author: Shivam Mathur (shivammathur)
Date: 2026-07-26T13:20:57+05:30

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

Publish PHP SBOM metadata

Changed paths:
  A  .github/workflows/sbom.yml
  M  API.md
  M  src/Console/Command/SeriesUpdateCommand.php
  M  src/Http/Controllers/SeriesUpdateController.php
  M  src/Validator.php
  M  tests/Console/Command/SeriesUpdateCommandTest.php
  M  tests/Http/Controllers/SeriesUpdateControllerTest.php
  M  tests/ValidatorTest.php


Diff:

diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml
new file mode 100644
index 0000000..e8bd1fa
--- /dev/null
+++ b/.github/workflows/sbom.yml
@@ -0,0 +1,30 @@
+name: Publish PHP SBOM metadata
+run-name: Publish PHP ${{ inputs.php-version }} SBOM metadata
+on:
+  workflow_dispatch:
+    inputs:
+      php-version:
+        description: PHP version
+        required: true
+      sbom:
+        description: SBOM metadata JSON
+        required: true
+
+jobs:
+  publish:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Publish
+        env:
+          AUTH_TOKEN: ${{ secrets.AUTH_TOKEN }}
+          PHP_VERSION: ${{ inputs.php-version }}
+          SBOM: ${{ inputs.sbom }}
+        run: |
+          jq --null-input --arg php_version "$PHP_VERSION" --argjson sbom 
"$SBOM" \
+             '{php_version: $php_version, sbom: $sbom}' > request.json
+          curl --fail-with-body \
+               --request POST \
+               --location https://downloads.php.net/api/series-update \
+               --header "Authorization: Bearer $AUTH_TOKEN" \
+               --header 'Content-Type: application/json' \
+               --data-binary @request.json
diff --git a/API.md b/API.md
index 947d095..998c6af 100644
--- a/API.md
+++ b/API.md
@@ -246,6 +246,7 @@ curl -i -X POST \
     - `stability` (string, required): Either `stable` or `staging`.
     - `library` (string, required): Library identifier to update/remove.
     - `ref` (string, required but may be empty): Matches 
`^([a-zA-Z0-9\.-]+)?$`, 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.
+    - For an SBOM metadata update, send `php_version` and an `sbom` object 
instead of the series fields.
 - Success: `200 OK`, empty body.
 - Errors:
     - `400` with validation details if the payload is invalid.
@@ -285,6 +286,28 @@ curl -i -X POST \
     https://downloads.php.net/api/series-update
 ```
 
+Example (SBOM metadata)
+
+```bash
+curl -i -X POST \
+    -H "Authorization: Bearer $AUTH_TOKEN" \
+    -H "Content-Type: application/json" \
+    -d '{
+            "php_version": "8.2",
+            "sbom": {
+                "license": "PHP-3.01",
+                "components": [{
+                    "name": "pcre2lib",
+                    "version": "10.40",
+                    "path": "ext/pcre/pcre2lib",
+                    "license": "BSD-3-Clause WITH PCRE2-exception",
+                    "purl": "pkg:generic/[email protected]"
+                }]
+            }
+        }' \
+    https://downloads.php.net/api/series-update
+```
+
 ---
 
 ### POST /api/series-stability
diff --git a/src/Console/Command/SeriesUpdateCommand.php 
b/src/Console/Command/SeriesUpdateCommand.php
index b5f4373..715fd0e 100644
--- a/src/Console/Command/SeriesUpdateCommand.php
+++ b/src/Console/Command/SeriesUpdateCommand.php
@@ -45,13 +45,17 @@ public function handle(): int
             foreach ($pendingTasks as $taskFile) {
                 $data = $this->decodeTask($taskFile);
 
-                $this->updateSeriesFiles(
-                    $data['php_version'],
-                    $data['vs_version'],
-                    $data['stability'],
-                    $data['library'],
-                    $data['ref']
-                );
+                if (($data['type'] ?? null) === 'sbom') {
+                    $this->publishSbomMetadata($data);
+                } else {
+                    $this->updateSeriesFiles(
+                        $data['php_version'],
+                        $data['vs_version'],
+                        $data['stability'],
+                        $data['library'],
+                        $data['ref']
+                    );
+                }
 
                 unlink($taskFile);
                 unlink($taskFile . '.lock');
@@ -68,6 +72,16 @@ private function decodeTask(string $taskFile): array
     {
         $data = json_decode(file_get_contents($taskFile), true, 512, 
JSON_THROW_ON_ERROR);
 
+        if (($data['type'] ?? null) === 'sbom') {
+            if (!isset($data['php_version']) || 
!is_string($data['php_version'])) {
+                throw new Exception('Missing field: php_version');
+            }
+            if (!isset($data['sbom']) || !is_array($data['sbom'])) {
+                throw new Exception('Missing field: sbom');
+            }
+            return $data;
+        }
+
         $required = ['php_version', 'vs_version', 'stability', 'library', 
'ref'];
         foreach ($required as $field) {
             if (!array_key_exists($field, $data)) {
@@ -82,6 +96,52 @@ private function decodeTask(string $taskFile): array
         return $data;
     }
 
+    private function publishSbomMetadata(array $data): void
+    {
+        $phpVersion = $data['php_version'];
+        if (preg_match('/^(?:\d+\.\d+|master)$/', $phpVersion) !== 1) {
+            throw new Exception('Invalid SBOM task');
+        }
+        $metadata = $data['sbom'];
+        if (!is_array($metadata)
+                || !is_string($metadata['license'] ?? null)
+                || $metadata['license'] === ''
+                || !is_array($metadata['components'] ?? null)
+                || $metadata['components'] === []) {
+            throw new Exception('Invalid SBOM metadata');
+        }
+        foreach ($metadata['components'] as $component) {
+            if (!is_array($component)
+                    || !is_string($component['name'] ?? null)
+                    || !is_string($component['path'] ?? null)
+                    || !is_string($component['license'] ?? null)
+                    || !is_string($component['purl'] ?? null)
+                    || $component['name'] === ''
+                    || $component['path'] === ''
+                    || $component['license'] === ''
+                    || $component['purl'] === ''
+                    || (isset($component['version'])
+                        && (!is_string($component['version']) || 
$component['version'] === ''))) {
+                throw new Exception('Invalid SBOM component');
+            }
+        }
+
+        $destinationDirectory = $this->baseDirectory . '/php-sdk/sbom';
+        if (!is_dir($destinationDirectory)) {
+            mkdir($destinationDirectory, 0755, true);
+        }
+        $destination = $destinationDirectory . '/php-' . $phpVersion . '.json';
+        $temporary = tempnam($destinationDirectory, '.php-sbom-');
+        if ($temporary === false
+                || file_put_contents(
+                    $temporary,
+                    json_encode($metadata, JSON_THROW_ON_ERROR | 
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
+                ) === false
+                || !rename($temporary, $destination)) {
+            throw new Exception("Could not publish '$destination'");
+        }
+    }
+
     private function updateSeriesFiles(
         string $phpVersion,
         string $vsVersion,
diff --git a/src/Http/Controllers/SeriesUpdateController.php 
b/src/Http/Controllers/SeriesUpdateController.php
index 25c0cf4..b4649bd 100644
--- a/src/Http/Controllers/SeriesUpdateController.php
+++ b/src/Http/Controllers/SeriesUpdateController.php
@@ -10,13 +10,18 @@ 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|regex:/^[a-zA-Z0-9_-]+$/',
-            'ref' => 'string|regex:/^([a-zA-Z0-9\.-]+)?$/',
-        ]);
+        $validator = new Validator(isset($data['sbom'])
+            ? [
+                'php_version' => 
'required|string|regex:/^(?:\d+\.\d+|master)$/',
+                'sbom' => 'required|array',
+            ]
+            : [
+                '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|regex:/^[a-zA-Z0-9_-]+$/',
+                'ref' => 'string|regex:/^([a-zA-Z0-9\.-]+)?$/',
+            ]);
 
         $validator->validate($data);
 
@@ -43,15 +48,23 @@ protected function execute(array $data): void
             mkdir($seriesDirectory, 0755, true);
         }
 
-        $payload = [
-            'php_version' => $data['php_version'],
-            'vs_version' => $data['vs_version'],
-            'stability' => $data['stability'],
-            'library' => $data['library'],
-            'ref' => $data['ref'],
-        ];
+        $payload = isset($data['sbom'])
+            ? [
+                'type' => 'sbom',
+                'php_version' => $data['php_version'],
+                'sbom' => $data['sbom'],
+            ]
+            : [
+                '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']) . uniqid('', true);
+        $hash = isset($data['sbom'])
+            ? hash('sha256', $data['php_version'] . 
json_encode($data['sbom'])) . uniqid('', true)
+            : hash('sha256', $data['php_version'] . $data['vs_version'] . 
$data['library']) . uniqid('', true);
         $file = $seriesDirectory . '/series-update-' . $hash . '.json';
 
         file_put_contents($file, json_encode($payload));
diff --git a/src/Validator.php b/src/Validator.php
index 3b7fbe4..fa126e7 100644
--- a/src/Validator.php
+++ b/src/Validator.php
@@ -66,6 +66,11 @@ protected function string(mixed $value): bool
         return is_string($value);
     }
 
+    protected function array(mixed $value): bool
+    {
+        return is_array($value);
+    }
+
     protected function regex(mixed $value, ?string $pattern): bool
     {
         return is_string($value) && is_string($pattern) && 
preg_match($pattern, $value) === 1;
@@ -77,6 +82,7 @@ protected function getErrorMessage(string $field, string 
$rule, ?string $value):
             'required' => "The $field field is required.",
             'url' => "The $field field must be a valid URL.",
             'string' => "The $field field must be a string.",
+            'array' => "The $field field must be an array.",
             'regex' => "The $field field must match the pattern $value.",
         ];
 
diff --git a/tests/Console/Command/SeriesUpdateCommandTest.php 
b/tests/Console/Command/SeriesUpdateCommandTest.php
index fb7d532..4ec4738 100644
--- a/tests/Console/Command/SeriesUpdateCommandTest.php
+++ b/tests/Console/Command/SeriesUpdateCommandTest.php
@@ -298,6 +298,41 @@ public function testHandlesCorruptJson(): void
         $this->assertFileExists($taskFile . '.lock');
     }
 
+    public function testPublishesSbomMetadata(): void
+    {
+        $metadata = [
+            'license' => 'PHP-3.01',
+            'components' => [
+                [
+                    'name' => 'pcre2lib',
+                    'version' => '10.40',
+                    'path' => 'ext/pcre/pcre2lib',
+                    'license' => 'BSD-3-Clause WITH PCRE2-exception',
+                    'purl' => 'pkg:generic/[email protected]',
+                ],
+            ],
+        ];
+        $taskFile = $this->createTask([
+            'type' => 'sbom',
+            'php_version' => '8.2',
+            'sbom' => $metadata,
+        ]);
+
+        $command = new SeriesUpdateCommand();
+        $command->options = [
+            'base-directory' => $this->baseDirectory,
+            'builds-directory' => $this->buildsDirectory,
+        ];
+
+        $this->assertSame(0, $command->handle());
+        $destination = $this->baseDirectory . '/php-sdk/sbom/php-8.2.json';
+        $this->assertFileExists($destination);
+        $metadata = json_decode(file_get_contents($destination), true, 512, 
JSON_THROW_ON_ERROR);
+        $this->assertSame('PHP-3.01', $metadata['license']);
+        $this->assertSame('pcre2lib', $metadata['components'][0]['name']);
+        $this->assertFileDoesNotExist($taskFile);
+    }
+
     private function createTask(array $data): string
     {
         $seriesDir = $this->buildsDirectory . '/series';
diff --git a/tests/Http/Controllers/SeriesUpdateControllerTest.php 
b/tests/Http/Controllers/SeriesUpdateControllerTest.php
index 7bd79f3..c2ae0ef 100644
--- a/tests/Http/Controllers/SeriesUpdateControllerTest.php
+++ b/tests/Http/Controllers/SeriesUpdateControllerTest.php
@@ -132,10 +132,32 @@ public function testRejectsInvalidRefFormat(): void
         $this->assertEmpty(glob($this->buildsDirectory . 
'/series/series-update-*.json'));
     }
 
+    public function testEnqueuesSbomUpdate(): void
+    {
+        $payload = [
+            'php_version' => '8.2',
+            'sbom' => [
+                'license' => 'PHP-3.01',
+                'components' => [],
+            ],
+        ];
+        $inputPath = $this->createInputFile($payload);
+
+        (new SeriesUpdateController($inputPath))->handle();
+        unlink($inputPath);
+
+        $taskFiles = glob($this->buildsDirectory . 
'/series/series-update-*.json');
+        $this->assertCount(1, $taskFiles);
+        $task = json_decode(file_get_contents($taskFiles[0]), true, 512, 
JSON_THROW_ON_ERROR);
+        $this->assertSame('sbom', $task['type']);
+        $this->assertSame($payload['php_version'], $task['php_version']);
+        $this->assertSame($payload['sbom'], $task['sbom']);
+    }
+
     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
+}
diff --git a/tests/ValidatorTest.php b/tests/ValidatorTest.php
index a815ba3..4211c8e 100644
--- a/tests/ValidatorTest.php
+++ b/tests/ValidatorTest.php
@@ -23,6 +23,12 @@ public function testValidateStringField() {
         $this->assertFalse($validator->isValid, 'Validation should fail when 
required field is missing.');
     }
 
+    public function testValidateArrayField() {
+        $validator = new Validator(['items' => 'array']);
+        $validator->validate(['items' => []]);
+        $this->assertTrue($validator->isValid, 'Validation should pass when 
the field is an array.');
+    }
+
     public function testValidationRegexField() {
         $validator = new Validator(['date' => 'regex:/\d{4}-\d{2}-\d{2}/']);
         $validator->validate(['date' => '01/01/2025']);

Reply via email to