From 1e1407d8803631e6797d9ccb664c93ca8ef60a6a Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Fri, 6 Nov 2026 23:58:03 +0100 Subject: [PATCH] feat: add reusable PHP CI and Composer release workflows Add Composer setup, linting, Composer test reporting, package validation, API documentation, and independent CI and release workflows. Reuse shared HoverKraft quality and release actions and cover the supported paths with action and workflow integration tests. Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: neilime <314088+neilime@users.noreply.github.com> Signed-off-by: Emilien Escalle --- .devcontainer/devcontainer.json | 21 + .github/ISSUE_TEMPLATE/bug_report.md | 40 + .github/ISSUE_TEMPLATE/feature_request.md | 19 + .github/dependabot.yml | 55 + .github/linters/.checkov.yaml | 4 + .github/linters/.jscpd.json | 8 + .github/renovate.json | 46 + .github/workflows/__greetings.yml | 17 + .github/workflows/__main-ci.yml | 57 + .github/workflows/__need-fix-to-issue.yml | 31 + .github/workflows/__pull-request-ci.yml | 22 + .github/workflows/__renovate.yml | 43 + .github/workflows/__semantic-pull-request.yml | 18 + .github/workflows/__shared-ci.yml | 46 + .github/workflows/__stale.yml | 14 + .../workflows/__test-action-package-test.yml | 73 + .github/workflows/__test-action-setup-php.yml | 83 + .github/workflows/__test-action-test.yml | 93 + ...__test-workflow-continuous-integration.yml | 133 ++ .../__test-workflow-documentation.yml | 27 + .github/workflows/__test-workflow-release.yml | 73 + .github/workflows/continuous-integration.md | 293 +++ .github/workflows/continuous-integration.yml | 391 ++++ .github/workflows/documentation.md | 71 + .github/workflows/documentation.yml | 62 + .github/workflows/release.md | 181 ++ .github/workflows/release.yml | 227 +++ .gitignore | 40 + .php-version | 1 + AGENTS.md | 11 + CONTRIBUTING.md | 92 + Dockerfile | 13 + LICENSE | 21 + Makefile | 75 + README.md | 51 +- actions/lint/README.md | 66 + actions/lint/action.yml | 52 + actions/package/test/README.md | 42 + actions/package/test/action.yml | 90 + actions/setup-php/README.md | 78 + actions/setup-php/action.yml | 119 ++ actions/test/README.md | 69 + actions/test/action.yml | 100 + biome.json | 13 + docs/README.md | 5 + docs/adr/0001-php-package-workflows.md | 108 ++ docs/development/README.md | 53 + tests/php-project/.php-cs-fixer.php | 17 + tests/php-project/README.md | 6 + tests/php-project/composer.json | 29 + tests/php-project/composer.lock | 1699 +++++++++++++++++ tests/php-project/phpunit.xml | 17 + tests/php-project/src/Sample.php | 11 + tests/php-project/tests/SampleTest.php | 15 + tests/php-project/tools/composer.json | 8 + tests/php-project/tools/composer.lock | 84 + 56 files changed, 5131 insertions(+), 2 deletions(-) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/dependabot.yml create mode 100644 .github/linters/.checkov.yaml create mode 100644 .github/linters/.jscpd.json create mode 100644 .github/renovate.json create mode 100644 .github/workflows/__greetings.yml create mode 100644 .github/workflows/__main-ci.yml create mode 100644 .github/workflows/__need-fix-to-issue.yml create mode 100644 .github/workflows/__pull-request-ci.yml create mode 100644 .github/workflows/__renovate.yml create mode 100644 .github/workflows/__semantic-pull-request.yml create mode 100644 .github/workflows/__shared-ci.yml create mode 100644 .github/workflows/__stale.yml create mode 100644 .github/workflows/__test-action-package-test.yml create mode 100644 .github/workflows/__test-action-setup-php.yml create mode 100644 .github/workflows/__test-action-test.yml create mode 100644 .github/workflows/__test-workflow-continuous-integration.yml create mode 100644 .github/workflows/__test-workflow-documentation.yml create mode 100644 .github/workflows/__test-workflow-release.yml create mode 100644 .github/workflows/continuous-integration.md create mode 100644 .github/workflows/continuous-integration.yml create mode 100644 .github/workflows/documentation.md create mode 100644 .github/workflows/documentation.yml create mode 100644 .github/workflows/release.md create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .php-version create mode 100644 AGENTS.md create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 actions/lint/README.md create mode 100644 actions/lint/action.yml create mode 100644 actions/package/test/README.md create mode 100644 actions/package/test/action.yml create mode 100644 actions/setup-php/README.md create mode 100644 actions/setup-php/action.yml create mode 100644 actions/test/README.md create mode 100644 actions/test/action.yml create mode 100644 biome.json create mode 100644 docs/README.md create mode 100644 docs/adr/0001-php-package-workflows.md create mode 100644 docs/development/README.md create mode 100644 tests/php-project/.php-cs-fixer.php create mode 100644 tests/php-project/README.md create mode 100644 tests/php-project/composer.json create mode 100644 tests/php-project/composer.lock create mode 100644 tests/php-project/phpunit.xml create mode 100644 tests/php-project/src/Sample.php create mode 100644 tests/php-project/tests/SampleTest.php create mode 100644 tests/php-project/tools/composer.json create mode 100644 tests/php-project/tools/composer.lock diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..7fc5b10 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,21 @@ +{ + "name": "Alpine", + "image": "mcr.microsoft.com/devcontainers/base:alpine-3.21", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers-extra/features/act:1": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "eamodio.gitlens", + "github.vscode-github-actions", + "github.copilot", + "github.copilot-chat", + "ms-vscode.makefile-tools", + "esbenp.prettier-vscode" + ] + } + } +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..2c6cc29 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,40 @@ +--- +name: Bug report +about: Create a bug report +title: "" +labels: bug, needs triage +assignees: "" +--- + + + + +**Description:** +A clear and concise description of what the bug is. + +**Action version:** +Specify the action version + +**Platform:** + +- [ ] Ubuntu +- [ ] macOS +- [ ] Windows + +**Runner type:** + +- [ ] Hosted +- [ ] Self-hosted + +**Tools version:** + + + +**Repro steps:** +A description with steps to reproduce the issue. If you have a public example or repository to share, please provide the link. + +**Expected behavior:** +A description of what you expected to happen. + +**Actual behavior:** +A description of what is actually happening. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..2bc5d5f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "" +labels: "" +assignees: "" +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1d9c1e8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,55 @@ +version: 2 +updates: + - package-ecosystem: docker + open-pull-requests-limit: 20 + directory: / + schedule: + interval: weekly + day: friday + time: "04:00" + groups: + docker-dependencies: + patterns: + - "*" + cooldown: + default-days: 7 + - package-ecosystem: github-actions + open-pull-requests-limit: 20 + directories: + - / + - /actions/**/* + schedule: + interval: weekly + day: friday + time: "04:00" + groups: + github-actions-dependencies: + patterns: + - "*" + cooldown: + default-days: 7 + - package-ecosystem: composer + open-pull-requests-limit: 20 + versioning-strategy: increase + directories: + - /tests/php-project + - /tests/php-project/tools + schedule: + interval: weekly + day: friday + time: "04:00" + groups: + php-dependencies: + patterns: + - "*" + cooldown: + default-days: 7 + - package-ecosystem: devcontainers + open-pull-requests-limit: 20 + directory: / + schedule: + interval: weekly + day: friday + time: "04:00" + cooldown: + default-days: 7 diff --git a/.github/linters/.checkov.yaml b/.github/linters/.checkov.yaml new file mode 100644 index 0000000..f30a1ee --- /dev/null +++ b/.github/linters/.checkov.yaml @@ -0,0 +1,4 @@ +--- +quiet: true +skip-path: + - (^|/)vendor/ diff --git a/.github/linters/.jscpd.json b/.github/linters/.jscpd.json new file mode 100644 index 0000000..ea510b7 --- /dev/null +++ b/.github/linters/.jscpd.json @@ -0,0 +1,8 @@ +{ + "threshold": 5, + "ignore": [ + "**/vendor/**", + "**/actions/**/README.md", + "**/.github/workflows/*.md" + ] +} diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..c531cdf --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "enabledManagers": ["github-actions", "custom.regex"], + "semanticCommits": "enabled", + "minimumReleaseAge": "7 days", + "minimumReleaseAgeBehaviour": "timestamp-optional", + "pinDigests": true, + "packageRules": [ + { + "description": "Dependabot is the primary dependency updater.", + "matchPackageNames": ["*"], + "enabled": false + }, + { + "description": "Update docker:// action references unsupported by Dependabot.", + "matchDatasources": ["docker"], + "matchDepTypes": ["docker"], + "enabled": true + }, + { + "description": "Keep the PHP runtime and Composer bootstrap current.", + "matchManagers": ["custom.regex"], + "enabled": true, + "automerge": false + } + ], + "customManagers": [ + { + "customType": "regex", + "managerFilePatterns": ["/^\\.php-version$/"], + "matchStrings": ["(?\\d+\\.\\d+(?:\\.\\d+)?)"], + "datasourceTemplate": "github-tags", + "depNameTemplate": "php/php-src", + "versioningTemplate": "semver-coerced", + "extractVersionTemplate": "^php-(?\\d+\\.\\d+)\\.\\d+$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^actions/setup-php/action\\.yml$/"], + "matchStrings": ["tools: composer:(?\\d+\\.\\d+\\.\\d+)"], + "datasourceTemplate": "github-releases", + "depNameTemplate": "composer/composer" + } + ] +} diff --git a/.github/workflows/__greetings.yml b/.github/workflows/__greetings.yml new file mode 100644 index 0000000..9086816 --- /dev/null +++ b/.github/workflows/__greetings.yml @@ -0,0 +1,17 @@ +name: Greetings + +on: # zizmor: ignore[dangerous-triggers] shared workflow handles PR metadata without checking out PR code + issues: + types: [opened] + pull_request_target: + branches: [main] + +permissions: {} + +jobs: + greetings: + uses: hoverkraft-tech/ci-github-common/.github/workflows/greetings.yml@5fbb531db839a26ebcc35c4946be279f945f7633 # 1.0.0 + permissions: + contents: read + issues: write + pull-requests: write diff --git a/.github/workflows/__main-ci.yml b/.github/workflows/__main-ci.yml new file mode 100644 index 0000000..c47aeec --- /dev/null +++ b/.github/workflows/__main-ci.yml @@ -0,0 +1,57 @@ +name: Internal - Main - Continuous Integration +on: + push: + branches: + - main + tags: + - "*" + workflow_dispatch: null + schedule: + - cron: 25 8 * * 1 +permissions: {} +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + ci: + uses: $/.github/workflows/__shared-ci.yml + permissions: + actions: read + contents: read + id-token: write + security-events: write + statuses: write + issues: write + packages: read + pull-requests: write + release: + needs: ci + if: github.event_name != 'schedule' + uses: hoverkraft-tech/ci-github-publish/.github/workflows/release-actions.yml@f06e0e72a76bfca57c34d08d03e5c1914275fd4c # 2.0.0 + permissions: + contents: read + with: + update-all: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || github.event_name == 'workflow_dispatch' }} + github-app-client-id: ${{ vars.CI_BOT_APP_CLIENT_ID }} + merge-method: squash + # yamllint disable rule:line-length + extra-badges: | + [ + { + "label": "GitHub Verified Creator", + "url": "https://img.shields.io/badge/GitHub-Verified%20Creator-4493F8?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiIgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSJyZ2IoNjgsIDE0NywgMjQ4KSI+CiAgPHBhdGggZD0ibTkuNTg1LjUyLjkyOS42OGMuMTUzLjExMi4zMzEuMTg2LjUxOC4yMTVsMS4xMzguMTc1YTIuNjc4IDIuNjc4IDAgMCAxIDIuMjQgMi4yNGwuMTc0IDEuMTM5Yy4wMjkuMTg3LjEwMy4zNjUuMjE1LjUxOGwuNjguOTI4YTIuNjc3IDIuNjc3IDAgMCAxIDAgMy4xN2wtLjY4LjkyOGExLjE3NCAxLjE3NCAwIDAgMC0uMjE1LjUxOGwtLjE3NSAxLjEzOGEyLjY3OCAyLjY3OCAwIDAgMS0yLjI0MSAyLjI0MWwtMS4xMzguMTc1YTEuMTcgMS4xNyAwIDAgMC0uNTE4LjIxNWwtLjkyOC42OGEyLjY3NyAyLjY3NyAwIDAgMS0zLjE3IDBsLS45MjgtLjY4YTEuMTc0IDEuMTc0IDAgMCAwLS41MTgtLjIxNUwzLjgzIDE0LjQxYTIuNjc4IDIuNjc4IDAgMCAxLTIuMjQtMi4yNGwtLjE3NS0xLjEzOGExLjE3IDEuMTcgMCAwIDAtLjIxNS0uNTE4bC0uNjgtLjkyOGEyLjY3NyAyLjY3NyAwIDAgMSAwLTMuMTdsLjY4LS45MjhjLjExMi0uMTUzLjE4Ni0uMzMxLjIxNS0uNTE4bC4xNzUtMS4xNGEyLjY3OCAyLjY3OCAwIDAgMSAyLjI0LTIuMjRsMS4xMzktLjE3NWMuMTg3LS4wMjkuMzY1LS4xMDMuNTE4LS4yMTVsLjkyOC0uNjhhMi42NzcgMi42NzcgMCAwIDEgMy4xNyAwWk03LjMwMyAxLjcyOGwtLjkyNy42OGEyLjY3IDIuNjcgMCAwIDEtMS4xOC40ODlsLTEuMTM3LjE3NGExLjE3OSAxLjE3OSAwIDAgMC0uOTg3Ljk4N2wtLjE3NCAxLjEzNmEyLjY3NyAyLjY3NyAwIDAgMS0uNDg5IDEuMThsLS42OC45MjhhMS4xOCAxLjE4IDAgMCAwIDAgMS4zOTRsLjY4LjkyN2MuMjU2LjM0OC40MjQuNzUzLjQ4OSAxLjE4bC4xNzQgMS4xMzdjLjA3OC41MDkuNDc4LjkwOS45ODcuOTg3bDEuMTM2LjE3NGEyLjY3IDIuNjcgMCAwIDEgMS4xOC40ODlsLjkyOC42OGMuNDE0LjMwNS45NzkuMzA1IDEuMzk0IDBsLjkyNy0uNjhhMi42NyAyLjY3IDAgMCAxIDEuMTgtLjQ4OWwxLjEzNy0uMTc0YTEuMTggMS4xOCAwIDAgMCAuOTg3LS45ODdsLjE3NC0xLjEzNmEyLjY3IDIuNjcgMCAwIDEgLjQ4OS0xLjE4bC42OC0uOTI4YTEuMTc2IDEuMTc2IDAgMCAwIDAtMS4zOTRsLS42OC0uOTI3YTIuNjg2IDIuNjg2IDAgMCAxLS40ODktMS4xOGwtLjE3NC0xLjEzN2ExLjE3OSAxLjE3OSAwIDAgMC0uOTg3LS45ODdsLTEuMTM2LS4xNzRhMi42NzcgMi42NzcgMCAwIDEtMS4xOC0uNDg5bC0uOTI4LS42OGExLjE3NiAxLjE3NiAwIDAgMC0xLjM5NCAwWk0xMS4yOCA2Ljc4bC0zLjc1IDMuNzVhLjc1Ljc1IDAgMCAxLTEuMDYgMEw0LjcyIDguNzhhLjc1MS43NTEgMCAwIDEgLjAxOC0xLjA0Mi43NTEuNzUxIDAgMCAxIDEuMDQyLS4wMThMNyA4Ljk0bDMuMjItMy4yMmEuNzUxLjc1MSAwIDAgMSAxLjA0Mi4wMTguNzUxLjc1MSAwIDAgMSAuMDE4IDEuMDQyWiI+PC9wYXRoPgo8L3N2Zz4K" + } + ] + secrets: + github-app-key: ${{ secrets.CI_BOT_APP_PRIVATE_KEY }} + sync-docs: + needs: release + if: github.event_name != 'schedule' && github.ref_name == github.event.repository.default_branch && needs.release.outputs.artifact-id + uses: hoverkraft-tech/public-docs/.github/workflows/sync-docs-dispatcher.yml@f3c9291760d927e6214e8d5f0a376af2d537c369 # 0.4.0 + permissions: + contents: read + with: + artifact-id: ${{ needs.release.outputs.artifact-id }} + github-app-client-id: ${{ vars.CI_BOT_APP_CLIENT_ID }} + secrets: + github-app-key: ${{ secrets.CI_BOT_APP_PRIVATE_KEY }} diff --git a/.github/workflows/__need-fix-to-issue.yml b/.github/workflows/__need-fix-to-issue.yml new file mode 100644 index 0000000..ea95bd7 --- /dev/null +++ b/.github/workflows/__need-fix-to-issue.yml @@ -0,0 +1,31 @@ +name: Need fix to Issue + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + #checkov:skip=CKV_GHA_7: required + manual-commit-ref: + description: "The SHA of the commit to get the diff for" + required: true + manual-base-ref: + description: "By default, the commit entered above is compared to the one directly before it; to go back further, enter an earlier SHA here" + required: false + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + main: + uses: hoverkraft-tech/ci-github-common/.github/workflows/need-fix-to-issue.yml@5fbb531db839a26ebcc35c4946be279f945f7633 # 1.0.0 + permissions: + contents: read + issues: write + with: + manual-commit-ref: ${{ inputs.manual-commit-ref }} + manual-base-ref: ${{ inputs.manual-base-ref }} diff --git a/.github/workflows/__pull-request-ci.yml b/.github/workflows/__pull-request-ci.yml new file mode 100644 index 0000000..d80edfc --- /dev/null +++ b/.github/workflows/__pull-request-ci.yml @@ -0,0 +1,22 @@ +name: Internal - Pull request - Continuous Integration +on: + merge_group: null + pull_request: + branches: + - main +permissions: {} +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + ci: + uses: $/.github/workflows/__shared-ci.yml + permissions: + actions: read + contents: read + id-token: write + security-events: write + statuses: write + issues: write + packages: read + pull-requests: write diff --git a/.github/workflows/__renovate.yml b/.github/workflows/__renovate.yml new file mode 100644 index 0000000..3af229f --- /dev/null +++ b/.github/workflows/__renovate.yml @@ -0,0 +1,43 @@ +--- +name: Internal - Renovate dependency updates + +on: # yamllint disable-line rule:truthy + workflow_dispatch: + schedule: + - cron: "23 4 * * 5" + +permissions: {} + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + renovate: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Generate GitHub App token + id: generate-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ vars.CI_BOT_APP_CLIENT_ID }} + private-key: ${{ secrets.CI_BOT_APP_PRIVATE_KEY }} # zizmor: ignore[secrets-outside-env] scheduled maintenance uses the CI bot + permission-checks: read + permission-contents: write + permission-issues: write + permission-pull-requests: write + permission-statuses: write + permission-workflows: write + + - name: Run Renovate + uses: renovatebot/github-action@1c0cf989c02653fba0efe7ca5772338860ca71dc # v46.3.12 + with: + token: ${{ steps.generate-token.outputs.token }} + env: + RENOVATE_PLATFORM: github + RENOVATE_REPOSITORIES: ${{ github.repository }} + RENOVATE_ONBOARDING: "false" + RENOVATE_REQUIRE_CONFIG: required + RENOVATE_USERNAME: ${{ steps.generate-token.outputs.app-slug }}[bot] + RENOVATE_PLATFORM_COMMIT: enabled diff --git a/.github/workflows/__semantic-pull-request.yml b/.github/workflows/__semantic-pull-request.yml new file mode 100644 index 0000000..ddbfddb --- /dev/null +++ b/.github/workflows/__semantic-pull-request.yml @@ -0,0 +1,18 @@ +--- +name: "Pull Request - Semantic Lint" + +on: # zizmor: ignore[dangerous-triggers] shared workflow handles PR metadata without checking out PR code + pull_request_target: + types: + - opened + - edited + - synchronize + +permissions: {} + +jobs: + main: + uses: hoverkraft-tech/ci-github-common/.github/workflows/semantic-pull-request.yml@5fbb531db839a26ebcc35c4946be279f945f7633 # 1.0.0 + permissions: + contents: write + pull-requests: write diff --git a/.github/workflows/__shared-ci.yml b/.github/workflows/__shared-ci.yml new file mode 100644 index 0000000..856dc6f --- /dev/null +++ b/.github/workflows/__shared-ci.yml @@ -0,0 +1,46 @@ +name: Internal - Common Continuous Integration tasks +on: + workflow_call: null +permissions: {} +jobs: + linter: + uses: hoverkraft-tech/ci-github-common/.github/workflows/linter.yml@5fbb531db839a26ebcc35c4946be279f945f7633 # 1.0.0 + permissions: + contents: read + statuses: write + issues: write + packages: read + pull-requests: write + actions: read + security-events: write + with: + linter-env: |- + VALIDATE_PHP_BUILTIN=false + VALIDATE_PHP_PHPCS=false + VALIDATE_PHP_PHPSTAN=false + VALIDATE_PHP_PSALM=false + test-workflow-continuous-integration: + uses: $/.github/workflows/__test-workflow-continuous-integration.yml + permissions: + contents: read + id-token: write + test-action-setup-php: + uses: $/.github/workflows/__test-action-setup-php.yml + permissions: + contents: read + test-action-package-test: + uses: $/.github/workflows/__test-action-package-test.yml + permissions: + contents: read + test-workflow-release: + uses: $/.github/workflows/__test-workflow-release.yml + permissions: + contents: read + test-action-test: + uses: $/.github/workflows/__test-action-test.yml + permissions: + contents: read + test-workflow-documentation: + uses: $/.github/workflows/__test-workflow-documentation.yml + permissions: + contents: read diff --git a/.github/workflows/__stale.yml b/.github/workflows/__stale.yml new file mode 100644 index 0000000..e451316 --- /dev/null +++ b/.github/workflows/__stale.yml @@ -0,0 +1,14 @@ +name: Mark stale issues and pull requests + +on: + schedule: + - cron: "30 1 * * *" + +permissions: {} + +jobs: + main: + uses: hoverkraft-tech/ci-github-common/.github/workflows/stale.yml@5fbb531db839a26ebcc35c4946be279f945f7633 # 1.0.0 + permissions: + issues: write + pull-requests: write diff --git a/.github/workflows/__test-action-package-test.yml b/.github/workflows/__test-action-package-test.yml new file mode 100644 index 0000000..6d4287b --- /dev/null +++ b/.github/workflows/__test-action-package-test.yml @@ -0,0 +1,73 @@ +name: Internal - Test package/test action +on: + workflow_call: null +permissions: {} +jobs: + package: + name: Package (${{ matrix.fixture }}) + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + fixture: + - valid + - missing-autoload + - missing-binary + - failing-command + - mutated-archive + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: $/actions/setup-php + with: + working-directory: tests/php-project + install-dependencies: "false" + - name: Arrange - Independent source repository + id: fixture + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + FIXTURE: ${{ matrix.fixture }} + with: + script: |- + const fs = require('node:fs'); + const path = require('node:path'); + const directory = path.join(process.env.GITHUB_WORKSPACE, '.test-package-fixture'); + await io.mkdirP(path.join(directory, 'src')); + const manifest = {name: 'ci/example-' + process.env.FIXTURE, description: 'Composer package test fixture', type: 'library', license: 'MIT', require: {php: '^8.2'}, autoload: {'psr-4': {'Example\\': 'src/'}}, bin: ['bin/example'], scripts: {'post-install-cmd': 'exit 1'}}; + fs.writeFileSync(path.join(directory, 'src', 'Message.php'), 'text() !== "hello") { throw new RuntimeException("Invalid package"); }' + test "$(example)" = example + if [ "$FIXTURE" = failing-command ]; then exit 1; fi + if [ "$FIXTURE" = mutated-archive ]; then printf modified >> "$PACKAGE_ARCHIVE_PATH"; fi + env: + FIXTURE: ${{ matrix.fixture }} + - name: Assert - Expected result and cleanup + env: + OUTCOME: ${{ steps.test.outcome }} + FIXTURE: ${{ matrix.fixture }} + run: |- + if [ "$FIXTURE" = valid ]; then test "$OUTCOME" = success; else test "$OUTCOME" = failure; fi + test -z "$(find "$RUNNER_TEMP" -maxdepth 1 -type d -name 'composer-test-*' -print -quit)" diff --git a/.github/workflows/__test-action-setup-php.yml b/.github/workflows/__test-action-setup-php.yml new file mode 100644 index 0000000..e1d14e3 --- /dev/null +++ b/.github/workflows/__test-action-setup-php.yml @@ -0,0 +1,83 @@ +name: Internal - Test setup-php action +on: + workflow_call: null +permissions: {} +jobs: + setup: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Arrange - Copy a project without a lockfile + run: |- + cp -r tests/php-project "$GITHUB_WORKSPACE/.test-php-project" + rm -f "$GITHUB_WORKSPACE/.test-php-project/composer.lock" + - name: Act - Discover and install project tooling + id: setup + uses: $/actions/setup-php + with: + working-directory: ${{ github.workspace }}/.test-php-project + - name: Assert - Dependencies installed without installation hooks + run: |- + test -f "$GITHUB_WORKSPACE/.test-php-project/vendor/autoload.php" + test -f "$GITHUB_WORKSPACE/.test-php-project/tools/vendor/autoload.php" + test ! -e "$GITHUB_WORKSPACE/.test-php-project/install-hook.txt" + - name: Arrange - Remove installed tooling + run: rm -rf "$GITHUB_WORKSPACE/.test-php-project/tools/vendor" + - name: Act - Explicitly install only the root and allow hooks + uses: $/actions/setup-php + with: + working-directory: ${{ github.workspace }}/.test-php-project + composer-directories: '["."]' + ignore-scripts: "false" + - name: Assert - Explicit selection skips tooling and enables hooks + run: |- + test -f "$GITHUB_WORKSPACE/.test-php-project/install-hook.txt" + test ! -d "$GITHUB_WORKSPACE/.test-php-project/tools/vendor" + - name: Arrange - Root-only project with an unrelated nested manifest + run: |- + mkdir -p "$GITHUB_WORKSPACE/.test-php-empty/nested" + printf '%s\n' '{"name":"ci/root-only","description":"Root-only Composer fixture","license":"MIT"}' > "$GITHUB_WORKSPACE/.test-php-empty/composer.json" + cp "$GITHUB_WORKSPACE/.test-php-empty/composer.json" "$GITHUB_WORKSPACE/.test-php-empty/nested/composer.json" + - name: Act - Install a root-only project + uses: $/actions/setup-php + with: + working-directory: ${{ github.workspace }}/.test-php-empty + - name: Assert - Discovery does not recurse + run: |- + test -f "$GITHUB_WORKSPACE/.test-php-empty/vendor/autoload.php" + test ! -d "$GITHUB_WORKSPACE/.test-php-empty/nested/vendor" + rm -rf "$GITHUB_WORKSPACE/.test-php-empty/vendor" + - name: Act - Set up runtime only + uses: $/actions/setup-php + with: + working-directory: ${{ github.workspace }}/.test-php-empty + install-dependencies: "false" + - name: Assert - Installation was skipped + run: test ! -d "$GITHUB_WORKSPACE/.test-php-empty/vendor" + - name: Act - Reject a path outside the project + id: invalid + uses: $/actions/setup-php + continue-on-error: true + with: + working-directory: ${{ github.workspace }}/.test-php-project + composer-directories: '["../.test-php-empty"]' + - name: Assert - Invalid directory rejected + env: + OUTCOME: ${{ steps.invalid.outcome }} + run: test "$OUTCOME" = failure + - name: Arrange - Tooling symlink outside the project + run: ln -s "$GITHUB_WORKSPACE/.test-php-project/tools" "$GITHUB_WORKSPACE/.test-php-empty/tools" + - name: Act - Reject discovered tooling outside the project + id: invalid-symlink + uses: $/actions/setup-php + continue-on-error: true + with: + working-directory: ${{ github.workspace }}/.test-php-empty + - name: Assert - Tooling symlink rejected + env: + OUTCOME: ${{ steps.invalid-symlink.outcome }} + run: test "$OUTCOME" = failure diff --git a/.github/workflows/__test-action-test.yml b/.github/workflows/__test-action-test.yml new file mode 100644 index 0000000..d977b13 --- /dev/null +++ b/.github/workflows/__test-action-test.yml @@ -0,0 +1,93 @@ +name: Internal - Test PHP test action +on: + workflow_call: null +permissions: {} +jobs: + test: + name: Test (${{ matrix.scenario }}) + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - scenario: reports + coverage: github + - scenario: publication-disabled + coverage: "" + - scenario: preparation + coverage: "" + - scenario: composer-command + coverage: "" + - scenario: failing-command + coverage: "" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Arrange - Project test script + id: fixture + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 + env: + SCENARIO: ${{ matrix.scenario }} + with: + script: |- + const fs = require('node:fs/promises'); + const path = require('node:path'); + const directory = path.join(process.env.GITHUB_WORKSPACE, '.test-php-tests'); + await io.cp(path.join(process.env.GITHUB_WORKSPACE, 'tests/php-project'), directory, {recursive: true}); + const manifestPath = path.join(directory, 'composer.json'); + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); + const scenario = process.env.SCENARIO; + if (scenario === 'preparation') { + await fs.writeFile(path.join(directory, 'prepare.php'), ''); + `); + manifest.scripts['test:ci'] = '@php custom.php'; + } + if (scenario === 'failing-command') { + manifest.scripts['test:ci'] = ['@test --log-junit=junit.xml --no-coverage', "@php -r 'exit(7);'"]; + } + await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); + core.setOutput('directory', directory); + - uses: $/actions/setup-php + with: + working-directory: ${{ steps.fixture.outputs.directory }} + composer-directories: '["."]' + - name: Act - Run project tests + id: tests + continue-on-error: ${{ matrix.scenario == 'failing-command' }} + uses: $/actions/test + with: + working-directory: ${{ steps.fixture.outputs.directory }} + coverage: ${{ matrix.coverage }} + - name: Assert - Reports and project preparation + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 + env: + DIRECTORY: ${{ steps.fixture.outputs.directory }} + OUTCOME: ${{ steps.tests.outcome }} + SCENARIO: ${{ matrix.scenario }} + with: + script: |- + const assert = require('node:assert/strict'); + const fs = require('node:fs'); + const path = require('node:path'); + const directory = process.env.DIRECTORY; + const scenario = process.env.SCENARIO; + assert.equal(process.env.OUTCOME, scenario === 'failing-command' ? 'failure' : 'success'); + assert.match(fs.readFileSync(path.join(directory, 'junit.xml'), 'utf8'), /]*\bline-rate=/); + } else { + assert.equal(fs.existsSync(cobertura), false, 'Only the Composer script controls report generation.'); + } + if (scenario === 'preparation') { + assert.equal(fs.readFileSync(path.join(directory, 'prepared.txt'), 'utf8'), 'ready'); + } diff --git a/.github/workflows/__test-workflow-continuous-integration.yml b/.github/workflows/__test-workflow-continuous-integration.yml new file mode 100644 index 0000000..dcbd1e5 --- /dev/null +++ b/.github/workflows/__test-workflow-continuous-integration.yml @@ -0,0 +1,133 @@ +name: Internal - Test continuous integration workflow +on: + workflow_call: null +permissions: {} +jobs: + package-configuration: + name: Assert - Package configuration + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const assert = require('node:assert/strict'); + const fs = require('node:fs'); + const workflow = fs.readFileSync('.github/workflows/continuous-integration.yml', 'utf8'); + const step = workflow.split(' - id: package-config\n')[1]?.split('\n - ')[0]; + assert.ok(step, 'Package configuration step must exist'); + const script = step.split(' script: |\n')[1]?.replace(/^ {12}/gm, ''); + assert.ok(script, 'Package configuration script must exist'); + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + const configure = new AsyncFunction('core', 'process', 'require', script); + async function parse(input, container = '') { + const outputs = {}; + await configure({ + setOutput: (name, value) => { outputs[name] = typeof value === 'string' ? value : JSON.stringify(value); } + }, { env: { PACKAGE_INPUT: input, CONTAINER_INPUT: container } }, require); + return outputs; + } + for (const input of ['', 'false', ' false ']) { + const outputs = await parse(input); + assert.equal(outputs.enabled, 'false', input); + assert.equal(outputs.tests, ''); + } + for (const input of ['true', '{}', ' true ']) { + const outputs = await parse(input); + assert.equal(outputs.enabled, 'true', input); + assert.equal(outputs.tests, ''); + } + const commands = 'echo "$PACKAGE_NAME"\necho "consumer check"\n'; + const custom = await parse(JSON.stringify({ tests: commands })); + assert.equal(custom.enabled, 'true'); + assert.equal(custom.tests, commands, 'Preserve multiline commands and shell quoting'); + for (const input of ['no', 'null', '[]', '0', '"true"', '{']) { + await assert.rejects(() => parse(input), /package must be true, false, or a JSON object/, input); + } + await assert.rejects(() => parse('{"unknown":true}'), /Unknown package options/); + for (const tests of [true, 1, [], {}, null]) { + await assert.rejects(() => parse(JSON.stringify({ tests })), /must be.*string/); + } + core.info('Package configuration checks passed.'); + + configuration: + name: Assert - Lint and test configuration + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const assert = require('node:assert/strict'); + const fs = require('node:fs'); + const workflow = fs.readFileSync('.github/workflows/continuous-integration.yml', 'utf8'); + const step = workflow.split(' - id: config\n')[1]?.split('\n dependency-review:')[0]; + const script = step?.split(' script: |-\n')[1]?.replace(/^ {12}/gm, ''); + assert.ok(script, 'Configuration script must exist'); + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + const configure = new AsyncFunction('core', 'process', script); + async function parse(env = {}) { + const outputs = {}; + await configure({setOutput: (key, value) => { outputs[key] = value; }}, { + env: {VERSIONS: '', LINT: 'true', TEST: 'true', ...env} + }); + return {...outputs, lint: JSON.parse(outputs.lint), test: JSON.parse(outputs.test)}; + } + const defaults = await parse(); + assert.deepEqual(defaults.lint, {command: 'lint:ci'}); + assert.equal((await parse({LINT: '{"command":"lint:custom"}'})).lint.command, 'lint:custom'); + assert.deepEqual(defaults.test, {command: 'test:ci', coverage: 'github', cache: ''}); + for (const value of ['', 'false', 'null']) { + const config = await parse({LINT: value, TEST: value}); + assert.equal(config.lint, null); + assert.equal(config.test, null); + } + const configured = await parse({TEST: '{"command":"test:custom","coverage":"","cache":".cache"}', VERSIONS: '["8.2", "8.5"]'}); + assert.deepEqual(configured.test, {command: 'test:custom', coverage: '', cache: '.cache'}); + assert.equal(configured['primary-version'], '8.5'); + assert.equal((await parse({TEST: 'test:custom'})).test.command, 'test:custom'); + for (const name of ['LINT', 'TEST']) { + for (const value of ['{', '[]', '{"unknown":true}', '{"command":null}', '{"command":[]}', '{"command":""}', '-bad']) { + await assert.rejects(() => parse({[name]: value}), undefined, name + '=' + value); + } + } + for (const test of [{coverage: false}, {coverage: 'unknown'}, {cache: '../vendor'}, {cache: '/tmp'}, {cache: '.'}]) { + await assert.rejects(() => parse({TEST: JSON.stringify(test)})); + } + for (const versions of ['[]', '["8.2","8.2"]', '[8.2]', '["latest"]']) { + await assert.rejects(() => parse({VERSIONS: versions})); + } + core.info('Lint/test configuration checks passed.'); + + act: + name: Act - Validate Composer project + uses: $/.github/workflows/continuous-integration.yml + permissions: + contents: read + id-token: write + with: + working-directory: tests/php-project + php-versions: '["8.2", ""]' + test: '{"cache":".phpunit.cache"}' + runs-on: '["ubuntu-24.04"]' + dependency-review: false + checks-disabled: + name: Act - Disable checks + uses: $/.github/workflows/continuous-integration.yml + permissions: + contents: read + id-token: write + with: + working-directory: tests/php-project + checks: false + lint: must-not-run + test: must-not-run + package: "true" diff --git a/.github/workflows/__test-workflow-documentation.yml b/.github/workflows/__test-workflow-documentation.yml new file mode 100644 index 0000000..50dbe7b --- /dev/null +++ b/.github/workflows/__test-workflow-documentation.yml @@ -0,0 +1,27 @@ +name: Internal - Test documentation workflow +on: + workflow_call: null +permissions: {} +jobs: + act: + name: Act - Generate PHP API documentation + uses: $/.github/workflows/documentation.yml + permissions: + contents: read + with: + working-directory: tests/php-project + runs-on: '["ubuntu-24.04"]' + assert: + name: Assert - Documentation artifact contains the API + needs: act + runs-on: ubuntu-latest + permissions: {} + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.act.outputs.artifact-id }} + path: _site + - name: Check generated pages + run: | + test -s _site/phpdoc/index.html + test -s _site/phpdoc/classes/TestCi-Sample.html diff --git a/.github/workflows/__test-workflow-release.yml b/.github/workflows/__test-workflow-release.yml new file mode 100644 index 0000000..073ddad --- /dev/null +++ b/.github/workflows/__test-workflow-release.yml @@ -0,0 +1,73 @@ +name: Internal - Test release workflow +on: + workflow_call: null +permissions: {} +jobs: + release: + name: Assert - Planning and publication guards + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const assert = require('node:assert/strict'); + const fs = require('node:fs'); + const workflow = fs.readFileSync('.github/workflows/release.yml', 'utf8'); + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + function script(id) { + const step = workflow.split(' - id: ' + id + '\n')[1]?.split('\n - ')[0]; + const source = step?.split(' script: |-\n')[1]?.replace(/^ {12}/gm, ''); + assert.ok(source, 'Release script exists: ' + id); + return new AsyncFunction('core', 'context', 'github', 'process', 'require', source); + } + const releaseContext = {repo: {owner: 'example', repo: 'library'}, sha: 'source-sha', ref: 'refs/heads/main', eventName: 'workflow_dispatch', payload: {repository: {default_branch: 'main'}}}; + const missing = () => { throw Object.assign(new Error('Not found'), {status: 404}); }; + async function run(id, {env = {}, overrides = {}, github = {}, manifest = {name: 'example/library'}} = {}) { + const outputs = {}; + await script(id)({setOutput: (key, value) => { outputs[key] = value; }, info: () => {}}, + {...releaseContext, ...overrides}, github, {env}, name => { + assert.equal(name, 'node:fs'); + return {readFileSync: () => JSON.stringify(manifest)}; + }); + return outputs; + } + await run('event'); + await run('event', {overrides: {eventName: 'schedule'}}); + await run('event', {overrides: {ref: 'refs/tags/v1.2.3'}}); + for (const overrides of [{eventName: 'pull_request'}, {eventName: 'push'}, {ref: 'refs/heads/feature'}, {eventName: 'schedule', ref: 'refs/tags/v1.2.3'}]) { + await assert.rejects(() => run('event', {overrides}), /Release planning requires/); + } + const noChanges = {TAG: 'v1.2.3', HAS_CHANGES: 'false', FORCE: 'false'}; + assert.equal((await run('result', {env: noChanges} ))['has-changes'], 'false'); + assert.equal((await run('result', {env: {...noChanges, HAS_CHANGES: 'true'}} ))['has-changes'], 'true'); + assert.equal((await run('result', {env: {...noChanges, FORCE: 'true'}, overrides: {eventName: 'schedule'}} ))['has-changes'], 'false'); + const existing = {rest: {git: {getRef: async () => ({data: {}})}}}; + assert.equal((await run('result', {env: {...noChanges, FORCE: 'true'}, github: existing})).tag, 'v1.2.4'); + assert.equal((await run('result', {env: {...noChanges, FORCE: 'true'}, github: {rest: {git: {getRef: missing}}}})).tag, 'v1.2.3'); + assert.equal((await run('result', {env: {...noChanges, FORCE: 'true'}, overrides: {ref: 'refs/tags/v1.2.3'}})).tag, 'v1.2.3'); + await assert.rejects(() => run('result', {env: {...noChanges, TAG: 'v01.2.3'}}), /stable/); + await assert.rejects(() => run('result', {env: {...noChanges, FORCE: 'true'}, github: {rest: {git: {getRef: () => { throw Object.assign(new Error('Denied'), {status: 403}); }}}}}), /Denied/); + function api({commit, release, drafts = []} = {}) { + return {rest: {repos: { + getCommit: commit ? async args => { assert.equal(args.ref, 'tags/v1.2.3'); return {data: {sha: commit}}; } : missing, + getReleaseByTag: release ? async () => ({data: release}) : missing, + listReleases: () => {} + }}, paginate: async () => drafts}; + } + const verify = (github, extra = {}) => run('verify', {env: {TAG: 'v1.2.3'}, github, ...extra}); + assert.equal((await verify(api())).published, false); + assert.equal((await verify(api({commit: 'source-sha'}))).published, false); + assert.equal((await verify(api({commit: 'source-sha', release: {draft: false}}))).published, true); + assert.equal((await verify(api({commit: 'source-sha', drafts: [{tag_name: 'v1.2.3', draft: true}]}))).published, false); + await assert.rejects(() => verify(api({commit: 'another-sha'})), /source commit/); + await assert.rejects(() => verify(api({release: {draft: false}})), /no matching Git tag/); + await assert.rejects(() => verify(api(), {overrides: {ref: 'refs/tags/v1.2.3'}}), /retry tag/); + for (const manifest of [{}, {name: 'Example/invalid'}, {name: 'example/library', version: '1.2.3'}]) { + await assert.rejects(() => verify(api(), {manifest}), /public Composer package/); + } + core.info('Release event, planning, no-change, force and recovery checks passed without publishing.'); diff --git a/.github/workflows/continuous-integration.md b/.github/workflows/continuous-integration.md new file mode 100644 index 0000000..fbb03fb --- /dev/null +++ b/.github/workflows/continuous-integration.md @@ -0,0 +1,293 @@ + + +# GitHub Reusable Workflow: Continuous integration + + + + +## Overview + +Use `continuous-integration.yml` from both your pull request and default-branch +workflows. Pin the same reviewed commit in each caller. An application can use +lint and tests without enabling Composer package publication. + +```yaml +permissions: {} +jobs: + checks: + uses: hoverkraft-tech/ci-github-php/.github/workflows/continuous-integration.yml@ + permissions: + contents: read + id-token: write + with: + php-versions: '["8.2", "8.3", "8.4", "8.5"]' +``` + +Define `lint:ci` and `test:ci` in `composer.json`. These are explicit project commands; +they execute even when dependency installation scripts are disabled. An empty +`lint` or `test` input disables that job; `test: "false"` also disables tests. Missing named scripts fail the job. `lint` also accepts a JSON object such as +`lint: '{"command":"lint:custom"}'`, or `"false"` to skip linting. + +Use `runs-on` for a JSON array of Linux runner labels, for example +`runs-on: '["self-hosted", "linux"]'`. Every job uses those labels. +Set `checks: false` to skip lint, tests, dependency review and distribution checks. + +### Runtime and dependencies + +With no `php-versions` input, setup reads the project's `.php-version`, then the +repository's `.php-version`, then delegates platform detection to `setup-php`. +An explicit version matrix tests all listed versions. The last entry runs lint +and package checks; only that version publishes coverage. An empty entry +in the matrix uses the default version selection. + +Composer projects with or without a committed lockfile are supported. Each +project is installed independently. By default, setup installs the root manifest +and `tools/composer.json` when it exists. It does not search nested directories. +Use `composer-directories` to override discovery, for example `["."]` for the root +only or `[".", "dev-tools"]` for another tooling directory. Only Composer's download cache is shared between +runs; dependencies are installed and platform requirements checked each time. + +Installation scripts and plugins are disabled by default. Use +`ignore-scripts: false` when installation hooks are required. Use +`ignore-plugins: false` for a project with explicit Composer `allow-plugins` +configuration, such as PHPStan's extension installer. These options affect +installation, not explicit lint or test commands. + +For custom jobs, call `actions/setup-php` after checkout, then the individual +lint or test actions. `install-dependencies: false` prepares the runtime +without installing the project, for example before isolated package checks. + +### Reports and coverage + +The lint and test actions reuse `ci-github-common/actions/quality/parse-ci-reports` for +GitHub annotations and job summaries. The test action runs your Composer script unchanged. The script selects its test tool, +preparation, configuration and report formats. For PHPUnit, keep the everyday test +command and add a CI script: + +```json +{ + "scripts": { + "test": "phpunit --configuration tests/phpunit.xml", + "test:ci": "@test --log-junit=junit.xml --coverage-cobertura=cobertura.xml" + } +} +``` + +The action sets `CI=true` and adds no test-runner arguments. JUnit reports are +parsed even after the command fails. The same Composer command runs on every PHP +version; only the last version publishes coverage. The `coverage` option controls +publication, not collection. Set it to an empty string to skip coverage publication; +omit coverage arguments from your script when collection is not needed. + +For Codecov, use `--coverage-clover=clover.xml` in the script and configure: + +```yaml +with: + test: '{"coverage":"codecov"}' +``` + +Codecov requires `id-token: write` and repository access. The action discovers +Clover reports, falling back to Cobertura. For GitHub coverage summaries, generate +Cobertura. Add generated report files to `.gitignore`. Clean any retained report +files in your project command if it reuses an existing workspace. + +Preparation belongs in the Composer script, with report arguments attached only +to the test command: + +```json +{ + "scripts": { + "test:ci": [ + "@test:load-fixtures", + "@test --log-junit=junit.xml --coverage-clover=clover.xml" + ] + } +} +``` + +Pest and Codeception projects use the same contract; define their commands and +report options in Composer. No test-runner selection is needed in the action. +Use `command` for a script name other than `test:ci`. Group custom settings in one +input, omitting fields that use defaults: + +```yaml +with: + test: '{"coverage":"codecov","cache":".cache"}' +``` + +The test `cache` option can retain a directory of generated test fixtures or results. Do not +cache `vendor`; Composer installation manages dependencies. + +Dependency review runs only on pull requests. PHP CodeQL analysis is not part of +the workflow because PHP is not a supported CodeQL language. Run your PHP static +analyzer through the lint script. + +### Generated application files and documentation + +Projects needing asset compilation or generated code keep those steps in their +Composer commands or dedicated caller jobs. PHP libraries need no generic build +step; Composer distributes committed source selected by Git export rules. + +For API documentation, use `documentation.yml`, with optional `runs-on` labels +for a Linux runner supporting Docker. It uses the project's phpDocumentor +configuration when present, otherwise `src/`, and uploads a site containing +`phpdoc/`. Run it during pull request validation and restore its `artifact-id` into +`_site` for a separate deployment job using `ci-github-publish` actions. Site +publishing and project-specific documentation transformations remain in the caller. + +### Package checks + +Set `package: "true"` for a public Composer package at the Git repository root. The +test action snapshots the checked-out commit using `git archive`, respecting +`.gitattributes` `export-ignore` and `export-subst`. Uncommitted files and generated +build output are not included. + +The test action validates `composer.json`, verifies declared PSR autoload paths, +autoload files, and binaries, installs the archive in an isolated consumer with +production dependencies, checks platform requirements, and loads the autoloader. +Installation hooks and plugins remain disabled in that consumer. + +Distribution testing is disabled by default. Omit `package` or pass `"false"` to skip it. +The input is a string, so boolean values must be quoted. A JSON object enables +distribution testing with specific options; unknown options and non-string `tests` fail +configuration validation. + +Use `tests` only for behavior specific to your package, such as rendering a +template or invoking a CLI: + +```yaml +with: + package: '{"tests":"my-cli --help"}' +``` + +Commands execute in the consumer, with `vendor/bin` +on `PATH` and `PACKAGE_NAME`, `PACKAGE_INSTALL_PATH`, and `PACKAGE_ARCHIVE_PATH` +available. The temporary consumer is removed after success or failure. Modifying +the temporary source archive fails validation. Nothing is uploaded: this tests +the files GitHub will distribute, without adding a package publication step. + +Package checks run on the primary PHP version. The source test matrix remains +responsible for compatibility across every supported PHP version. Release callers should depend on successful validation with `needs`. + +### Permissions + +- **`contents`**: `read` +- **`id-token`**: `write` + + + + +## Usage + +```yaml +name: Continuous integration +on: + push: + branches: + - main +permissions: {} +jobs: + continuous-integration: + uses: hoverkraft-tech/ci-github-php/.github/workflows/continuous-integration.yml@ + permissions: + contents: read + id-token: write + with: + # JSON array of Linux runner labels. Applies to every job, including the configuration job. + # Default: `["ubuntu-latest"]` + runs-on: '["ubuntu-latest"]' + + # Enable lint, tests, dependency review and optional distribution checks. + # Default: `true` + checks: true + + # Composer project directory relative to the checkout. + # Default: `.` + working-directory: . + + # Optional JSON array of PHP versions to test. The last entry runs lint and package checks and publishes coverage. An empty entry uses .php-version or setup-php platform detection. Empty input runs that default once. + php-versions: "" + + # PHP extensions forwarded to setup-php. + # Default: `mbstring, dom, xml, xmlwriter, curl, zip, intl` + extensions: mbstring, dom, xml, xmlwriter, curl, zip, intl + + # Optional JSON array of Composer directories relative to working-directory. Empty installs the root project and tools/composer.json when present, without recursive discovery. + composer-directories: "" + + # Disable Composer installation scripts. Explicit project commands still run. + # Default: `true` + ignore-scripts: true + + # Disable Composer plugins during installation. When false, Composer allow-plugins configuration still applies. + # Default: `true` + ignore-plugins: true + + # Composer lint script name or a JSON object with `command` (default `lint:ci`). Pass "false", "null" or an empty string to disable. + # Default: `true` + lint: "true" + + # Run the Composer `test:ci` script unchanged, or provide another script name. + # Pass "false", "null" or an empty string to disable tests. + # A JSON object groups test options: `command` (default `test:ci`), + # `coverage` (`github`, `codecov` or empty), and `cache` (one directory relative to working-directory). + # Configure report/coverage generation in the Composer script itself. + # Coverage is published on the last PHP version only; the command controls collection on every version. Codecov requires id-token: write. + # Example: `{"coverage":"codecov","cache":".cache"}`. + # + # Default: `true` + test: "true" + + # Validate a public Composer source distribution from the Git repository root. + # Pass "false" (the default) to disable, "true" for archive and autoload checks, + # or a JSON object with `tests` for additional commands in an isolated Composer consumer. + # Quote booleans because this input has type string. Example: `{"tests":"my-cli --help"}`. + # vendor/bin is on PATH; PACKAGE_NAME, PACKAGE_INSTALL_PATH and PACKAGE_ARCHIVE_PATH identify the package. + # Unknown options and non-string tests are rejected before distribution checks. + # + # Default: `false` + package: "false" + + # Review dependency changes on pull requests. + # Default: `true` + dependency-review: true +``` + + + + +## Inputs + +### Workflow Call Inputs + +| **Input** | **Description** | **Required** | **Type** | **Default** | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------- | ------------------------------------------------ | +| **`runs-on`** | JSON array of Linux runner labels. Applies to every job, including the configuration job. | **false** | **string** | `["ubuntu-latest"]` | +| **`checks`** | Enable lint, tests, dependency review and optional distribution checks. | **false** | **boolean** | `true` | +| **`working-directory`** | Composer project directory relative to the checkout. | **false** | **string** | `.` | +| **`php-versions`** | Optional JSON array of PHP versions to test. The last entry runs lint and package checks and publishes coverage. An empty entry uses .php-version or setup-php platform detection. Empty input runs that default once. | **false** | **string** | - | +| **`extensions`** | PHP extensions forwarded to setup-php. | **false** | **string** | `mbstring, dom, xml, xmlwriter, curl, zip, intl` | +| **`composer-directories`** | Optional JSON array of Composer directories relative to working-directory. Empty installs the root project and tools/composer.json when present, without recursive discovery. | **false** | **string** | - | +| **`ignore-scripts`** | Disable Composer installation scripts. Explicit project commands still run. | **false** | **boolean** | `true` | +| **`ignore-plugins`** | Disable Composer plugins during installation. When false, Composer allow-plugins configuration still applies. | **false** | **boolean** | `true` | +| **`lint`** | Composer lint script name or a JSON object with `command` (default `lint:ci`). Pass "false", "null" or an empty string to disable. | **false** | **string** | `true` | +| **`test`** | Run the Composer `test:ci` script unchanged, or provide another script name. | **false** | **string** | `true` | +| | Pass "false", "null" or an empty string to disable tests. | | | | +| | A JSON object groups test options: `command` (default `test:ci`), | | | | +| | `coverage` (`github`, `codecov` or empty), and `cache` (one directory relative to working-directory). | | | | +| | Configure report/coverage generation in the Composer script itself. | | | | +| | Coverage is published on the last PHP version only; the command controls collection on every version. Codecov requires id-token: write. | | | | +| | Example: `{"coverage":"codecov","cache":".cache"}`. | | | | +| **`package`** | Validate a public Composer source distribution from the Git repository root. | **false** | **string** | `false` | +| | Pass "false" (the default) to disable, "true" for archive and autoload checks, | | | | +| | or a JSON object with `tests` for additional commands in an isolated Composer consumer. | | | | +| | Quote booleans because this input has type string. Example: `{"tests":"my-cli --help"}`. | | | | +| | vendor/bin is on PATH; PACKAGE_NAME, PACKAGE_INSTALL_PATH and PACKAGE_ARCHIVE_PATH identify the package. | | | | +| | Unknown options and non-string tests are rejected before distribution checks. | | | | +| **`dependency-review`** | Review dependency changes on pull requests. | **false** | **boolean** | `true` | + + + + + + diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml new file mode 100644 index 0000000..4fa0f3a --- /dev/null +++ b/.github/workflows/continuous-integration.yml @@ -0,0 +1,391 @@ +# Use `continuous-integration.yml` from both your pull request and default-branch +# workflows. Pin the same reviewed commit in each caller. An application can use +# lint and tests without enabling Composer package publication. +# +# ```yaml +# permissions: {} +# jobs: +# checks: +# uses: hoverkraft-tech/ci-github-php/.github/workflows/continuous-integration.yml@ +# permissions: +# contents: read +# id-token: write +# with: +# php-versions: '["8.2", "8.3", "8.4", "8.5"]' +# ``` +# +# Define `lint:ci` and `test:ci` in `composer.json`. These are explicit project commands; +# they execute even when dependency installation scripts are disabled. An empty +# `lint` or `test` input disables that job; `test: "false"` also disables tests. Missing named scripts fail the job. `lint` also accepts a JSON object such as +# `lint: '{"command":"lint:custom"}'`, or `"false"` to skip linting. +# +# Use `runs-on` for a JSON array of Linux runner labels, for example +# `runs-on: '["self-hosted", "linux"]'`. Every job uses those labels. +# Set `checks: false` to skip lint, tests, dependency review and distribution checks. +# +# ### Runtime and dependencies +# +# With no `php-versions` input, setup reads the project's `.php-version`, then the +# repository's `.php-version`, then delegates platform detection to `setup-php`. +# An explicit version matrix tests all listed versions. The last entry runs lint +# and package checks; only that version publishes coverage. An empty entry +# in the matrix uses the default version selection. +# +# Composer projects with or without a committed lockfile are supported. Each +# project is installed independently. By default, setup installs the root manifest +# and `tools/composer.json` when it exists. It does not search nested directories. +# Use `composer-directories` to override discovery, for example `["."]` for the root +# only or `[".", "dev-tools"]` for another tooling directory. Only Composer's download cache is shared between +# runs; dependencies are installed and platform requirements checked each time. +# +# Installation scripts and plugins are disabled by default. Use +# `ignore-scripts: false` when installation hooks are required. Use +# `ignore-plugins: false` for a project with explicit Composer `allow-plugins` +# configuration, such as PHPStan's extension installer. These options affect +# installation, not explicit lint or test commands. +# +# For custom jobs, call `actions/setup-php` after checkout, then the individual +# lint or test actions. `install-dependencies: false` prepares the runtime +# without installing the project, for example before isolated package checks. +# +# ### Reports and coverage +# +# The lint and test actions reuse `ci-github-common/actions/quality/parse-ci-reports` for +# GitHub annotations and job summaries. The test action runs your Composer script unchanged. The script selects its test tool, +# preparation, configuration and report formats. For PHPUnit, keep the everyday test +# command and add a CI script: +# +# ```json +# { +# "scripts": { +# "test": "phpunit --configuration tests/phpunit.xml", +# "test:ci": "@test --log-junit=junit.xml --coverage-cobertura=cobertura.xml" +# } +# } +# ``` +# +# The action sets `CI=true` and adds no test-runner arguments. JUnit reports are +# parsed even after the command fails. The same Composer command runs on every PHP +# version; only the last version publishes coverage. The `coverage` option controls +# publication, not collection. Set it to an empty string to skip coverage publication; +# omit coverage arguments from your script when collection is not needed. +# +# For Codecov, use `--coverage-clover=clover.xml` in the script and configure: +# +# ```yaml +# with: +# test: '{"coverage":"codecov"}' +# ``` +# +# Codecov requires `id-token: write` and repository access. The action discovers +# Clover reports, falling back to Cobertura. For GitHub coverage summaries, generate +# Cobertura. Add generated report files to `.gitignore`. Clean any retained report +# files in your project command if it reuses an existing workspace. +# +# Preparation belongs in the Composer script, with report arguments attached only +# to the test command: +# +# ```json +# { +# "scripts": { +# "test:ci": [ +# "@test:load-fixtures", +# "@test --log-junit=junit.xml --coverage-clover=clover.xml" +# ] +# } +# } +# ``` +# +# Pest and Codeception projects use the same contract; define their commands and +# report options in Composer. No test-runner selection is needed in the action. +# Use `command` for a script name other than `test:ci`. Group custom settings in one +# input, omitting fields that use defaults: +# +# ```yaml +# with: +# test: '{"coverage":"codecov","cache":".cache"}' +# ``` +# +# The test `cache` option can retain a directory of generated test fixtures or results. Do not +# cache `vendor`; Composer installation manages dependencies. +# +# Dependency review runs only on pull requests. PHP CodeQL analysis is not part of +# the workflow because PHP is not a supported CodeQL language. Run your PHP static +# analyzer through the lint script. +# +# ### Generated application files and documentation +# +# Projects needing asset compilation or generated code keep those steps in their +# Composer commands or dedicated caller jobs. PHP libraries need no generic build +# step; Composer distributes committed source selected by Git export rules. +# +# For API documentation, use `documentation.yml`, with optional `runs-on` labels +# for a Linux runner supporting Docker. It uses the project's phpDocumentor +# configuration when present, otherwise `src/`, and uploads a site containing +# `phpdoc/`. Run it during pull request validation and restore its `artifact-id` into +# `_site` for a separate deployment job using `ci-github-publish` actions. Site +# publishing and project-specific documentation transformations remain in the caller. +# +# ### Package checks +# +# Set `package: "true"` for a public Composer package at the Git repository root. The +# test action snapshots the checked-out commit using `git archive`, respecting +# `.gitattributes` `export-ignore` and `export-subst`. Uncommitted files and generated +# build output are not included. +# +# The test action validates `composer.json`, verifies declared PSR autoload paths, +# autoload files, and binaries, installs the archive in an isolated consumer with +# production dependencies, checks platform requirements, and loads the autoloader. +# Installation hooks and plugins remain disabled in that consumer. +# +# Distribution testing is disabled by default. Omit `package` or pass `"false"` to skip it. +# The input is a string, so boolean values must be quoted. A JSON object enables +# distribution testing with specific options; unknown options and non-string `tests` fail +# configuration validation. +# +# Use `tests` only for behavior specific to your package, such as rendering a +# template or invoking a CLI: +# +# ```yaml +# with: +# package: '{"tests":"my-cli --help"}' +# ``` +# +# Commands execute in the consumer, with `vendor/bin` +# on `PATH` and `PACKAGE_NAME`, `PACKAGE_INSTALL_PATH`, and `PACKAGE_ARCHIVE_PATH` +# available. The temporary consumer is removed after success or failure. Modifying +# the temporary source archive fails validation. Nothing is uploaded: this tests +# the files GitHub will distribute, without adding a package publication step. +# +# Package checks run on the primary PHP version. The source test matrix remains +# responsible for compatibility across every supported PHP version. Release callers should depend on successful validation with `needs`. +name: Continuous integration +on: + workflow_call: + inputs: + runs-on: + description: JSON array of Linux runner labels. Applies to every job, including the configuration job. + type: string + default: '["ubuntu-latest"]' + checks: + description: Enable lint, tests, dependency review and optional distribution checks. + type: boolean + default: true + working-directory: + description: Composer project directory relative to the checkout. + type: string + default: . + php-versions: + description: Optional JSON array of PHP versions to test. The last entry runs lint and package checks and publishes coverage. An empty entry uses .php-version or setup-php platform detection. Empty input runs that default once. + type: string + default: "" + extensions: + description: PHP extensions forwarded to setup-php. + type: string + default: mbstring, dom, xml, xmlwriter, curl, zip, intl + composer-directories: + description: Optional JSON array of Composer directories relative to working-directory. Empty installs the root project and tools/composer.json when present, without recursive discovery. + type: string + default: "" + ignore-scripts: + description: Disable Composer installation scripts. Explicit project commands still run. + type: boolean + default: true + ignore-plugins: + description: Disable Composer plugins during installation. When false, Composer allow-plugins configuration still applies. + type: boolean + default: true + lint: + description: Composer lint script name or a JSON object with `command` (default `lint:ci`). Pass "false", "null" or an empty string to disable. + type: string + default: "true" + test: + description: | + Run the Composer `test:ci` script unchanged, or provide another script name. + Pass "false", "null" or an empty string to disable tests. + A JSON object groups test options: `command` (default `test:ci`), + `coverage` (`github`, `codecov` or empty), and `cache` (one directory relative to working-directory). + Configure report/coverage generation in the Composer script itself. + Coverage is published on the last PHP version only; the command controls collection on every version. Codecov requires id-token: write. + Example: `{"coverage":"codecov","cache":".cache"}`. + type: string + default: "true" + package: + description: | + Validate a public Composer source distribution from the Git repository root. + Pass "false" (the default) to disable, "true" for archive and autoload checks, + or a JSON object with `tests` for additional commands in an isolated Composer consumer. + Quote booleans because this input has type string. Example: `{"tests":"my-cli --help"}`. + vendor/bin is on PATH; PACKAGE_NAME, PACKAGE_INSTALL_PATH and PACKAGE_ARCHIVE_PATH identify the package. + Unknown options and non-string tests are rejected before distribution checks. + type: string + default: "false" + dependency-review: + description: Review dependency changes on pull requests. + type: boolean + default: true +permissions: {} +jobs: + configuration: + runs-on: ${{ fromJSON(inputs.runs-on) }} + permissions: {} + outputs: + versions: ${{ steps.config.outputs.versions }} + primary-version: ${{ steps.config.outputs.primary-version }} + lint: ${{ steps.config.outputs.lint }} + test: ${{ steps.config.outputs.test }} + package-enabled: ${{ steps.package-config.outputs.enabled }} + package-tests: ${{ steps.package-config.outputs.tests }} + steps: + - id: package-config + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + PACKAGE_INPUT: ${{ inputs.package }} + with: + script: | + let config; + try { + config = JSON.parse(process.env.PACKAGE_INPUT.trim() || 'false'); + } catch { + throw new Error('package must be true, false, or a JSON object.'); + } + if (typeof config !== 'boolean' && (!config || typeof config !== 'object' || Array.isArray(config))) { + throw new Error('package must be true, false, or a JSON object.'); + } + const enabled = config !== false; + if (typeof config === 'boolean') config = {}; + const unknown = Object.keys(config).filter(key => key !== 'tests'); + if (unknown.length) throw new Error(`Unknown package options: ${unknown.join(', ')}.`); + const { tests = '' } = config; + if (typeof tests !== 'string') throw new Error('package.tests must be a string.'); + core.setOutput('enabled', enabled); + core.setOutput('tests', tests); + - id: config + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + VERSIONS: ${{ inputs.php-versions }} + LINT: ${{ inputs.lint }} + TEST: ${{ inputs.test }} + with: + script: |- + const versions = process.env.VERSIONS.trim() ? JSON.parse(process.env.VERSIONS) : ['']; + if (!Array.isArray(versions) || !versions.length || versions.length > 20 || versions.some(version => typeof version !== 'string' || (version && !/^\d+\.\d+(?:\.\d+)?$/.test(version))) || new Set(versions).size !== versions.length) { + throw new Error('php-versions must be a JSON array of unique numeric PHP versions.'); + } + function options(name, input, defaults) { + const value = input.trim(); + if (['', 'false', 'null'].includes(value)) return null; + let config; + if (value === 'true') config = {}; + else if (value.startsWith('{') || value.startsWith('[')) { + try { config = JSON.parse(value); } catch { throw new Error(name + ' must contain valid JSON.'); } + } else config = {command: value}; + if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error(name + ' must be a script name or a JSON object.'); + const unknown = Object.keys(config).filter(key => !Object.hasOwn(defaults, key)); + if (unknown.length) throw new Error('Unknown ' + name + ' options: ' + unknown.join(', ') + '.'); + config = {...defaults, ...config}; + for (const [key, option] of Object.entries(config)) { + if (typeof option !== 'string') throw new Error(name + '.' + key + ' must be a string.'); + } + if (!config.command.trim() || config.command.startsWith('-') || /\s/.test(config.command)) throw new Error(name + '.command must name a Composer script.'); + return config; + } + const lint = options('lint', process.env.LINT, {command: 'lint:ci'}); + const test = options('test', process.env.TEST, {command: 'test:ci', coverage: 'github', cache: ''}); + if (test && !['', 'github', 'codecov'].includes(test.coverage)) throw new Error('test.coverage must be github, codecov or empty.'); + const cache = test?.cache; + if (cache && (cache === '.' || cache.startsWith('/') || cache.includes('\\') || cache.split('/').includes('..') || /[\r\n*?]/.test(cache))) { + throw new Error('test.cache must be one directory inside working-directory.'); + } + core.setOutput('lint', JSON.stringify(lint)); + core.setOutput('test', JSON.stringify(test)); + core.setOutput('versions', versions); + core.setOutput('primary-version', versions.at(-1)); + dependency-review: + if: inputs.checks && github.event_name == 'pull_request' && inputs.dependency-review + runs-on: ${{ fromJSON(inputs.runs-on) }} + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 + lint: + if: inputs.checks && needs.configuration.outputs.lint != 'null' + needs: configuration + runs-on: ${{ fromJSON(inputs.runs-on) }} + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: $/actions/setup-php + with: + working-directory: ${{ inputs.working-directory }} + php-version: ${{ needs.configuration.outputs.primary-version }} + extensions: ${{ inputs.extensions }} + composer-directories: ${{ inputs.composer-directories }} + ignore-scripts: ${{ inputs.ignore-scripts }} + ignore-plugins: ${{ inputs.ignore-plugins }} + - uses: $/actions/lint + with: + working-directory: ${{ inputs.working-directory }} + command: ${{ fromJSON(needs.configuration.outputs.lint).command }} + test: + if: inputs.checks && needs.configuration.outputs.test != 'null' + needs: configuration + runs-on: ${{ fromJSON(inputs.runs-on) }} + permissions: + contents: read + id-token: write + strategy: + fail-fast: false + matrix: + php-version: ${{ fromJSON(needs.configuration.outputs.versions) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: $/actions/setup-php + with: + working-directory: ${{ inputs.working-directory }} + php-version: ${{ matrix.php-version }} + extensions: ${{ inputs.extensions }} + composer-directories: ${{ inputs.composer-directories }} + ignore-scripts: ${{ inputs.ignore-scripts }} + ignore-plugins: ${{ inputs.ignore-plugins }} + - if: fromJSON(needs.configuration.outputs.test).cache != '' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ inputs.working-directory }}/${{ fromJSON(needs.configuration.outputs.test).cache }} + key: php-tests-${{ runner.os }}-${{ matrix.php-version }}-${{ inputs.working-directory }}-${{ fromJSON(needs.configuration.outputs.test).cache }}-${{ hashFiles(format('{0}/**/composer.json', inputs.working-directory)) }} + restore-keys: php-tests-${{ runner.os }}-${{ matrix.php-version }}-${{ inputs.working-directory }}-${{ fromJSON(needs.configuration.outputs.test).cache }}- + - uses: $/actions/test + with: + working-directory: ${{ inputs.working-directory }} + command: ${{ fromJSON(needs.configuration.outputs.test).command }} + coverage: ${{ matrix.php-version == needs.configuration.outputs.primary-version && fromJSON(needs.configuration.outputs.test).coverage || '' }} + package: + if: inputs.checks && needs.configuration.outputs.package-enabled == 'true' + needs: configuration + runs-on: ${{ fromJSON(inputs.runs-on) }} + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: $/actions/setup-php + with: + working-directory: ${{ inputs.working-directory }} + php-version: ${{ needs.configuration.outputs.primary-version }} + extensions: ${{ inputs.extensions }} + ignore-scripts: ${{ inputs.ignore-scripts }} + install-dependencies: "false" + coverage: none + - uses: $/actions/package/test + with: + working-directory: ${{ inputs.working-directory }} + commands: ${{ needs.configuration.outputs.package-tests }} diff --git a/.github/workflows/documentation.md b/.github/workflows/documentation.md new file mode 100644 index 0000000..0a48e96 --- /dev/null +++ b/.github/workflows/documentation.md @@ -0,0 +1,71 @@ + + +# GitHub Reusable Workflow: Build PHP documentation + + + + +## Overview + +Build PHP API documentation with phpDocumentor and upload it for a separate deployment job. + +The workflow uses the project's phpDocumentor configuration when present, or documents +`src/` by default. It puts the generated site in `_site/phpdoc/` and uploads `_site/`. +No Composer installation or deployment is performed. Use ci-github-publish deployment +actions in the caller to combine API documentation with guides or publish GitHub Pages. + +### Permissions + +- **`contents`**: `read` + + + + +## Usage + +```yaml +name: Build PHP documentation +on: + push: + branches: + - main +permissions: {} +jobs: + documentation: + uses: hoverkraft-tech/ci-github-php/.github/workflows/documentation.yml@ + permissions: + contents: read + with: + # JSON array of Linux runner labels. The runner must support Docker containers. + # Default: `["ubuntu-latest"]` + runs-on: '["ubuntu-latest"]' + + # PHP project directory relative to the checkout. + # Default: `.` + working-directory: . +``` + + + + +## Inputs + +### Workflow Call Inputs + +| **Input** | **Description** | **Required** | **Type** | **Default** | +| ----------------------- | ----------------------------------------------------------------------------- | ------------ | ---------- | ------------------- | +| **`runs-on`** | JSON array of Linux runner labels. The runner must support Docker containers. | **false** | **string** | `["ubuntu-latest"]` | +| **`working-directory`** | PHP project directory relative to the checkout. | **false** | **string** | `.` | + + + + + + +## Outputs + +| **Output** | **Description** | +| ----------------- | ----------------------------------- | +| **`artifact-id`** | Uploaded documentation artifact ID. | + + diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 0000000..f47496f --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,62 @@ +# Build PHP API documentation with phpDocumentor and upload it for a separate deployment job. +# +# The workflow uses the project's phpDocumentor configuration when present, or documents +# `src/` by default. It puts the generated site in `_site/phpdoc/` and uploads `_site/`. +# No Composer installation or deployment is performed. Use ci-github-publish deployment +# actions in the caller to combine API documentation with guides or publish GitHub Pages. +name: Build PHP documentation +on: + workflow_call: + inputs: + runs-on: + description: JSON array of Linux runner labels. The runner must support Docker containers. + type: string + default: '["ubuntu-latest"]' + working-directory: + description: PHP project directory relative to the checkout. + type: string + default: . + outputs: + artifact-id: + description: Uploaded documentation artifact ID. + value: ${{ jobs.build.outputs.artifact-id }} +permissions: {} +jobs: + build: + runs-on: ${{ fromJSON(inputs.runs-on) }} + permissions: + contents: read + container: + image: phpdoc/phpdoc:3.10.0@sha256:312ebf61ed88a6ea79aac768e43c9e9af0dd5bf3e710ed6d40ab8e53bfeeb121 + outputs: + artifact-id: ${{ steps.upload.outputs.artifact-id }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - id: directory + uses: hoverkraft-tech/ci-github-common/actions/working-directory@5fbb531db839a26ebcc35c4946be279f945f7633 # 1.0.0 + with: + working-directory: ${{ inputs.working-directory }} + - name: Generate API documentation + shell: bash + working-directory: ${{ steps.directory.outputs.absolute-path }} + run: | + arguments=(run --target=_site/phpdoc) + configured=false + for configuration in phpdoc.xml phpdoc.dist.xml phpdoc.xml.dist .phpdoc.xml.dist .phpdoc.xml .phpdoc.dist.xml; do + if [[ -f "$configuration" ]]; then + configured=true + break + fi + done + if [[ "$configured" == false ]]; then + arguments+=(--directory=src) + fi + phpdoc "${arguments[@]}" + - id: upload + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: php-documentation-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.directory.outputs.absolute-path }}/_site + if-no-files-found: error diff --git a/.github/workflows/release.md b/.github/workflows/release.md new file mode 100644 index 0000000..71cf313 --- /dev/null +++ b/.github/workflows/release.md @@ -0,0 +1,181 @@ + + +# GitHub Reusable Workflow: Release Composer package + + + + +## Overview + +Publish a Composer library from its Git tag. Call after successful validation of +this source commit; the caller controls that dependency with `needs`. + +### Release model + +Composer derives library versions from Git tags. Keep `version` out of +`composer.json`. The workflow uses `ci-github-publish/actions/release/plan` and +`actions/release/create` for version planning, release notes and publication. +No version commit, preparation PR, GitHub App, `CHANGELOG.md` or package upload +is required. GitHub provides source archives for tags; Packagist reads the +repository through its configured update integration. + +The workflow token needs permission to create the chosen tags and releases. +Repository and tag rules still apply. Register the repository with Packagist +and configure its GitHub integration separately. + +### Complete caller workflow + +Pin the same reviewed PHP workflow commit throughout the caller. Validation is +independent: use your own job or the shared CI workflow, with optional distribution +checks. Release publication does not build, install dependencies or run tests. + +```yaml +name: Release +on: + workflow_dispatch: + inputs: + force: + description: Release even without relevant changes + type: boolean + default: false + schedule: + - cron: "37 7 * * 1" +permissions: {} +concurrency: + group: composer-release + cancel-in-progress: false +jobs: + validate: + uses: hoverkraft-tech/ci-github-php/.github/workflows/continuous-integration.yml@ + permissions: + contents: read + id-token: write + with: + package: "true" + publish: + needs: validate + uses: hoverkraft-tech/ci-github-php/.github/workflows/release.yml@ + permissions: + contents: write + pull-requests: read + with: + force: ${{ github.event_name == 'workflow_dispatch' && inputs.force || false }} + release-config: | + tag-template: v$RESOLVED_VERSION + tag-prefix: v + secrets: + changelog-summary: ${{ secrets.CHANGELOG_SUMMARY }} +``` + +### No changes, force, and retries + +Planning happens inside publication. If there are no relevant changes, no tag or +release is created. The caller's preceding validation jobs still run; this keeps +one release entry point without a separate planning workflow. + +On a manual default-branch run, `force: true` permits a release without changes. +If the planned tag already exists, its patch version is incremented. Scheduled +runs ignore force. Existing tags pointing at another commit are always rejected. + +Rerun a failed workflow to recover publication. To recover an older version after +the default branch advances, dispatch against that existing tag. Tag retries keep +the original version. An already published release at the same commit is left +unchanged. The workflow never moves an existing tag. + +The caller's concurrency group serializes validation and publication. The reusable +publisher has its own group; keep these names distinct and disable cancellation. + +### Release configuration and summaries + +Use a partial YAML or JSON `release-config` input or `.github/release-config.yml`, +as supported by `ci-github-publish`. Shared defaults are inherited, repository +settings override them, and input settings take precedence. The same input is +forwarded to planning and publication. Tags must be stable `X.Y.Z` or `vX.Y.Z` +versions; match the repository's existing tag prefix. + +`changelog-summary` accepts the public JSON configuration supported by +`ci-github-publish/actions/release/create`, including its summary providers. +Use the optional secret for configuration containing credentials; it takes +precedence over the input. Omit both for generated release notes without AI. + +See the [ci-github-publish release guide](https://github.com/hoverkraft-tech/ci-github-publish/blob/main/.github/workflows/release.md) +for configuration and summary details. + +### Prepare release labels + +Keep a separate `.github/workflows/prepare-release.yml` caller for the shared +`ci-github-publish/.github/workflows/prepare-release.yml`. It applies release labels +from Conventional Commit pull request titles; it does not publish packages. +Use pull request events `opened`, `reopened`, `synchronize`, and `edited`. +Grant `contents: read` and `pull-requests: write`. +The shared workflow owns its concurrency group. + +### Permissions + +- **`contents`**: `write` +- **`pull-requests`**: `read` + + + + +## Usage + +```yaml +name: Release Composer package +on: + push: + branches: + - main +permissions: {} +jobs: + release: + uses: hoverkraft-tech/ci-github-php/.github/workflows/release.yml@ + permissions: + contents: write + pull-requests: read + secrets: + # Complete optional JSON summary configuration, including provider credentials. Overrides the input. + changelog-summary: "" + with: + # Partial YAML or JSON configuration forwarded to ci-github-publish/actions/release/plan and create. See the public ci-github-publish documentation. Composer tags must be X.Y.Z or vX.Y.Z. + release-config: "" + + # Release without relevant changes on manual default-branch runs. Existing planned tags receive a patch increment. Ignored on schedules and tag retries. + force: false + + # Optional JSON summary configuration forwarded to ci-github-publish/actions/release/create. Supports its providers and options. Use the secret for credentials. + changelog-summary: "" +``` + + + + +## Inputs + +### Workflow Call Inputs + +| **Input** | **Description** | **Required** | **Type** | **Default** | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------- | ----------- | +| **`release-config`** | Partial YAML or JSON configuration forwarded to ci-github-publish/actions/release/plan and create. See the public ci-github-publish documentation. Composer tags must be X.Y.Z or vX.Y.Z. | **false** | **string** | - | +| **`force`** | Release without relevant changes on manual default-branch runs. Existing planned tags receive a patch increment. Ignored on schedules and tag retries. | **false** | **boolean** | `false` | +| **`changelog-summary`** | Optional JSON summary configuration forwarded to ci-github-publish/actions/release/create. Supports its providers and options. Use the secret for credentials. | **false** | **string** | - | + + + + +## Secrets + +| **Secret** | **Description** | **Required** | +| ----------------------- | -------------------------------------------------------------------------------------------------- | ------------ | +| **`changelog-summary`** | Complete optional JSON summary configuration, including provider credentials. Overrides the input. | **false** | + + + + +## Outputs + +| **Output** | **Description** | +| ---------- | ------------------------------------------------------------------------- | +| **`tag`** | Published or recovered version tag. Empty when no changes need releasing. | + + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8bd319b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,227 @@ +# Publish a Composer library from its Git tag. Call after successful validation of +# this source commit; the caller controls that dependency with `needs`. +# +# ### Release model +# +# Composer derives library versions from Git tags. Keep `version` out of +# `composer.json`. The workflow uses `ci-github-publish/actions/release/plan` and +# `actions/release/create` for version planning, release notes and publication. +# No version commit, preparation PR, GitHub App, `CHANGELOG.md` or package upload +# is required. GitHub provides source archives for tags; Packagist reads the +# repository through its configured update integration. +# +# The workflow token needs permission to create the chosen tags and releases. +# Repository and tag rules still apply. Register the repository with Packagist +# and configure its GitHub integration separately. +# +# ### Complete caller workflow +# +# Pin the same reviewed PHP workflow commit throughout the caller. Validation is +# independent: use your own job or the shared CI workflow, with optional distribution +# checks. Release publication does not build, install dependencies or run tests. +# +# ```yaml +# name: Release +# on: +# workflow_dispatch: +# inputs: +# force: +# description: Release even without relevant changes +# type: boolean +# default: false +# schedule: +# - cron: "37 7 * * 1" +# permissions: {} +# concurrency: +# group: composer-release +# cancel-in-progress: false +# jobs: +# validate: +# uses: hoverkraft-tech/ci-github-php/.github/workflows/continuous-integration.yml@ +# permissions: +# contents: read +# id-token: write +# with: +# package: "true" +# publish: +# needs: validate +# uses: hoverkraft-tech/ci-github-php/.github/workflows/release.yml@ +# permissions: +# contents: write +# pull-requests: read +# with: +# force: ${{ github.event_name == 'workflow_dispatch' && inputs.force || false }} +# release-config: | +# tag-template: v$RESOLVED_VERSION +# tag-prefix: v +# secrets: +# changelog-summary: ${{ secrets.CHANGELOG_SUMMARY }} +# ``` +# +# ### No changes, force, and retries +# +# Planning happens inside publication. If there are no relevant changes, no tag or +# release is created. The caller's preceding validation jobs still run; this keeps +# one release entry point without a separate planning workflow. +# +# On a manual default-branch run, `force: true` permits a release without changes. +# If the planned tag already exists, its patch version is incremented. Scheduled +# runs ignore force. Existing tags pointing at another commit are always rejected. +# +# Rerun a failed workflow to recover publication. To recover an older version after +# the default branch advances, dispatch against that existing tag. Tag retries keep +# the original version. An already published release at the same commit is left +# unchanged. The workflow never moves an existing tag. +# +# The caller's concurrency group serializes validation and publication. The reusable +# publisher has its own group; keep these names distinct and disable cancellation. +# +# ### Release configuration and summaries +# +# Use a partial YAML or JSON `release-config` input or `.github/release-config.yml`, +# as supported by `ci-github-publish`. Shared defaults are inherited, repository +# settings override them, and input settings take precedence. The same input is +# forwarded to planning and publication. Tags must be stable `X.Y.Z` or `vX.Y.Z` +# versions; match the repository's existing tag prefix. +# +# `changelog-summary` accepts the public JSON configuration supported by +# `ci-github-publish/actions/release/create`, including its summary providers. +# Use the optional secret for configuration containing credentials; it takes +# precedence over the input. Omit both for generated release notes without AI. +# +# See the [ci-github-publish release guide](https://github.com/hoverkraft-tech/ci-github-publish/blob/main/.github/workflows/release.md) +# for configuration and summary details. +# +# ### Prepare release labels +# +# Keep a separate `.github/workflows/prepare-release.yml` caller for the shared +# `ci-github-publish/.github/workflows/prepare-release.yml`. It applies release labels +# from Conventional Commit pull request titles; it does not publish packages. +# Use pull request events `opened`, `reopened`, `synchronize`, and `edited`. +# Grant `contents: read` and `pull-requests: write`. +# The shared workflow owns its concurrency group. +name: Release Composer package +on: + workflow_call: + inputs: + release-config: + description: Partial YAML or JSON configuration forwarded to ci-github-publish/actions/release/plan and create. See the public ci-github-publish documentation. Composer tags must be X.Y.Z or vX.Y.Z. + type: string + default: "" + force: + description: Release without relevant changes on manual default-branch runs. Existing planned tags receive a patch increment. Ignored on schedules and tag retries. + type: boolean + default: false + changelog-summary: + description: Optional JSON summary configuration forwarded to ci-github-publish/actions/release/create. Supports its providers and options. Use the secret for credentials. + type: string + default: "" + secrets: + changelog-summary: + description: Complete optional JSON summary configuration, including provider credentials. Overrides the input. + required: false + outputs: + tag: + description: Published or recovered version tag. Empty when no changes need releasing. + value: ${{ jobs.release.outputs.tag }} +permissions: {} +concurrency: + group: php-package-release-${{ github.repository }} + cancel-in-progress: false +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + pull-requests: read + outputs: + tag: ${{ steps.result.outputs.has-changes == 'true' && steps.result.outputs.tag || '' }} + steps: + - id: event + name: Validate release event + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: |- + const defaultRef = 'refs/heads/' + context.payload.repository.default_branch; + const manualTag = context.eventName === 'workflow_dispatch' && context.ref.startsWith('refs/tags/'); + if (!['workflow_dispatch', 'schedule'].includes(context.eventName) || (context.ref !== defaultRef && !manualTag)) { + throw new Error('Release planning requires a manual or scheduled default-branch run, or a manual tag retry.'); + } + - id: plan + if: "!startsWith(github.ref, 'refs/tags/')" + uses: hoverkraft-tech/ci-github-publish/actions/release/plan@f06e0e72a76bfca57c34d08d03e5c1914275fd4c # 2.0.0 + with: + release-config: ${{ inputs.release-config }} + target-sha: ${{ github.sha }} + - id: result + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + TAG: ${{ steps.plan.outputs.tag }} + HAS_CHANGES: ${{ steps.plan.outputs.has-changes }} + FORCE: ${{ inputs.force }} + with: + script: |- + const retry = context.ref.startsWith('refs/tags/'); + let tag = retry ? context.ref.slice('refs/tags/'.length) : process.env.TAG; + const force = !retry && context.eventName === 'workflow_dispatch' && process.env.FORCE === 'true'; + const hasChanges = retry || force || process.env.HAS_CHANGES === 'true'; + const match = /^(v?)(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(tag); + if (!match) throw new Error('Composer release tags must be stable X.Y.Z or vX.Y.Z versions.'); + if (force && process.env.HAS_CHANGES !== 'true') { + try { + await github.rest.git.getRef({ ...context.repo, ref: 'tags/' + tag }); + tag = match[1] + match[2] + '.' + match[3] + '.' + (BigInt(match[4]) + 1n); + } catch (error) { + if (error.status !== 404) throw error; + } + } + core.setOutput('has-changes', hasChanges ? 'true' : 'false'); + core.setOutput('tag', tag); + core.info(hasChanges ? 'Validate the source before publishing ' + tag + '.' : 'No changes to release.'); + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: steps.result.outputs.has-changes == 'true' + with: + persist-credentials: false + fetch-depth: 0 + - id: verify + name: Verify Composer source and release tag + if: steps.result.outputs.has-changes == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + TAG: ${{ steps.result.outputs.tag }} + with: + script: |- + const manifest = JSON.parse(require('node:fs').readFileSync('composer.json', 'utf8')); + if (typeof manifest.name !== 'string' || !/^[a-z0-9][a-z0-9_.-]*\/[a-z0-9][a-z0-9_.-]*$/.test(manifest.name) || manifest.version !== undefined) { + throw new Error('Declare a public Composer package without a hardcoded version.'); + } + const tag = process.env.TAG; + let commit; + try { + // The commits endpoint resolves lightweight and annotated tags to their commit. + commit = (await github.rest.repos.getCommit({...context.repo, ref: 'tags/' + tag})).data.sha; + } catch (error) { + if (error.status !== 404) throw error; + } + if (commit && commit !== context.sha) throw new Error('Release tag must identify this run\'s source commit.'); + if (!commit && context.ref.startsWith('refs/tags/')) throw new Error('The retry tag no longer exists.'); + let release; + try { + release = (await github.rest.repos.getReleaseByTag({...context.repo, tag})).data; + } catch (error) { + if (error.status !== 404) throw error; + // Include drafts, which are not guaranteed by the by-tag endpoint. + const releases = await github.paginate(github.rest.repos.listReleases, {...context.repo, per_page: 100}); + release = releases.find(item => item.tag_name === tag); + } + if (release && !commit) throw new Error('Existing release has no matching Git tag.'); + core.setOutput('published', Boolean(release && !release.draft)); + - name: Create GitHub release + if: steps.result.outputs.has-changes == 'true' && steps.verify.outputs.published != 'true' + uses: hoverkraft-tech/ci-github-publish/actions/release/create@f06e0e72a76bfca57c34d08d03e5c1914275fd4c # 2.0.0 + with: + tag: ${{ steps.result.outputs.tag }} + target-sha: ${{ github.sha }} + release-config: ${{ inputs.release-config }} + changelog-summary: ${{ secrets.changelog-summary || inputs.changelog-summary }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b25e815 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Composer +vendor/ +composer.phar + +# PHPUnit +junit.xml +clover.xml +cobertura.xml +.phpunit.cache/ + +# Test project specific - keep phpunit.xml +!/tests/**/phpunit.xml + +# PHPStan +.phpstan.cache/ + +# Psalm +.psalm.cache/ + +# PHP CS Fixer +.php-cs-fixer.cache + +# Build artifacts +dist/ +build/ +coverage/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Workflow self-reference +self-workflow/ diff --git a/.php-version b/.php-version new file mode 100644 index 0000000..188c409 --- /dev/null +++ b/.php-version @@ -0,0 +1 @@ +8.5 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6c9f64a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# Agent instructions + +Follow the shared [Hoverkraft engineering guidance](https://github.com/hoverkraft-tech/.github/blob/main/AGENTS.md). + +This repository provides PHP and Composer actions and workflows. Read the +[development guide](docs/development/README.md) for validation and documentation +generation, and the [ADR](docs/adr/0001-php-package-workflows.md) for design constraints. + +Keep the root readme focused on end users. Action readmes and workflow reference +sections are generated; update metadata and regenerate them with CI Dokumentor. +Keep authored release instructions in `.github/workflows/release.md`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b01125d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,92 @@ +# Contributing + +When contributing to this repository, please first discuss the change you wish to make via issue, +email, or any other method with the owners of this repository before making a change. + +Please note we have a code of conduct, please follow it in all your interactions with the project. + +## Pull Request Process + +1. Ensure any install or build dependencies are removed before the end of the layer when doing a + build. +2. Update the README.md with details of changes to the interface, this includes new environment + variables, exposed ports, useful file locations and container parameters. +3. Increase the version numbers in any examples files and the README.md to the new version that this + Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). +4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you + do not have permission to do that, you may request the second reviewer to merge it for you. + +## Code of Conduct + +### Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +### Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual attention or + advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic + address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +### Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +### Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project email +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +### Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at [INSERT EMAIL ADDRESS]. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +### Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ee72f24 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM ghcr.io/hoverkraft-tech/docker-base-images/super-linter:2.0.0@sha256:a37519f1afdc1c239254fe3d9a3e76ee9f114990f09bba123c96b1737737d43e + +HEALTHCHECK --interval=5m --timeout=10s --start-period=30s --retries=3 CMD ["/bin/sh","-c","test -d /github/home"] +ARG UID=1000 +ARG GID=1000 +USER ${UID}:${GID} + +# The PHP integration workflow runs PHPStan and PHPUnit with the project's runtime. +# Super-Linter's PHP bootstrap installs every nested Composer project with its own PHP. +ENV VALIDATE_PHP_BUILTIN=false \ + VALIDATE_PHP_PHPCS=false \ + VALIDATE_PHP_PHPSTAN=false \ + VALIDATE_PHP_PSALM=false diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4752f13 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 hoverkraft + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..04e0b1b --- /dev/null +++ b/Makefile @@ -0,0 +1,75 @@ +.PHONY: help setup deps-install deps-update deps-update-interactive deps-audit test ci lint lint-fix + +COMPOSER_DIRECTORIES := $(patsubst %/composer.json,%,$(shell git ls-files '**/composer.json')) + +help: ## Display help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +setup: deps-install ## Set up development dependencies + +deps-install: ## Install action and fixture dependencies without hooks or plugins + @set -e; for directory in $(COMPOSER_DIRECTORIES); do \ + composer --working-dir="$$directory" --no-interaction --no-plugins install --no-scripts --prefer-dist; \ + done + +deps-update: ## Update action and fixture dependencies and lockfiles + @set -e; for directory in $(COMPOSER_DIRECTORIES); do \ + composer --working-dir="$$directory" --no-interaction --no-plugins update --no-scripts --prefer-dist; \ + done + +deps-update-interactive: ## Interactively select fixture dependency updates + @set -e; for directory in $(COMPOSER_DIRECTORIES); do \ + composer --working-dir="$$directory" --no-plugins update --no-scripts --interactive; \ + done + +deps-audit: ## Audit action and fixture dependencies + @set -e; for directory in $(COMPOSER_DIRECTORIES); do \ + composer --working-dir="$$directory" --no-interaction --no-plugins audit; \ + done + +test: ## Run fixture lint and PHPUnit + composer --working-dir=tests/php-project lint:ci + composer --working-dir=tests/php-project test + +ci: ## Install, audit, test and lint without rewriting source files + $(MAKE) deps-install + $(MAKE) deps-audit + $(MAKE) test + $(MAKE) lint + +lint: ## Execute linting + $(call run_linter,) + +lint-fix: ## Execute linting and fix + $(call run_linter, \ + -e FIX_JSON_PRETTIER=true \ + -e FIX_JAVASCRIPT_PRETTIER=true \ + -e FIX_YAML_PRETTIER=true \ + -e FIX_MARKDOWN=true \ + -e FIX_MARKDOWN_PRETTIER=true \ + -e FIX_NATURAL_LANGUAGE=true) + +define run_linter + DEFAULT_WORKSPACE="$(CURDIR)"; \ + LINTER_IMAGE="linter:latest"; \ + VOLUME="$$DEFAULT_WORKSPACE:$$DEFAULT_WORKSPACE"; \ + DEFAULT_BRANCH="$$(git -C "$$DEFAULT_WORKSPACE" rev-parse --abbrev-ref HEAD 2>/dev/null)"; \ + docker build --platform=linux/amd64 --build-arg UID=$(shell id -u) --build-arg GID=$(shell id -g) --tag $$LINTER_IMAGE .; \ + docker run \ + --platform=linux/amd64 \ + -e DEFAULT_WORKSPACE="$$DEFAULT_WORKSPACE" \ + -e DEFAULT_BRANCH="$$DEFAULT_BRANCH" \ + -e USE_FIND_ALGORITHM=false \ + -e FILTER_REGEX_INCLUDE="$(filter-out $@,$(MAKECMDGOALS))" \ + -e IGNORE_GITIGNORED_FILES=true \ + $(1) \ + -v $$VOLUME \ + --rm \ + $$LINTER_IMAGE +endef + +############################# +# Argument fix workaround +############################# +%: + @: \ No newline at end of file diff --git a/README.md b/README.md index ef992a8..83bf67d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,49 @@ -# ci-github-php -Opinionated Github actions and workflows for continuous integration in PHP context +# Continuous Integration - GitHub - PHP + +![GitHub Verified Creator](https://img.shields.io/badge/GitHub-Verified%20Creator-4493F8?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiIgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSJyZ2IoNjgsIDE0NywgMjQ4KSI+CiAgPHBhdGggZD0ibTkuNTg1LjUyLjkyOS42OGMuMTUzLjExMi4zMzEuMTg2LjUxOC4yMTVsMS4xMzguMTc1YTIuNjc4IDIuNjc4IDAgMCAxIDIuMjQgMi4yNGwuMTc0IDEuMTM5Yy4wMjkuMTg3LjEwMy4zNjUuMjE1LjUxOGwuNjguOTI4YTIuNjc3IDIuNjc3IDAgMCAxIDAgMy4xN2wtLjY4LjkyOGExLjE3NCAxLjE3NCAwIDAgMC0uMjE1LjUxOGwtLjE3NSAxLjEzOGEyLjY3OCAyLjY3OCAwIDAgMS0yLjI0MSAyLjI0MWwtMS4xMzguMTc1YTEuMTcgMS4xNyAwIDAgMC0uNTE4LjIxNWwtLjkyOC42OGEyLjY3NyAyLjY3NyAwIDAgMS0zLjE3IDBsLS45MjgtLjY4YTEuMTc0IDEuMTc0IDAgMCAwLS41MTgtLjIxNUwzLjgzIDE0LjQxYTIuNjc4IDIuNjc4IDAgMCAxLTIuMjQtMi4yNGwtLjE3NS0xLjEzOGExLjE3IDEuMTcgMCAwIDAtLjIxNS0uNTE4bC0uNjgtLjkyOGEyLjY3NyAyLjY3NyAwIDAgMSAwLTMuMTdsLjY4LS45MjhjLjExMi0uMTUzLjE4Ni0uMzMxLjIxNS0uNTE4bC4xNzUtMS4xNGEyLjY3OCAyLjY3OCAwIDAgMSAyLjI0LTIuMjRsMS4xMzktLjE3NWMuMTg3LS4wMjkuMzY1LS4xMDMuNTE4LS4yMTVsLjkyOC0uNjhhMi42NzcgMi42NzcgMCAwIDEgMy4xNyAwWk03LjMwMyAxLjcyOGwtLjkyNy42OGEyLjY3IDIuNjcgMCAwIDEtMS4xOC40ODlsLTEuMTM3LjE3NGExLjE3OSAxLjE3OSAwIDAgMC0uOTg3Ljk4N2wtLjE3NCAxLjEzNmEyLjY3NyAyLjY3NyAwIDAgMS0uNDg5IDEuMThsLS42OC45MjhhMS4xOCAxLjE4IDAgMCAwIDAgMS4zOTRsLjY4LjkyN2MuMjU2LjM0OC40MjQuNzUzLjQ4OSAxLjE4bC4xNzQgMS4xMzdjLjA3OC41MDkuNDc4LjkwOS45ODcuOTg3bDEuMTM2LjE3NGEyLjY3IDIuNjcgMCAwIDEgMS4xOC40ODlsLjkyOC42OGMuNDE0LjMwNS45NzkuMzA1IDEuMzk0IDBsLjkyNy0uNjhhMi42NyAyLjY3IDAgMCAxIDEuMTgtLjQ4OWwxLjEzNy0uMTc0YTEuMTggMS4xOCAwIDAgMCAuOTg3LS45ODdsLjE3NC0xLjEzNmEyLjY3IDIuNjcgMCAwIDEgLjQ4OS0xLjE4bC42OC0uOTI4YTEuMTc2IDEuMTc2IDAgMCAwIDAtMS4zOTRsLS42OC0uOTI3YTIuNjg2IDIuNjg2IDAgMCAxLS40ODktMS4xOGwtLjE3NC0xLjEzN2ExLjE3OSAxLjE3OSAwIDAgMC0uOTg3LS45ODdsLTEuMTM2LS4xNzRhMi42NzcgMi42NzcgMCAwIDEtMS4xOC0uNDg5bC0uOTI4LS42OGExLjE3NiAxLjE3NiAwIDAgMC0xLjM5NCAwWk0xMS4yOCA2Ljc4bC0zLjc1IDMuNzVhLjc1Ljc1IDAgMCAxLTEuMDYgMEw0LjcyIDguNzhhLjc1MS43NTEgMCAwIDEgLjAxOC0xLjA0Mi43NTEuNzUxIDAgMCAxIDEuMDQyLS4wMThMNyA4Ljk0bDMuMjItMy4yMmEuNzUxLjc1MSAwIDAgMSAxLjA0Mi4wMTguNzUxLjc1MSAwIDAgMSAuMDE4IDEuMDQyWiI+PC9wYXRoPgo8L3N2Zz4K) +[![Continuous Integration](https://github.com/hoverkraft-tech/ci-github-php/actions/workflows/__main-ci.yml/badge.svg)](https://github.com/hoverkraft-tech/ci-github-php/actions/workflows/__main-ci.yml) +[![GitHub tag](https://img.shields.io/github/tag/hoverkraft-tech/ci-github-php?include_prereleases=&sort=semver&color=blue)](https://github.com/hoverkraft-tech/ci-github-php/releases/) +[![License](https://img.shields.io/badge/License-MIT-blue)](#license) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) + +Reusable GitHub Actions and workflows for PHP applications and Composer libraries. +Install dependencies, lint, test, generate API documentation, and publish +GitHub releases with Conventional Commit version planning. + +## Prerequisites + +Use GitHub.com and Linux runners. Self-hosted runners require GitHub Actions +runner 2.336.0 or newer for native `$/` action references. GitHub Enterprise Server +is not supported. Documentation builds require Docker on the runner. + +Define project commands in Composer scripts. Start with the +[CI guide](.github/workflows/continuous-integration.md) for the default conventions and +optional configuration. + +## Actions + +### Continuous integration + +- [Lint](actions/lint/README.md): run Composer lint scripts and report issues. +- [Test](actions/test/README.md): run tests, reports and coverage. +- [Test a Composer distribution](actions/package/test/README.md): check the committed source as an installed dependency. + +### Environment setup + +- [Setup PHP](actions/setup-php/README.md): install PHP and Composer dependencies. + +## Reusable workflows and guides + +- [Continuous integration](.github/workflows/continuous-integration.md): reusable CI inputs and outputs. +- [Documentation](.github/workflows/documentation.md): generate PHP API documentation for separate deployment. +- [Release](.github/workflows/release.md): manual and scheduled Composer releases, configuration and recovery. +- [Documentation index](docs/README.md): usage and development guides. + +## Contributing + +See the [contributing guidelines](CONTRIBUTING.md) and +[development guide](docs/development/README.md). + +## License + +[MIT](LICENSE). Copyright [Hoverkraft](https://hoverkraft.cloud). diff --git a/actions/lint/README.md b/actions/lint/README.md new file mode 100644 index 0000000..f7a06e9 --- /dev/null +++ b/actions/lint/README.md @@ -0,0 +1,66 @@ + + +# ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItcGFja2FnZSIgY29sb3I9ImJsdWUiPjxsaW5lIHgxPSIxNi41IiB5MT0iOS40IiB4Mj0iNy41IiB5Mj0iNC4yMSI+PC9saW5lPjxwYXRoIGQ9Ik0yMSAxNlY4YTIgMiAwIDAgMC0xLTEuNzNsLTctNGEyIDIgMCAwIDAtMiAwbC03IDRBMiAyIDAgMCAwIDMgOHY4YTIgMiAwIDAgMCAxIDEuNzNsNyA0YTIgMiAwIDAgMCAyIDBsNy00QTIgMiAwIDAgMCAyMSAxNnoiPjwvcGF0aD48cG9seWxpbmUgcG9pbnRzPSIzLjI3IDYuOTYgMTIgMTIuMDEgMjAuNzMgNi45NiI+PC9wb2x5bGluZT48bGluZSB4MT0iMTIiIHkxPSIyMi4wOCIgeDI9IjEyIiB5Mj0iMTIiPjwvbGluZT48L3N2Zz4=) GitHub Action: Lint PHP + + + + +## Overview + +Run a project-defined Composer lint script and report results. + + + + +## Usage + +```yaml +- uses: hoverkraft-tech/ci-github-php/actions/lint@ + with: + # Composer project directory. Run setup-php first to install its dependencies. + # Default: `.` + working-directory: . + + # Composer script to execute, normally `lint:ci` in composer.json. + # Define the tools and their configuration in that script. Examples: + # + # - PHPStan: `phpstan analyse --error-format=checkstyle > phpstan-checkstyle.xml` + # - Psalm: `psalm --output-format=checkstyle > psalm-checkstyle.xml` + # - PHP_CodeSniffer: `phpcs --report=checkstyle --report-file=phpcs-checkstyle.xml` + # - PHP CS Fixer: `php-cs-fixer check --format=checkstyle > php-cs-fixer-checkstyle.xml` + # + # Redirecting output preserves the tool's exit status. The action discovers + # Checkstyle reports for annotations and summaries. Use paths relative to the project; + # for separate tooling installations, prefix binaries with `tools/vendor/bin/`. + # See the [CI guide](../../.github/workflows/continuous-integration.md). + # + # Default: `lint:ci` + command: lint:ci +``` + + + + +## Inputs + +| **Input** | **Description** | **Required** | **Default** | +| ----------------------- | -------------------------------------------------------------------------------------- | ------------ | ----------- | +| **`working-directory`** | Composer project directory. Run setup-php first to install its dependencies. | **false** | `.` | +| **`command`** | Composer script to execute, normally `lint:ci` in composer.json. | **false** | `lint:ci` | +| | Define the tools and their configuration in that script. Examples: | | | +| | | | | +| | - PHPStan: `phpstan analyse --error-format=checkstyle > phpstan-checkstyle.xml` | | | +| | - Psalm: `psalm --output-format=checkstyle > psalm-checkstyle.xml` | | | +| | - PHP_CodeSniffer: `phpcs --report=checkstyle --report-file=phpcs-checkstyle.xml` | | | +| | - PHP CS Fixer: `php-cs-fixer check --format=checkstyle > php-cs-fixer-checkstyle.xml` | | | +| | | | | +| | Redirecting output preserves the tool's exit status. The action discovers | | | +| | Checkstyle reports for annotations and summaries. Use paths relative to the project; | | | +| | for separate tooling installations, prefix binaries with `tools/vendor/bin/`. | | | +| | See the [CI guide](../../.github/workflows/continuous-integration.md). | | | + + + + + + diff --git a/actions/lint/action.yml b/actions/lint/action.yml new file mode 100644 index 0000000..105bc22 --- /dev/null +++ b/actions/lint/action.yml @@ -0,0 +1,52 @@ +name: Lint PHP +description: Run a project-defined Composer lint script and report results. +author: hoverkraft +branding: + icon: package + color: blue +inputs: + working-directory: + description: Composer project directory. Run setup-php first to install its dependencies. + default: . + command: + description: | + Composer script to execute, normally `lint:ci` in composer.json. + Define the tools and their configuration in that script. Examples: + + - PHPStan: `phpstan analyse --error-format=checkstyle > phpstan-checkstyle.xml` + - Psalm: `psalm --output-format=checkstyle > psalm-checkstyle.xml` + - PHP_CodeSniffer: `phpcs --report=checkstyle --report-file=phpcs-checkstyle.xml` + - PHP CS Fixer: `php-cs-fixer check --format=checkstyle > php-cs-fixer-checkstyle.xml` + + Redirecting output preserves the tool's exit status. The action discovers + Checkstyle reports for annotations and summaries. Use paths relative to the project; + for separate tooling installations, prefix binaries with `tools/vendor/bin/`. + See the [CI guide](../../.github/workflows/continuous-integration.md). + default: lint:ci +runs: + using: composite + steps: + - id: directory + uses: hoverkraft-tech/ci-github-common/actions/working-directory@5fbb531db839a26ebcc35c4946be279f945f7633 # 1.0.0 + with: + working-directory: ${{ inputs.working-directory }} + - id: run + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + WORKING_DIRECTORY: ${{ steps.directory.outputs.absolute-path }} + COMMAND: ${{ inputs.command }} + with: + script: |- + const command = process.env.COMMAND.trim(); + if (!command || command.startsWith('-')) throw new Error('command must name a Composer script.'); + await exec.exec('composer', ['--no-interaction', 'run-script', '--timeout=0', command], { + cwd: process.env.WORKING_DIRECTORY, + }); + - name: Report lint results + if: ${{ !cancelled() && steps.directory.outcome == 'success' }} + uses: hoverkraft-tech/ci-github-common/actions/quality/parse-ci-reports@5fbb531db839a26ebcc35c4946be279f945f7633 # 1.0.0 + with: + working-directory: ${{ steps.directory.outputs.absolute-path }} + report-paths: auto:lint + report-name: PHP lint + output-format: annotations,summary diff --git a/actions/package/test/README.md b/actions/package/test/README.md new file mode 100644 index 0000000..1c472e5 --- /dev/null +++ b/actions/package/test/README.md @@ -0,0 +1,42 @@ + + +# ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItcGFja2FnZSIgY29sb3I9ImJsdWUiPjxsaW5lIHgxPSIxNi41IiB5MT0iOS40IiB4Mj0iNy41IiB5Mj0iNC4yMSI+PC9saW5lPjxwYXRoIGQ9Ik0yMSAxNlY4YTIgMiAwIDAgMC0xLTEuNzNsLTctNGEyIDIgMCAwIDAtMiAwbC03IDRBMiAyIDAgMCAwIDMgOHY4YTIgMiAwIDAgMCAxIDEuNzNsNyA0YTIgMiAwIDAgMCAyIDBsNy00QTIgMiAwIDAgMCAyMSAxNnoiPjwvcGF0aD48cG9seWxpbmUgcG9pbnRzPSIzLjI3IDYuOTYgMTIgMTIuMDEgMjAuNzMgNi45NiI+PC9wb2x5bGluZT48bGluZSB4MT0iMTIiIHkxPSIyMi4wOCIgeDI9IjEyIiB5Mj0iMTIiPjwvbGluZT48L3N2Zz4=) GitHub Action: Test Composer package + + + + +## Overview + +Test the committed Composer distribution in an isolated production installation, respecting Git export rules. No release artifact is produced. + + + + +## Usage + +```yaml +- uses: hoverkraft-tech/ci-github-php/actions/package/test@ + with: + # Git repository root containing the public composer.json. Set up PHP and Composer first. + # Default: `.` + working-directory: . + + # Optional shell commands in the isolated consumer. vendor/bin is on PATH. PACKAGE_NAME, PACKAGE_INSTALL_PATH and PACKAGE_ARCHIVE_PATH identify the tested package. Installation disables plugins and scripts; cleanup runs after success or failure. + commands: "" +``` + + + + +## Inputs + +| **Input** | **Description** | **Required** | **Default** | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------- | +| **`working-directory`** | Git repository root containing the public composer.json. Set up PHP and Composer first. | **false** | `.` | +| **`commands`** | Optional shell commands in the isolated consumer. vendor/bin is on PATH. PACKAGE_NAME, PACKAGE_INSTALL_PATH and PACKAGE_ARCHIVE_PATH identify the tested package. Installation disables plugins and scripts; cleanup runs after success or failure. | **false** | - | + + + + + + diff --git a/actions/package/test/action.yml b/actions/package/test/action.yml new file mode 100644 index 0000000..65c42b3 --- /dev/null +++ b/actions/package/test/action.yml @@ -0,0 +1,90 @@ +name: Test Composer package +description: Test the committed Composer distribution in an isolated production installation, respecting Git export rules. No release artifact is produced. +author: hoverkraft +branding: + icon: package + color: blue +inputs: + working-directory: + description: Git repository root containing the public composer.json. Set up PHP and Composer first. + default: . + commands: + description: Optional shell commands in the isolated consumer. vendor/bin is on PATH. PACKAGE_NAME, PACKAGE_INSTALL_PATH and PACKAGE_ARCHIVE_PATH identify the tested package. Installation disables plugins and scripts; cleanup runs after success or failure. + default: "" +runs: + using: composite + steps: + - id: directory + uses: hoverkraft-tech/ci-github-common/actions/working-directory@5fbb531db839a26ebcc35c4946be279f945f7633 # 1.0.0 + with: + working-directory: ${{ inputs.working-directory }} + - id: test + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + DIRECTORY: ${{ steps.directory.outputs.absolute-path }} + COMMANDS: ${{ inputs.commands }} + with: + script: |- + const fs = require('node:fs'); + const path = require('node:path'); + const { createHash } = require('node:crypto'); + const cwd = process.env.DIRECTORY; + const prefix = await exec.getExecOutput('git', ['rev-parse', '--show-prefix'], {cwd, silent: true}); + if (prefix.stdout.trim()) throw new Error('Composer distributions must use a Git repository root.'); + const directory = fs.mkdtempSync(path.join(process.env.RUNNER_TEMP, 'composer-test-')); + const archive = path.join(directory, 'source.zip'); + const digest = () => createHash('sha256').update(fs.readFileSync(archive)).digest('hex'); + try { + await exec.exec('git', ['archive', '--format=zip', '--output', archive, 'HEAD'], {cwd}); + const expected = digest(); + const source = path.join(directory, 'package'); + await io.mkdirP(source); + const entries = await exec.getExecOutput('unzip', ['-Z1', archive], { silent: true }); + for (const entry of entries.stdout.trim().split(/\r?\n/)) { + const target = path.resolve(source, entry); + if (!target.startsWith(source + path.sep)) throw new Error('Archive entries must stay inside the package.'); + } + await exec.exec('unzip', ['-q', archive, '-d', source]); + const manifest = JSON.parse(fs.readFileSync(path.join(source, 'composer.json'), 'utf8')); + const name = manifest.name; + if (typeof name !== 'string' || !/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/.test(name)) throw new Error('Archive must declare a Composer package name.'); + if (manifest.version !== undefined) throw new Error('Composer library versions must come from Git tags.'); + await exec.exec('composer', ['--no-interaction', '--no-plugins', 'validate', '--strict'], { cwd: source }); + const checkPath = entry => { + if (typeof entry !== 'string') throw new Error('Invalid packaged autoload or binary path.'); + const file = path.resolve(source, entry); + if (file !== source && !file.startsWith(source + path.sep)) throw new Error('Packaged entry points must stay inside the package.'); + if (!fs.existsSync(file)) throw new Error('Missing packaged entry point: ' + entry); + const resolved = fs.realpathSync(file); + if (resolved !== source && !resolved.startsWith(source + path.sep)) throw new Error('Packaged entry points must not link outside the package.'); + }; + for (const map of [manifest.autoload?.['psr-4'], manifest.autoload?.['psr-0']]) { + for (const entry of Object.values(map || {}).flat()) checkPath(entry); + } + for (const entry of [...(manifest.autoload?.files || []), ...(typeof manifest.bin === 'string' ? [manifest.bin] : manifest.bin || [])]) checkPath(entry); + const consumer = path.join(directory, 'consumer'); + await io.mkdirP(consumer); + fs.writeFileSync(path.join(consumer, 'composer.json'), JSON.stringify({ + name: 'ci/package-consumer', type: 'project', + repositories: [{ type: 'path', url: source, options: { symlink: false, versions: { [name]: 'dev-package-test' } } }], + require: { [name]: 'dev-package-test' }, + config: { 'allow-plugins': false }, + })); + await exec.exec('composer', ['--no-interaction', '--no-plugins', 'install', '--no-scripts', '--no-progress', '--prefer-dist'], { cwd: consumer }); + await exec.exec('composer', ['--no-interaction', '--no-plugins', 'check-platform-reqs'], { cwd: consumer }); + await exec.exec('php', ['-r', 'require "vendor/autoload.php";'], { cwd: consumer }); + const installed = path.join(consumer, 'vendor', name); + const installedManifest = JSON.parse(fs.readFileSync(path.join(installed, 'composer.json'), 'utf8')); + if (installedManifest.name !== name) throw new Error('Installed package does not match the source archive.'); + if (process.env.COMMANDS.trim()) { + await exec.exec('bash', ['--noprofile', '--norc', '-e', '-o', 'pipefail', '-c', process.env.COMMANDS], { + cwd: consumer, + env: { ...process.env, PATH: path.join(consumer, 'vendor', 'bin') + path.delimiter + process.env.PATH, + PACKAGE_NAME: name, PACKAGE_INSTALL_PATH: installed, PACKAGE_ARCHIVE_PATH: archive }, + }); + } + if (digest() !== expected) throw new Error('Package tests must not modify the source archive.'); + core.info('Validated installed Composer package ' + name + '.'); + } finally { + await io.rmRF(directory); + } diff --git a/actions/setup-php/README.md b/actions/setup-php/README.md new file mode 100644 index 0000000..bfb353a --- /dev/null +++ b/actions/setup-php/README.md @@ -0,0 +1,78 @@ + + +# ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItcGFja2FnZSIgY29sb3I9ImJsdWUiPjxsaW5lIHgxPSIxNi41IiB5MT0iOS40IiB4Mj0iNy41IiB5Mj0iNC4yMSI+PC9saW5lPjxwYXRoIGQ9Ik0yMSAxNlY4YTIgMiAwIDAgMC0xLTEuNzNsLTctNGEyIDIgMCAwIDAtMiAwbC03IDRBMiAyIDAgMCAwIDMgOHY4YTIgMiAwIDAgMCAxIDEuNzNsNyA0YTIgMiAwIDAgMCAyIDBsNy00QTIgMiAwIDAgMCAyMSAxNnoiPjwvcGF0aD48cG9seWxpbmUgcG9pbnRzPSIzLjI3IDYuOTYgMTIgMTIuMDEgMjAuNzMgNi45NiI+PC9wb2x5bGluZT48bGluZSB4MT0iMTIiIHkxPSIyMi4wOCIgeDI9IjEyIiB5Mj0iMTIiPjwvbGluZT48L3N2Zz4=) GitHub Action: Setup PHP + + + + +## Overview + +Set up PHP and Composer, cache downloads and install project dependencies with or without lockfiles. + + + + +## Usage + +```yaml +- uses: hoverkraft-tech/ci-github-php/actions/setup-php@ + with: + # Project directory relative to the checkout. + # Default: `.` + working-directory: . + + # Explicit PHP version. Empty reads the project or repository .php-version, then setup-php platform detection. + php-version: "" + + # PHP extensions forwarded to shivammathur/setup-php. + # Default: `mbstring, dom, xml, xmlwriter, curl, zip, intl` + extensions: mbstring, dom, xml, xmlwriter, curl, zip, intl + + # Coverage driver: none, pcov or xdebug. + # Default: `pcov` + coverage: pcov + + # JSON array of Composer project directories relative to working-directory. Empty installs the root project and tools/composer.json when present, without recursive discovery. + composer-directories: "" + + # Install project dependencies. Disable for jobs that only create an archive or install a separate consumer. + # Default: `true` + install-dependencies: "true" + + # Disable Composer installation scripts. Explicit Composer scripts run by later actions are unaffected. + # Default: `true` + ignore-scripts: "true" + + # Disable Composer plugins during installation. Set false when needed; composer.json allow-plugins still controls which plugins may run. + # Default: `true` + ignore-plugins: "true" +``` + + + + +## Inputs + +| **Input** | **Description** | **Required** | **Default** | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ------------------------------------------------ | +| **`working-directory`** | Project directory relative to the checkout. | **false** | `.` | +| **`php-version`** | Explicit PHP version. Empty reads the project or repository .php-version, then setup-php platform detection. | **false** | - | +| **`extensions`** | PHP extensions forwarded to shivammathur/setup-php. | **false** | `mbstring, dom, xml, xmlwriter, curl, zip, intl` | +| **`coverage`** | Coverage driver: none, pcov or xdebug. | **false** | `pcov` | +| **`composer-directories`** | JSON array of Composer project directories relative to working-directory. Empty installs the root project and tools/composer.json when present, without recursive discovery. | **false** | - | +| **`install-dependencies`** | Install project dependencies. Disable for jobs that only create an archive or install a separate consumer. | **false** | `true` | +| **`ignore-scripts`** | Disable Composer installation scripts. Explicit Composer scripts run by later actions are unaffected. | **false** | `true` | +| **`ignore-plugins`** | Disable Composer plugins during installation. Set false when needed; composer.json allow-plugins still controls which plugins may run. | **false** | `true` | + + + + + + +## Outputs + +| **Output** | **Description** | +| ----------------- | -------------------------------------- | +| **`php-version`** | Installed PHP major and minor version. | + + diff --git a/actions/setup-php/action.yml b/actions/setup-php/action.yml new file mode 100644 index 0000000..214efbc --- /dev/null +++ b/actions/setup-php/action.yml @@ -0,0 +1,119 @@ +name: Setup PHP +description: Set up PHP and Composer, cache downloads and install project dependencies with or without lockfiles. +author: hoverkraft +branding: + icon: package + color: blue +inputs: + working-directory: + description: Project directory relative to the checkout. + default: . + php-version: + description: Explicit PHP version. Empty reads the project or repository .php-version, then setup-php platform detection. + default: "" + extensions: + description: PHP extensions forwarded to shivammathur/setup-php. + default: mbstring, dom, xml, xmlwriter, curl, zip, intl + coverage: + description: "Coverage driver: none, pcov or xdebug." + default: pcov + composer-directories: + description: JSON array of Composer project directories relative to working-directory. Empty installs the root project and tools/composer.json when present, without recursive discovery. + default: "" + install-dependencies: + description: Install project dependencies. Disable for jobs that only create an archive or install a separate consumer. + default: "true" + ignore-scripts: + description: Disable Composer installation scripts. Explicit Composer scripts run by later actions are unaffected. + default: "true" + ignore-plugins: + description: Disable Composer plugins during installation. Set false when needed; composer.json allow-plugins still controls which plugins may run. + default: "true" +outputs: + php-version: + description: Installed PHP major and minor version. + value: ${{ steps.configuration.outputs.php-version }} +runs: + using: composite + steps: + - id: directory + uses: hoverkraft-tech/ci-github-common/actions/working-directory@5fbb531db839a26ebcc35c4946be279f945f7633 # 1.0.0 + with: + working-directory: ${{ inputs.working-directory }} + - id: version-file + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + DIRECTORY: ${{ steps.directory.outputs.absolute-path }} + with: + script: |- + const fs = require('node:fs'); + const path = require('node:path'); + const local = path.join(process.env.DIRECTORY, '.php-version'); + core.setOutput('path', fs.existsSync(local) ? local : path.join(process.env.GITHUB_WORKSPACE, '.php-version')); + - id: php + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + env: + COMPOSER_PROJECT_DIR: ${{ steps.directory.outputs.absolute-path }} + with: + php-version: ${{ inputs.php-version }} + php-version-file: ${{ steps.version-file.outputs.path }} + tools: composer:2.11.0 + extensions: ${{ inputs.extensions }} + coverage: ${{ inputs.coverage }} + - id: configuration + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + WORKING_DIRECTORY: ${{ steps.directory.outputs.absolute-path }} + COMPOSER_DIRECTORIES: ${{ inputs.composer-directories }} + INSTALL_DEPENDENCIES: ${{ inputs.install-dependencies }} + IGNORE_SCRIPTS: ${{ inputs.ignore-scripts }} + IGNORE_PLUGINS: ${{ inputs.ignore-plugins }} + with: + script: |- + const fs = require('node:fs'); + const path = require('node:path'); + for (const key of ['INSTALL_DEPENDENCIES', 'IGNORE_SCRIPTS', 'IGNORE_PLUGINS']) { + if (!['true', 'false'].includes(process.env[key])) throw new Error(key + ' must be true or false.'); + } + const root = fs.realpathSync(process.env.WORKING_DIRECTORY); + const configured = process.env.COMPOSER_DIRECTORIES.trim(); + const directories = configured ? JSON.parse(configured) : ['.']; + if (!configured && fs.statSync(path.join(root, 'tools', 'composer.json'), { throwIfNoEntry: false })?.isFile()) directories.push('tools'); + if (!Array.isArray(directories) || !directories.length || directories.some(value => typeof value !== 'string' || !value || path.isAbsolute(value))) { + throw new Error('composer-directories must be a nonempty JSON array of relative directories.'); + } + const resolved = directories.map(directory => { + const absolute = fs.realpathSync(path.resolve(root, directory)); + if (absolute !== root && !absolute.startsWith(root + path.sep)) throw new Error('Composer directories must stay inside working-directory.'); + if (!fs.statSync(path.join(absolute, 'composer.json'), { throwIfNoEntry: false })?.isFile()) throw new Error('Missing composer.json in ' + directory); + return absolute; + }); + if (new Set(resolved).size !== resolved.length) throw new Error('Composer directories must be unique.'); + const cache = await exec.getExecOutput('composer', ['--no-plugins', 'config', 'cache-files-dir'], { cwd: root, silent: true }); + const php = await exec.getExecOutput('php', ['-r', 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;'], { silent: true }); + core.setOutput('directories', resolved); + core.setOutput('cache', cache.stdout.trim()); + core.setOutput('php-version', php.stdout.trim()); + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: ${{ steps.configuration.outputs.cache }} + key: composer-${{ runner.os }}-${{ steps.configuration.outputs.php-version }}-${{ hashFiles(format('{0}/**/composer.json', inputs.working-directory), format('{0}/**/composer.lock', inputs.working-directory)) }} + restore-keys: composer-${{ runner.os }}-${{ steps.configuration.outputs.php-version }}- + - id: install + if: inputs.install-dependencies == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + DIRECTORIES: ${{ steps.configuration.outputs.directories }} + IGNORE_SCRIPTS: ${{ inputs.ignore-scripts }} + IGNORE_PLUGINS: ${{ inputs.ignore-plugins }} + with: + script: |- + for (const directory of JSON.parse(process.env.DIRECTORIES)) { + const flags = ['--no-interaction']; + if (process.env.IGNORE_PLUGINS === 'true') flags.push('--no-plugins'); + await exec.exec('composer', [...flags, 'validate', '--no-check-publish'], { cwd: directory }); + const install = [...flags, 'install', '--prefer-dist', '--no-progress', '--optimize-autoloader']; + if (process.env.IGNORE_SCRIPTS === 'true') install.push('--no-scripts'); + await exec.exec('composer', install, { cwd: directory }); + await exec.exec('composer', [...flags, 'check-platform-reqs'], { cwd: directory }); + } diff --git a/actions/test/README.md b/actions/test/README.md new file mode 100644 index 0000000..bbe7133 --- /dev/null +++ b/actions/test/README.md @@ -0,0 +1,69 @@ + + +# ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItcGFja2FnZSIgY29sb3I9ImJsdWUiPjxsaW5lIHgxPSIxNi41IiB5MT0iOS40IiB4Mj0iNy41IiB5Mj0iNC4yMSI+PC9saW5lPjxwYXRoIGQ9Ik0yMSAxNlY4YTIgMiAwIDAgMC0xLTEuNzNsLTctNGEyIDIgMCAwIDAtMiAwbC03IDRBMiAyIDAgMCAwIDMgOHY4YTIgMiAwIDAgMCAxIDEuNzNsNyA0YTIgMiAwIDAgMCAyIDBsNy00QTIgMiAwIDAgMCAyMSAxNnoiPjwvcGF0aD48cG9seWxpbmUgcG9pbnRzPSIzLjI3IDYuOTYgMTIgMTIuMDEgMjAuNzMgNi45NiI+PC9wb2x5bGluZT48bGluZSB4MT0iMTIiIHkxPSIyMi4wOCIgeDI9IjEyIiB5Mj0iMTIiPjwvbGluZT48L3N2Zz4=) GitHub Action: Test PHP + + + + +## Overview + +Run Composer tests and report results, optionally uploading coverage. + + + + +## Usage + +```yaml +- uses: hoverkraft-tech/ci-github-php/actions/test@ + with: + # Composer project directory. Run setup-php first to install its dependencies. + # Default: `.` + working-directory: . + + # Composer script to execute unchanged, normally `test:ci` in composer.json. + # The project defines its test tool, preparation and report generation. Examples: + # + # - PHPUnit: `phpunit --configuration phpunit.xml --log-junit=junit.xml --coverage-cobertura=cobertura.xml` + # - Pest: `pest --ci --log-junit=junit.xml --coverage-cobertura=cobertura.xml` + # - Codeception: `codecept run --xml=junit.xml --coverage-xml=clover.xml`; + # enable coverage in codeception.yml and select `coverage: codecov` for its Clover XML. + # + # For Codecov, PHPUnit and Pest can use `--coverage-clover=clover.xml`. + # Omit coverage arguments when collection is not needed. The coverage input controls publication only. + # See the [CI guide](../../.github/workflows/continuous-integration.md). + # + # Default: `test:ci` + command: test:ci + + # Coverage destination: `github` for report parsing, `codecov` for OIDC upload, or empty to disable coverage publication. Coverage files must be produced by the test command. + # Default: `github` + coverage: github +``` + + + + +## Inputs + +| **Input** | **Description** | **Required** | **Default** | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------- | +| **`working-directory`** | Composer project directory. Run setup-php first to install its dependencies. | **false** | `.` | +| **`command`** | Composer script to execute unchanged, normally `test:ci` in composer.json. | **false** | `test:ci` | +| | The project defines its test tool, preparation and report generation. Examples: | | | +| | | | | +| | - PHPUnit: `phpunit --configuration phpunit.xml --log-junit=junit.xml --coverage-cobertura=cobertura.xml` | | | +| | - Pest: `pest --ci --log-junit=junit.xml --coverage-cobertura=cobertura.xml` | | | +| | - Codeception: `codecept run --xml=junit.xml --coverage-xml=clover.xml`; | | | +| | enable coverage in codeception.yml and select `coverage: codecov` for its Clover XML. | | | +| | | | | +| | For Codecov, PHPUnit and Pest can use `--coverage-clover=clover.xml`. | | | +| | Omit coverage arguments when collection is not needed. The coverage input controls publication only. | | | +| | See the [CI guide](../../.github/workflows/continuous-integration.md). | | | +| **`coverage`** | Coverage destination: `github` for report parsing, `codecov` for OIDC upload, or empty to disable coverage publication. Coverage files must be produced by the test command. | **false** | `github` | + + + + + + diff --git a/actions/test/action.yml b/actions/test/action.yml new file mode 100644 index 0000000..723f901 --- /dev/null +++ b/actions/test/action.yml @@ -0,0 +1,100 @@ +name: Test PHP +description: Run Composer tests and report results, optionally uploading coverage. +author: hoverkraft +branding: + icon: package + color: blue +inputs: + working-directory: + description: Composer project directory. Run setup-php first to install its dependencies. + default: . + command: + description: | + Composer script to execute unchanged, normally `test:ci` in composer.json. + The project defines its test tool, preparation and report generation. Examples: + + - PHPUnit: `phpunit --configuration phpunit.xml --log-junit=junit.xml --coverage-cobertura=cobertura.xml` + - Pest: `pest --ci --log-junit=junit.xml --coverage-cobertura=cobertura.xml` + - Codeception: `codecept run --xml=junit.xml --coverage-xml=clover.xml`; + enable coverage in codeception.yml and select `coverage: codecov` for its Clover XML. + + For Codecov, PHPUnit and Pest can use `--coverage-clover=clover.xml`. + Omit coverage arguments when collection is not needed. The coverage input controls publication only. + See the [CI guide](../../.github/workflows/continuous-integration.md). + default: test:ci + coverage: + description: "Coverage destination: `github` for report parsing, `codecov` for OIDC upload, or empty to disable coverage publication. Coverage files must be produced by the test command." + default: github +runs: + using: composite + steps: + - id: directory + uses: hoverkraft-tech/ci-github-common/actions/working-directory@5fbb531db839a26ebcc35c4946be279f945f7633 # 1.0.0 + with: + working-directory: ${{ inputs.working-directory }} + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 + env: + COVERAGE: ${{ inputs.coverage }} + with: + script: |- + if (!['', 'github', 'codecov'].includes(process.env.COVERAGE)) throw new Error('coverage must be github, codecov or empty.'); + - id: run + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + WORKING_DIRECTORY: ${{ steps.directory.outputs.absolute-path }} + COMMAND: ${{ inputs.command }} + with: + script: |- + const command = process.env.COMMAND.trim(); + if (!command || command.startsWith('-')) throw new Error('command must name a Composer script.'); + const args = ['--no-interaction', 'run-script', '--timeout=0', command]; + await exec.exec('composer', args, {cwd: process.env.WORKING_DIRECTORY, env: {...process.env, CI: 'true'}}); + - name: Report test results + if: ${{ !cancelled() && steps.directory.outcome == 'success' }} + uses: hoverkraft-tech/ci-github-common/actions/quality/parse-ci-reports@5fbb531db839a26ebcc35c4946be279f945f7633 # 1.0.0 + with: + working-directory: ${{ steps.directory.outputs.absolute-path }} + report-paths: ${{ inputs.coverage == 'github' && 'auto:test,auto:coverage' || 'auto:test' }} + report-name: PHP tests + output-format: annotations,summary + - name: Find coverage reports + id: coverage-reports + if: "!cancelled() && steps.run.outcome != 'skipped' && inputs.coverage == 'codecov'" + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + WORKING_DIRECTORY: ${{ steps.directory.outputs.absolute-path }} + with: + script: |- + const fs = require('node:fs/promises'); + const directory = process.env.WORKING_DIRECTORY; + const reports = {clover: [], cobertura: []}; + const search = await glob.create([ + `${directory}/**/*.xml`, + `!${directory}/**/vendor/**`, + `!${directory}/**/node_modules/**`, + `!${directory}/**/.git/**`, + ].join('\n'), {followSymbolicLinks: false, matchDirectories: false}); + for await (const file of search.globGenerator()) { + const handle = await fs.open(file, 'r'); + let header; + try { + const {buffer, bytesRead} = await handle.read(Buffer.alloc(8192), 0, 8192, 0); + header = buffer.toString('utf8', 0, bytesRead); + } finally { + await handle.close(); + } + if (/]*>\s*]*\bline-rate=/.test(header)) reports.cobertura.push(file); + } + const files = reports.clover.length ? reports.clover : reports.cobertura; + if (!files.length) throw new Error('No Clover or Cobertura coverage reports found. Enable coverage in the Composer test script.'); + if (files.some(file => file.includes(','))) throw new Error('Coverage report paths must not contain commas.'); + core.info(`Uploading ${files.length} ${reports.clover.length ? 'Clover' : 'Cobertura'} coverage report(s).`); + core.setOutput('files', files.sort().join(',')); + - name: Upload coverage + if: "!cancelled() && steps.coverage-reports.outcome == 'success'" + uses: hoverkraft-tech/ci-github-common/actions/quality/codecov@5fbb531db839a26ebcc35c4946be279f945f7633 # 1.0.0 + with: + working-directory: ${{ steps.directory.outputs.absolute-path }} + files: ${{ steps.coverage-reports.outputs.files }} + fail-ci-if-error: "true" diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..825a8d6 --- /dev/null +++ b/biome.json @@ -0,0 +1,13 @@ +{ + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!!.devcontainer/devcontainer-lock.json"] + }, + "formatter": { + "indentStyle": "space" + } +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..379578f --- /dev/null +++ b/docs/README.md @@ -0,0 +1,5 @@ +# Documentation + +- [Continuous integration](../.github/workflows/continuous-integration.md): configure PHP and Composer checks. +- [Development](development/README.md): fixtures, validation, and documentation generation. +- [Architecture decision](adr/0001-php-package-workflows.md): scope, trade-offs, and candidate findings. diff --git a/docs/adr/0001-php-package-workflows.md b/docs/adr/0001-php-package-workflows.md new file mode 100644 index 0000000..6a89331 --- /dev/null +++ b/docs/adr/0001-php-package-workflows.md @@ -0,0 +1,108 @@ +# ADR 0001: Composer CI and source releases + +- Status: Proposed +- Date: 2026-11-05 + +## Context + +The PHP workflow prototype mixed runtime detection, dependency installation, and +individual tool detection. Its integration workflow still referred to Node.js +fixtures, and its PHP CodeQL configuration could not run. The Node.js workflows +provide the desired separation between validation, package creation, and release. + +The five initial candidates are Composer libraries rather than deployable web +applications. Each supports PHP 8.2 through 8.5, has a separate `tools/composer.json`, +and does not commit a Composer lockfile. Their Composer scripts already describe +lint and test commands. + +## Decision + +Use Composer scripts as the project interface. Setup installs one or more Composer +projects, caches downloads, and disables installation scripts and plugins by +default. Reuse `ci-github-common` for reports and `ci-github-publish` for release +planning, configuration, summaries, and publication. Default dependency discovery +is bounded to the root and `tools/composer.json`; explicit directories override it. +CI groups command, coverage publication and cache options under `test`. Linux +runner selection applies to all jobs; `checks: false` skips validation. Action +integration workflows each target a single action. Projects define `lint:ci` +and `test:ci` Composer scripts, including their report formats and preparation. +The action adds no test-runner arguments. Only the primary PHP version publishes +coverage; the project command controls collection across all tested versions. + +The follow-up review on 2026-11-06 supersedes the earlier proposed PHPUnit argument +injection, test-runner switch and generic PHP build action. None of the five +candidates used that build contract. Composer scripts and dedicated caller jobs +already handle project-specific generated files. The CI guide is maintained in +the workflow's leading comments and generated into its adjacent Markdown file. + +Use the shared release planner and creator. Verify the Composer manifest and tag +identity in the PHP release workflow. The current shared verifier requires artifacts, +so it cannot serve tag-only Composer releases without an upstream interface change. +Use `ci-github-common/actions/quality/codecov` for coverage upload and dependency setup; +test commands own report generation and format selection. + +Keep CI independent from publication. CI validates application source or a public +source package. Package checks install the committed archive +as an isolated production dependency and allow a small project-specific smoke test. + +Plan versions inside the single release workflow. The caller gates publication on +successful validation with `needs`. A no-change run still validates, then skips +publication. An existing tag must resolve to the validated source commit; retries +never move tags. Caller concurrency covers validation and publication, while the +reusable publisher serializes publication. + +The 2026-11-06 review supersedes the proposed separate planning workflow and archive +handoff. Composer publication requires a source tag, not a package upload. Distribution +checks remain optional CI behavior: the test action creates and cleans its own +snapshot without a public creation action or artifact contract. + +Composer resolves library versions from Git tags. Omit `composer.json`'s `version` +field and avoid an npm-style version commit or preparation PR. GitHub release notes +hold the changelog; no `CHANGELOG.md` is generated. A GitHub App is not required for +this package release flow, subject to the caller's tag rules and repository access. + +The initial package scope is one public Composer package at a Git repository root. +Private registry authentication, splitting monorepos, prerelease channels, and +application deployment targets need concrete consumers before adding interfaces. + +## Alternatives + +Copying npm's version-update step would introduce source churn that Composer does +not require. Packaging build output would test a different distribution from the +one Packagist obtains from GitHub. Automatic discovery of individual lint tools +would duplicate project scripts and fail for separate tool installations. + +## Consequences + +Source packages must contain their runtime files in Git and use `.gitattributes` +to control distribution. Applications supply their own generation and deployment +jobs when needed. Projects requiring Composer installation hooks or plugins opt in. +A configured Packagist integration is needed to discover newly created tags. + +## Candidate findings + +The initial audit and local source-archive installation checks were performed on +2026-11-05. These are dated design evidence; caller PR checks provide current +integration results. + +| Candidate | Baseline commit | Additional consumer check | +| ------------------------------------- | ------------------------------------------ | --------------------------------------------------- | +| `neilime/twbs-helper-module` | `13252379e4ca357691ad3f3af56f925406d573a6` | Load module configuration and packaged templates | +| `neilime/ansi-to-html-twig-extension` | `47fbe480819bf5913f830f93b08f476fd9093988` | Render ANSI colors through Twig | +| `neilime/ansi-escapes-to-html` | `587947f13b98ab8018a5d043e74d9a23f4765b83` | Convert ANSI color codes | +| `neilime/geshi-twig-extension` | `5991c622dbc1928c1d18170c7800954332ceb974` | Render highlighted code through Twig and GeSHi | +| `neilime/php-css-lint` | `1b1a322e2ad5053e0293b2f024cc6af56c7b9b80` | Run the installed CLI against valid and invalid CSS | + +The CLI's test fixtures require network downloads and retain their existing cache. +The module's documentation build remains an independent validation job. +Four candidates share phpDocumentor API generation. A dedicated build workflow +provides its artifact for PR validation and separate site deployment; the module's +custom Markdown and Docusaurus generation remains project-owned. + +## Sources + +- [Composer libraries and versioning](https://getcomposer.org/doc/02-libraries.md) +- [Composer script and plugin execution](https://getcomposer.org/doc/faqs/how-to-install-untrusted-packages-safely.md) +- [PHP setup action](https://github.com/shivammathur/setup-php) +- [Packagist publishing](https://packagist.org/about) +- [GitHub language support](https://docs.github.com/en/get-started/learning-about-github/github-language-support) diff --git a/docs/development/README.md b/docs/development/README.md new file mode 100644 index 0000000..1782773 --- /dev/null +++ b/docs/development/README.md @@ -0,0 +1,53 @@ +# Development + +Actions are standalone. Prefer `actions/github-script` and its supplied `core`, +`exec`, `io`, and `glob` helpers. Composer metadata validation needs no dependency +installation. Release planning and creation use `ci-github-publish`; the release +workflow validates Composer metadata and tag identity before publication. + +## Validation + +`__shared-ci.yml` runs lint, dedicated workflows for each action, and an end-to-end call to the +public CI and documentation workflows. The Composer fixture covers automatic and explicit tool installation, +lockfile and lockfile-free setup, installation hooks, PHPUnit reports, and PHPStan. +Test-action cases cover Composer-owned report generation, coverage publication, +preparation, custom commands, and failure propagation. Package integration tests create independent Git +repositories and exercise distribution omissions, consumer commands, archive +immutability, and cleanup. + +Release guard checks live in `__test-workflow-release.yml`. They exercise the +actual workflow scripts with mocked GitHub responses: event restrictions, no +changes, force, tag conflicts and retries. They never create a tag or release. + +With PHP, Composer and Git installed, run: + +```sh +make setup +make test +``` + +`setup` installs dependencies for tracked Composer fixtures with scripts and plugins +disabled. `test` runs fixture lint and PHPUnit. `deps-update` refreshes their +lockfiles, with `deps-update-interactive` for selecting updates; `deps-audit` reports dependency advisories without changing files. +`make ci` installs, audits, tests and lints; it does not automatically fix files. + +Run `make lint` for the repository's Docker lint checks. PHP analysis runs in the +Composer integration workflow, using its declared runtime and tools; the generic +linter does not reinstall the PHP fixtures with its own runtime. Checkov skips +third-party `vendor/` contents installed by local fixture setup. Release integration tests +must not publish tags or releases in consumer repositories. Validate planning and +publication guards independently, and use caller pull requests for real CI runs. + +## Documentation and updates + +Action readmes and workflow reference sections are generated by CI Dokumentor +through `ci-github-publish/.github/workflows/release-actions.yml`. Update metadata, +then regenerate using that tool. The CI and release workflows include their authored guides in leading comments; +regeneration writes them to the corresponding `.github/workflows/*.md` files. + +Dependabot updates GitHub Actions, Composer fixtures, containers, and development +containers. Renovate follows the common Hoverkraft maintenance workflow for +Docker action references, the root `.php-version`, and the Composer bootstrap +version. The maintenance workflow receives its GitHub App credentials from this +repository's CI bot configuration; public reusable workflows do not assume those +variables or secrets exist in callers. diff --git a/tests/php-project/.php-cs-fixer.php b/tests/php-project/.php-cs-fixer.php new file mode 100644 index 0000000..8687bf5 --- /dev/null +++ b/tests/php-project/.php-cs-fixer.php @@ -0,0 +1,17 @@ +in(__DIR__ . '/src') + ->in(__DIR__ . '/tests'); + +$config = new PhpCsFixer\Config(); +return $config + ->setRules([ + '@PSR12' => true, + 'array_syntax' => ['syntax' => 'short'], + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'no_unused_imports' => true, + 'trailing_comma_in_multiline' => true, + ]) + ->setFinder($finder) + ->setCacheFile(__DIR__ . '/.php-cs-fixer.cache'); diff --git a/tests/php-project/README.md b/tests/php-project/README.md new file mode 100644 index 0000000..3583f6f --- /dev/null +++ b/tests/php-project/README.md @@ -0,0 +1,6 @@ +# Composer integration fixture + +A minimal Composer project with independent developer tools and PHPUnit tests. +Composer scripts own test execution and report generation. Used by the repository's action and reusable workflow tests. + +See the [development guide](../../docs/development/README.md) for validation commands. diff --git a/tests/php-project/composer.json b/tests/php-project/composer.json new file mode 100644 index 0000000..8e064c6 --- /dev/null +++ b/tests/php-project/composer.json @@ -0,0 +1,29 @@ +{ + "name": "hoverkraft/test-ci-composer", + "description": "Test project for PHP CI workflow", + "type": "library", + "require": { + "php": "^8.2" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "autoload": { + "psr-4": { + "TestCi\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "TestCi\\Tests\\": "tests/" + } + }, + "scripts": { + "lint:ci": "tools/vendor/bin/phpstan analyse src --level=max --no-progress --error-format=github", + "test": "phpunit", + "post-install-cmd": "@php -r \"file_put_contents('install-hook.txt', 'hook executed');\"", + "post-update-cmd": "@post-install-cmd", + "test:ci": "@test --log-junit=junit.xml --coverage-cobertura=cobertura.xml" + }, + "license": "MIT" +} diff --git a/tests/php-project/composer.lock b/tests/php-project/composer.lock new file mode 100644 index 0000000..515c078 --- /dev/null +++ b/tests/php-project/composer.lock @@ -0,0 +1,1699 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "08ff23951e026e795039302c7e08969d", + "packages": [], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00", + "published-time": "2026-08-11T10:29:20+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.9.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" + }, + "time": "2026-09-13T18:51:52+00:00", + "published-time": "2026-09-13T18:53:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00", + "published-time": "2024-03-03T12:39:44+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00", + "published-time": "2022-02-21T01:04:30+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "10.1.16", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=8.1", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-text-template": "^3.0.1", + "sebastian/code-unit-reverse-lookup": "^3.0.0", + "sebastian/complexity": "^3.2.0", + "sebastian/environment": "^6.1.0", + "sebastian/lines-of-code": "^2.0.2", + "sebastian/version": "^4.0.1", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^10.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:31:57+00:00", + "published-time": "2024-08-22T04:32:20+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T06:24:48+00:00", + "published-time": "2023-08-31T06:25:42+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:56:09+00:00", + "published-time": "2023-02-03T06:56:14+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T14:07:24+00:00", + "published-time": "2023-08-31T14:08:05+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:57:52+00:00", + "published-time": "2023-02-03T06:57:58+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "10.5.66", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "747c9889613d0b3ffa08b74272e50ed3b8fa6d72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/747c9889613d0b3ffa08b74272e50ed3b8fa6d72", + "reference": "747c9889613d0b3ffa08b74272e50ed3b8fa6d72", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.1", + "phpunit/php-code-coverage": "^10.1.16", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-invoker": "^4.0.0", + "phpunit/php-text-template": "^3.0.1", + "phpunit/php-timer": "^6.0.0", + "sebastian/cli-parser": "^2.0.1", + "sebastian/code-unit": "^2.0.0", + "sebastian/comparator": "^5.0.5", + "sebastian/diff": "^5.1.1", + "sebastian/environment": "^6.1.0", + "sebastian/exporter": "^5.1.4", + "sebastian/global-state": "^6.0.2", + "sebastian/object-enumerator": "^5.0.0", + "sebastian/recursion-context": "^5.0.2", + "sebastian/type": "^4.0.0", + "sebastian/version": "^4.0.1" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.66" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-10-05T05:46:26+00:00", + "published-time": "2026-10-05T05:47:29+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:12:49+00:00", + "published-time": "2024-03-02T07:13:18+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:58:43+00:00", + "published-time": "2023-02-03T06:58:49+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:59:15+00:00", + "published-time": "2023-02-03T06:59:20+00:00" + }, + { + "name": "sebastian/comparator", + "version": "5.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/diff": "^5.0", + "sebastian/exporter": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:25:16+00:00", + "published-time": "2026-01-24T09:25:27+00:00" + }, + { + "name": "sebastian/complexity", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "68ff824baeae169ec9f2137158ee529584553799" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799", + "reference": "68ff824baeae169ec9f2137158ee529584553799", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:37:17+00:00", + "published-time": "2023-12-21T08:37:41+00:00" + }, + { + "name": "sebastian/diff", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "symfony/process": "^6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:15:17+00:00", + "published-time": "2024-03-02T07:15:52+00:00" + }, + { + "name": "sebastian/environment", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-23T08:47:14+00:00", + "published-time": "2024-03-23T08:48:26+00:00" + }, + { + "name": "sebastian/exporter", + "version": "5.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "0735b90f4da94969541dac1da743446e276defa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:09:11+00:00", + "published-time": "2025-09-24T06:10:37+00:00" + }, + { + "name": "sebastian/global-state", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:19:19+00:00", + "published-time": "2024-03-02T07:19:49+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:38:20+00:00", + "published-time": "2023-12-21T08:38:54+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:08:32+00:00", + "published-time": "2023-02-03T07:08:37+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:06:18+00:00", + "published-time": "2023-02-03T07:06:24+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "5d32fe257a9b39cb63146924d6b4e32a22d4502a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/5d32fe257a9b39cb63146924d6b4e32a22d4502a", + "reference": "5d32fe257a9b39cb63146924d6b4e32a22d4502a", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2026-08-11T05:27:39+00:00", + "published-time": "2026-08-11T05:28:01+00:00" + }, + { + "name": "sebastian/type", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:10:45+00:00", + "published-time": "2023-02-03T07:10:50+00:00" + }, + { + "name": "sebastian/version", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-07T11:34:05+00:00", + "published-time": "2023-02-07T11:34:22+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00", + "published-time": "2025-11-17T20:04:52+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^8.2" + }, + "platform-dev": {}, + "plugin-api-version": "2.11.0" +} diff --git a/tests/php-project/phpunit.xml b/tests/php-project/phpunit.xml new file mode 100644 index 0000000..ffcc4fc --- /dev/null +++ b/tests/php-project/phpunit.xml @@ -0,0 +1,17 @@ + + + + + tests + + + + + src + + + diff --git a/tests/php-project/src/Sample.php b/tests/php-project/src/Sample.php new file mode 100644 index 0000000..01a4682 --- /dev/null +++ b/tests/php-project/src/Sample.php @@ -0,0 +1,11 @@ +assertSame('sample', $sample->sample()); + } +} diff --git a/tests/php-project/tools/composer.json b/tests/php-project/tools/composer.json new file mode 100644 index 0000000..dbf6d59 --- /dev/null +++ b/tests/php-project/tools/composer.json @@ -0,0 +1,8 @@ +{ + "name": "ci/test-tools", + "description": "Independent Composer tooling fixture", + "license": "MIT", + "require-dev": { + "phpstan/phpstan": "^2.1" + } +} diff --git a/tests/php-project/tools/composer.lock b/tests/php-project/tools/composer.lock new file mode 100644 index 0000000..4e1e463 --- /dev/null +++ b/tests/php-project/tools/composer.lock @@ -0,0 +1,84 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "e8eaed660cc3cac8206ce8b37a7ea027", + "packages": [], + "packages-dev": [ + { + "name": "phpstan/phpstan", + "version": "2.3.5", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/645a1bd6bead15fc1b8474137467961d0fa30a79", + "reference": "645a1bd6bead15fc1b8474137467961d0fa30a79", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-11-03T12:14:22+00:00", + "published-time": "2026-11-03T12:14:36+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.11.0" +}