Author: Shivam Mathur (shivammathur)
Date: 2026-07-26T13:20:57+05:30
Commit: Publish PHP SBOM metadata · php/web-downloads@bf3d635 · GitHub
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/pcre2@10.40"
+ }]
+ }
+ }' \
+ 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/pcre2@10.40',
+ ],
+ ],
+ ];
+ $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']);