diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml new file mode 100644 index 0000000..3090522 --- /dev/null +++ b/.github/workflows/continuous-integration.yml @@ -0,0 +1,256 @@ +name: "Continuous Integration" + +on: + workflow_call: + inputs: + php-versions: + description: "JSON array of PHP versions to run the matrix against." + type: string + required: false + default: '["8.2", "8.3", "8.4", "8.5"]' + run-integration: + description: "Run the integration test suite in addition to the unit suite." + type: boolean + required: false + default: false + composer-options: + description: "Extra flags passed to composer install." + type: string + required: false + default: "" + db-image: + description: "Container image for the DB service (e.g. mysql:8.0). Empty = no DB." + type: string + required: false + default: "" + db-env-json: + description: "JSON object of DB container env vars." + type: string + required: false + default: "{}" + db-port: + description: "Port to expose/map for the DB container." + type: string + required: false + default: "" + db-health-cmd: + description: "Command run via docker exec to check DB readiness." + type: string + required: false + default: "" + db-health-retries: + description: "Max DB health-check attempts. Raise for slow-starting engines (Oracle, MSSQL)." + type: number + required: false + default: 30 + db-health-interval-seconds: + description: "Seconds to sleep between DB health-check attempts." + type: number + required: false + default: 2 + enable-codecov: + description: "Turn on the codecov job." + type: boolean + required: false + default: false + enable-infection: + description: "Turn on the mutation-test job." + type: boolean + required: false + default: false + coverage-php-version: + description: "Which php-versions entry is canonical for coverage/mutation testing." + type: string + required: false + default: "" + secrets: + CODECOV_TOKEN: + required: false + INFECTION_DASHBOARD_API_KEY: + required: false + +jobs: + mago: + name: "Mago (PHP ${{ matrix.php }})" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ${{ fromJSON(inputs.php-versions) }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up PHP and Mago + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: mago + + - name: Install dependencies + run: composer install --no-interaction --no-progress ${{ inputs.composer-options }} + + # Overrides php-version from the consumer's mago.toml for each matrix leg + # without mutating the committed config file. + - name: Pin Mago PHP version + run: echo "MAGO_PHP_VERSION=${{ matrix.php }}" >> "$GITHUB_ENV" + + - name: Check formatting + run: mago format --check + + - name: Lint + if: success() || failure() + run: mago lint + + - name: Analyze + if: success() || failure() + run: mago analyze + + - name: Guard + if: success() || failure() + run: mago guard + + test: + name: "Test (PHP ${{ matrix.php }} | ${{ matrix.dependencies }})" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ${{ fromJSON(inputs.php-versions) }} + dependencies: ["lowest", "locked", "latest"] + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: ${{ (matrix.php == inputs.coverage-php-version && matrix.dependencies == 'locked') && 'pcov' || 'none' }} + + # DB service is a manual step, not a native `services:` block, because + # `services:` can't be conditionally omitted per input, and driver + # packages need different engines (mysql, postgres, mariadb, oracle, + # mssql, ...). Repos with no DB simply omit `db-image` (default ""), + # so both steps below are skipped at zero cost. + - name: Start DB service + if: inputs.db-image != '' + run: | + docker run -d --name db \ + -p ${{ inputs.db-port }}:${{ inputs.db-port }} \ + $(echo '${{ inputs.db-env-json }}' | jq -r 'to_entries[] | "-e \(.key)=\(.value)"' | tr '\n' ' ') \ + ${{ inputs.db-image }} + + - name: Wait for DB to be healthy + if: inputs.db-image != '' + run: | + for i in $(seq 1 ${{ inputs.db-health-retries }}); do + docker exec db ${{ inputs.db-health-cmd }} && exit 0 + sleep ${{ inputs.db-health-interval-seconds }} + done + echo "DB did not become healthy in time" && exit 1 + + # Resolve dependencies at the configured strategy level. + # "locked" uses the committed composer.lock as-is. + - name: Install dependencies (lowest) + if: matrix.dependencies == 'lowest' + run: composer update --prefer-lowest --no-interaction --no-progress ${{ inputs.composer-options }} + + - name: Install dependencies (locked) + if: matrix.dependencies == 'locked' + run: composer install --no-interaction --no-progress ${{ inputs.composer-options }} + + - name: Install dependencies (latest) + if: matrix.dependencies == 'latest' + run: composer update --no-interaction --no-progress ${{ inputs.composer-options }} + + - name: Unit tests + if: ${{ !(matrix.php == inputs.coverage-php-version && matrix.dependencies == 'locked') }} + run: composer test + + - name: Unit tests with coverage + if: matrix.php == inputs.coverage-php-version && matrix.dependencies == 'locked' + run: composer test-coverage + + - name: Integration tests + if: inputs.run-integration + run: composer test-integration + + - name: Upload coverage artifact + if: matrix.php == inputs.coverage-php-version && matrix.dependencies == 'locked' + uses: actions/upload-artifact@v4 + with: + name: coverage-clover + path: clover.xml + + codecov: + name: "Codecov" + needs: [test] + if: inputs.enable-codecov + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download coverage artifact + uses: actions/download-artifact@v4 + with: + name: coverage-clover + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + # CODECOV_TOKEN is an org-wide upload token, which can't infer the + # target repo the way a repo-specific token can. `github.repository` + # resolves to the *calling* repo here, not phpdb-qa-tools. + slug: ${{ github.repository }} + files: clover.xml + fail_ci_if_error: true + + mutation-test: + name: "Mutation testing" + needs: [test] + if: inputs.enable-infection + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up PHP and Mago + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.coverage-php-version }} + coverage: pcov + tools: mago + + - name: Start DB service + if: inputs.db-image != '' && inputs.run-integration + run: | + docker run -d --name db \ + -p ${{ inputs.db-port }}:${{ inputs.db-port }} \ + $(echo '${{ inputs.db-env-json }}' | jq -r 'to_entries[] | "-e \(.key)=\(.value)"' | tr '\n' ' ') \ + ${{ inputs.db-image }} + + - name: Wait for DB to be healthy + if: inputs.db-image != '' && inputs.run-integration + run: | + for i in $(seq 1 ${{ inputs.db-health-retries }}); do + docker exec db ${{ inputs.db-health-cmd }} && exit 0 + sleep ${{ inputs.db-health-interval-seconds }} + done + echo "DB did not become healthy in time" && exit 1 + + - name: Install dependencies + run: composer install --no-interaction --no-progress ${{ inputs.composer-options }} + + - name: Pin Mago PHP version + run: echo "MAGO_PHP_VERSION=${{ inputs.coverage-php-version }}" >> "$GITHUB_ENV" + + # Mago is invoked internally by Infection itself (staticAnalysisTool config + # in infection.json5), not by this workflow directly — no needs:[mago] here. + - name: Mutation testing + env: + INFECTION_DASHBOARD_API_KEY: ${{ secrets.INFECTION_DASHBOARD_API_KEY }} + run: composer mutation-test diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml deleted file mode 100644 index de3425c..0000000 --- a/.github/workflows/qa.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: "QA" - -on: - workflow_call: - inputs: - php-versions: - description: "JSON array of PHP versions to run the matrix against." - type: string - required: false - default: '["8.2", "8.3", "8.4", "8.5"]' - run-integration: - description: "Run the integration test suite in addition to the unit suite." - type: boolean - required: false - default: false - composer-options: - description: "Extra flags passed to composer install." - type: string - required: false - default: "" - -jobs: - qa: - name: "PHP ${{ matrix.php }}" - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - php: ${{ fromJSON(inputs.php-versions) }} - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Set up PHP and Mago - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - coverage: none - tools: mago - - - name: Install dependencies - run: composer install --no-interaction --no-progress ${{ inputs.composer-options }} - - # Overrides php-version from the consumer's mago.toml for each matrix leg - # without mutating the committed config file. - - name: Pin Mago PHP version - run: echo "MAGO_PHP_VERSION=${{ matrix.php }}" >> "$GITHUB_ENV" - - - name: Check coding standards - run: composer cs-check - - - name: Static analysis - if: success() || failure() - run: composer static-analysis - - - name: Unit tests - if: success() || failure() - run: composer test - - - name: Integration tests - if: inputs.run-integration && (success() || failure()) - run: composer test-integration diff --git a/README.md b/README.md index 7063cb5..3c9d5a0 100644 --- a/README.md +++ b/README.md @@ -83,12 +83,16 @@ Add the standard scripts to your `composer.json`: ### 4. CI -This repository ships a reusable QA workflow ([`.github/workflows/qa.yml`](.github/workflows/qa.yml)). -A consuming repository's entire CI file becomes: +This repository ships a reusable CI workflow +([`.github/workflows/continuous-integration.yml`](.github/workflows/continuous-integration.yml)) +with four jobs: `mago` (format/lint/analyze/guard), `test` (unit + optional +integration, across a `php x [lowest, locked, latest]` matrix), and two +optional downstream jobs, `codecov` and `mutation-test`, both gated on +`test` succeeding. A consuming repository's entire CI file becomes: ```yaml -# .github/workflows/ci.yml -name: CI +# .github/workflows/continuous-integration.yml +name: "Continuous Integration" on: push: @@ -96,20 +100,27 @@ on: jobs: qa: - uses: php-db/phpdb-qa-tools/.github/workflows/qa.yml@main + uses: php-db/phpdb-qa-tools/.github/workflows/continuous-integration.yml@main + secrets: inherit with: php-versions: '["8.2", "8.3", "8.4", "8.5"]' run-integration: false + # DB service (only needed if run-integration: true); see + # docs/workflow-architecture.md for the full input list. + db-image: "" + enable-codecov: false + enable-infection: false ``` -The workflow installs Mago, then runs `composer cs-check`, `composer static-analysis`, -and `composer test` across the PHP version matrix. Pin `@main` to a tag (e.g. `@1.0.0`) -once released. +See [Workflow architecture](docs/workflow-architecture.md) for the full job +graph, the DB-service mechanics, and the Codecov/Infection secrets wiring. +Pin `@main` to a tag (e.g. `@1.0.0`) once released. ## Documentation - [Migration guide](docs/migration.md) — moving a repository off laminas-coding-standard. - [Rule rationale](docs/rules.md) — why the non-default choices are what they are. +- [Workflow architecture](docs/workflow-architecture.md) — job-split design for DB-backed integration tests, Codecov, and Infection. ## License diff --git a/docs/workflow-architecture.md b/docs/workflow-architecture.md new file mode 100644 index 0000000..31681e0 --- /dev/null +++ b/docs/workflow-architecture.md @@ -0,0 +1,160 @@ +# Workflow architecture + +This document describes the job-split design implemented in +[`continuous-integration.yml`](../.github/workflows/continuous-integration.yml) +to support driver packages that need a seeded RDBMS for integration testing, +plus optional Codecov and Infection mutation testing. + +## Job graph + +Only genuine data/gating dependencies are expressed via `needs:`; everything +else runs in parallel for speed. + +```mermaid +graph LR + mago[mago job] + test[test job] --> codecov[codecov job] + test --> infection[mutation-test job] +``` + +- `mago` and `test` have **no `needs:`** between them — they're independent + gates, neither consumes the other's output. +- `codecov` and `mutation-test` both **`needs: [test]`** — real dependencies + (artifact consumption / gating), not just ordering preference. + +## `mago` job + +Matrix: `php-versions` only. Runs `mago format --check`, `mago lint`, +`mago analyze`, `mago guard`. No DB, no dependency-strategy matrix — the +committed `composer.lock` is enough for type resolution. + +## `test` job + +Matrix: `php x [lowest, locked, latest]`. + +### DB service (manual step, not native `services:`) + +Native job-level `services:` blocks *can* be conditionally disabled (an +empty `image:` expression means the service won't start — see +[GitHub's docs](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idservicesservice_idimage)), +so conditionality alone isn't why this uses a manual step. The real +constraint: `services..env` is a **static YAML map** — its *keys* must +be fixed at authoring time, and expressions can only substitute values, not +key names. Different engines need different env var names entirely +(MySQL's `MYSQL_ROOT_HOST`/`MYSQL_DATABASE`, Postgres's +`POSTGRES_PASSWORD`/`POSTGRES_DB`, ...), so a single generic `db-env-json` +input (arbitrary keys, unknown to the workflow until runtime) can't be +expressed through native `services:` at all — only a step that parses JSON +at runtime (the `jq` unpacking below) can. + +**This is deliberately engine-agnostic** — the workflow never hardcodes +MySQL (or any other engine). `php-db/phpdb` (the base abstraction package, +no RDBMS at all) simply never sets `db-image` and pays zero cost. Every +driver package (MySQL, Postgres, MariaDB, Oracle, MSSQL, ...) supplies its +own image/env/port/health-command; SQLite drivers need no service at all +(embedded, no server) and also just omit `db-image`. The shared workflow +never needs to enumerate or special-case any specific engine. + +```yaml +- name: Start DB service + if: inputs.db-image != '' + run: | + docker run -d --name db \ + -p ${{ inputs.db-port }}:${{ inputs.db-port }} \ + $(echo '${{ inputs.db-env-json }}' | jq -r 'to_entries[] | "-e \(.key)=\(.value)"' | tr '\n' ' ') \ + ${{ inputs.db-image }} + +- name: Wait for DB to be healthy + if: inputs.db-image != '' + run: | + for i in $(seq 1 ${{ inputs.db-health-retries }}); do + docker exec db ${{ inputs.db-health-cmd }} && exit 0 + sleep ${{ inputs.db-health-interval-seconds }} + done + echo "DB did not become healthy in time" && exit 1 +``` + +`jq` unpacks the `db-env-json` map into `-e KEY=VALUE` flags since `docker +run` doesn't accept JSON directly. The health check polls via `docker exec` +in a retry loop rather than Docker's native `HEALTHCHECK`, so it works the +same regardless of image. No explicit teardown is needed — GitHub-hosted +runners are ephemeral. + +`db-health-retries` / `db-health-interval-seconds` default to `30` / `2` +(60s total — plenty for MySQL/Postgres/MariaDB) but are overridable per +caller, since Oracle/MSSQL images can take several minutes to become ready. + +Repos with no DB simply omit `db-image` (default `""`); both steps are +skipped, zero cost. + +### Coverage + +Unit tests always run; integration tests run `if: inputs.run-integration`. +On exactly one canonical leg (`matrix.php == inputs.coverage-php-version && +matrix.dependencies == 'locked'`), coverage is collected (pcov → `clover.xml`) +and uploaded via `actions/upload-artifact` — the only leg downstream jobs +need. + +## `codecov` job + +`needs: [test]`, job-level `if: inputs.enable-codecov`. Downloads the +`clover.xml` artifact and runs `codecov/codecov-action`. No PHP setup, no DB. + +`CODECOV_TOKEN` is an org-wide upload token (php-db org), so it can't infer +the target repo on its own — pass `slug: ${{ github.repository }}`. Inside a +*reusable* workflow, `github.repository` already resolves to the **calling** +repo, so this works with no extra input. + +## `mutation-test` job + +`needs: [test]` (gating — skip if base tests already failed). Job-level +`if: inputs.enable-infection`. Needs its own full environment (checkout, +setup-php **with `tools: mago`**, `composer install --locked`, the same +conditional DB-startup step as `test` if `run-integration`) since Infection +re-executes the suite per mutant — it can't just consume `test`'s artifact +the way `codecov` does. + +**No `needs: [mago]`.** `infection.json5`'s `staticAnalysisTool: "mago"` +makes Infection invoke `mago analyze` internally against mutants that escape +the test suite — that's a tooling requirement inside this job (the `mago` +binary + the repo's own `mago.toml`), not a cross-job dependency on the +`mago` job. + +`INFECTION_DASHBOARD_API_KEY` is a per-repo secret (one per driver package, +generated by registering the repo at dashboard.stryker-mutator.io). Either +`INFECTION_DASHBOARD_API_KEY` or `STRYKER_DASHBOARD_API_KEY` works as the env +var name. **Must be passed as an `env:` block on the `run:` step, not as a +`with:` input** — Infection reads it from the environment, not a CLI flag. + +## Secrets + +Because `CODECOV_TOKEN` is org-scoped and `INFECTION_DASHBOARD_API_KEY` is +repo-scoped, each consuming repo's caller workflow should invoke this +reusable workflow with `secrets: inherit` rather than an explicit per-secret +mapping — it transparently pulls from whichever scope actually defines each +secret. Cross-repo `secrets: inherit` works for reusable workflows called +within the same GitHub org. + +## Inputs + +| Input | Purpose | +|---|---| +| `db-image` | Container image for the DB service (e.g. `mysql:8.0`). Empty = no DB. | +| `db-env-json` | JSON object of container env vars. | +| `db-port` | Port to expose/map. | +| `db-health-cmd` | Command run via `docker exec` to check readiness. | +| `db-health-retries` | Max health-check attempts (default `30`). Raise for slow-starting engines (Oracle, MSSQL). | +| `db-health-interval-seconds` | Seconds to sleep between health-check attempts (default `2`). | +| `enable-codecov` | Turns on the `codecov` job. | +| `enable-infection` | Turns on the `mutation-test` job. | +| `coverage-php-version` | Which matrix leg is canonical for coverage/mutation. | + +Plus `secrets: CODECOV_TOKEN`, `INFECTION_DASHBOARD_API_KEY` on +`workflow_call` (both `required: false`). + +## Reference example + +phpdb-mysql's caller workflow (`.github/workflows/continuous-integration.yml`) +is the reference example for wiring up a DB-backed driver package — every +input set with a brief comment explaining what it controls, so other driver +packages (Postgres, SQLite, etc.) can copy/adapt it directly.