From 2b08506462a26ae3353cc51dbed526d1d8c6b12a Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Fri, 25 Sep 2026 15:16:43 -0700 Subject: [PATCH 1/2] WIP one package like laravel: root replaces all 29, 1173 tests fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated mechanically from the 29 package composer.json files: 29 replace entries, 28 psr-4 namespaces remapped to packages/*/src, 2 autoload files, and every firefly/* dropped from require and require-dev because a package cannot require what it replaces. composer validate and composer update both pass. The suite does not: 1173 failed, 2635 passed. The failures cluster in samples/lumen, which is a consumer application inside the repository with its own composer.json requiring firefly/* through path repositories — exactly the relationship the replace is meant to serve, and the one it breaks first. Package level autoload-dev and test support namespaces are the other suspects. So the approach is right and the edit is not sufficient: Laravel ships one package that replaces illuminate/*, but its repository is laid out for that from the start. Doing it here means deciding what samples/lumen consumes, what the package level test autoloads become, and whether monorepo-builder still validates. That is a piece of work, not a config change. NOT PUSHED and NOT merged. main is untouched and still on v26.09.8. --- composer.json | 99 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 68 insertions(+), 31 deletions(-) diff --git a/composer.json b/composer.json index c3bf8137..5cab4a6d 100644 --- a/composer.json +++ b/composer.json @@ -1,44 +1,14 @@ { "name": "firefly/monorepo", - "description": "LaraFly \u2014 the Firefly Framework for PHP. Development monorepo.", + "description": "LaraFly — the Firefly Framework for PHP. Development monorepo.", "type": "project", "license": "Apache-2.0", "require": { "php": "^8.3", - "firefly/actuator": "*@dev", - "firefly/autoconfigure": "*@dev", - "firefly/config": "*@dev", - "firefly/container": "*@dev", - "firefly/context": "*@dev", - "firefly/cqrs": "*@dev", - "firefly/data": "*@dev", - "firefly/domain": "*@dev", - "firefly/eda": "*@dev", - "firefly/kernel": "*@dev", - "firefly/messaging": "*@dev", - "firefly/observability": "*@dev", - "firefly/openapi": "*@dev", - "firefly/resilience": "*@dev", - "firefly/scheduling": "*@dev", - "firefly/scheduling-postgres": "*@dev", - "firefly/security": "*@dev", - "firefly/security-oauth2-client": "*@dev", - "firefly/security-oauth2-server": "*@dev", - "firefly/validation": "*@dev", - "firefly/web": "*@dev", "swagger-api/swagger-ui": "^5.17" }, "require-dev": { "deptrac/deptrac": "^4.6", - "firefly/admin": "*@dev", - "firefly/cli": "*@dev", - "firefly/eda-kafka": "*@dev", - "firefly/eda-postgres": "*@dev", - "firefly/eda-rabbitmq": "*@dev", - "firefly/firefly": "*@dev", - "firefly/installer": "*@dev", - "firefly/lumen": "*@dev", - "firefly/testing": "*@dev", "kwn/php-rdkafka-stubs": "^2.2", "larastan/larastan": "~3.11.0", "laravel/octane": "^2.0", @@ -130,5 +100,72 @@ "@test", "@deptrac" ] + }, + "replace": { + "firefly/actuator": "self.version", + "firefly/admin": "self.version", + "firefly/autoconfigure": "self.version", + "firefly/cli": "self.version", + "firefly/config": "self.version", + "firefly/container": "self.version", + "firefly/context": "self.version", + "firefly/cqrs": "self.version", + "firefly/data": "self.version", + "firefly/domain": "self.version", + "firefly/eda": "self.version", + "firefly/eda-kafka": "self.version", + "firefly/eda-postgres": "self.version", + "firefly/eda-rabbitmq": "self.version", + "firefly/firefly": "self.version", + "firefly/installer": "self.version", + "firefly/kernel": "self.version", + "firefly/messaging": "self.version", + "firefly/observability": "self.version", + "firefly/openapi": "self.version", + "firefly/resilience": "self.version", + "firefly/scheduling": "self.version", + "firefly/scheduling-postgres": "self.version", + "firefly/security": "self.version", + "firefly/security-oauth2-client": "self.version", + "firefly/security-oauth2-server": "self.version", + "firefly/testing": "self.version", + "firefly/validation": "self.version", + "firefly/web": "self.version" + }, + "autoload": { + "psr-4": { + "Firefly\\Actuator\\": "packages/actuator/src/", + "Firefly\\Admin\\": "packages/admin/src/", + "Firefly\\AutoConfigure\\": "packages/autoconfigure/src/", + "Firefly\\Cli\\": "packages/cli/src/", + "Firefly\\Config\\": "packages/config/src/", + "Firefly\\Container\\": "packages/container/src/", + "Firefly\\Context\\": "packages/context/src/", + "Firefly\\Cqrs\\": "packages/cqrs/src/", + "Firefly\\Data\\": "packages/data/src/", + "Firefly\\Domain\\": "packages/domain/src/", + "Firefly\\Eda\\": "packages/eda/src/", + "Firefly\\Eda\\Kafka\\": "packages/eda-kafka/src/", + "Firefly\\Eda\\Postgres\\": "packages/eda-postgres/src/", + "Firefly\\Eda\\Rabbitmq\\": "packages/eda-rabbitmq/src/", + "Firefly\\Installer\\": "packages/installer/src/", + "Firefly\\Kernel\\": "packages/kernel/src/", + "Firefly\\Messaging\\": "packages/messaging/src/", + "Firefly\\Observability\\": "packages/observability/src/", + "Firefly\\OpenApi\\": "packages/openapi/src/", + "Firefly\\Resilience\\": "packages/resilience/src/", + "Firefly\\Scheduling\\": "packages/scheduling/src/", + "Firefly\\Scheduling\\Postgres\\": "packages/scheduling-postgres/src/", + "Firefly\\Security\\": "packages/security/src/", + "Firefly\\Security\\OAuth2\\Client\\": "packages/security-oauth2-client/src/", + "Firefly\\Security\\OAuth2\\Server\\": "packages/security-oauth2-server/src/", + "Firefly\\Testing\\": "packages/testing/src/", + "Firefly\\Validation\\": "packages/validation/src/", + "Firefly\\Web\\": "packages/web/src/" + }, + "files": [ + "packages/security-oauth2-client/src/Registration/casters.php", + "packages/testing/src/functions.php" + ] } } From e6699efc9ad0a14e7262e7a8a52d843f92c13ca2 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Sat, 26 Sep 2026 21:13:58 -0700 Subject: [PATCH 2/2] Publish LaraFly as one library to remove split-mirror releases Make the root firefly/firefly library own component replacements, runtime dependencies, provider discovery, test autoloads and the installer binary. Keep component manifests as internal contracts validated by monorepo-builder. Use the bundled skeleton in the installer, declare its optional test harness explicitly, and gate PostgreSQL migrations on the selected transport. Exercise exported no-dev applications and copied Lumen consumers in CI, and replace the split release workflow with a tagged-package gate. --- .gitattributes | 17 ++ .github/workflows/ci.yml | 6 + .github/workflows/release.yml | 112 ++-------- CHANGELOG.md | 10 + README.md | 35 ++-- book/src-es/13-cli-cache.md | 7 +- book/src/13-cli-cache.md | 7 +- composer.json | 149 +++++++++++--- deptrac.yaml | 7 +- docs/contributing.md | 32 ++- docs/getting-started.md | 27 ++- docs/installation.md | 60 ++---- docs/modules.md | 13 +- docs/modules/installer.md | 21 +- docs/publishing.md | 185 +++++++---------- monorepo-builder.php | 11 +- .../Skeleton/CreateProjectOfflineTest.php | 4 +- .../src/EdaPostgresBootServiceProvider.php | 4 + .../eda-postgres/tests/BootDiscoveryTest.php | 24 +++ packages/firefly/README.md | 12 +- packages/installer/README.md | 6 +- packages/installer/bin/firefly | 7 +- packages/installer/composer.json | 3 +- packages/installer/src/ArchetypeApplier.php | 4 + packages/installer/src/CapabilityCatalog.php | 43 +--- packages/installer/src/Filesystem.php | 5 +- packages/installer/src/NewCommand.php | 10 +- packages/installer/tests/ArchetypeTest.php | 15 ++ .../installer/tests/CapabilityCatalogTest.php | 6 +- .../tests/CreateProjectInstallerTest.php | 2 +- packages/installer/tests/NewCommandTest.php | 16 +- samples/lumen/composer.json | 48 ++++- scripts/check-package-install.php | 135 ++++++++++++ testbench.yaml | 2 + tests/Browser/Support/DiscoveredProviders.php | 51 +---- tests/BrowserFixtureProvidersTest.php | 22 +- tests/DocsProseIsRealTest.php | 84 -------- tests/MetapackageCoverageTest.php | 104 +--------- tests/OnePackageTest.php | 45 ++++ tests/PackageLicenseTest.php | 1 + tests/PackageMetadataTest.php | 1 + tests/ReleaseWorkflowTest.php | 194 ++---------------- tests/SiteNavigationTest.php | 12 +- tests/Support/PackageServiceProvider.php | 20 ++ 44 files changed, 723 insertions(+), 856 deletions(-) create mode 100644 .gitattributes create mode 100644 packages/eda-postgres/tests/BootDiscoveryTest.php create mode 100644 scripts/check-package-install.php create mode 100644 testbench.yaml create mode 100644 tests/OnePackageTest.php create mode 100644 tests/Support/PackageServiceProvider.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..353cacaf --- /dev/null +++ b/.gitattributes @@ -0,0 +1,17 @@ +* text=auto eol=lf +/.github export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore +/tests export-ignore +/samples export-ignore +/docs export-ignore +/book export-ignore +/scripts export-ignore +/phpunit.xml.dist export-ignore +/phpstan.neon.dist export-ignore +/pint.json export-ignore +/deptrac.yaml export-ignore +/monorepo-builder.php export-ignore +/package.json export-ignore +/package-lock.json export-ignore +/testbench.yaml export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37579599..44b43c18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,12 @@ jobs: tools: composer:v2 - name: Install dependencies run: composer install --no-interaction --prefer-dist + - name: Package metadata + run: | + composer validate --strict + composer mono-validate + - name: Install exported library in a clean consumer + run: composer test:package - name: Pint (code style) run: composer pint-test - name: PHPStan (level max) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8bd470d1..1489a03b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,107 +1,33 @@ -name: Release (split mirrors) +name: Release (single package) -# Dormant until a v* tag is pushed by the controller (see docs/publishing.md). Pushes NOTHING on -# every other event — no branches:, no pull_request: trigger. -# -# The split faithfully mirrors the TAGGED commit's composer.json. Per docs/publishing.md, the -# release-time `vendor/bin/monorepo-builder bump-interdependency ` runs on the release -# commit BEFORE it is tagged, rewriting every `firefly/xyz: "*@dev"` sibling constraint to -# `^26.07` — so by the time this workflow runs (triggered by the tag push), the commit being split -# already carries `^26.07` interdependencies, not `*@dev`. Each mirror is therefore -# stable-installable on its own via Packagist. +# Packagist reads tags from this repository's root composer.json. +# This gate validates the tagged distribution; indexing is checked separately (docs/publishing.md). on: push: tags: - 'v*' -jobs: - preflight: - name: Preflight (release credentials) - runs-on: ubuntu-latest - steps: - # The split action below exits 0 even when its push fails, so without this gate a release with no - # credential produces a whole matrix of green jobs and zero published packages. Fail here instead, once, - # loudly. - - name: ACCESS_TOKEN must be present - env: - ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} - run: | - if [ -z "${ACCESS_TOKEN}" ]; then - echo "::error::ACCESS_TOKEN is not set, so nothing can be pushed to the mirrors." - echo "::error::Create the org PAT and the fireflyframework/firefly-* repositories first — see docs/publishing.md steps 6-7." - exit 1 - fi - echo "ACCESS_TOKEN is present." +permissions: + contents: read - split: - needs: preflight - name: Split ${{ matrix.package.local }} +jobs: + package: + name: Validate package (PHP ${{ matrix.php }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: - package: - - { local: packages/kernel, split: firefly-kernel } - - { local: packages/container, split: firefly-container } - - { local: packages/config, split: firefly-config } - - { local: packages/context, split: firefly-context } - - { local: packages/autoconfigure, split: firefly-autoconfigure } - - { local: packages/validation, split: firefly-validation } - - { local: packages/web, split: firefly-web } - - { local: packages/resilience, split: firefly-resilience } - - { local: packages/scheduling, split: firefly-scheduling } - - { local: packages/scheduling-postgres, split: firefly-scheduling-postgres } - - { local: packages/domain, split: firefly-domain } - - { local: packages/data, split: firefly-data } - - { local: packages/eda, split: firefly-eda } - - { local: packages/eda-kafka, split: firefly-eda-kafka } - - { local: packages/eda-postgres, split: firefly-eda-postgres } - - { local: packages/eda-rabbitmq, split: firefly-eda-rabbitmq } - - { local: packages/messaging, split: firefly-messaging } - - { local: packages/cqrs, split: firefly-cqrs } - - { local: packages/security, split: firefly-security } - - { local: packages/security-oauth2-client, split: firefly-security-oauth2-client } - - { local: packages/security-oauth2-server, split: firefly-security-oauth2-server } - - { local: packages/actuator, split: firefly-actuator } - - { local: packages/observability, split: firefly-observability } - - { local: packages/admin, split: firefly-admin } - - { local: packages/openapi, split: firefly-openapi } - - { local: packages/testing, split: firefly-testing } - - { local: packages/cli, split: firefly-cli } - - { local: packages/firefly, split: firefly-firefly } - - { local: packages/installer, split: firefly-installer } - - { local: skeleton, split: firefly-skeleton } + php: [ '8.3', '8.4', '8.5' ] steps: - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 with: - fetch-depth: 0 - - name: Guard (never split a tree with sensitive paths) - run: bash scripts/check-no-sensitive-tracked.sh - - name: Subtree split to the read-only mirror - uses: symplify/monorepo-split-github-action@v2.3.0 - env: - GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} - with: - tag: ${{ github.ref_name }} - package-directory: ${{ matrix.package.local }} - split-repository-organization: fireflyframework - split-repository-name: ${{ matrix.package.split }} - user-name: fireflybot - user-email: bot@fireflyframework.dev - - name: Verify the mirror actually received the tag - # symplify/monorepo-split-github-action@v2.3.0 exits 0 whether or not the push succeeded: on - # v26.09.1 all 28 jobs went green while the mirrors did not exist and nothing was published. - # A release is only released once the tag is readable on the mirror, so assert exactly that. - env: - GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} - run: | - set -euo pipefail - repo="fireflyframework/${{ matrix.package.split }}" - tag="${{ github.ref_name }}" - sha="$(gh api "repos/${repo}/git/ref/tags/${tag}" --jq '.object.sha' 2>/dev/null || true)" - if [ -z "${sha}" ]; then - echo "::error::${repo} has no tag ${tag}. The split step reported success but published nothing." - echo "::error::Usual cause: the mirror repository does not exist, or ACCESS_TOKEN cannot write to it." - exit 1 - fi - echo "${repo} @ ${tag} = ${sha}" + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + - run: bash scripts/check-no-sensitive-tracked.sh + - run: composer validate --strict + - run: composer install --no-interaction --prefer-dist + - run: composer check + - run: composer mono-validate + - run: composer test:package diff --git a/CHANGELOG.md b/CHANGELOG.md index 56d26cd9..b9ad1422 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`Y ## [Unreleased] +### Changed + +- Publish the repository root as the `firefly/firefly` library, replacing component names at the same + version. Runtime dependencies, autoloading and Laravel discovery now belong to that package; no split + mirrors or cross-repository release credential are needed. +- Keep Lumen and component test support in development autoloading, and validate exported and copied + consumer installations in CI. The installer uses the bundled skeleton and adds Testbench explicitly + when the testing capability is requested. PostgreSQL outbox migrations are registered only for that + configured transport. + ## [26.09.8] - 2026-09-25 ### Fixed diff --git a/README.md b/README.md index ffbab61c..2efc8645 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ will have — that they are worth naming outright: ```bash # 1 · Install the global installer once, then scaffold a new app -composer global require firefly/installer +composer global require firefly/firefly firefly new my-app cd my-app @@ -986,26 +986,27 @@ LaraFly layers onto Laravel, it is not a standalone runtime. Full details in **New project — the global installer:** ```bash -composer global require firefly/installer +composer global require firefly/firefly firefly new my-app ``` -**New project — without the installer** (exactly what `firefly new` wraps): +**New project — bundled installer:** ```bash -composer create-project firefly/skeleton my-app +composer global require firefly/firefly +firefly new my-app ``` -**Adding LaraFly to an existing Laravel app** — `firefly/firefly` is a `type: metapackage` (the Maven BOM -analogue) that pulls in the whole runtime family, developer console included, with one line: +**Adding LaraFly to an existing Laravel app** — `firefly/firefly` is the complete framework library, +developer console included: ```bash composer require firefly/firefly ``` The browser dashboard (`firefly/admin`) and the API-documentation package (`firefly/openapi`) come with it. The -broker adapters (`firefly/eda-rabbitmq`, `firefly/eda-postgres`, `firefly/eda-kafka`) and the test kit -(`firefly/testing`) stay separate — each binds you to an infrastructure choice or belongs in `require-dev`. +broker adapters and testing helpers are included too. Configure the transport you need; install its +required PHP extension separately. Applications using the test kit add Testbench with `--dev`. ```bash composer require firefly/admin # /firefly — the dashboard over the actuator (see its access model first) @@ -1055,9 +1056,9 @@ Full flag reference and generated-file contents: [CLI](docs/cli.md). ## Modules -29 packages under `packages/*`, plus `firefly/skeleton` at the top level — 30 shippable units in total. Each -is an independently installable Composer package with its own test suite; the one exception is -`firefly/firefly`, a `type: metapackage` that carries a dependency list and nothing else. The 32 +One published library, `firefly/firefly`, contains the components under `packages/*` and the bundled +application skeleton. Its `replace` entries satisfy component requirements at the framework's version; +component names remain useful module boundaries, with their own test suites. The 32 [module guides](docs/modules/) below group them by concern: | Group | Module | Package(s) | @@ -1095,9 +1096,8 @@ is an independently installable Composer package with its own test suite; the on | Testing | [Integration Testing](docs/modules/integration-testing.md) — `@group integration`, testcontainers | `firefly/testing` | | Tooling | [Installer](docs/modules/installer.md) — the global `firefly new` scaffolding tool | `firefly/installer` | -`firefly/firefly` (the runtime metapackage) and `firefly/cli` (the dev-console — see -[CLI & Project Scaffolding](#cli--project-scaffolding) above) round out the 29 packages; `firefly/skeleton` -is the 30th unit, a `type: project` create-project template at the top level. +`firefly/cli` supplies the developer console — see [CLI & Project Scaffolding](#cli--project-scaffolding). +All component names resolve through the root library; the top-level `skeleton/` template is bundled with it. --- @@ -1191,15 +1191,16 @@ model across every runtime: ## Contributing -`fireflyframework-php` is a single monorepo housing every LaraFly package as an independent Composer unit -under `packages/*`, plus `firefly/skeleton` at the top level. Before opening a PR, the full gate must pass: +`fireflyframework-php` publishes its root as one library. Component descriptors under `packages/*` +preserve module boundaries; `skeleton/` is the bundled application template. Before opening a PR, the full gate must pass: ```bash composer install git config core.hooksPath scripts/hooks # activates the committed pre-push safety guard composer check # pint --test && phpstan analyse && pest && deptrac analyse -composer mono-validate # validates every packages/*/composer.json +composer mono-validate # component dependency-version consistency +composer test:package # exported and copied consumer installations .venv-docs/bin/mkdocs build --strict # docs/: link integrity, no orphaned pages bash scripts/check-no-sensitive-tracked.sh ``` diff --git a/book/src-es/13-cli-cache.md b/book/src-es/13-cli-cache.md index 4bd2a047..78c37d3b 100644 --- a/book/src-es/13-cli-cache.md +++ b/book/src-es/13-cli-cache.md @@ -460,7 +460,12 @@ final class NewCommand extends Command $io = new SymfonyStyle($input, $output); $runner = $this->runner ?? new SymfonyProcessRunner($output); // … - $create = ['composer', 'create-project', 'firefly/skeleton', $directory, '--no-interaction']; + $repository = json_encode([ + 'type' => 'path', + 'url' => dirname(__DIR__, 3).'/skeleton', + 'options' => ['symlink' => false, 'versions' => ['firefly/skeleton' => Version::VERSION]], + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + $create = ['composer', 'create-project', 'firefly/skeleton', $directory, '--no-interaction', '--repository='.$repository]; if ($input->getOption('dev')) { $create[] = '--stability=dev'; } diff --git a/book/src/13-cli-cache.md b/book/src/13-cli-cache.md index 14ceaf20..a61de620 100644 --- a/book/src/13-cli-cache.md +++ b/book/src/13-cli-cache.md @@ -460,7 +460,12 @@ final class NewCommand extends Command $io = new SymfonyStyle($input, $output); $runner = $this->runner ?? new SymfonyProcessRunner($output); // … - $create = ['composer', 'create-project', 'firefly/skeleton', $directory, '--no-interaction']; + $repository = json_encode([ + 'type' => 'path', + 'url' => dirname(__DIR__, 3).'/skeleton', + 'options' => ['symlink' => false, 'versions' => ['firefly/skeleton' => Version::VERSION]], + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + $create = ['composer', 'create-project', 'firefly/skeleton', $directory, '--no-interaction', '--repository='.$repository]; if ($input->getOption('dev')) { $create[] = '--stability=dev'; } diff --git a/composer.json b/composer.json index 5cab4a6d..f9a7dac5 100644 --- a/composer.json +++ b/composer.json @@ -1,11 +1,38 @@ { - "name": "firefly/monorepo", - "description": "LaraFly — the Firefly Framework for PHP. Development monorepo.", - "type": "project", + "name": "firefly/firefly", + "description": "LaraFly — the Firefly Framework for PHP.", + "type": "library", "license": "Apache-2.0", "require": { "php": "^8.3", - "swagger-api/swagger-ui": "^5.17" + "ext-openssl": "*", + "firebase/php-jwt": "^7.0", + "guzzlehttp/promises": "^2.0 || ^3.0", + "illuminate/bus": "^13.0", + "illuminate/cache": "^13.0", + "illuminate/config": "^13.0", + "illuminate/console": "^13.0", + "illuminate/container": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/cookie": "^13.0", + "illuminate/database": "^13.0", + "illuminate/events": "^13.0", + "illuminate/http": "^13.0", + "illuminate/log": "^13.0", + "illuminate/queue": "^13.0", + "illuminate/routing": "^13.0", + "illuminate/session": "^13.0", + "illuminate/support": "^13.0", + "illuminate/translation": "^13.0", + "illuminate/validation": "^13.0", + "monolog/monolog": "^3.0", + "php-amqplib/php-amqplib": "^3.7", + "psr/http-message": "^1.0 || ^2.0", + "psr/log": "^3.0", + "swagger-api/swagger-ui": "^5.17", + "symfony/console": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0" }, "require-dev": { "deptrac/deptrac": "^4.6", @@ -54,25 +81,11 @@ "Firefly\\Tests\\": "tests/", "Firefly\\Validation\\Tests\\": "packages/validation/tests/", "Firefly\\Web\\Tests\\": "packages/web/tests/", - "Lumen\\Tests\\": "samples/lumen/tests/" + "Lumen\\Tests\\": "samples/lumen/tests/", + "Firefly\\Kernel\\Tests\\": "packages/kernel/tests/", + "Lumen\\": "samples/lumen/src/" } }, - "repositories": [ - { - "type": "path", - "url": "packages/*", - "options": { - "symlink": true - } - }, - { - "type": "path", - "url": "samples/*", - "options": { - "symlink": true - } - } - ], "minimum-stability": "stable", "prefer-stable": true, "config": { @@ -99,7 +112,8 @@ "@stan", "@test", "@deptrac" - ] + ], + "test:package": "@php scripts/check-package-install.php" }, "replace": { "firefly/actuator": "self.version", @@ -116,7 +130,6 @@ "firefly/eda-kafka": "self.version", "firefly/eda-postgres": "self.version", "firefly/eda-rabbitmq": "self.version", - "firefly/firefly": "self.version", "firefly/installer": "self.version", "firefly/kernel": "self.version", "firefly/messaging": "self.version", @@ -167,5 +180,95 @@ "packages/security-oauth2-client/src/Registration/casters.php", "packages/testing/src/functions.php" ] + }, + "homepage": "https://github.com/fireflyframework/fireflyframework-php", + "authors": [ + { + "name": "Firefly Software Solutions Inc.", + "homepage": "https://github.com/fireflyframework" + } + ], + "keywords": [ + "firefly", + "laravel", + "framework", + "ddd", + "cqrs" + ], + "support": { + "issues": "https://github.com/fireflyframework/fireflyframework-php/issues", + "source": "https://github.com/fireflyframework/fireflyframework-php" + }, + "suggest": { + "ext-pdo_pgsql": "Required to use the PostgreSQL event transport (firefly.eda.provider=postgres).", + "ext-rdkafka": "Required ONLY to activate the Kafka adapter (firefly.eda.provider=kafka). Needs librdkafka >= 1.5.3. Absent = the adapter is inert; the default gate + non-Kafka consumers never need it.", + "illuminate/testing": "^13.0 for the Firefly testing helpers; install with --dev.", + "open-telemetry/api": "The OpenTelemetry API — required with open-telemetry/sdk for the OpenTelemetryTracer adapter (firefly.observability.tracing.enabled).", + "open-telemetry/exporter-otlp": "OTLP span export over http/protobuf or http/json (firefly.observability.tracing.exporter=otlp).", + "open-telemetry/sdk": "The OpenTelemetry SDK (TracerProvider, samplers, console exporter) behind OpenTelemetryTracer.", + "open-telemetry/transport-grpc": "OTLP export over gRPC (firefly.observability.tracing.otlp.protocol=grpc; needs ext-grpc).", + "orchestra/testbench": "^11.1 for FireflyTestCase and Laravel test applications; install with --dev.", + "symfony/var-dumper": "What dd() and dump() are built on; the package registers a caster so a dumped ClientRegistration shows its client secret masked (laravel/framework already requires it).", + "testcontainers/testcontainers": "Ephemeral Docker backends for @group integration tests (fireflyConfigFor())." + }, + "extra": { + "branch-alias": { + "dev-main": "26.x-dev" + }, + "laravel": { + "providers": [ + "Firefly\\AutoConfigure\\FireflyAutoConfigureServiceProvider", + "Firefly\\Data\\DataServiceProvider", + "Firefly\\Data\\DataWiringProvider", + "Firefly\\Resilience\\ResilienceServiceProvider", + "Firefly\\Scheduling\\SchedulingServiceProvider", + "Firefly\\Scheduling\\SchedulingWiringProvider", + "Firefly\\Validation\\ValidationServiceProvider", + "Firefly\\Web\\WebServiceProvider", + "Firefly\\Actuator\\ActuatorServiceProvider", + "Firefly\\Actuator\\ActuatorWiringProvider", + "Firefly\\Admin\\AdminServiceProvider", + "Firefly\\Eda\\EdaServiceProvider", + "Firefly\\Eda\\EdaWiringProvider", + "Firefly\\Eda\\EdaConsumerServiceProvider", + "Firefly\\Cqrs\\CqrsServiceProvider", + "Firefly\\Cqrs\\CqrsWiringProvider", + "Firefly\\Messaging\\MessagingServiceProvider", + "Firefly\\Messaging\\MessagingWiringProvider", + "Firefly\\Observability\\ObservabilityServiceProvider", + "Firefly\\Observability\\ObservabilityWiringProvider", + "Firefly\\Security\\SecurityServiceProvider", + "Firefly\\Security\\SecurityWiringProvider", + "Firefly\\Security\\OAuth2\\Server\\SecurityOAuth2ServerServiceProvider", + "Firefly\\Security\\OAuth2\\Server\\SecurityOAuth2ServerWiringProvider", + "Firefly\\Cli\\CliServiceProvider", + "Firefly\\Eda\\Kafka\\EdaKafkaServiceProvider", + "Firefly\\Eda\\Postgres\\EdaPostgresServiceProvider", + "Firefly\\Eda\\Postgres\\EdaPostgresBootServiceProvider", + "Firefly\\Eda\\Rabbitmq\\EdaRabbitmqServiceProvider", + "Firefly\\OpenApi\\OpenApiServiceProvider", + "Firefly\\OpenApi\\OpenApiWiringProvider", + "Firefly\\Scheduling\\Postgres\\SchedulingPostgresServiceProvider", + "Firefly\\Security\\OAuth2\\Client\\SecurityOAuth2ClientServiceProvider", + "Firefly\\Security\\OAuth2\\Client\\SecurityOAuth2ClientWiringProvider", + "Firefly\\Cli\\Boot\\FireflyCacheServiceProvider" + ] + } + }, + "bin": [ + "packages/installer/bin/firefly" + ], + "archive": { + "exclude": [ + "/vendor", + "/node_modules", + "/.*", + "/composer.lock", + "/site", + "/coverage", + "/*.png", + "/packages/*/vendor", + "/packages/*/tests" + ] } } diff --git a/deptrac.yaml b/deptrac.yaml index f0d340f1..2eae4459 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -534,7 +534,6 @@ deptrac: - Observability - Resilience - # Installer is the standalone global `firefly new` tool: symfony/console + symfony/process ONLY, - # ZERO firefly runtime deps, so a global install never drags in the framework. Depends on nothing - # layered; depended on by none. - Installer: ~ + # The bundled installer reads the framework version for its local skeleton repository. + Installer: + - Kernel diff --git a/docs/contributing.md b/docs/contributing.md index 4330f666..a3d1cf70 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -2,19 +2,15 @@ ## Monorepo layout -`fireflyframework-php` is a single monorepo housing every LaraFly package as an independent Composer unit: - -- **`packages/*`** — one directory per package (`kernel`, `container`, `config`, `context`, `autoconfigure`, - `validation`, `web`, `resilience`, `scheduling`, `scheduling-postgres`, `domain`, `data`, `eda` + its three - broker adapters, `messaging`, `cqrs`, `security`, `security-oauth2-client`, `security-oauth2-server`, - `actuator`, `observability`, `admin`, `openapi`, `testing`, `cli`, `firefly`, `installer`), each with its - own `composer.json`, `src/`, and `tests/`. `ls packages` is the list that cannot go stale; - `.github/workflows/release.yml`'s split matrix is the one that has to name every one of them. -- **`skeleton/`** — the `firefly/skeleton` `type: project` create-project template, at the top level, outside - `packages/*`. -- The root `composer.json` is a `type: project` aggregator: it wires every `packages/*` directory as a local - Composer **path repository** (`*@dev`) so the whole monorepo installs and tests together from one - `vendor/`. +`fireflyframework-php` publishes the root `firefly/firefly` library. All component code stays under +`packages/*`; their manifests document module requirements and namespaces and are validated together. +They are not published or installed separately. `skeleton/` is the bundled application template used by +`firefly new`. `samples/lumen` consumes the root through a copied path repository. + +Production namespaces, autoload files and Laravel discovery live in the root manifest. Component test +namespaces and `Lumen\` belong in root `autoload-dev`, so consumers never load repository tests. When +adding a component, update both its internal descriptor and the root's dependency/autoload/discovery/ +replacement entries. The distribution consumer gate verifies the resulting installation. ## Local setup @@ -40,7 +36,8 @@ Before opening a PR, all of the following must pass: ```bash composer check # pint --test && phpstan analyse && pest && deptrac analyse -composer mono-validate # symplify/monorepo-builder: validates every packages/*/composer.json +composer mono-validate # consistency of internal component dependency versions +composer test:package # exported library, copied Lumen consumer and bundled installer .venv-docs/bin/mkdocs build --strict # docs/: link integrity, no orphaned pages bash scripts/check-no-sensitive-tracked.sh # the pre-push guard, run directly ``` @@ -191,8 +188,7 @@ inventory and every "404 until exposed" sentence, the exceptions `PersistenceExc builds, the order in which `ErrorPageRenderer` really reads an `Accept` header, the stereotype hierarchy PHP really declares, the Composer constraint tables `composer/semver` really matches, the `make:firefly-*` tables the generators really back, every `firefly:cache` figure `ManifestCacheWriter` really produces (the console -line, the pair count, the step count and the artifact count, in both manuscripts), the capabilities `--with` -really fetches — the ones the `firefly/firefly` metapackage does *not* already require — the roster of +line, the pair count, the step count and the artifact count, in both manuscripts), the roster of documentation guards this very section names, the registration default `DbHealthIndicator`'s own condition attribute really declares (a stale **default** is as dangerous as a stale count, and that one outlived its change in five places), the account these pages give of the book's own gate, pinned to what `verify_code.py` @@ -259,8 +255,8 @@ exist. Package boundaries are enforced with **Deptrac** (`deptrac.yaml`): every package is its own layer, and the `ruleset` section declares which layers each one may depend on. A dependency edge that isn't declared is a Deptrac violation — this is how the monorepo keeps, for example, `firefly/kernel` free of any dependency on -`firefly/web`, or `firefly/installer` free of every framework runtime dependency (it depends on nothing -layered at all — `symfony/console`/`symfony/process` only). +`firefly/web`. The bundled installer may read the kernel version but has no other framework layer +dependency. Packages from an already-shipped milestone are treated as **FROZEN**: once a package's milestone is done and reviewed, further edits to its `src/` are the exception, not the rule, and are called out explicitly in diff --git a/docs/getting-started.md b/docs/getting-started.md index 2355bee4..ecf62785 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -7,7 +7,8 @@ application pre-wired with the Firefly family, a `#[Controller]` welcome page, a `#[RestController]`/`#[Service]` pair, and `firefly:cache` already wired into `post-create-project-cmd`: ```bash -composer create-project firefly/skeleton my-app +composer global require firefly/firefly +firefly new my-app cd my-app php artisan firefly:cache php artisan firefly:serve @@ -25,17 +26,15 @@ installed) — see [CLI](cli.md) for the full command reference. ## Adding LaraFly to an existing Laravel app -Pull in the whole runtime family with one line — `firefly/firefly` is a Composer metapackage (the Maven BOM -analogue) that requires every runtime package, `firefly/cli` included, so `firefly:cache` and the -`make:firefly-*` generators are available straight away: +Install the complete framework library, including the CLI, dashboard and API documentation: ```bash composer require firefly/firefly ``` -The browser dashboard (`firefly/admin`) and the API-documentation package (`firefly/openapi`) come with it. The -broker adapters (`firefly/eda-rabbitmq`, `firefly/eda-postgres`, `firefly/eda-kafka`) and the test kit -(`firefly/testing`) stay separate — each binds you to an infrastructure choice or belongs in `require-dev`. +All component code is included. Optional adapters are activated by configuration and may require a PHP +extension. For the test kit, add `orchestra/testbench:^11.1` and `illuminate/testing:^13.0` with `--dev`. +Existing component requirements are satisfied by the root library's `replace` metadata. Then point LaraFly at your app's classes and compile it. The one key an application must get right is `firefly.scan.paths` — the PSR-4 roots every scanner walks; the skeleton's `config/firefly.php` ships it @@ -75,14 +74,12 @@ its `composer.json` asks for exactly two Firefly packages beside `php: ^8.3` and - **`firefly/cli`** — the developer-experience console: `firefly:cache`/`:clear`, actuator-over-CLI `firefly:about`/`:routes`/`:health`/`:metrics`, `firefly:oauth2:keys`, the `make:firefly-*` generator family, and thin `firefly:serve`/`:schedule`/`:db` passthroughs. See [CLI](cli.md). -- **`firefly/firefly`** — a `type: metapackage` runtime aggregator; `composer require firefly/firefly` pulls - the whole runtime family in one line, `firefly/cli` among them. (It is in the metapackage deliberately: - while it was `require-dev`-only, an application that never ran `firefly:cache` booted with empty manifests — - including an empty method-security manifest, which both enforcement sites read as ALLOW.) - -The skeleton lists `firefly/cli` explicitly as well as through the metapackage so that the template's own -`post-create-project-cmd` — which ends in `php artisan firefly:cache` — cannot be broken by a future change -to what the metapackage aggregates. +- **`firefly/firefly`** — the complete `type: library` distribution. It supplies `firefly/cli` through + `replace`, so both requirements resolve to the same installation. The template's CLI requirement + explicitly records its use of `firefly:cache`. + +The template is bundled in the library and supplied to Composer by `firefly new`; it does not need a +separate Packagist package. See [Publishing](publishing.md) for the transition from split packages. ## Where to next diff --git a/docs/installation.md b/docs/installation.md index 2aae82e1..f9c24181 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -4,16 +4,15 @@ - **PHP 8.3+** (8.4 recommended). - **Composer 2.** -- **Laravel 13** — LaraFly is a set of Composer packages that layer onto a Laravel application; it is not a +- **Laravel 13** — LaraFly is a Composer library that layers onto a Laravel application; it is not a standalone runtime. ## Quick install -The fastest path is the global installer, `firefly/installer` — a thin `firefly new` binary (Symfony Console, -no Firefly runtime dependencies) that wraps `composer create-project firefly/skeleton`: +The bundled `firefly new` installer supplies the application template to Composer and prepares the app: ```bash -composer global require firefly/installer +composer global require firefly/firefly firefly new my-app cd my-app php artisan firefly:serve @@ -30,33 +29,26 @@ off `NewCommand::configure()`: | `-f`, `--force` | **Empties the target directory first**, then scaffolds into it. | | `--git` / `--no-git` | Whether to `git init` and make an initial commit. On by default. | -For most capabilities `--with` does not decide whether code *exists*: every non-adapter, non-dev capability -is already required by the `firefly/firefly` metapackage — `tests/MetapackageCoverageTest.php` fails the -build if one of them is not — so naming one promotes an already-installed package to an explicit dependency -in the generated `composer.json` rather than fetching anything new. - -Four capabilities are the exception and really do fetch something. The broker adapters `eda-kafka`, -`eda-rabbitmq` and `eda-postgres` each bind an application to one transport, and each brings its own -baggage — `eda-postgres` will not install at all without `ext-pdo_pgsql`, `eda-rabbitmq` pulls -`php-amqplib/php-amqplib` in with it, and `eda-kafka` is inert until `ext-rdkafka` is present — so the -metapackage deliberately leaves all three out and `--with=eda-kafka` is what actually installs one. The -dev-only `testing` kit is the fourth: it requires `orchestra/testbench`, which belongs in `require-dev` or -nowhere, so `--with=testing` writes `firefly/testing` into **`require-dev`** rather than `require`. The -catalogue's fourth adapter, `scheduling-postgres`, needs nothing but a Postgres connection and *is* shipped -by the metapackage, so naming it only makes it explicit like any other capability. See -[Installer](modules/installer.md) for the full catalogue and how the tool is built. +Every component's code is included in `firefly/firefly`. `--with` records an explicit component +requirement in the generated manifest; it does not fetch a separate framework package. Configuration +selects the active transport. `eda-postgres` needs `ext-pdo_pgsql`, `eda-kafka` needs `ext-rdkafka`, +`eda-rabbitmq` uses the included AMQP client, and `scheduling-postgres` needs a PostgreSQL connection. +`--with=testing` adds `orchestra/testbench` and `illuminate/testing` to **`require-dev`**, alongside the +compatible `firefly/testing` requirement. See [Installer](modules/installer.md). ## Without the installer -If you'd rather not install a global binary, `composer create-project` alone gets you the same result — -`firefly new` is a convenience wrapper around exactly this command: +From a framework source checkout, Composer can also use the local bundled template directly: ```bash -composer create-project firefly/skeleton my-app +composer create-project --repository='{"type":"path","url":"./skeleton","options":{"symlink":false}}' firefly/skeleton my-app --stability=dev cd my-app php artisan firefly:serve ``` +The generated application installs `firefly/firefly` from Packagist; before first publication, supply a +root VCS or path repository as described in [Publishing](publishing.md). + `firefly/skeleton`'s `composer.json` wires `post-create-project-cmd` to run automatically, so by the time the command above finishes you already have, in this order: @@ -69,27 +61,9 @@ command above finishes you already have, in this order: ## What the template requires -`skeleton/composer.json` asks for exactly four things: `php: ^8.3`, `laravel/framework: ^13.0`, -`firefly/cli` and `firefly/firefly`. The last of those is the metapackage, and it is what pulls the whole -runtime family in: - - - -```json -"require": { - "php": "^8.3", - "firefly/actuator": "*@dev", - "firefly/admin": "*@dev", - "firefly/autoconfigure": "*@dev", - "firefly/cli": "*@dev", -// … - "firefly/security": "*@dev", - "firefly/security-oauth2-client": "*@dev", - "firefly/security-oauth2-server": "*@dev", - "firefly/validation": "*@dev", - "firefly/web": "*@dev" -}, -``` +`skeleton/composer.json` requires PHP, Laravel, `firefly/firefly` and `firefly/cli`. The library supplies +all framework code; the CLI requirement is satisfied by `replace`. Providers are discovered from the +root package's `extra.laravel.providers` metadata. Two of those arrived in the `26.09` line and are worth naming, because both are **off until you configure them** and neither costs you anything until then: diff --git a/docs/modules.md b/docs/modules.md index 21e2509c..184286cc 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -1,12 +1,11 @@ # Modules -LaraFly is one monorepo of **29 installable Composer packages** under `packages/*` — 28 libraries plus the -`firefly/firefly` runtime metapackage — each with its own test suite, and the **32 guides** below are the long -form of what they do. Several packages carry more than one guide, because the surfaces they ship are read -separately: `firefly/data` alone answers for *Data & Repositories*, *Relational Data* and *Transactions*. -Installing a package is the whole wiring step: its service provider is auto-discovered from the package's own -`composer.json`, everything that provider registers is a default, and a bean you declare yourself wins over -that default — nothing here asks you to register a provider by hand. Two idioms deliver that outcome, and +LaraFly publishes one `firefly/firefly` library containing the **29 component directories** under +`packages/*`, each with its own tests or metadata. The **32 guides** below explain their public surfaces. +Several components carry more than one guide: `firefly/data` answers for *Data & Repositories*, +*Relational Data* and *Transactions*. Laravel discovers the root library's providers, and configuration +selects optional features. A bean you declare wins over the framework default. +Two idioms deliver that outcome, and which one you are looking at matters the moment you go reading the source. **17 of the 29 packages** carry a `#[Configuration]` class whose `#[Bean]` methods sit behind `#[ConditionalOnMissingBean]`, so a bean of yours makes the framework's back off silently — `packages/data/src/DataAutoConfiguration.php` is the model. The diff --git a/docs/modules/installer.md b/docs/modules/installer.md index 2985649d..e0b16e63 100644 --- a/docs/modules/installer.md +++ b/docs/modules/installer.md @@ -1,13 +1,12 @@ # Installer -`firefly/installer` is LaraFly's global scaffolding tool — the `laravel/installer` analogue. It is a single -`firefly new` Symfony Console binary with **zero Firefly runtime dependencies**, so `composer global require -firefly/installer` stays a light, fast install regardless of how large the rest of the framework family gets. +The installer is bundled in the `firefly/firefly` library. Its `firefly new` Symfony Console binary +uses the skeleton shipped in that same distribution, so creating an application needs no skeleton mirror. ## `firefly new ` ```bash -composer global require firefly/installer +composer global require firefly/firefly firefly new my-app ``` @@ -37,7 +36,7 @@ php artisan firefly:serve run via its `ProcessRunner` seam: 1. ```bash - composer create-project firefly/skeleton --no-interaction [--stability=dev] + composer create-project firefly/skeleton --no-interaction --repository= [--stability=dev] ``` Exactly the same command documented in [Installation § Without the installer](../installation.md#without-the-installer) — `firefly/skeleton`'s own `post-create-project-cmd` hooks (`.env` copy, sqlite file, `key:generate`, @@ -54,13 +53,13 @@ run via its `ProcessRunner` seam: If the `composer create-project` step fails, `firefly new` reports the error and exits non-zero without attempting git initialization. -## The zero-firefly-deps design +## Distribution -`packages/installer/composer.json` requires only `php`, `symfony/console`, and `symfony/process` — no -`firefly/kernel`, no `firefly/container`, nothing from the runtime family. This is deliberate: `firefly/installer` -is meant to be installed **globally** (`composer global require`), running entirely outside the context of any -particular LaraFly project, so it must not drag in framework packages it will never use. The Deptrac `Installer` -layer reflects this — it depends on no other layer in `deptrac.yaml`, and is depended on by none. +The root manifest exposes `packages/installer/bin/firefly` as a Composer binary. Its autoloader supports +both Composer's installed proxy and direct execution in a source checkout. The installer reads the kernel +version for its bundled skeleton path repository; Deptrac permits that one framework dependency. +`--with=testing` also declares the external test harness in the generated application's development +requirements, because replacing `firefly/testing` does not install its old transitive requirements. ## The `ProcessRunner` seam diff --git a/docs/publishing.md b/docs/publishing.md index 75b633f5..2c262c90 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -1,110 +1,75 @@ -# Publishing - -This page is the release/split runbook for the LaraFly monorepo: how the 29 packages under `packages/*` -(including `firefly/installer` and the `firefly/firefly` metapackage), plus `firefly/skeleton` at the top level -— 30 shippable units in total — end up as individually-installable Packagist packages, and the exact, gated -sequence for the first manual publish. The authoritative list is the `matrix.package` block in -`.github/workflows/release.yml`: one entry per unit, and `ls packages` plus `skeleton` is what it has to -match. - -## Model - -Development happens in one monorepo (`fireflyframework-php`); each package is published as a **read-only** -Packagist mirror at `fireflyframework/firefly-` (e.g. `fireflyframework/firefly-kernel`). All mirrors -share **one CalVer tag** — a single `v26.09.3`-style tag on the monorepo cuts a release of every package at -once, at the same version, even for packages that had no code change that cycle. There is no independent -per-package versioning; see [Versioning](versioning.md) for why. - -The mirrors are read-only by design: nobody commits directly to `fireflyframework/firefly-kernel` — every -change flows through the monorepo and gets split out mechanically. This keeps the 30 mirror repos from ever -drifting out of sync with each other or with the monorepo history. - -## Automated split - -Once wired (tracked separately from this docs task), `.github/workflows/release.yml` fires on a pushed `v*` -tag and runs [`symplify/monorepo-split-github-action`](https://github.com/symplify/monorepo-split-github-action) -once per shippable unit (all 29 `packages/*` + `skeleton`), pushing each subtree to its own -`fireflyframework/firefly-` mirror repository at that tag. The workflow needs an `ACCESS_TOKEN` — an -organization-level GitHub Personal Access Token with `repo` scope on every mirror — stored as a repository (or -organization) secret, since the default `GITHUB_TOKEN` can't push to a *different* repository. - -**The mirror repositories must already exist when the tag is pushed.** The split action creates neither the -repository nor the token, and — this is the trap — it **exits 0 when its push fails**. On `v26.09.1` every -split job reported success while no mirror existed and nothing whatsoever was published. The workflow -therefore wraps the action in two guards of its own, and neither is optional: - -- a `preflight` job that fails the run once, with a pointer to this page, when `ACCESS_TOKEN` is unset — - rather than letting a matrix of credential-less jobs go green; -- a per-package `Verify the mirror actually received the tag` step that reads the tag back from the mirror - through the GitHub API and fails if it is not there. - -So a green **Release (split mirrors)** run now means the packages really are published. Treat a green run -from before those guards existed as unverified. - -## Interdependency - -Dev uses `*@dev` path repos (untouched) — every package in `packages/*/composer.json` requires its siblings -as `firefly/xyz: "*@dev"`, resolved locally via the root `composer.json`'s `path` repository entry. That's -what makes `composer install` at the monorepo root wire the whole tree together for local development and the -test suite. - -At release, `vendor/bin/monorepo-builder bump-interdependency 26.09.3` rewrites every sibling constraint from -`*@dev` to `^26.09` **on the release commit that gets tagged and split**, so the resulting mirrors are -stable-installable on their own — a consumer running `composer require fireflyframework/firefly-eda` never -sees a `*@dev` constraint, which `minimum-stability: stable` (the default posture) would refuse to resolve. -Immediately after the tag is cut and split, the monorepo tree is restored to `*@dev` so local development -continues undisturbed. - -The `extra.branch-alias: { "dev-main": "26.x-dev" }` entry every package carries is unaffected by this -dance — it remains in place for anyone tracking `dev-main` directly rather than a tagged release. - -## Manual first publish (controller + user, gated) - -The very first publish is done by hand, one careful step at a time, with a human in the loop at every -irreversible action — not scripted end-to-end. Steps 4 and onward are **irreversible**: they push public -history, create public mirror repositories, and register public Packagist packages. Do not proceed past step -3 without the operator (a human, not an agent) explicitly confirming each subsequent step. - -1. **`git config core.hooksPath scripts/hooks`** — activate the committed pre-push guard hook for this local - clone. This is the *first* action, before anything else in this runbook, so the guard fires automatically - on every push below without relying on anyone remembering to run it manually. -2. Confirm access to the `fireflyframework` GitHub org and repo-creation rights within it; confirm the - `ACCESS_TOKEN` org PAT and a Packagist API token are both available to whoever is running this runbook. -3. Run the pre-push guard (it also runs automatically via the hook from step 1, but run it explicitly here as - a checkpoint) plus a final manual sweep that must come back **empty**: - ```bash - bash scripts/check-no-sensitive-tracked.sh - git ls-files | grep -iE 'superpowers|\.claude|\.env$' - ``` -4. **On a release commit:** - ```bash - vendor/bin/monorepo-builder bump-interdependency 26.09.3 - ``` - Verify every `packages/*/composer.json` now requires its siblings as `^26.09` (not `*@dev`), run - `composer validate` per package, commit the result, and **re-tag** `v26.09.3` at this commit — so the tag - that gets pushed and split in the next step is the one carrying `^26.09` constraints, not `*@dev`. -5. **`git remote add origin git@github.com:fireflyframework/fireflyframework-php.git` then - `git push origin main`** — **irreversible**: this publishes the monorepo's history publicly for the first - time. Push the **branch only**; hold the tag back until step 6. Pushing `--tags` here fires the split - workflow immediately, before the mirrors of step 6 exist, and every job would then have nothing to push - to. -6. **Create the 30 mirror repositories under the `fireflyframework` org, confirm `ACCESS_TOKEN` can write to - them, and only then `git push origin v26.09.3`** — **irreversible**: pushing the tag runs the split, and - each `fireflyframework/firefly-` mirror now exists publicly, carrying `^26.09` sibling constraints. - Wait for the **Release (split mirrors)** run to go green: with the guards above in place, green means each - mirror answered with the tag. -7. **Staged Packagist registration** — register only a first wave, then verify, before committing the rest: - register `firefly/kernel`, `firefly/container`, `firefly/config`, and `firefly/eda` on Packagist first. - Then, in a scratch directory, under Composer's default `minimum-stability: stable`: - ```bash - mkdir /tmp/firefly-publish-check && cd /tmp/firefly-publish-check - composer init --no-interaction - composer require firefly/eda:^26.09 - ``` - **Confirm this resolves and installs cleanly** before doing anything else. Only if it succeeds, register - every remaining package plus `firefly/firefly` (the runtime metapackage) and `firefly/installer`. If it - fails, **stop** — the interdependency-constraint strategy needs fixing, and only four packages are affected - (versus discovering the same problem after all 30 are already permanently registered on Packagist). -8. After publishing, restore the monorepo dev tree to `*@dev` — revert the `bump-interdependency` commit (or - bump the constraints back by hand) — so local development on `main` continues exactly as before this - runbook started. +# Publishing LaraFly + +LaraFly publishes **one library, `firefly/firefly`**, from this repository's root `composer.json`. +Its tagged archive contains every component's code, configuration, migrations, views, compiled manifests, +the `firefly` installer binary and the application skeleton. No component mirror repositories or +cross-repository `ACCESS_TOKEN` are needed. + +## Package identity and compatibility + +The root package replaces the 28 other component names with `self.version`. Together with its own +`firefly/firefly` name, that covers the 29 former packages. `firefly/lumen` is a sample, not a replacement; +`firefly/skeleton` is a bundled project template, not a second published package. + +Consumers first require the provider package: + +```bash +composer require firefly/firefly +composer require firefly/eda-kafka +``` + +The second requirement is satisfied by the installed framework at a compatible version. Composer does +not automatically discover an unknown provider from a replacement name alone: an empty project must +require `firefly/firefly` explicitly. `self.version` prevents a framework from satisfying component +constraints from another release line. See [Composer's replace documentation](https://getcomposer.org/doc/04-schema.md#replace). + +The component manifests under `packages/*` remain internal dependency and namespace descriptors. +`composer mono-validate` still checks their version consistency. Do not run monorepo-builder `merge`, +`bump-interdependency` or `release`: there are no component publications to coordinate. The root manifest +owns runtime requirements, autoloading, Laravel discovery and the installer binary. Root `autoload-dev` +loads component test support and Lumen only when developing this repository. + +All adapter code is included. Configuration still chooses the active transport; PostgreSQL needs +`ext-pdo_pgsql`, Kafka needs `ext-rdkafka`, and the RabbitMQ client is included. Testbench and +`illuminate/testing` are development dependencies of an application using the testing kit, not production +dependencies of the library: + +```bash +composer require --dev orchestra/testbench:^11.1 illuminate/testing:^13.0 +``` + +## Validate a change + +```bash +composer install +composer validate --strict +composer mono-validate +composer check +composer test:package +composer test:browser +``` + +`test:package` exports the current library, installs it without development dependencies in a separate +application, requires every component name, boots and compiles a real route, runs the bundled installer, +installs the Lumen consumer through a copied root path repository, and rejects an incompatible component +version. Its package repositories exclude `firefly/*` from Packagist so a mirror cannot mask a missing +replacement. The consumer directories are retained under the system temporary directory for inspection. +CI runs these package checks on PHP 8.3, 8.4 and 8.5 for PRs to `main` and pushes to `main`. + +## First publication and later releases + +1. Merge a reviewed PR only after all quality, browser, documentation and package checks pass. +2. Update the framework version, README version badge and changelog together, then run the gates again. +3. Tag that release commit with its new `vYY.MM.Patch` version. Do not move an existing tag. Tags through + `v26.09.8` describe the old development aggregator and cannot serve as this new library. +4. Register `firefly/firefly` on Packagist using + `https://github.com/fireflyframework/fireflyframework-php` and enable the repository webhook. This is a + one-time publisher action; subsequent releases use tags from this same repository. +5. Push the new release tag. **Release (single package)** validates the tagged distribution with read-only + repository permissions. A green workflow proves the archive checks passed; it does not prove Packagist + has indexed the tag. +6. Confirm the exact new version is visible on Packagist and install it in a fresh project with default + stable resolution, no custom repositories, and matching `firefly/firefly` and `firefly/eda-kafka` + constraints. Publication is complete only when that install succeeds. + +An unmerged branch or a local consumer check is not a published release. diff --git a/monorepo-builder.php b/monorepo-builder.php index 24d972e0..bad9d8d0 100644 --- a/monorepo-builder.php +++ b/monorepo-builder.php @@ -5,14 +5,7 @@ use Symplify\MonorepoBuilder\Config\MBConfig; return static function (MBConfig $mbConfig): void { - // All runtime + tooling packages (incl. firefly/installer) are discovered here for - // `bump-interdependency` / `release`. The actual git subtree split to the read-only Packagist - // mirrors is driven by .github/workflows/release.yml (monorepo-builder v11 has no split command); - // that workflow's split matrix is the authoritative mirror registry (26 units: every dir here - // plus `skeleton/`, which is intentionally NOT registered below). - // - // `skeleton/` is deliberately excluded from packageDirectories: it keeps *@dev + - // minimum-stability: dev until the family is live on Packagist (see docs/publishing.md), so it - // must not be touched by `bump-interdependency` in this phase. + // Component manifests document module dependencies; validate keeps their versions consistent. + // The root library is authoritative for installation. Never merge or split these descriptors. $mbConfig->packageDirectories([__DIR__.'/packages']); }; diff --git a/packages/cli/tests/Skeleton/CreateProjectOfflineTest.php b/packages/cli/tests/Skeleton/CreateProjectOfflineTest.php index f779b725..024c17b9 100644 --- a/packages/cli/tests/Skeleton/CreateProjectOfflineTest.php +++ b/packages/cli/tests/Skeleton/CreateProjectOfflineTest.php @@ -58,7 +58,7 @@ // symlink:false forces a real copy-install from the local path repos (no symlink into the monorepo). file_put_contents($home.'/config.json', (string) json_encode([ 'repositories' => [ - 'firefly-pkgs' => ['type' => 'path', 'url' => $mono.'/packages/*', 'options' => ['symlink' => false]], + 'firefly-pkgs' => ['type' => 'path', 'url' => $mono, 'options' => ['symlink' => false]], 'firefly-skeleton' => ['type' => 'path', 'url' => $mono.'/skeleton', 'options' => ['symlink' => false]], ], 'minimum-stability' => 'dev', @@ -80,7 +80,7 @@ try { expect($process->isSuccessful())->toBeTrue($process->getOutput().$process->getErrorOutput()) ->and(is_file($target.'/artisan'))->toBeTrue() // BLOCKER-2: runnable app - ->and(is_dir($target.'/vendor/firefly/web'))->toBeTrue() // firefly family installed + ->and(is_dir($target.'/vendor/firefly/firefly/packages/web'))->toBeTrue() // firefly family installed ->and(is_file($target.'/bootstrap/cache/firefly/component.php'))->toBeTrue() // post-create firefly:cache ran ->and(is_file($target.'/bootstrap/cache/firefly/context.php'))->toBeTrue() ->and(is_file($target.'/bootstrap/cache/firefly/routes.php'))->toBeTrue(); // the sample #[RestController] compiled diff --git a/packages/eda-postgres/src/EdaPostgresBootServiceProvider.php b/packages/eda-postgres/src/EdaPostgresBootServiceProvider.php index a5b00e8a..1d6586ea 100644 --- a/packages/eda-postgres/src/EdaPostgresBootServiceProvider.php +++ b/packages/eda-postgres/src/EdaPostgresBootServiceProvider.php @@ -12,6 +12,10 @@ final class EdaPostgresBootServiceProvider extends ServiceProvider { public function boot(): void { + if ($this->app->make('config')->get('firefly.eda.provider') !== 'postgres') { + return; + } + $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); if ($this->app->runningInConsole()) { diff --git a/packages/eda-postgres/tests/BootDiscoveryTest.php b/packages/eda-postgres/tests/BootDiscoveryTest.php new file mode 100644 index 00000000..b9742a04 --- /dev/null +++ b/packages/eda-postgres/tests/BootDiscoveryTest.php @@ -0,0 +1,24 @@ +instance('config', new Repository(['firefly' => ['eda' => ['provider' => $provider]]])); + $app->instance('files', new Filesystem); + $app->register(DatabaseServiceProvider::class); + $app->register(MigrationServiceProvider::class); + (new EdaPostgresBootServiceProvider($app))->boot(); + /** @var Migrator $migrator */ + $migrator = $app->make('migrator'); + expect($migrator->paths() !== [])->toBe($expected); + $app->flush(); +})->with(['default' => ['sync', false], 'Kafka' => ['kafka', false], 'Postgres' => ['postgres', true]]); diff --git a/packages/firefly/README.md b/packages/firefly/README.md index e0ea6fea..8e68afd8 100644 --- a/packages/firefly/README.md +++ b/packages/firefly/README.md @@ -1,14 +1,14 @@ # firefly/firefly -LaraFly's runtime metapackage — the Composer analog of a Maven BOM. `composer require firefly/firefly` -pulls the whole runtime framework family in one line (kernel, container, context, config, autoconfigure, -web, validation, resilience, scheduling(+postgres), data, domain, eda, messaging, cqrs, security, -observability, actuator) instead of requiring each package individually. It excludes the dev-scoped -`firefly/testing` and `firefly/cli` packages and the `firefly/installer`, which stay `require-dev`/ -top-level concerns rather than runtime dependencies. +LaraFly publishes the repository root as the `firefly/firefly` library, containing all framework +components, the installer and the application skeleton. The root's `replace` entries satisfy compatible +component requirements without split repositories. ```bash composer require firefly/firefly ``` +This directory retains the former runtime dependency list as internal module metadata. It is not the +published package. See [Publishing](../../docs/publishing.md) for installation and release verification. + Apache-2.0 © Firefly Software Solutions Inc. diff --git a/packages/installer/README.md b/packages/installer/README.md index ce3097af..73a57438 100644 --- a/packages/installer/README.md +++ b/packages/installer/README.md @@ -4,14 +4,12 @@ The LaraFly global installer — the `laravel/installer` analog, with a Spring-I picker. ```bash -composer global require firefly/installer +composer global require firefly/firefly firefly new my-app ``` `firefly new ` wraps `composer create-project firefly/skeleton`, shapes the result into the requested -archetype, then (unless `--no-git`) runs `git init` + an initial commit and prints the next steps. It -depends only on `symfony/console` + `symfony/process` — never the firefly runtime family — so a global -install stays light. +archetype, then (unless `--no-git`) runs `git init` + an initial commit and prints the next steps. It ships with the root library and supplies the bundled skeleton as a copied path repository. ## Archetypes diff --git a/packages/installer/bin/firefly b/packages/installer/bin/firefly index e2458f6f..0e0cc87d 100755 --- a/packages/installer/bin/firefly +++ b/packages/installer/bin/firefly @@ -3,11 +3,14 @@ declare(strict_types=1); -foreach ([ +foreach (array_filter([ + $_composer_autoload_path ?? null, // Composer's installed binary proxy + __DIR__.'/../../../../../autoload.php', // direct invocation from the installed root library + __DIR__.'/../../../../vendor/autoload.php', // source checkout __DIR__.'/../../../autoload.php', // installed as vendor/firefly/installer/bin/firefly (global/project install) __DIR__.'/../../../vendor/autoload.php', // running from packages/installer/bin inside the monorepo checkout __DIR__.'/../vendor/autoload.php', // firefly/installer developed standalone with its own vendor/ -] as $autoload) { +]) as $autoload) { if (file_exists($autoload)) { require $autoload; break; diff --git a/packages/installer/composer.json b/packages/installer/composer.json index aff516ba..d7679b82 100644 --- a/packages/installer/composer.json +++ b/packages/installer/composer.json @@ -1,6 +1,6 @@ { "name": "firefly/installer", - "description": "The LaraFly global installer — `firefly new ` scaffolds a fresh LaraFly app by wrapping `composer create-project firefly/skeleton`, shapes it into an archetype (--api/--web/--full/--with=), then git-inits and prints next steps. A thin Symfony Console binary with no firefly runtime dependencies.", + "description": "The LaraFly global installer \u2014 `firefly new ` scaffolds a fresh LaraFly app by wrapping `composer create-project firefly/skeleton`, shapes it into an archetype (--api/--web/--full/--with=), then git-inits and prints next steps. A thin Symfony Console binary with no firefly runtime dependencies.", "type": "library", "license": "Apache-2.0", "homepage": "https://github.com/fireflyframework/fireflyframework-php", @@ -24,6 +24,7 @@ "source": "https://github.com/fireflyframework/fireflyframework-php/tree/main/packages/installer" }, "require": { + "firefly/kernel": "*@dev", "php": "^8.3", "symfony/console": "^7.4|^8.0", "symfony/process": "^7.4|^8.0" diff --git a/packages/installer/src/ArchetypeApplier.php b/packages/installer/src/ArchetypeApplier.php index ab3c0092..3672b8e3 100644 --- a/packages/installer/src/ArchetypeApplier.php +++ b/packages/installer/src/ArchetypeApplier.php @@ -67,6 +67,10 @@ private function rewriteManifest(string $directory): array if (isset($existing[$capability->package])) { continue; // already a direct dependency — the skeleton's own, or a duplicate --with } + if ($capability->id === 'testing') { + $requireDev['orchestra/testbench'] ??= '^11.1'; + $requireDev['illuminate/testing'] ??= '^13.0'; + } if ($capability->dev) { $requireDev[$capability->package] = $constraint; } else { diff --git a/packages/installer/src/CapabilityCatalog.php b/packages/installer/src/CapabilityCatalog.php index 12877430..c48f9931 100644 --- a/packages/installer/src/CapabilityCatalog.php +++ b/packages/installer/src/CapabilityCatalog.php @@ -7,42 +7,15 @@ use InvalidArgumentException; /** - * The catalog behind `--with=` and the interactive dependency picker: capability id -> firefly/* package. + * The catalog behind `--with=` and the interactive picker: capability id -> component requirement. * - * WHY A DECLARATIVE MAP RATHER THAN A DIRECTORY SCAN - * -------------------------------------------------- - * The obvious implementation is "list packages/* and offer every firefly package you find". It cannot work - * here, and not for a stylistic reason: firefly/installer is a GLOBAL install. `composer global require - * firefly/installer` puts this binary in ~/.composer/vendor with symfony/console + symfony/process and - * nothing else — there is no monorepo checkout on that machine, no packages/ directory to enumerate, and no - * firefly runtime package to introspect. The installer runs BEFORE the framework exists on disk. + * The root library ships all component code. This curated list distinguishes application capabilities + * from framework plumbing, rather than presenting every source directory as a user choice. The skeleton + * requires the root package, so its manifest alone cannot describe those choices. * - * The second candidate, "read the list out of the skeleton's composer.json", fails for a different reason: - * the skeleton requires exactly `firefly/cli` + `firefly/firefly`. firefly/firefly is the runtime BOM (the - * Composer analog of a Maven BOM) and drags the whole family behind it, so the skeleton's require block - * names two packages and describes the lot. There is no capability list in it to read, and reading one - * would require resolving the dependency graph — i.e. running Composer — before we are allowed to ask the - * user anything. - * - * WHICH IS ALSO WHY `--with` NEVER DECIDES WHETHER CODE EXISTS. Every non-adapter capability's package is - * required by the BOM, so `--with security` promotes an already-installed package to an explicit dependency - * in the generated composer.json — it does not fetch anything new. That uniformity is asserted by - * tests/MetapackageCoverageTest.php, and it is not free: firefly/admin and firefly/openapi were once - * offered here while the BOM required neither, so `--with admin` was the only way to get the dashboard at - * all and a plain `create-project` silently had none. - * - * So the map below is owned here, and the rot it invites is handled where it can actually be caught: the - * CapabilityCatalogTest enumerates the REAL packages/* directory in the monorepo and fails the build if any - * firefly/* package there is neither a capability nor listed in self::corePackages(). Adding a package to - * the family therefore forces a deliberate decision — "is this something a user picks?" — instead of - * silently going missing from the installer for a year. The enumeration still happens; it happens at CI - * time, where the monorepo exists, rather than at install time, where it does not. - * - * WHAT IS NOT A CAPABILITY - * ------------------------ - * kernel/container/config/context/autoconfigure/web/cli are the framework itself — an app without them is - * not a LaraFly app, so offering them as opt-ins would be offering the user a way to build something - * broken. firefly/firefly is the BOM that ships them, and firefly/installer is this tool. + * CapabilityCatalogTest checks every internal module descriptor against this list and corePackages(). + * Adapter selection remains a configuration decision; the testing capability also adds its external + * development harness through ArchetypeApplier. */ final class CapabilityCatalog { @@ -103,7 +76,7 @@ public static function corePackages(): array 'firefly/autoconfigure' => 'the conditional auto-configuration engine', 'firefly/web' => 'the HTTP layer; both the api and web archetypes route through it', 'firefly/cli' => 'the developer console the skeleton already requires directly', - 'firefly/firefly' => 'the runtime BOM that ships the family in one line', + 'firefly/firefly' => 'the root library containing every component', 'firefly/installer' => 'this tool', ]; } diff --git a/packages/installer/src/Filesystem.php b/packages/installer/src/Filesystem.php index ef08ab3e..7a5141d9 100644 --- a/packages/installer/src/Filesystem.php +++ b/packages/installer/src/Filesystem.php @@ -9,10 +9,7 @@ use RecursiveIteratorIterator; use SplFileInfo; -/** - * The small filesystem surface the installer needs. symfony/filesystem would do all of this, but it is a - * third dependency on a binary whose whole selling point is that a global install pulls two. - */ +/** The filesystem operations used by the project installer. */ final class Filesystem { public static function directoryIsNotEmpty(string $directory): bool diff --git a/packages/installer/src/NewCommand.php b/packages/installer/src/NewCommand.php index 86464adf..1bc149be 100644 --- a/packages/installer/src/NewCommand.php +++ b/packages/installer/src/NewCommand.php @@ -4,6 +4,7 @@ namespace Firefly\Installer; +use Firefly\Kernel\Version; use InvalidArgumentException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -16,7 +17,7 @@ /** * `firefly new ` — the LaraFly project generator, the Spring Initializr analog. * - * It wraps `composer create-project firefly/skeleton`, then shapes the result into the requested archetype + * It supplies the bundled skeleton to `composer create-project`, then shapes the result into the requested archetype * (see ArchetypeApplier) and git-inits it. Every external process goes through the ProcessRunner seam so * the whole flow is assertable without a network. */ @@ -101,7 +102,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int ]); $io->newLine(); - $create = ['composer', 'create-project', 'firefly/skeleton', $directory, '--no-interaction']; + $repository = json_encode([ + 'type' => 'path', + 'url' => dirname(__DIR__, 3).'/skeleton', + 'options' => ['symlink' => false, 'versions' => ['firefly/skeleton' => Version::VERSION]], + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + $create = ['composer', 'create-project', 'firefly/skeleton', $directory, '--no-interaction', '--repository='.$repository]; if ($input->getOption('dev')) { $create[] = '--stability=dev'; } diff --git a/packages/installer/tests/ArchetypeTest.php b/packages/installer/tests/ArchetypeTest.php index 7abd94eb..979dc96b 100644 --- a/packages/installer/tests/ArchetypeTest.php +++ b/packages/installer/tests/ArchetypeTest.php @@ -2,6 +2,9 @@ declare(strict_types=1); +use Firefly\Installer\Archetype; +use Firefly\Installer\ArchetypeApplier; +use Firefly\Installer\CapabilityCatalog; use Firefly\Installer\NewCommand; use Firefly\Installer\Tests\Support\FakeProcessRunner; use Firefly\Installer\Tests\Support\Skeleton; @@ -325,3 +328,15 @@ function cleanUp(string $dir): void cleanUp($dir); } }); + +it('adds the external test harness when the bundled testing capability is requested', function () { + $directory = sys_get_temp_dir().'/testing-capability-'.bin2hex(random_bytes(5)); + Skeleton::copy(Skeleton::path(), $directory); + $capability = array_values(array_filter(CapabilityCatalog::all(), static fn ($capability): bool => $capability->id === 'testing'))[0]; + (new ArchetypeApplier(Archetype::Web, [$capability]))->applyTo($directory); + /** @var array{require: array, require-dev: array} $manifest */ + $manifest = json_decode((string) file_get_contents($directory.'/composer.json'), true, flags: JSON_THROW_ON_ERROR); + expect($manifest['require-dev']['orchestra/testbench'] ?? null)->toBe('^11.1') + ->and($manifest['require-dev']['illuminate/testing'] ?? null)->toBe('^13.0') + ->and($manifest['require'])->not->toHaveKey('orchestra/testbench'); +}); diff --git a/packages/installer/tests/CapabilityCatalogTest.php b/packages/installer/tests/CapabilityCatalogTest.php index 35e262b9..746892f4 100644 --- a/packages/installer/tests/CapabilityCatalogTest.php +++ b/packages/installer/tests/CapabilityCatalogTest.php @@ -8,10 +8,8 @@ /** * The anti-rot guard for the hand-owned capability map. * - * firefly/installer is a GLOBAL install with no monorepo on disk and no firefly runtime dependency, so it - * cannot enumerate packages/* when it runs. The enumeration therefore happens HERE, where the monorepo - * exists: every firefly/* package in packages/ must be either a capability or an explicitly-justified core - * package, so adding a package to the family fails this test until someone decides which it is. + * Every internal component descriptor must correspond to an offered capability or a documented core + * component. The public installer choices are curated even though the distribution contains all code. * * @return list every `name` in packages/ * /composer.json */ diff --git a/packages/installer/tests/CreateProjectInstallerTest.php b/packages/installer/tests/CreateProjectInstallerTest.php index 1fab0ffe..dfc5d813 100644 --- a/packages/installer/tests/CreateProjectInstallerTest.php +++ b/packages/installer/tests/CreateProjectInstallerTest.php @@ -33,7 +33,7 @@ @mkdir($home, 0o755, true); file_put_contents($home.'/config.json', (string) json_encode([ 'repositories' => [ - 'firefly-pkgs' => ['type' => 'path', 'url' => $mono.'/packages/*', 'options' => ['symlink' => false]], + 'firefly-pkgs' => ['type' => 'path', 'url' => $mono, 'options' => ['symlink' => false]], 'firefly-skeleton' => ['type' => 'path', 'url' => $mono.'/skeleton', 'options' => ['symlink' => false]], ], 'minimum-stability' => 'dev', diff --git a/packages/installer/tests/NewCommandTest.php b/packages/installer/tests/NewCommandTest.php index 16311d77..1c6512f6 100644 --- a/packages/installer/tests/NewCommandTest.php +++ b/packages/installer/tests/NewCommandTest.php @@ -34,7 +34,7 @@ function runNew(FakeProcessRunner $runner, array $input, array $answers = []): C $tester = runNew($runner, ['name' => $dir, '--no-git' => true]); $tester->assertCommandIsSuccessful(); - expect($runner->calls[0]['command'])->toBe( + expect(array_slice($runner->calls[0]['command'], 0, 5))->toBe( ['composer', 'create-project', 'firefly/skeleton', $dir, '--no-interaction'] ); }); @@ -93,7 +93,7 @@ function runNew(FakeProcessRunner $runner, array $input, array $answers = []): C $tester->assertCommandIsSuccessful(); expect(is_dir($dir))->toBeTrue() // the directory itself survives ->and(scandir($dir))->toBe(['.', '..']) // ...but nothing inside it does - ->and($runner->calls[0]['command'])->toBe( + ->and(array_slice($runner->calls[0]['command'], 0, 5))->toBe( ['composer', 'create-project', 'firefly/skeleton', $dir, '--no-interaction'] ); } finally { @@ -212,3 +212,15 @@ function runNew(FakeProcessRunner $runner, array $input, array $answers = []): C ->toContain('api') ->toContain('security, eda'); }); + +it('creates projects from the bundled skeleton without a mirror repository', function () { + $runner = new FakeProcessRunner; + runNew($runner, ['name' => sys_get_temp_dir().'/bundled-'.bin2hex(random_bytes(5)), '--no-git' => true]); + $repositories = array_values(array_filter($runner->calls[0]['command'], static fn (string $arg): bool => str_starts_with($arg, '--repository='))); + expect($repositories)->toHaveCount(1); + /** @var array{type: string, url: string, options: array{symlink: bool}} $repository */ + $repository = json_decode(substr($repositories[0], strlen('--repository=')), true, flags: JSON_THROW_ON_ERROR); + expect($repository['type'])->toBe('path') + ->and(realpath($repository['url']))->toBe(realpath(dirname(__DIR__, 3).'/skeleton')) + ->and($repository['options']['symlink'])->toBeFalse(); +}); diff --git a/samples/lumen/composer.json b/samples/lumen/composer.json index 5171206f..a72cf147 100644 --- a/samples/lumen/composer.json +++ b/samples/lumen/composer.json @@ -1,13 +1,23 @@ { "name": "firefly/lumen", "description": "Lumen — a DDD digital-wallet & ledger sample application built on LaraFly. Not published; a monorepo sample.", - "type": "library", + "type": "project", "license": "Apache-2.0", "homepage": "https://github.com/fireflyframework/fireflyframework-php", "authors": [ - { "name": "Firefly Software Solutions Inc.", "homepage": "https://github.com/fireflyframework" } + { + "name": "Firefly Software Solutions Inc.", + "homepage": "https://github.com/fireflyframework" + } + ], + "keywords": [ + "firefly", + "laravel", + "sample", + "wallet", + "ddd", + "cqrs" ], - "keywords": ["firefly", "laravel", "sample", "wallet", "ddd", "cqrs"], "require": { "php": "^8.3", "firefly/actuator": "*@dev", @@ -23,14 +33,36 @@ "firefly/validation": "*@dev", "firefly/web": "*@dev", "illuminate/database": "^13.0", - "illuminate/support": "^13.0" + "illuminate/support": "^13.0", + "firefly/firefly": "*@dev" }, "require-dev": { "firefly/eda-postgres": "*@dev", - "firefly/testing": "*@dev" + "firefly/testing": "*@dev", + "orchestra/testbench": "^11.1", + "illuminate/testing": "^13.0" + }, + "autoload": { + "psr-4": { + "Lumen\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Lumen\\Tests\\": "tests/" + } }, - "autoload": { "psr-4": { "Lumen\\": "src/" } }, - "autoload-dev": { "psr-4": { "Lumen\\Tests\\": "tests/" } }, "minimum-stability": "stable", - "config": { "sort-packages": true } + "config": { + "sort-packages": true + }, + "repositories": [ + { + "type": "path", + "url": "../..", + "options": { + "symlink": false + } + } + ] } diff --git a/scripts/check-package-install.php b/scripts/check-package-install.php new file mode 100644 index 00000000..f407e65e --- /dev/null +++ b/scripts/check-package-install.php @@ -0,0 +1,135 @@ + '1'], timeout: 600); + $process->mustRun(); + + return $process->getOutput(); +} + +function packageAssert(bool $condition, string $message): void +{ + if (! $condition) { + throw new RuntimeException($message); + } +} + +function writePackageJson(string $path, array $value): void +{ + file_put_contents($path, json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)."\n"); +} + +try { + $manifest = json_decode(file_get_contents($root.'/composer.json'), true, flags: JSON_THROW_ON_ERROR); + packageAssert($manifest['name'] === 'firefly/firefly', 'The repository root must publish firefly/firefly.'); + runPackageCommand(['composer', 'archive', '--format=zip', '--dir='.$work, '--file=firefly'], $root); + $manifest['version'] = '26.9.99'; + $manifest['dist'] = ['type' => 'zip', 'url' => $work.'/firefly.zip']; + unset($manifest['source']); + + // Only this distribution supplies firefly/*; Packagist is used for third-party dependencies. + $repositories = [ + ['type' => 'package', 'package' => $manifest], + ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['firefly/*']], + ['packagist.org' => false], + ]; + $app = $work.'/app'; + mkdir($app); + $copy = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root.'/skeleton', FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST); + foreach ($copy as $entry) { + $target = $app.'/'.substr($entry->getPathname(), strlen($root.'/skeleton/')); + $entry->isDir() ? mkdir($target, 0755, true) : copy($entry->getPathname(), $target); + } + $consumer = json_decode(file_get_contents($app.'/composer.json'), true, flags: JSON_THROW_ON_ERROR); + $consumer['repositories'] = $repositories; + $consumer['require']['firefly/firefly'] = '26.9.99'; + // Every former component must resolve to the same installed library. + foreach (array_keys($manifest['replace']) as $name) { + $consumer['require'][$name] = '^26.9'; + } + unset($consumer['require-dev'], $consumer['minimum-stability']); + writePackageJson($app.'/composer.json', $consumer); + runPackageCommand(['composer', 'update', '--no-dev', '--prefer-dist', '--no-scripts', '--no-progress'], $app); + $installed = json_decode(file_get_contents($app.'/vendor/composer/installed.json'), true, flags: JSON_THROW_ON_ERROR)['packages']; + $names = array_column($installed, 'name'); + packageAssert(array_values(array_filter($names, fn ($name) => str_starts_with($name, 'firefly/'))) === ['firefly/firefly'], 'A component was installed separately.'); + foreach (['orchestra/testbench', 'pestphp/pest', 'phpunit/phpunit'] as $dev) { + packageAssert(! in_array($dev, $names, true), $dev.' leaked into a production install.'); + } + packageAssert(! is_link($app.'/vendor/firefly/firefly'), 'Consumer must use copied sources.'); + copy($app.'/.env.example', $app.'/.env'); + touch($app.'/database/database.sqlite'); + foreach ([['package:discover'], ['key:generate'], ['migrate', '--force'], ['firefly:cache']] as $arguments) { + runPackageCommand([PHP_BINARY, 'artisan', ...$arguments], $app); + } + runPackageCommand([PHP_BINARY, 'vendor/bin/firefly', '--help'], $app); + runPackageCommand([PHP_BINARY, 'vendor/firefly/firefly/packages/installer/bin/firefly', '--help'], $app); + packageAssert(! is_dir($app.'/vendor/firefly/firefly/vendor'), 'Development vendor directory leaked into the archive.'); + packageAssert(! is_dir($app.'/vendor/firefly/firefly/tests'), 'Repository tests leaked into the archive.'); + file_put_contents($app.'/probe.php', <<<'PROBE' +make(Illuminate\Contracts\Http\Kernel::class); +$response = $kernel->handle(Illuminate\Http\Request::create('/greetings/Package')); +if ($response->getStatusCode() !== 200 || ! str_contains($response->getContent(), 'Package')) { + throw new RuntimeException('Installed application route failed: '.$response->getContent()); +} +if (Illuminate\Support\Facades\Schema::hasTable(Firefly\Eda\Postgres\Outbox\OutboxSchema::TABLE)) { + throw new RuntimeException('An unselected adapter registered its migrations.'); +} +if (class_exists('Lumen\\Domain\\Money') || class_exists('Firefly\\Tests\\Browser\\Support\\DiscoveredProviders')) { + throw new RuntimeException('Repository test autoloads leaked into a consumer.'); +} +if (! class_exists(Firefly\Eda\Kafka\EdaKafkaServiceProvider::class) || ! function_exists('fireflyApplication')) { + throw new RuntimeException('Component code or autoload.files is missing.'); +} +echo "Installed application boots and serves its compiled route.\n"; +PROBE); + echo runPackageCommand([PHP_BINARY, 'probe.php'], $app); + $home = $work.'/composer-home'; + mkdir($home); + writePackageJson($home.'/config.json', ['repositories' => $repositories]); + $installer = new Process([PHP_BINARY, 'vendor/bin/firefly', 'new', $work.'/generated', '--no-git', '--no-interaction'], $app, ['COMPOSER_HOME' => $home], timeout: 600); + $installer->mustRun(); + packageAssert(is_file($work.'/generated/bootstrap/cache/firefly/routes.php'), 'Bundled installer did not create a compiled app.'); + packageAssert(is_file($work.'/generated/tests/TestCase.php'), 'Bundled skeleton lost its application tests.'); + + $lumen = $work.'/lumen'; + mkdir($lumen); + $sample = json_decode(file_get_contents($root.'/samples/lumen/composer.json'), true, flags: JSON_THROW_ON_ERROR); + $sample['repositories'] = [ + ['type' => 'path', 'url' => $root, 'options' => ['symlink' => false, 'versions' => ['firefly/firefly' => '26.9.99']]], + $repositories[1], $repositories[2], + ]; + // Source/test namespaces in the sample remain relative to the sample project. + $sample['autoload']['psr-4']['Lumen\\'] = $root.'/samples/lumen/src/'; + writePackageJson($lumen.'/composer.json', $sample); + runPackageCommand(['composer', 'update', '--no-dev', '--prefer-dist', '--no-scripts', '--no-progress'], $lumen); + $sampleInstalled = json_decode(file_get_contents($lumen.'/vendor/composer/installed.json'), true, flags: JSON_THROW_ON_ERROR)['packages']; + packageAssert(array_values(array_filter(array_column($sampleInstalled, 'name'), fn ($name) => str_starts_with($name, 'firefly/'))) === ['firefly/firefly'], 'Lumen installed a split package.'); + packageAssert(! is_link($lumen.'/vendor/firefly/firefly'), 'Lumen must exercise copied path sources.'); + runPackageCommand([PHP_BINARY, '-r', 'require "vendor/autoload.php"; if (! class_exists("Lumen\\Domain\\Money")) { exit(1); }'], $lumen); + + $consumer['require']['firefly/eda-kafka'] = '^27.0'; + writePackageJson($app.'/composer.json', $consumer); + $conflict = new Process(['composer', 'update', '--dry-run', '--no-dev', '--no-scripts'], $app, timeout: 120); + $conflict->run(); + packageAssert(! $conflict->isSuccessful() && str_contains($conflict->getErrorOutput(), 'firefly/eda-kafka'), 'self.version accepted an incompatible component version.'); + echo "Package distribution, component replacement, production dependencies and version conflict verified.\n"; + echo "Consumer retained at {$app}\n"; +} catch (Throwable $e) { + fwrite(STDERR, $e->getMessage()."\nConsumer retained at {$work}\n"); + exit(1); +} diff --git a/testbench.yaml b/testbench.yaml new file mode 100644 index 00000000..75a7fc5c --- /dev/null +++ b/testbench.yaml @@ -0,0 +1,2 @@ +providers: + - Firefly\Tests\Support\PackageServiceProvider diff --git a/tests/Browser/Support/DiscoveredProviders.php b/tests/Browser/Support/DiscoveredProviders.php index c8955fa7..deb49ee3 100644 --- a/tests/Browser/Support/DiscoveredProviders.php +++ b/tests/Browser/Support/DiscoveredProviders.php @@ -9,62 +9,29 @@ use Illuminate\Support\ServiceProvider; use RuntimeException; -/** - * The service providers a created LaraFly app registers through Laravel's package discovery. - * - * `composer create-project firefly/skeleton` installs `firefly/firefly` (the metapackage) and `firefly/cli`, - * and Laravel discovers every provider those packages' composer.json files declare. The browser suite has - * to boot the same set — a dashboard page that renders only because a provider is missing would be a false - * green — so the list is READ from vendor/composer/installed.json rather than typed here, and stays true as - * packages are added to the metapackage. - * - * Two adjustments: FireflyAutoConfigureServiceProvider is dropped because FireflyTestCase registers it first - * itself, and FireflyCacheServiceProvider goes last because its unconditional `$app->instance()` overrides - * must win over every *WiringProvider's bound()-guarded default. - */ +/** The root library's provider set, as Laravel discovers it in a consumer application. */ final class DiscoveredProviders { - private const array ROOTS = ['firefly/firefly', 'firefly/cli']; - /** @return list> */ public static function forSkeleton(): array { - /** @var array{packages: list, extra?: array{laravel?: array{providers?: list}}}>} $installed */ - $installed = json_decode( - (string) file_get_contents(dirname(__DIR__, 3).'/vendor/composer/installed.json'), + /** @var array{extra: array{laravel: array{providers: list}}} $package */ + $package = json_decode( + (string) file_get_contents(dirname(__DIR__, 3).'/composer.json'), true, 512, JSON_THROW_ON_ERROR, ); - $byName = []; - foreach ($installed['packages'] as $package) { - $byName[$package['name']] = $package; - } - - $wanted = []; - foreach (self::ROOTS as $root) { - foreach (array_keys($byName[$root]['require'] ?? []) as $name) { - if (str_starts_with($name, 'firefly/')) { - $wanted[$name] = true; - } - } - } - $providers = []; - foreach ($byName as $name => $package) { - if (! isset($wanted[$name])) { + foreach ($package['extra']['laravel']['providers'] as $provider) { + if ($provider === FireflyAutoConfigureServiceProvider::class || $provider === FireflyCacheServiceProvider::class) { continue; } - foreach ($package['extra']['laravel']['providers'] ?? [] as $provider) { - if ($provider === FireflyAutoConfigureServiceProvider::class || $provider === FireflyCacheServiceProvider::class) { - continue; - } - if (! is_subclass_of($provider, ServiceProvider::class)) { - throw new RuntimeException("{$name} declares {$provider} as a provider, but it is not a ServiceProvider."); - } - $providers[] = $provider; + if (! is_subclass_of($provider, ServiceProvider::class)) { + throw new RuntimeException("{$provider} is not a ServiceProvider."); } + $providers[] = $provider; } $providers[] = FireflyCacheServiceProvider::class; diff --git a/tests/BrowserFixtureProvidersTest.php b/tests/BrowserFixtureProvidersTest.php index f1d3f2b1..2622206e 100644 --- a/tests/BrowserFixtureProvidersTest.php +++ b/tests/BrowserFixtureProvidersTest.php @@ -11,7 +11,7 @@ /** * The browser suite boots the skeleton with the provider set a CREATED app gets from Laravel's package - * discovery — derived from installed.json rather than typed by hand, so adding a package to firefly/firefly + * discovery — derived from the root manifest rather than typed by hand, so adding a provider to firefly/firefly * changes the browser fixture without anyone remembering to. This test runs in the default gate (no * browser) and pins the derivation. */ @@ -28,22 +28,10 @@ ->and(array_count_values($providers))->each->toBe(1); }); -it('names every provider a package in the metapackage declares', function (): void { - /** @var array{packages: list}}}>} $installed */ - $installed = json_decode((string) file_get_contents(__DIR__.'/../vendor/composer/installed.json'), true, 512, JSON_THROW_ON_ERROR); - /** @var array{require?: array} $meta */ - $meta = json_decode((string) file_get_contents(__DIR__.'/../packages/firefly/composer.json'), true, 512, JSON_THROW_ON_ERROR); - - $declared = []; - foreach ($installed['packages'] as $package) { - if (isset($meta['require'][$package['name']])) { - foreach ($package['extra']['laravel']['providers'] ?? [] as $provider) { - if ($provider !== FireflyAutoConfigureServiceProvider::class) { - $declared[] = $provider; - } - } - } - } +it('names every provider declared by the root library', function (): void { + /** @var array{extra: array{laravel: array{providers: list}}} $root */ + $root = json_decode((string) file_get_contents(__DIR__.'/../composer.json'), true, flags: JSON_THROW_ON_ERROR); + $declared = array_filter($root['extra']['laravel']['providers'], static fn (string $provider): bool => $provider !== FireflyAutoConfigureServiceProvider::class); expect(array_diff($declared, DiscoveredProviders::forSkeleton()))->toBe([]); }); diff --git a/tests/DocsProseIsRealTest.php b/tests/DocsProseIsRealTest.php index 27ab3b1a..6ed430cf 100644 --- a/tests/DocsProseIsRealTest.php +++ b/tests/DocsProseIsRealTest.php @@ -19,7 +19,6 @@ use Firefly\Data\Repository\EloquentRepository; use Firefly\Data\Repository\Locking\HasOptimisticLock; use Firefly\Data\Repository\Locking\OptimisticLockException; -use Firefly\Installer\CapabilityCatalog; use Firefly\Kernel\Exception\Infrastructure\OptimisticLockingFailureException; use Firefly\Observability\HttpExchanges\HeaderMasker; use Firefly\OpenApi\OpenApiProperties; @@ -1533,89 +1532,6 @@ function fireflyOAuth2InstallProse(array $client, array $server): array ->and($recapClaims)->toBe(2); }); -it('pins the capabilities `--with` really fetches to the metapackage manifest', function () { - // The ninth. `docs/installation.md` explained `--with` with one flat sentence — "every non-adapter - // capability is already required by the `firefly/firefly` metapackage, so naming one promotes an - // installed package to an explicit dependency rather than fetching anything new" — and a reader who - // believed it ran `firefly new --with=testing` expecting nothing to be downloaded. firefly/testing is - // deliberately OUTSIDE the metapackage (it pulls orchestra/testbench), so that install fetches a package - // and writes it into require-dev. The sentence was inherited from CapabilityCatalog's own docblock, and - // `docs/getting-started.md` said the opposite two pages away. - // - // The real invariant is the one tests/MetapackageCoverageTest.php asserts: every non-adapter, NON-DEV - // capability is required by the metapackage. What is left over — the brokers an application chooses for - // itself, plus the dev-only test kit — is exactly the set `--with` really installs, and it is computable, - // so the page is held to it rather than to a list somebody kept in their head. `scheduling-postgres` is - // why this is derived and not typed: it is an `adapter: true` capability that the metapackage DOES ship, - // because an advisory-lock backend needs nothing but a Postgres connection, so "adapter" and "fetched" - // are not the same set and a hand-written caveat gets that wrong in the obvious direction. - /** @var mixed $manifest */ - $manifest = json_decode( - (string) file_get_contents(dirname(__DIR__).'/packages/firefly/composer.json'), - true, - ); - $require = is_array($manifest) && is_array($manifest['require'] ?? null) ? $manifest['require'] : []; - $shipped = array_map(strval(...), array_keys($require)); - - expect($shipped)->not->toBeEmpty('packages/firefly/composer.json requires nothing at all'); - - $fetched = []; - $shippedAdapters = []; - - foreach (CapabilityCatalog::all() as $capability) { - if (! in_array($capability->package, $shipped, true)) { - $fetched[$capability->id] = $capability; - } elseif ($capability->adapter) { - $shippedAdapters[] = $capability->id; - } - } - - $prose = (string) preg_replace( - '/\s+/', - ' ', - (string) file_get_contents(dirname(__DIR__).'/docs/installation.md'), - ); - - // The count, as the page writes it: "Four capabilities are the exception". - $found = preg_match('/(?:\*\*)?(\p{L}+)(?:\*\*)?\s+capabilities\s+are\s+the\s+exception/u', $prose, $match) === 1; - - expect($found)->toBeTrue('docs/installation.md no longer counts the capabilities --with really fetches'); - - $counted = $match[1] ?? ''; - - expect(fireflyWrittenNumber($counted))->toBe(count($fetched), sprintf( - 'docs/installation.md says %s capabilities are fetched by --with; the metapackage leaves %d out of ' - .'its require block: %s.', - $counted, - count($fetched), - implode(', ', array_keys($fetched)), - )); - - $drifted = []; - - foreach ($fetched as $id => $capability) { - if (! str_contains($prose, '`'.$id.'`')) { - $drifted[] = $id.' is fetched by --with and the page never names it'; - } - - // A dev-only capability lands somewhere else in the generated manifest, which is the part a reader - // acts on: ArchetypeApplier writes it under require-dev, not require. - if ($capability->dev && ! str_contains($prose, '**`require-dev`**')) { - $drifted[] = $id.' is dev-only and the page no longer says it lands in require-dev'; - } - } - - // The other direction, and the one a hand-written caveat gets wrong: an adapter the metapackage DOES - // ship must still be named, or "the adapters are fetched" quietly becomes true of one package too many. - foreach ($shippedAdapters as $id) { - if (! str_contains($prose, '`'.$id.'`')) { - $drifted[] = $id.' is an adapter the metapackage ships, and the page no longer says so'; - } - } - - expect($drifted)->toBe([], implode('; ', $drifted)); -}); - it('pins the contributing guide\'s documentation-gate roster to the test files it names', function () { // The tenth, and the one closest to home: it guards the page that explains the guards. The Documentation // section opened "held to the same gate as code, by five Pest tests" and then named five — while seven diff --git a/tests/MetapackageCoverageTest.php b/tests/MetapackageCoverageTest.php index 34a2858d..fc8da8cf 100644 --- a/tests/MetapackageCoverageTest.php +++ b/tests/MetapackageCoverageTest.php @@ -1,107 +1,13 @@ toBe([]); - - // An exclusion naming a package that no longer exists is a stale comment pretending to be a decision. - foreach ($excluded as $name) { - expect(packageNames())->toContain($name); - } -}); -it('installs every non-adapter capability by default, so --with only ever makes a dependency explicit', function () { - $required = requiredBy(dirname(__DIR__).'/packages/firefly/composer.json'); +use Firefly\Installer\CapabilityCatalog; - $missing = []; +it('includes every installer capability in the single root library', function () { + /** @var array{replace: array} $root */ + $root = json_decode((string) file_get_contents(dirname(__DIR__).'/composer.json'), true, flags: JSON_THROW_ON_ERROR); foreach (CapabilityCatalog::all() as $capability) { - if ($capability->adapter || $capability->dev) { - continue; - } - - if (! in_array($capability->package, $required, true)) { - $missing[] = $capability->id; - } + expect($root['replace'][$capability->package] ?? null)->toBe('self.version'); } - - sort($missing); - - expect($missing)->toBe([]); }); - -/** @return list */ -function requiredBy(string $composer): array -{ - /** @var mixed $json */ - $json = json_decode((string) file_get_contents($composer), true); - $declared = is_array($json) ? ($json['require'] ?? null) : null; - - return is_array($declared) ? array_map(strval(...), array_keys($declared)) : []; -} - -/** @return list */ -function packageNames(): array -{ - $names = []; - foreach (glob(dirname(__DIR__).'/packages/*/composer.json') ?: [] as $composer) { - /** @var mixed $json */ - $json = json_decode((string) file_get_contents($composer), true); - if (is_array($json) && is_string($json['name'] ?? null)) { - $names[] = $json['name']; - } - } - - return $names; -} diff --git a/tests/OnePackageTest.php b/tests/OnePackageTest.php new file mode 100644 index 00000000..280073c7 --- /dev/null +++ b/tests/OnePackageTest.php @@ -0,0 +1,45 @@ + $root */ + $root = json_decode((string) file_get_contents(dirname(__DIR__).'/composer.json'), true, flags: JSON_THROW_ON_ERROR); + expect($root['name'])->toBe('firefly/firefly')->and($root['type'])->toBe('library'); + + /** @var array $replaced */ + $replaced = $root['replace']; + /** @var array $required */ + $required = $root['require']; + /** @var array{psr-4: array, files: list} $autoload */ + $autoload = $root['autoload']; + /** @var array{laravel: array{providers: list}} $extra */ + $extra = $root['extra']; + + foreach (glob(dirname(__DIR__).'/packages/*/composer.json') ?: [] as $file) { + /** @var array{name: string, require?: array, autoload?: array{psr-4?: array, files?: list}, extra?: array{laravel?: array{providers?: list}}} $module */ + $module = json_decode((string) file_get_contents($file), true, flags: JSON_THROW_ON_ERROR); + if ($module['name'] !== 'firefly/firefly') { + expect($replaced[$module['name']] ?? null)->toBe('self.version'); + } + foreach ($module['require'] ?? [] as $name => $constraint) { + if (str_starts_with($name, 'firefly/') || in_array($name, ['ext-pdo_pgsql', 'orchestra/testbench', 'illuminate/testing'], true)) { + continue; + } + expect($required[$name] ?? null)->toBe($constraint, $module['name'].' needs '.$name); + } + $prefix = 'packages/'.basename(dirname($file)).'/'; + foreach ($module['autoload']['psr-4'] ?? [] as $namespace => $path) { + expect($autoload['psr-4'][$namespace] ?? null)->toBe($prefix.$path); + } + foreach ($module['autoload']['files'] ?? [] as $path) { + expect($autoload['files'])->toContain($prefix.$path); + } + foreach ($module['extra']['laravel']['providers'] ?? [] as $provider) { + expect($extra['laravel']['providers'])->toContain($provider); + } + } + + expect($required)->not->toHaveKeys(array_keys($replaced)) + ->not->toHaveKeys(['orchestra/testbench', 'pestphp/pest', 'illuminate/testing']); +}); diff --git a/tests/PackageLicenseTest.php b/tests/PackageLicenseTest.php index 0c360ec1..76383045 100644 --- a/tests/PackageLicenseTest.php +++ b/tests/PackageLicenseTest.php @@ -8,6 +8,7 @@ function fireflyUnitDirs(): array $root = dirname(__DIR__); $dirs = glob($root.'/packages/*', GLOB_ONLYDIR) ?: []; $dirs[] = $root.'/skeleton'; + $dirs[] = $root; return $dirs; } diff --git a/tests/PackageMetadataTest.php b/tests/PackageMetadataTest.php index b809014a..d9e6a70e 100644 --- a/tests/PackageMetadataTest.php +++ b/tests/PackageMetadataTest.php @@ -8,6 +8,7 @@ function fireflyComposerFiles(): array $root = dirname(__DIR__); $files = glob($root.'/packages/*/composer.json') ?: []; $files[] = $root.'/skeleton/composer.json'; + $files[] = $root.'/composer.json'; return $files; } diff --git a/tests/ReleaseWorkflowTest.php b/tests/ReleaseWorkflowTest.php index 443b6561..2a3ca4cb 100644 --- a/tests/ReleaseWorkflowTest.php +++ b/tests/ReleaseWorkflowTest.php @@ -64,189 +64,23 @@ function releaseWorkflowYaml(): array expect($tags)->toContain('v*'); }); -it('the split matrix covers exactly the 30 publishable units, each mapped to fireflyframework/firefly-', function () { - $root = dirname(__DIR__); +it('validates the tagged single package without cross-repository credentials', function () { $yaml = releaseWorkflowYaml(); - $jobs = asYamlMap($yaml['jobs'] ?? null, 'jobs'); - $splitJob = asYamlMap($jobs['split'] ?? null, 'jobs.split'); - $strategy = asYamlMap($splitJob['strategy'] ?? null, 'jobs.split.strategy'); - $matrixMap = asYamlMap($strategy['matrix'] ?? null, 'jobs.split.strategy.matrix'); - $matrix = asYamlList($matrixMap['package'] ?? null, 'jobs.split.strategy.matrix.package'); - - // Build the expected unit list independently from the filesystem — this is the - // ground truth the matrix must match exactly (fails if a unit is added/removed - // under packages/* without updating the workflow, and fails if the workflow lists - // a unit that doesn't exist). - /** @var list $expectedLocals */ - $expectedLocals = array_map( - static fn (string $dir): string => 'packages/'.basename($dir), - glob($root.'/packages/*', GLOB_ONLYDIR) ?: [] - ); - $expectedLocals[] = 'skeleton'; - sort($expectedLocals); - - // 29 packages under packages/* plus the skeleton. The number is spelled out so that adding a package - // without adding its split row (or the reverse) fails here rather than silently shipping an unpublished unit. - expect($expectedLocals)->toHaveCount(30); - - /** @var list $actualLocals */ - $actualLocals = []; - /** @var array $actualSplitByLocal */ - $actualSplitByLocal = []; - - foreach ($matrix as $row) { - $rowMap = asYamlMap($row, 'matrix row'); - $local = $rowMap['local'] ?? null; - $splitName = $rowMap['split'] ?? null; - - if (! is_string($local) || ! is_string($splitName)) { - throw new RuntimeException('matrix row local/split must be strings.'); - } - - $actualLocals[] = $local; - $actualSplitByLocal[$local] = $splitName; - } - sort($actualLocals); - - expect($actualLocals)->toBe($expectedLocals, 'split matrix does not match packages/* + skeleton exactly'); - - // Every mirror target must be fireflyframework/firefly- using the LOCAL dirname - // (e.g. packages/eda-postgres -> firefly-eda-postgres), consumed by - // split-repository-organization: fireflyframework alongside split-repository-name. - foreach ($actualSplitByLocal as $local => $splitName) { - $dir = basename($local); - expect($splitName)->toBe("firefly-{$dir}", "mirror name for {$local} must be firefly-{$dir}"); - } -}); - -it('uses symplify/monorepo-split-github-action (v11 monorepo-builder has no split command)', function () { - $blob = (string) file_get_contents(dirname(__DIR__).'/.github/workflows/release.yml'); - - expect($blob)->toContain('symplify/monorepo-split-github-action') - ->and($blob)->not->toContain('monorepo-builder split'); -}); - -it('references the cross-repo PAT only via secrets.ACCESS_TOKEN, never hardcoded', function () { - $blob = (string) file_get_contents(dirname(__DIR__).'/.github/workflows/release.yml'); - - expect($blob)->toContain('${{ secrets.ACCESS_TOKEN }}'); - - // No plausible hardcoded GitHub PAT literal (ghp_/github_pat_ prefixes). - expect($blob)->not->toMatch('/ghp_[A-Za-z0-9]{20,}/') - ->and($blob)->not->toMatch('/github_pat_[A-Za-z0-9_]{20,}/'); -}); - -it('checks out full history (fetch-depth: 0) for the split', function () { - $yaml = releaseWorkflowYaml(); - $jobs = asYamlMap($yaml['jobs'] ?? null, 'jobs'); - $splitJob = asYamlMap($jobs['split'] ?? null, 'jobs.split'); - $steps = asYamlList($splitJob['steps'] ?? null, 'jobs.split.steps'); - - /** @var array $checkout */ - $checkout = []; + expect(array_keys($jobs))->toBe(['package']); + $package = asYamlMap($jobs['package'], 'package'); + $steps = asYamlList($package['steps'] ?? null, 'steps'); + $commands = []; foreach ($steps as $step) { - $stepMap = asYamlMap($step, 'step'); - $uses = $stepMap['uses'] ?? null; - if (is_string($uses) && str_starts_with($uses, 'actions/checkout@')) { - $checkout = $stepMap; - break; + $map = asYamlMap($step, 'step'); + if (is_string($map['run'] ?? null)) { + $commands[] = $map['run']; + expect($map['run'])->not->toContain('github.event.'); } } - - expect($checkout)->not->toBe([], 'no actions/checkout step found'); - - $with = asYamlMap($checkout['with'] ?? null, 'checkout.with'); - expect($with['fetch-depth'] ?? null)->toBe(0); -}); - -it('does not interpolate untrusted github.event.* into a run: step', function () { - $yaml = releaseWorkflowYaml(); - $jobs = asYamlMap($yaml['jobs'] ?? null, 'jobs'); - $splitJob = asYamlMap($jobs['split'] ?? null, 'jobs.split'); - $steps = asYamlList($splitJob['steps'] ?? null, 'jobs.split.steps'); - - foreach ($steps as $step) { - $stepMap = asYamlMap($step, 'step'); - $run = $stepMap['run'] ?? null; - if (is_string($run)) { - expect($run)->not->toContain('github.event.'); - } - } -}); - -// The split action exits 0 even when its push fails. On v26.09.1 that produced a whole matrix of green -// jobs, no mirror repositories, and nothing published — a release run that reported success while shipping -// nothing. The workflow now carries two guards against that, and these tests exist so neither can be -// dropped quietly. - -it('refuses to start the split when ACCESS_TOKEN is absent, instead of running a matrix of no-op jobs', function () { - $yaml = releaseWorkflowYaml(); - $jobs = asYamlMap($yaml['jobs'] ?? null, 'jobs'); - - expect($jobs)->toHaveKey('preflight'); - - $splitJob = asYamlMap($jobs['split'] ?? null, 'jobs.split'); - expect($splitJob['needs'] ?? null)->toBe('preflight'); - - $preflight = asYamlMap($jobs['preflight'] ?? null, 'jobs.preflight'); - $steps = asYamlList($preflight['steps'] ?? null, 'jobs.preflight.steps'); - - $script = ''; - foreach ($steps as $step) { - $stepMap = asYamlMap($step, 'step'); - $run = $stepMap['run'] ?? null; - if (is_string($run)) { - $script .= $run; - } - } - - // It must read the secret and exit non-zero when it is empty. - expect($script)->toContain('ACCESS_TOKEN') - ->and($script)->toContain('exit 1'); -}); - -it('reads the tag back from each mirror, because a green split step does not mean anything was pushed', function () { - $yaml = releaseWorkflowYaml(); - $jobs = asYamlMap($yaml['jobs'] ?? null, 'jobs'); - $splitJob = asYamlMap($jobs['split'] ?? null, 'jobs.split'); - $steps = asYamlList($splitJob['steps'] ?? null, 'jobs.split.steps'); - - /** @var array $verify */ - $verify = []; - $splitIndex = null; - $verifyIndex = null; - - foreach ($steps as $index => $step) { - $stepMap = asYamlMap($step, 'step'); - - $uses = $stepMap['uses'] ?? null; - if (is_string($uses) && str_starts_with($uses, 'symplify/monorepo-split-github-action')) { - $splitIndex = $index; - } - - $run = $stepMap['run'] ?? null; - if (is_string($run) && str_contains($run, 'git/ref/tags/')) { - $verify = $stepMap; - $verifyIndex = $index; - } - } - - expect($verify)->not->toBe([], 'no step reads the tag back from the mirror'); - expect($splitIndex)->not->toBeNull(); - // Verifying before the push would assert nothing. - expect($verifyIndex)->toBeGreaterThan((int) $splitIndex); - - $run = $verify['run'] ?? null; - expect($run)->toBeString(); - - /** @var string $run */ - // The mirror is asked about THIS tag, and a missing tag fails the job. - expect($run)->toContain('github.ref_name') - ->and($run)->toContain('matrix.package.split') - ->and($run)->toContain('exit 1'); - - // `set -e` alone would not catch it: the lookup is deliberately allowed to fail so the message can be - // ours, which only works if the emptiness of the result is what is actually tested. - expect($run)->toMatch('/if \[ -z .*sha/'); + expect($commands)->toContain('composer check', 'composer mono-validate', 'composer test:package'); + $permissions = asYamlMap($yaml['permissions'] ?? null, 'permissions'); + expect($permissions)->toBe(['contents' => 'read']); + $blob = (string) file_get_contents(dirname(__DIR__).'/.github/workflows/release.yml'); + expect($blob)->not->toContain('ACCESS_TOKEN', 'monorepo-split', 'git push'); }); diff --git a/tests/SiteNavigationTest.php b/tests/SiteNavigationTest.php index 74c3e015..eaf7824f 100644 --- a/tests/SiteNavigationTest.php +++ b/tests/SiteNavigationTest.php @@ -115,23 +115,13 @@ $claims = [ 'docs/modules.md' => [ - '**'.$packages.' installable Composer packages**', - ($packages - 1).' libraries plus the `firefly/firefly` runtime metapackage', + '**'.$packages.' component directories**', '**'.$guides.' guides**', ], 'docs/index.md' => [ 'lays all '.$guides.' guides out by concern', ], - // The README says it three times — the section's opening sentence, the closing sentence under the - // table, and the guide count that introduces the table itself — and all three are the same two - // glob()s. `$packages + 1` is the skeleton: a `type: project` template at the top level rather than - // under `packages/*`, which is exactly why it has to be counted separately and exactly why a - // hand-counted total forgets it. 'README.md' => [ - $packages.' packages under `packages/*`', - ($packages + 1).' shippable units in total', - 'round out the '.$packages.' packages', - 'is the '.($packages + 1).'th unit', 'The '.$guides.' [module guides]', ], // `docs/README.md` is the documentation folder's own index — what GitHub renders when somebody opens diff --git a/tests/Support/PackageServiceProvider.php b/tests/Support/PackageServiceProvider.php new file mode 100644 index 00000000..84054dca --- /dev/null +++ b/tests/Support/PackageServiceProvider.php @@ -0,0 +1,20 @@ +>}}} $manifest */ + $manifest = json_decode((string) file_get_contents(dirname(__DIR__, 2).'/composer.json'), true, flags: JSON_THROW_ON_ERROR); + foreach ($manifest['extra']['laravel']['providers'] as $provider) { + $this->app->register($provider); + } + } +}