[web-downloads] main: Publish PHP SBOM metadata

From: Date: Sun, 26 Jul 2026 07:51:25 +0000
Subject: [web-downloads] main: Publish PHP SBOM metadata
Groups: php.webmaster 
Request: Send a blank email to php-webmaster+get-33891@lists.php.net to get a copy of this message
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/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']);

« previous php.webmaster (#33891) next »