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" +}