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/.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: 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/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 579559f..9fe4bce 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,14 @@ 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`, `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 @@ -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/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..4c83218 --- /dev/null +++ b/tools/src/Manifest/Validator.php @@ -0,0 +1,170 @@ +, 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'; + } + } + $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'; + } + } + + 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