diff --git a/.github/workflows/composer-validate.yml b/.github/workflows/composer-validate.yml new file mode 100644 index 0000000..d7f4c8f --- /dev/null +++ b/.github/workflows/composer-validate.yml @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Netresearch DTT GmbH +# +# Manifest/lock consistency gate. +# +# A composer.json raised without regenerating its composer.lock used to surface +# only once a multi-arch image build reached `composer install`, roughly ninety +# seconds in, as a bare `exit code: 4` whose actual message sat four lines +# earlier in the log. It has happened: Renovate bumped a guzzle constraint and +# left the lock behind, and four CI runs were spent on it. +# +# `composer validate --check-lock` answers the same question in about a second +# and prints the package and the two versions by name, e.g. +# Required package "guzzlehttp/guzzle" is in the lock file as "7.15.2" but +# that does not satisfy your constraint "^8.0". +# Where a constraint merely moved without becoming unsatisfiable, only the +# recorded content hash diverges and composer reports that instead — no package +# can be named there, because none is in conflict. +# +# This is a diagnosis-speed gate, not a replacement for build.yml or test.yml: +# it proves the manifests and locks agree, never that the image builds. + +name: Composer Validate + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: {} + +jobs: + validate: + name: manifests vs. lock files + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Report toolchain + # No setup-php step: the check compares the lock's content hash and its + # recorded versions against the manifest's constraints, which is + # independent of the platform PHP. Verified on PHP 8.3 and 8.5 with + # composer 2.7 and 2.9 against these manifests, which require php>=8.5. + # Printed so a change in the runner image is visible in the log. + run: | + php --version | head -1 + composer --version + + - name: Validate every manifest against its lock + # git ls-files rather than find, so the set is exactly the tracked + # manifests — vendor/ and any untracked leftovers on the runner cannot + # widen it. Today that is app/ and app/full/; a third variant is picked + # up without editing this workflow. + run: | + set -uo pipefail + mapfile -t manifests < <(git ls-files '*composer.json') + if [ "${#manifests[@]}" -eq 0 ]; then + echo "::error::No tracked composer.json found — this workflow is validating nothing." + exit 1 + fi + status=0 + for manifest in "${manifests[@]}"; do + dir=$(dirname "$manifest") + echo "::group::$manifest" + if [ ! -f "$dir/composer.lock" ]; then + echo "::error file=$manifest::No composer.lock beside this manifest. Every composer.json here is installed from a lock during the image build, so an unlocked one fails the build instead of this check." + status=1 + elif ! (cd "$dir" && composer validate --check-lock --no-check-publish --strict --no-interaction); then + echo "::error file=$dir/composer.lock::Out of date with $manifest. Re-resolve in $dir/: 'composer update ' for a package named above, or 'composer update --lock' when only the content hash drifted. '--lock' alone refreshes the hash and leaves a named mismatch in place." + status=1 + fi + echo "::endgroup::" + done + exit $status diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 065a97e..0e5674b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,6 +94,7 @@ flowchart LR - [ ] Dockerfile passes hadolint - [ ] `docker buildx bake --print` validates bake file +- [ ] Every `composer.json` still agrees with its `composer.lock` - [ ] Image builds successfully - [ ] `--version` and `--help` work - [ ] No new critical/high vulnerabilities (Trivy) @@ -122,6 +123,32 @@ docker run --rm phpbu:ci-full --version docker run --rm phpbu:ci-full which rsync gpg ssh ``` +### Composer Manifests + +Both `app/` and `app/full/` are installed from their lock files during the image +build, so a constraint changed without regenerating the lock breaks the build +about ninety seconds in, as a bare `exit code: 4`. The same question takes a +second up front: + +```bash +# Per manifest — repeat in app/ and app/full/ +composer validate --check-lock --no-check-publish --strict +``` + +Two things can be wrong, and they need different fixes: + +- **A named package** — `Required package "x/y" is in the lock file as "1.2.3" + but that does not satisfy your constraint "^2.0"`. Re-resolve it: + `composer update x/y`. +- **Only the content hash** — `The lock file is not up to date with the latest + changes in composer.json`, with no package named. The lock still satisfies + every constraint; refresh the hash with `composer update --lock`. + +`composer update --lock` does *not* fix the first case: it rewrites the hash and +leaves the version mismatch behind, so `validate` still fails. + +CI runs this over every tracked `composer.json` in `composer-validate.yml`. + ### Security Scanning ```bash