diff --git a/.gitattributes b/.gitattributes index 0c165ef..944e1c3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,11 +8,16 @@ # The README links into examples/ and docs/. Those links keep working where the # README is actually read (GitHub, Packagist); inside an installed vendor/ they # dangle, which is the trade for not shipping them. +# +# The Symfony Flex recipe under src/Bridge/Symfony/recipe/ is ignored although +# it sits in src/: Flex never reads it from the package, it gets the release +# assets that bin/build-flex-endpoint.php builds from it. /.github export-ignore /bin export-ignore /docs export-ignore /examples export-ignore /test export-ignore +/src/Bridge/Symfony/recipe export-ignore /.gitattributes export-ignore /.gitignore export-ignore /CODE_OF_CONDUCT.md export-ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5a5cdd5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,36 @@ +name: Release assets + +# Attaches the Symfony Flex recipe endpoint to every published release, so that +# applications can use releases/latest/download/index.json as a recipe source +# (#182). Run it by hand to attach the files to a release that lacks them. +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to attach the Flex endpoint to" + required: true + +permissions: + contents: write + +jobs: + flex-endpoint: + runs-on: ubuntu-latest + env: + TAG: ${{ github.event.release.tag_name || inputs.tag }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ env.TAG }} + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + # The recipe files must be served from `latest`, not from this tag: the + # index is fetched through releases/latest/download, and Flex resolves + # the recipe URL from the index it got. + - run: php bin/build-flex-endpoint.php build/flex "https://github.com/${{ github.repository }}/releases/latest/download" + - run: gh release upload "$TAG" build/flex/*.json --clobber + env: + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a9ca896..0143979 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,7 +7,7 @@ on: jobs: unit: - name: "Unit / PHP ${{ matrix.php }}" + name: "Unit / PHP ${{ matrix.php }} / Guzzle ${{ matrix.guzzle }}" runs-on: ubuntu-latest strategy: fail-fast: false @@ -15,6 +15,9 @@ jobs: # No PHP 8.1 leg even though the library supports 8.1: PHPUnit 11 # requires PHP >= 8.2. php: ["8.2", "8.3", "8.4"] + # Both majors are allowed by composer.json, and the host application + # decides which one is installed (#180). + guzzle: ["^7.10", "^8.0"] steps: - uses: actions/checkout@v7 - uses: shivammathur/setup-php@v2 @@ -23,7 +26,7 @@ jobs: extensions: xdebug # update, not install: this is a library, so the root composer.lock is # git-ignored and each PHP leg resolves the versions it can actually run. - - run: composer update --no-interaction + - run: composer update --no-interaction --with "guzzlehttp/guzzle:${{ matrix.guzzle }}" - run: | if [ -d src ]; then vendor/bin/phpcs --standard=PSR12 src/; else echo "→ Skip (src/ not yet present)"; fi - run: | @@ -31,7 +34,7 @@ jobs: - run: | if [ -d test/Unit ]; then XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=unit --display-skipped --log-junit junit.xml; else echo "→ Skip (test/Unit/ not yet present)"; fi - name: Upload test artifacts - if: always() && matrix.php == '8.3' + if: always() && matrix.php == '8.3' && matrix.guzzle == '^8.0' uses: actions/upload-artifact@v7 with: name: unit-test-results @@ -59,6 +62,10 @@ jobs: # leg below instead of exercising the floor of the declared range. - symfony: "~7.0.0" php: "8.2" + # 7.0 is out of support and Packagist lists advisories for it, + # which Composer refuses to install by default. This leg tests the + # floor of the declared range and never ships, so it alone may. + allow_insecure: true - symfony: "^7.4" php: "8.3" - symfony: "^8.0" @@ -71,6 +78,9 @@ jobs: extensions: xdebug - name: Install bridge project run: | + if [ "${{ matrix.allow_insecure }}" = "true" ]; then + composer config -d test/Bridge/Symfony policy.advisories.block false + fi composer require --dev --no-update --no-interaction -d test/Bridge/Symfony \ "symfony/config:${{ matrix.symfony }}" \ "symfony/dependency-injection:${{ matrix.symfony }}" \ @@ -135,9 +145,41 @@ jobs: test/Bridge/Laravel/build/ if-no-files-found: ignore + # Installs the client into fresh framework skeletons with a plain + # `composer require`, the way an application would. The bridge jobs above + # build projects of their own and so cannot see conflicts with what a real + # skeleton locks, such as Guzzle 8 in Laravel 13 (#181). + install: + name: "Install / ${{ matrix.framework }} ${{ matrix.skeleton }}" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - framework: laravel + skeleton: "^12.0" + - framework: laravel + skeleton: "^13.0" + - framework: symfony + skeleton: "6.4.*" + - framework: symfony + skeleton: "7.4.*" + - framework: symfony + skeleton: "^8.0" + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" # the one version every skeleton above accepts + - run: test/Install/run.sh ${{ matrix.framework }} "${{ matrix.skeleton }}" + integration: runs-on: ubuntu-latest continue-on-error: true + # zammad-ci preloads jemalloc for Ruby. The PHP that setup-php installs + # into the container crashes on start under it (#184). + env: + LD_PRELOAD: "" container: image: zammad/zammad-ci:latest services: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5133e2b..06f8bae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [3.2.1] — unreleased + +### Added +- Symfony Flex recipe, published as assets of every GitHub release. With `releases/latest/download/index.json` added to `extra.symfony.endpoint`, `composer require` registers the bundle, creates `config/packages/zammad.yaml` and adds `ZAMMAD_URL` / `ZAMMAD_TOKEN` to `.env`; `composer remove` reverts it. Applications without Flex keep registering the bundle by hand ([#182](https://github.com/zammad/zammad-api-client-php/issues/182)) + +### Changed +- Guzzle 8 is supported: `guzzlehttp/guzzle` is now `^7.10 || ^8.0` and `guzzlehttp/psr7` is now `^2.7 || ^3.0`. Before, installing the client into a project already on Guzzle 8, such as every new Laravel 13 application, failed unless Composer was allowed to downgrade Guzzle ([#180](https://github.com/zammad/zammad-api-client-php/issues/180), [#181](https://github.com/zammad/zammad-api-client-php/issues/181)) + ## [3.2.0] — 2026-11-04 ### Added diff --git a/Makefile b/Makefile index 0348b0e..9981706 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help test test-bridge bridge-install test-integration coverage cookbook clean +.PHONY: help test test-bridge bridge-install test-install test-integration coverage cookbook clean help: ## Show available make targets @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' @@ -27,6 +27,10 @@ test-bridge: ## Run the Symfony + Laravel bridge suites (needs `make bridge-inst fi; \ done +test-install: ## Install the client into fresh Laravel + Symfony skeletons (needs network) + @test/Install/run.sh laravel + @test/Install/run.sh symfony + test-integration: ## Run integration tests (needs Zammad instance) @echo "→ Integration test setup" @echo " ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL = $${ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL:-not set}" diff --git a/README.md b/README.md index 612c00f..dd46077 100644 --- a/README.md +++ b/README.md @@ -485,7 +485,33 @@ Only if you opt this package out of package discovery: register ### Symfony Supported: **Symfony 6.4 LTS, 7.x and 8.x**. Each of those is exercised in CI — -see the `bridge-symfony` job in [`.github/workflows/tests.yml`](.github/workflows/tests.yml). +see the `bridge-symfony` and `install` jobs in [`.github/workflows/tests.yml`](.github/workflows/tests.yml). + +#### With Symfony Flex + +Every release ships a Flex recipe as release assets. Add it as a recipe source +once, **before** requiring the package, then require it as usual: + +```bash +composer config --json extra.symfony.endpoint \ + '["https://github.com/zammad/zammad-api-client-php/releases/latest/download/index.json", "flex://defaults"]' +composer require zammad/zammad-api-client-php +``` + +The recipe registers the bundle, creates `config/packages/zammad.yaml` and adds +`ZAMMAD_URL` / `ZAMMAD_TOKEN` to `.env` — set the real values in `.env.local`. +`composer remove` takes all of it out again. + +- Keep `flex://defaults` in the list, it is what serves Symfony's own recipes. If + your `composer.json` already has an `extra.symfony.endpoint` list, add the URL + to it instead of replacing it. +- Already installed the package? `composer recipes:install zammad/zammad-api-client-php --force` + applies the recipe afterwards. `--force` overwrites an existing + `config/packages/zammad.yaml`, so back up a hand-written one first. +- The recipe applies to 3.2.0 and later. The source lives in + [`src/Bridge/Symfony/recipe/`](src/Bridge/Symfony/recipe). + +#### Without Flex Register the bundle in `config/bundles.php`: @@ -830,6 +856,22 @@ Symfony 6.4. Separate projects also keep the frameworks out of the root No Zammad instance and no HTTP traffic are involved — these exercise container wiring only. +### Install tests + +The bridge tests above build test projects of their own, so they cannot see a +conflict between the client's dependencies and what a real application locks. +[`test/Install/run.sh`](test/Install/run.sh) closes that gap: it creates a fresh +`laravel/laravel` or `symfony/skeleton`, runs a plain `composer require` against +this checkout and checks that `ZammadClient` resolves from the container: + +```bash +make test-install # latest Laravel and Symfony +test/Install/run.sh symfony "6.4.*" # one framework, one skeleton version +``` + +Needs PHP, Composer and network access. CI runs it for Laravel 12/13 and +Symfony 6.4/7.4/8.x in the `install` job. + ### Integration tests These require a running Zammad instance and authentication credentials: diff --git a/bin/build-flex-endpoint.php b/bin/build-flex-endpoint.php new file mode 100644 index 0000000..5d699b0 --- /dev/null +++ b/bin/build-flex-endpoint.php @@ -0,0 +1,102 @@ +/manifest.json`, `/config/...`), so it can be + * contributed there unchanged. + * + * Usage: php bin/build-flex-endpoint.php + * + * The base URL is where the generated files will be served from, e.g. + * `https://github.com/zammad/zammad-api-client-php/releases/latest/download`. + */ + +const PACKAGE = 'zammad/zammad-api-client-php'; +const SOURCE = __DIR__ . '/../src/Bridge/Symfony/recipe'; + +[, $outDir, $baseUrl] = $argv + [null, null, null]; +if ($outDir === null || $baseUrl === null) { + fwrite(STDERR, "Usage: php bin/build-flex-endpoint.php \n"); + exit(2); +} + +if (!is_dir($outDir) && !mkdir($outDir, 0777, true)) { + fwrite(STDERR, "Cannot create $outDir\n"); + exit(1); +} + +$dotted = str_replace('/', '.', PACKAGE); +$versions = []; + +foreach (glob(SOURCE . '/*/manifest.json') ?: [] as $manifestFile) { + $dir = dirname($manifestFile); + $version = basename($dir); + + $files = []; + $entries = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)); + foreach ($entries as $entry) { + $relative = substr($entry->getPathname(), strlen($dir) + 1); + if ($relative === 'manifest.json') { + continue; + } + $files[$relative] = [ + 'contents' => explode("\n", (string) file_get_contents($entry->getPathname())), + 'executable' => false, + ]; + } + ksort($files); + + $recipe = [ + 'manifests' => [ + PACKAGE => [ + 'manifest' => json_decode((string) file_get_contents($manifestFile), true, 512, JSON_THROW_ON_ERROR), + 'files' => $files, + // Flex records this in symfony.lock to tell later recipe updates apart. + 'ref' => sha1(json_encode([$version, file_get_contents($manifestFile), $files], JSON_THROW_ON_ERROR)), + ], + ], + ]; + + write("$outDir/$dotted.$version.json", $recipe); + $versions[] = $version; +} + +if ($versions === []) { + fwrite(STDERR, 'No recipe found under ' . SOURCE . "\n"); + exit(1); +} +usort($versions, 'version_compare'); + +write("$outDir/index.json", [ + 'recipes' => [PACKAGE => $versions], + 'branch' => 'main', + // Not a contrib endpoint: the application opted in by adding it, so Flex + // must apply the recipe without asking for `allow-contrib`. With `true` + // a non-interactive install would skip it without a word. + 'is_contrib' => false, + '_links' => [ + 'repository' => 'github.com/zammad/zammad-api-client-php', + 'origin_template' => '{package}:{version}@github.com/zammad/zammad-api-client-php:releases', + 'recipe_template' => rtrim($baseUrl, '/') . '/{package_dotted}.{version}.json', + ], +]); + +echo implode("\n", array_map(static fn (string $v): string => "$outDir/$dotted.$v.json", $versions)), "\n$outDir/index.json\n"; + +/** + * @param array $data + */ +function write(string $file, array $data): void +{ + file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n"); +} diff --git a/composer.json b/composer.json index b1e193d..2780d47 100644 --- a/composer.json +++ b/composer.json @@ -12,8 +12,8 @@ "php": ">=8.1", "psr/http-client": "^1.0", "psr/http-factory": "^1.0", - "guzzlehttp/guzzle": "^7.10", - "guzzlehttp/psr7": "^2.7", + "guzzlehttp/guzzle": "^7.10 || ^8.0", + "guzzlehttp/psr7": "^2.7 || ^3.0", "psr/log": "^3.0" }, "require-dev": { diff --git a/src/Bridge/Symfony/recipe/3.2/config/packages/zammad.yaml b/src/Bridge/Symfony/recipe/3.2/config/packages/zammad.yaml new file mode 100644 index 0000000..5aadc49 --- /dev/null +++ b/src/Bridge/Symfony/recipe/3.2/config/packages/zammad.yaml @@ -0,0 +1,3 @@ +zammad: + url: '%env(ZAMMAD_URL)%' + token: '%env(ZAMMAD_TOKEN)%' diff --git a/src/Bridge/Symfony/recipe/3.2/manifest.json b/src/Bridge/Symfony/recipe/3.2/manifest.json new file mode 100644 index 0000000..383e206 --- /dev/null +++ b/src/Bridge/Symfony/recipe/3.2/manifest.json @@ -0,0 +1,15 @@ +{ + "bundles": { + "ZammadAPIClient\\Bridge\\SymfonyBundle": ["all"] + }, + "copy-from-recipe": { + "config/": "%CONFIG_DIR%/" + }, + "env": { + "ZAMMAD_URL": "https://zammad.example.com", + "ZAMMAD_TOKEN": "" + }, + "post-install-output": [ + " * Set ZAMMAD_URL and ZAMMAD_TOKEN in .env.local" + ] +} diff --git a/src/Bridge/SymfonyBundle.php b/src/Bridge/SymfonyBundle.php index 86082b9..8d6e98b 100644 --- a/src/Bridge/SymfonyBundle.php +++ b/src/Bridge/SymfonyBundle.php @@ -21,7 +21,22 @@ * {@see ZammadClient} for autowiring, and exposes an `zammad_client` alias — so * any service can type-hint `ZammadClient` and get a ready-to-use instance. * - * **Setup** + * **Setup with Symfony Flex** + * + * Every release ships a Flex recipe as release assets. Add it as a recipe + * source once, before requiring the package: + * + * ```bash + * composer config --json extra.symfony.endpoint \ + * '["https://github.com/zammad/zammad-api-client-php/releases/latest/download/index.json", "flex://defaults"]' + * composer require zammad/zammad-api-client-php + * ``` + * + * The recipe registers the bundle, creates `config/packages/zammad.yaml` and + * adds `ZAMMAD_URL` / `ZAMMAD_TOKEN` to `.env` — the three steps below. The + * recipe source is `src/Bridge/Symfony/recipe/`. + * + * **Setup without Flex** * * - Register the bundle in `config/bundles.php`: * diff --git a/test/Install/run.sh b/test/Install/run.sh new file mode 100755 index 0000000..d195210 --- /dev/null +++ b/test/Install/run.sh @@ -0,0 +1,106 @@ +#!/bin/sh +# Installs the client from this checkout into a fresh framework skeleton with a +# plain `composer require`, the way an application would, and checks that it +# works without editing a project file. +# +# The bridge suites under test/Bridge/ install the frameworks into a project of +# their own, so they cannot see conflicts with what a real skeleton locks — +# Guzzle 8 in Laravel 13 went unnoticed that way (#181). +# +# Usage: test/Install/run.sh laravel|symfony [skeleton constraint] +set -eu + +framework=${1:?usage: test/Install/run.sh laravel|symfony [skeleton constraint]} +constraint=${2:-*} +root=$(cd "$(dirname "$0")/../.." && pwd) +app=$(mktemp -d)/app +export COMPOSER_NO_INTERACTION=1 + +ok() { printf ' \342\234\224 %s\n' "$*"; } +fail() { printf ' \342\234\230 %s\n' "$*" >&2; exit 1; } + +case $framework in + laravel) skeleton=laravel/laravel ;; + symfony) skeleton=symfony/skeleton ;; + *) echo "unknown framework: $framework" >&2; exit 2 ;; +esac + +echo "→ $skeleton:$constraint" +composer create-project --quiet "$skeleton:$constraint" "$app" +cd "$app" + +# Offers this checkout under a release version, so that the constraint below is +# the one the README tells users to type, not a dev-branch alias. +composer config repositories.zammad \ + "{\"type\":\"path\",\"url\":\"$root\",\"options\":{\"symlink\":false,\"versions\":{\"zammad/zammad-api-client-php\":\"3.99.0\"}}}" + +# Prints the class of the client the framework's container hands out. +resolve_laravel() { + ZAMMAD_TOKEN=install-test php -r ' + require "vendor/autoload.php"; + $app = require "bootstrap/app.php"; + $app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + echo get_class($app->make(ZammadAPIClient\ZammadClient::class)); + ' +} + +resolve_symfony() { + ZAMMAD_TOKEN=install-test php -r ' + require "vendor/autoload.php"; + (new Symfony\Component\Dotenv\Dotenv())->bootEnv(".env"); + $kernel = new App\Kernel("dev", true); + $kernel->boot(); + echo get_class($kernel->getContainer()->get("zammad_client")); + ' +} + +case $framework in +laravel) + before=$(cksum bootstrap/providers.php config/app.php) + composer require --quiet 'zammad/zammad-api-client-php:^3.2' \ + || fail 'composer require failed' + [ "$(cksum bootstrap/providers.php config/app.php)" = "$before" ] \ + || fail 'bootstrap/providers.php or config/app.php was changed' + ok 'bootstrap/providers.php and config/app.php untouched' + grep -q 'ZammadAPIClient\\\\Bridge\\\\LaravelServiceProvider' bootstrap/cache/packages.php \ + || fail 'provider not found by package discovery' + ok 'provider found by package discovery' + ;; +symfony) + # Serves the Flex recipe the way the release assets do (#182), built by the + # same script from the same source. + endpoint=$(mktemp -d) + php "$root/bin/build-flex-endpoint.php" "$endpoint" http://127.0.0.1:8099 >/dev/null + php -S 127.0.0.1:8099 -t "$endpoint" >/dev/null 2>&1 & + trap "kill $!" EXIT + until php -r 'exit(@fsockopen("127.0.0.1", 8099) ? 0 : 1);'; do sleep 0.2; done + composer config secure-http false # the local endpoint is plain http + composer config --json extra.symfony.endpoint '["http://127.0.0.1:8099/index.json", "flex://defaults"]' + + before=$(cksum config/bundles.php .env) + composer require --quiet 'zammad/zammad-api-client-php:^3.2' \ + || fail 'composer require failed' + grep -q 'ZammadAPIClient\\Bridge\\SymfonyBundle::class' config/bundles.php \ + || fail 'bundle not registered by the recipe' + ok 'bundle registered by the recipe' + [ -f config/packages/zammad.yaml ] \ + || fail 'config/packages/zammad.yaml not created by the recipe' + ok 'config/packages/zammad.yaml created by the recipe' + grep -q '^ZAMMAD_URL=' .env && grep -q '^ZAMMAD_TOKEN=' .env \ + || fail 'ZAMMAD_URL / ZAMMAD_TOKEN not added to .env' + ok 'ZAMMAD_URL / ZAMMAD_TOKEN added to .env' + ;; +esac + +printf ' guzzlehttp/guzzle %s\n' "$(composer show guzzlehttp/guzzle | sed -n 's/^versions : \* //p')" +[ "$(resolve_$framework)" = 'ZammadAPIClient\ZammadClient' ] \ + || fail 'ZammadClient does not resolve from the container' +ok 'ZammadClient resolves from the container' + +if [ "$framework" = symfony ]; then + composer remove --quiet zammad/zammad-api-client-php \ + || fail 'composer remove failed' + [ "$(cksum config/bundles.php .env)" = "$before" ] && [ ! -e config/packages/zammad.yaml ] \ + || fail 'composer remove left recipe changes behind' + ok 'composer remove reverts the recipe' +fi