From d6c196d089e7a74e9edc11cc261e15bfedba8baa Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 7 Nov 2026 18:40:34 +0000 Subject: [PATCH 1/3] Adopt the shared tools console for manifest validation Port tools/validate.php into the same shape FOSSBilling/FOSSBilling now uses for its tooling: a FOSSBilling\Tools\Manifest\Validator class plus a manifest:validate command behind tools/bin/console. Repo-specific rules are preserved verbatim: package discovery over root directories (excluding tools and dotdirs), the 'type must be module in this repo' rule, and the icon_url containment check. The composer requirements move to the shared tooling baseline (PHP ~8.5, Symfony Console/Finder 8.1) that the CI workflow already runs on. --- .github/workflows/validate.yml | 2 +- README.md | 2 +- tools/bin/console | 25 + tools/bootstrap.php | 17 + tools/composer.json | 28 +- tools/composer.lock | 902 +++++++++++++++++- .../src/Manifest/Command/ValidateCommand.php | 56 ++ tools/src/Manifest/Validator.php | 169 ++++ tools/validate.php | 78 -- 9 files changed, 1192 insertions(+), 87 deletions(-) create mode 100755 tools/bin/console create mode 100644 tools/bootstrap.php create mode 100644 tools/src/Manifest/Command/ValidateCommand.php create mode 100644 tools/src/Manifest/Validator.php delete mode 100644 tools/validate.php diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ddeb728..36f0b98 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -21,7 +21,7 @@ jobs: run: composer install --no-progress --no-interaction --working-dir=tools - name: Validate Manifest - run: php tools/validate.php + run: tools/bin/console manifest:validate - name: Test release tooling run: python3 -m unittest discover -s .github/scripts -p 'test_*.py' diff --git a/README.md b/README.md index 579559f..5af18e5 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Branding/ `manifest.json` follows the v1 spec (`manifest_version: 1`, `type: "module"`). The canonical spec is [`manifest.schema.json`](./manifest.schema.json); every package is -validated against it (plus semantic checks in `tools/validate.php`) by CI. +validated against it (plus semantic checks via `tools/bin/console manifest:validate`) by CI. ## Releasing diff --git a/tools/bin/console b/tools/bin/console new file mode 100755 index 0000000..e2dac43 --- /dev/null +++ b/tools/bin/console @@ -0,0 +1,25 @@ +#!/usr/bin/env php +addCommands([ + new ValidateCommand($repoRoot, $repoRoot . '/manifest.schema.json'), +]); +$application->run(); diff --git a/tools/bootstrap.php b/tools/bootstrap.php new file mode 100644 index 0000000..e1b8254 --- /dev/null +++ b/tools/bootstrap.php @@ -0,0 +1,17 @@ +=8.3", "composer/semver": "^3.5", "composer/spdx-licenses": "^1.5", "justinrainbow/json-schema": "^6.0"}, - "config": {"sort-packages": true} -} \ No newline at end of file + "name": "fossbilling/modules-tools", + "description": "FOSSBilling modules repository tooling.", + "license": "Apache-2.0", + "require": { + "php": "~8.5.0", + "composer/semver": "^3.5", + "composer/spdx-licenses": "^1.5", + "justinrainbow/json-schema": "^6.0", + "symfony/console": "^8.1", + "symfony/finder": "^8.1" + }, + "autoload": { + "psr-4": { + "FOSSBilling\\Tools\\": "src/" + } + }, + "config": { + "sort-packages": true, + "platform": { + "php": "8.5.0" + } + } +} diff --git a/tools/composer.lock b/tools/composer.lock index b25a583..ed7d6a3 100644 --- a/tools/composer.lock +++ b/tools/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2f4173acf95b0abfea912b1d04bd165e", + "content-hash": "08d3608f6e0dce9edb54b9099d5de00c", "packages": [ { "name": "composer/semver", @@ -300,6 +300,901 @@ }, "time": "2025-09-14T11:18:39+00:00", "published-time": "2025-09-14T11:32:17+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00", + "published-time": "2021-11-05T16:53:27+00:00" + }, + { + "name": "symfony/console", + "version": "v8.1.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "5a315b092b81f3e920e9daea1a2ae4c66ed298b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/5a315b092b81f3e920e9daea1a2ae4c66ed298b9", + "reference": "5a315b092b81f3e920e9daea1a2ae4c66ed298b9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4.6|^8.0.6" + }, + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v8.1.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-11-04T07:07:11+00:00", + "published-time": "2026-11-04T11:31:39+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.8.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "feb1edf4e546af3b85c5caebe8359a20db230ca3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/feb1edf4e546af3b85c5caebe8359a20db230ca3", + "reference": "feb1edf4e546af3b85c5caebe8359a20db230ca3", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.8-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.8.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-16T14:15:10+00:00", + "published-time": "2026-11-04T11:25:34+00:00" + }, + { + "name": "symfony/finder", + "version": "v8.1.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "a2902e479ba5691d20d51d100a715ea19ad2c837" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/a2902e479ba5691d20d51d100a715ea19ad2c837", + "reference": "a2902e479ba5691d20d51d100a715ea19ad2c837", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v8.1.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-11-02T10:58:30+00:00", + "published-time": "2026-11-04T11:31:20+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00", + "published-time": "2026-04-26T14:44:50+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.43.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "5a8f33c87aeecffce24e721b5064fab05f4becf8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5a8f33c87aeecffce24e721b5064fab05f4becf8", + "reference": "5a8f33c87aeecffce24e721b5064fab05f4becf8", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-15T09:18:53+00:00", + "published-time": "2026-10-28T10:07:44+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T22:02:09+00:00", + "published-time": "2026-09-30T10:23:47+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.43.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "122889ed24dc000165bcbfd77ec412f15cbdfc6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/122889ed24dc000165bcbfd77ec412f15cbdfc6a", + "reference": "122889ed24dc000165bcbfd77ec412f15cbdfc6a", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-28T09:35:49+00:00", + "published-time": "2026-10-28T10:07:45+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/86a42cbcc07adc331481a5ab310a4024bfc227f6", + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00", + "published-time": "2026-09-30T10:23:32+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.8.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "052852a96fd5e5ebdb617dba22031e41a3300fb5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/052852a96fd5e5ebdb617dba22031e41a3300fb5", + "reference": "052852a96fd5e5ebdb617dba22031e41a3300fb5", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.8-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.8.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-20T17:38:25+00:00", + "published-time": "2026-11-04T11:25:33+00:00" + }, + { + "name": "symfony/string", + "version": "v8.1.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "6c6110979c0150393bbb336170e286d51ab4d76c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/6c6110979c0150393bbb336170e286d51ab4d76c", + "reference": "6c6110979c0150393bbb336170e286d51ab4d76c", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.1.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-23T17:06:53+00:00", + "published-time": "2026-10-28T18:24:27+00:00" } ], "packages-dev": [], @@ -309,8 +1204,11 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.3" + "php": "~8.5.0" }, "platform-dev": {}, + "platform-overrides": { + "php": "8.5.0" + }, "plugin-api-version": "2.11.0" } diff --git a/tools/src/Manifest/Command/ValidateCommand.php b/tools/src/Manifest/Command/ValidateCommand.php new file mode 100644 index 0000000..e5ca0c2 --- /dev/null +++ b/tools/src/Manifest/Command/ValidateCommand.php @@ -0,0 +1,56 @@ +addArgument('modules', InputArgument::OPTIONAL, 'Packages checkout directory (defaults to this repository)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $modulesDir = $input->getArgument('modules') ?? $this->defaultModulesDir; + $result = (new Validator($modulesDir, $this->schemaPath))->validate(); + foreach ($result['lines'] as $line) { + $output->writeln($line); + } + + if ($result['failures'] !== []) { + $errorOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + $errorOutput->writeln(sprintf('FAIL: %s', implode(', ', $result['failures']))); + + return Command::FAILURE; + } + $output->writeln(sprintf('OK: %d package(s) valid', $result['packages'])); + + return Command::SUCCESS; + } +} diff --git a/tools/src/Manifest/Validator.php b/tools/src/Manifest/Validator.php new file mode 100644 index 0000000..a7e65ba --- /dev/null +++ b/tools/src/Manifest/Validator.php @@ -0,0 +1,169 @@ +, lines: list} + * + * @throws RuntimeException when no package directories are found + */ + public function validate(): array + { + $finder = new Finder(); + $finder->directories()->in($this->modulesDir)->depth('== 0')->sortByName(); + $packages = array_map(static fn ($file) => $file->getPathname(), iterator_to_array($finder, false)); + // Only module packages carry a manifest; dotdirs and tooling are not packages. + $packages = array_values(array_filter( + $packages, + static fn (string $dir): bool => basename($dir)[0] !== '.' && basename($dir) !== 'tools', + )); + if ($packages === []) { + throw new RuntimeException("No module directories found under {$this->modulesDir}"); + } + + $parser = new VersionParser(); + $spdx = new SpdxLicenses(); + $failures = []; + $lines = []; + + foreach ($packages as $dir) { + $id = basename($dir); + $errors = []; + if (!is_file($dir . '/manifest.json')) { + $errors[] = 'manifest.json is missing'; + $lines = $this->result($id, $errors, $failures, $lines); + + continue; + } + $raw = file_get_contents($dir . '/manifest.json'); + $data = json_decode((string) $raw); + if (!$data instanceof stdClass) { + $errors[] = 'manifest.json is not valid JSON'; + $lines = $this->result($id, $errors, $failures, $lines); + + continue; + } + + $validator = new SchemaValidator(); + $validator->validate($data, (object) ['$ref' => 'file://' . $this->schemaPath]); + if (!$validator->isValid()) { + foreach ($validator->getErrors() as $e) { + $errors[] = sprintf('[%s] %s', $e['property'] ?: '(root)', $e['message']); + } + } + + $m = json_decode((string) $raw, true); + if (($m['type'] ?? null) !== 'module') { + $errors[] = 'type must be "module" in this repo'; + } + if (strtolower($id) !== ($m['id'] ?? null)) { + $errors[] = sprintf('id "%s" must match directory "%s" (case-insensitive)', $m['id'] ?? '?', $id); + } + foreach (['author.url' => $m['author']['url'] ?? null, 'homepage_url' => $m['homepage_url'] ?? null] as $field => $url) { + if ($url !== null && (!filter_var($url, FILTER_VALIDATE_URL) || !in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true))) { + $errors[] = $field . ' must be an http(s) URL'; + } + } + if (isset($m['icon_url'])) { + $icon = $m['icon_url']; + $resolved = realpath($dir . '/' . $icon) ?: $dir . '/' . $icon; + $base = realpath($dir); + if (!is_file($dir . '/' . $icon) || $base === false || !str_starts_with($resolved, $base . DIRECTORY_SEPARATOR)) { + $errors[] = 'icon_url target missing or escapes package dir'; + } + } + + try { + $parser->normalize($m['version'] ?? ''); + if (str_starts_with((string) ($m['version'] ?? ''), 'v')) { + $errors[] = 'version must not have a "v" prefix'; + } + } catch (UnexpectedValueException) { + $errors[] = 'version is not valid semver'; + } + foreach (['fossbilling' => $m['requires']['fossbilling'] ?? null, 'php' => $m['requires']['php'] ?? null] as $field => $c) { + try { + $parser->parseConstraints((string) $c); + } catch (UnexpectedValueException) { + $errors[] = "requires.{$field} is not a valid composer/semver constraint"; + } + } + foreach ($m['requires']['modules'] ?? [] as $dep => $c) { + if ($dep === ($m['id'] ?? null)) { + $errors[] = 'requires.modules must not self-reference'; + } + + try { + $parser->parseConstraints((string) $c); + } catch (UnexpectedValueException) { + $errors[] = "requires.modules.{$dep} is not a valid constraint"; + } + } + if (!$spdx->validate((string) ($m['license'] ?? ''))) { + $errors[] = 'license is not a valid SPDX expression'; + } + + $lines = $this->result($id, $errors, $failures, $lines); + } + + return ['packages' => count($packages), 'failures' => $failures, 'lines' => $lines]; + } + + /** + * @param list $errors + * @param list $failures + * @param list $lines + * + * @return list + */ + private function result(string $id, array $errors, array &$failures, array $lines): array + { + if ($errors === []) { + $lines[] = "PASS {$id}"; + + return $lines; + } + $failures[] = $id; + $lines[] = "FAIL {$id}"; + foreach ($errors as $error) { + $lines[] = " - {$error}"; + } + + return $lines; + } +} diff --git a/tools/validate.php b/tools/validate.php deleted file mode 100644 index 5384b56..0000000 --- a/tools/validate.php +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env php - basename($d)[0] !== '.' && basename($d) !== 'tools', -)); -if ($packages === []) { - fwrite(STDERR, "No packages found\n"); - exit(1); -} - -foreach ($packages as $dir) { - $id = basename($dir); - $errors = []; - if (!is_file($dir . '/manifest.json')) { - $errors[] = 'manifest.json is missing'; - printResult($id, $errors, $failures); - - continue; - } - $raw = file_get_contents($dir . '/manifest.json'); - $data = json_decode($raw); - if (!$data instanceof stdClass) { $errors[] = 'manifest.json is not valid JSON'; printResult($id, $errors, $failures); continue; } - - $validator = new \JsonSchema\Validator(); - $validator->validate($data, (object) ['$ref' => 'file://' . $schemaPath]); - if (!$validator->isValid()) { - foreach ($validator->getErrors() as $e) { $errors[] = sprintf('[%s] %s', $e['property'] ?: '(root)', $e['message']); } - } - - $m = json_decode((string) $raw, true); - if (($m['type'] ?? null) !== 'module') { $errors[] = 'type must be "module" in this repo'; } - if (strtolower($id) !== ($m['id'] ?? null)) { $errors[] = sprintf('id "%s" must match directory "%s" (case-insensitive)', $m['id'] ?? '?', $id); } - foreach (['author.url' => $m['author']['url'] ?? null, 'homepage_url' => $m['homepage_url'] ?? null] as $field => $url) { - if ($url !== null && (!filter_var($url, FILTER_VALIDATE_URL) || !in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true))) { $errors[] = $field . ' must be an http(s) URL'; } - } - if (isset($m['icon_url'])) { - $icon = $m['icon_url']; - $resolved = realpath($dir . '/' . $icon) ?: $dir . '/' . $icon; - $base = realpath($dir); - if (!is_file($dir . '/' . $icon) || $base === false || !str_starts_with($resolved, $base . DIRECTORY_SEPARATOR)) { $errors[] = 'icon_url target missing or escapes package dir'; } - } - try { $parser->normalize($m['version'] ?? ''); if (str_starts_with((string) ($m['version'] ?? ''), 'v')) { $errors[] = 'version must not have a "v" prefix'; } } - catch (\UnexpectedValueException) { $errors[] = 'version is not valid semver'; } - foreach (['fossbilling' => $m['requires']['fossbilling'] ?? null, 'php' => $m['requires']['php'] ?? null] as $field => $c) { - try { $parser->parseConstraints((string) $c); } catch (\UnexpectedValueException) { $errors[] = "requires.{$field} is not a valid composer/semver constraint"; } - } - foreach ($m['requires']['modules'] ?? [] as $dep => $c) { - if ($dep === ($m['id'] ?? null)) { $errors[] = 'requires.modules must not self-reference'; } - try { $parser->parseConstraints((string) $c); } catch (\UnexpectedValueException) { $errors[] = "requires.modules.{$dep} is not a valid constraint"; } - } - if (!$spdx->validate((string) ($m['license'] ?? ''))) { $errors[] = 'license is not a valid SPDX expression'; } - - printResult($id, $errors, $failures); -} - -if ($failures !== []) { fwrite(STDERR, sprintf("FAIL: %s\n", implode(', ', $failures))); exit(1); } -echo sprintf("OK: %d package(s) valid\n", count($packages)); - -function printResult(string $id, array $errors, array &$failures): void { - if ($errors === []) { echo "PASS {$id}\n"; return; } - $failures[] = $id; - echo "FAIL {$id}\n"; - foreach ($errors as $e) { echo " - {$e}\n"; } -} \ No newline at end of file From b2f68e1c287a5a814b31286a46902e0eef73b4e2 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Mon, 9 Nov 2026 07:38:00 +0000 Subject: [PATCH 2/3] Validate manifests through the console in schema deployment schema-deploy.yml still invoked tools/validate.php, which this PR deletes, so schema deployment on main and workflow_dispatch would exit 1 during the Validate Manifest step and skip the R2 upload. Invoke tools/bin/console manifest:validate as validate.yml already does. --- .github/workflows/schema-deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/schema-deploy.yml b/.github/workflows/schema-deploy.yml index 450cf79..34342e1 100644 --- a/.github/workflows/schema-deploy.yml +++ b/.github/workflows/schema-deploy.yml @@ -25,7 +25,7 @@ jobs: run: composer install --no-progress --no-interaction --working-dir=tools - name: Validate Manifest - run: php tools/validate.php + run: tools/bin/console manifest:validate - name: Upload Manifest Schema to R2 env: From 917bf254bb25b4b89f3b135bdd55a8efebf7da04 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Mon, 9 Nov 2026 08:46:56 +0000 Subject: [PATCH 3/3] Enforce the icon file standard and drop manifest icon_url Module icons are the shipped file at /icon. (svg, png, webp or avif): the FOSSBilling runtime resolves icons from that file and ignores any manifest declaration. Remove the icon_url property from the schema, replace the containment check with the file rule (at most one icon, no symlinks, absence allowed - mirroring FOSSBilling), delete the field from the two manifests that carried it, and stop sending icon_url to the extensions directory payload. The schema is now identical to the one bundled with FOSSBilling. --- .github/scripts/build_payload.py | 4 ---- .github/scripts/test_build_payload.py | 7 ++++--- Branding/manifest.json | 1 - Cookieconsent/manifest.json | 1 - README.md | 5 +++-- manifest.schema.json | 1 - tools/src/Manifest/Validator.php | 19 ++++++++++--------- 7 files changed, 17 insertions(+), 21 deletions(-) diff --git a/.github/scripts/build_payload.py b/.github/scripts/build_payload.py index 81c860c..585d1da 100644 --- a/.github/scripts/build_payload.py +++ b/.github/scripts/build_payload.py @@ -263,10 +263,6 @@ def main() -> None: "version": version, "download_url": download_url, } - icon_url = meta.get("icon_url", "") - if isinstance(icon_url, str) and icon_url: - content["icon_url"] = icon_url - if "homepage_url" in meta: content["homepage_url"] = meta["homepage_url"] diff --git a/.github/scripts/test_build_payload.py b/.github/scripts/test_build_payload.py index 86d6117..0f1798b 100644 --- a/.github/scripts/test_build_payload.py +++ b/.github/scripts/test_build_payload.py @@ -30,7 +30,7 @@ def setUp(self): "manifest_version": 1, "id": "example_module", "type": "module", "name": "Example", "description": "Example module", "version": "1.2.3", "author": {"name": "Author", "url": "https://example.com"}, - "license": "MIT OR Apache-2.0", "icon_url": "assets/icon.svg", + "license": "MIT OR Apache-2.0", "requires": {"fossbilling": "^0.8.9 || ^1.0", "php": ">=8.3 <9", "modules": {"branding": "^1.2"}}, } (self.package / "README.md").write_text("# Example\n") @@ -61,11 +61,12 @@ def test_preserves_manifest_metadata_and_compound_requirements(self): self.assertEqual(create["readme"], "# Example\n") self.assertEqual(create, {**update, "identifier": "example_module"}) - def test_optional_homepage_and_icon(self): + def test_optional_homepage(self): self.meta["homepage_url"] = "https://example.com" - del self.meta["icon_url"] create, _ = self.build() self.assertEqual(create["homepage_url"], "https://example.com") + # icon_url is gone from the payload contract: icons ship as + # `/icon.` files and are not directory metadata. self.assertNotIn("icon_url", create) def test_reruns_replace_the_same_version_and_preserve_other_requirements(self): diff --git a/Branding/manifest.json b/Branding/manifest.json index a2e1a26..5de2399 100644 --- a/Branding/manifest.json +++ b/Branding/manifest.json @@ -8,6 +8,5 @@ "author": {"name": "FOSSBilling", "url": "https://fossbilling.org"}, "license": "Apache-2.0", "homepage_url": "https://fossbilling.org", - "icon_url": "icon.svg", "requires": {"fossbilling": ">=0.8.9", "php": ">=8.3", "modules": {}} } diff --git a/Cookieconsent/manifest.json b/Cookieconsent/manifest.json index 8bf8011..b7a0f41 100644 --- a/Cookieconsent/manifest.json +++ b/Cookieconsent/manifest.json @@ -8,6 +8,5 @@ "author": {"name": "FOSSBilling", "url": "https://fossbilling.org"}, "license": "Apache-2.0", "homepage_url": "https://fossbilling.org", - "icon_url": "icon.svg", "requires": {"fossbilling": ">=0.8.9", "php": ">=8.3", "modules": {}} } \ No newline at end of file diff --git a/README.md b/README.md index 5af18e5..9fe4bce 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ layout (`Branding/` for id `branding`): Branding/ manifest.json # module manifest, v1 spec (required) README.md # becomes the extensions directory readme (required) - Service.php, Api/, Controller/, templates/, icon.svg, ... + icon.svg # optional package icon; at most one of icon.svg, icon.png, icon.webp, icon.avif + Service.php, Api/, Controller/, templates/, ... ``` `manifest.json` follows the v1 spec (`manifest_version: 1`, @@ -26,7 +27,7 @@ Tag the release `-v` (e.g. `branding-v0.8.9`, version matching the package's `manifest.json`) and attach the distributable `.zip` as a release asset. The zip must contain the package directory contents at the archive root, with no wrapper folder, so it installs as-is. -Publishing a release runs `.github/workflows/publish.yml`, which sends the manifest v1 fields directly to the extensions API and approves the new revision. `type: "module"`, string licenses, optional `homepage_url`, package-relative `icon_url`, author metadata, and the complete `requires` object are preserved. A manual run is available via `workflow_dispatch` with the same tag. Both triggers read package files from that tag, while using publisher tooling from the reviewed workflow ref. +Publishing a release runs `.github/workflows/publish.yml`, which sends the manifest v1 fields directly to the extensions API and approves the new revision. `type: "module"`, string licenses, optional `homepage_url`, author metadata, and the complete `requires` object are preserved. A manual run is available via `workflow_dispatch` with the same tag. Both triggers read package files from that tag, while using publisher tooling from the reviewed workflow ref. FOSSBilling, PHP, and module constraints are retained verbatim, including compound Composer ranges. Each release stores its own requirements. Deploy the API manifest migration and matching extensions site before running this publisher; it refuses history without `requires` instead of discarding compatibility information. diff --git a/manifest.schema.json b/manifest.schema.json index bff03b6..52b2ba7 100644 --- a/manifest.schema.json +++ b/manifest.schema.json @@ -21,7 +21,6 @@ }, "license": {"type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9.\\-+_:() |]+$"}, "homepage_url": {"type": "string", "pattern": "^https?://"}, - "icon_url": {"type": "string", "pattern": "^(?!/)(?![a-zA-Z][a-zA-Z0-9+.-]*:)(?!.*\\.\\.)[^\\\\]+$"}, "requires": { "type": "object", "required": ["fossbilling", "php"], "additionalProperties": false, "properties": { diff --git a/tools/src/Manifest/Validator.php b/tools/src/Manifest/Validator.php index a7e65ba..4c83218 100644 --- a/tools/src/Manifest/Validator.php +++ b/tools/src/Manifest/Validator.php @@ -24,9 +24,9 @@ * Validates package manifest.json files against the manifest JSON schema plus * project-specific checks that the schema cannot express. * - * Mirrors the Validator in FOSSBilling/FOSSBilling tools; the package discovery - * and icon rules are specific to this repository (published extensions carry a - * package-relative `icon_url`, and only `tools` is not a package). + * Mirrors the Validator in FOSSBilling/FOSSBilling tools; the package + * discovery filter is specific to this repository (only `tools` is not a + * package). */ final readonly class Validator { @@ -99,12 +99,13 @@ public function validate(): array $errors[] = $field . ' must be an http(s) URL'; } } - if (isset($m['icon_url'])) { - $icon = $m['icon_url']; - $resolved = realpath($dir . '/' . $icon) ?: $dir . '/' . $icon; - $base = realpath($dir); - if (!is_file($dir . '/' . $icon) || $base === false || !str_starts_with($resolved, $base . DIRECTORY_SEPARATOR)) { - $errors[] = 'icon_url target missing or escapes package dir'; + $icons = array_filter(['icon.svg', 'icon.png', 'icon.webp', 'icon.avif'], static fn ($icon): bool => is_file($dir . '/' . $icon)); + if (count($icons) > 1) { + $errors[] = 'include only one package icon: icon.svg, icon.png, icon.webp or icon.avif'; + } + foreach (['icon.svg', 'icon.png', 'icon.webp', 'icon.avif'] as $icon) { + if (is_link($dir . '/' . $icon)) { + $errors[] = 'package icons must not be symbolic links'; } }