From 1b8fc25ff83edf3cb43217024c528aad41f03c9a Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Sun, 9 Aug 2026 16:41:06 -0500 Subject: [PATCH 1/2] ci: call mago directly, add guard step, rename workflow - Rename qa.yml to continuous-integration.yml - Replace composer cs-check/static-analysis steps with direct mago format/lint/analyze calls, and add a mago guard step - Document the planned job-split design for DB service + Codecov + Infection support in docs/workflow-architecture.md --- .../{qa.yml => continuous-integration.yml} | 18 ++- README.md | 6 +- docs/workflow-architecture.md | 141 ++++++++++++++++++ 3 files changed, 158 insertions(+), 7 deletions(-) rename .github/workflows/{qa.yml => continuous-integration.yml} (84%) create mode 100644 docs/workflow-architecture.md diff --git a/.github/workflows/qa.yml b/.github/workflows/continuous-integration.yml similarity index 84% rename from .github/workflows/qa.yml rename to .github/workflows/continuous-integration.yml index de3425c..9c4c8f4 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/continuous-integration.yml @@ -1,4 +1,4 @@ -name: "QA" +name: "Continuous Integration" on: workflow_call: @@ -46,12 +46,20 @@ jobs: - name: Pin Mago PHP version run: echo "MAGO_PHP_VERSION=${{ matrix.php }}" >> "$GITHUB_ENV" - - name: Check coding standards - run: composer cs-check + - name: Check formatting + run: mago format --check - - name: Static analysis + - name: Lint if: success() || failure() - run: composer static-analysis + run: mago lint + + - name: Analyze + if: success() || failure() + run: mago analyze + + - name: Guard + if: success() || failure() + run: mago guard - name: Unit tests if: success() || failure() diff --git a/README.md b/README.md index 7063cb5..0dd5b17 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,8 @@ 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)). +This repository ships a reusable QA workflow +([`.github/workflows/continuous-integration.yml`](.github/workflows/continuous-integration.yml)). A consuming repository's entire CI file becomes: ```yaml @@ -96,7 +97,7 @@ 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 with: php-versions: '["8.2", "8.3", "8.4", "8.5"]' run-integration: false @@ -110,6 +111,7 @@ once released. - [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) — planned 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..4093d8f --- /dev/null +++ b/docs/workflow-architecture.md @@ -0,0 +1,141 @@ +# Workflow architecture (planned) + +This document describes the planned job-split design for +[`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. **Not yet implemented** +— the current workflow only runs the `qa` job (Mago + PHPUnit). This is a +reference for the follow-up PR that implements it. + +## 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:`) + +GitHub Actions job-level `services:` blocks can't be conditionally omitted +per input (no `if:` support there), and different driver packages need +different engines (MySQL now, Postgres/SQLite later). Instead, DB startup is +a manual step gated on a generic `db-image` input: + +```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 30); do + docker exec db ${{ inputs.db-health-cmd }} && exit 0 + sleep 2 + 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. + +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. + +## New inputs (not yet added) + +| 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. | +| `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 + +Once implemented, 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. From b5fc5c69608e8e71a7c44a3cd8201ea9d44b3a49 Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Sun, 9 Aug 2026 18:05:43 -0500 Subject: [PATCH 2/2] feat: job-split workflow with DB service, Codecov, and Infection support Splits the reusable continuous-integration.yml into four jobs (mago, test, codecov, mutation-test) so driver packages can run DB-backed integration tests and optionally enable Codecov/Infection, without dragging that complexity into packages that don't need it (e.g. the base phpdb package). - mago and test run in parallel (no data dependency between them) - test collects coverage on one canonical php-versions leg and uploads it as an artifact - codecov and mutation-test both needs:[test] (real dependencies: artifact consumption / gating), each independently toggle-able per consumer - DB service is a manual docker run step (not native services:) since env var key names differ per RDBMS engine and can't be expressed generically through a static services..env map - Documents the design in docs/workflow-architecture.md and updates the README's usage example accordingly --- .github/workflows/continuous-integration.yml | 194 ++++++++++++++++++- README.md | 27 ++- docs/workflow-architecture.md | 53 +++-- 3 files changed, 244 insertions(+), 30 deletions(-) diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 9c4c8f4..3090522 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -18,10 +18,60 @@ on: 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: - qa: - name: "PHP ${{ matrix.php }}" + mago: + name: "Mago (PHP ${{ matrix.php }})" runs-on: ubuntu-latest strategy: fail-fast: false @@ -61,10 +111,146 @@ jobs: 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: success() || failure() + 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 && (success() || failure()) + 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/README.md b/README.md index 0dd5b17..3c9d5a0 100644 --- a/README.md +++ b/README.md @@ -83,13 +83,16 @@ Add the standard scripts to your `composer.json`: ### 4. CI -This repository ships a reusable QA workflow -([`.github/workflows/continuous-integration.yml`](.github/workflows/continuous-integration.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: @@ -98,20 +101,26 @@ on: jobs: qa: 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) — planned job-split design for DB-backed integration tests, Codecov, and Infection. +- [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 index 4093d8f..31681e0 100644 --- a/docs/workflow-architecture.md +++ b/docs/workflow-architecture.md @@ -1,11 +1,9 @@ -# Workflow architecture (planned) +# Workflow architecture -This document describes the planned job-split design for +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. **Not yet implemented** -— the current workflow only runs the `qa` job (Mago + PHPUnit). This is a -reference for the follow-up PR that implements it. +plus optional Codecov and Infection mutation testing. ## Job graph @@ -36,10 +34,26 @@ Matrix: `php x [lowest, locked, latest]`. ### DB service (manual step, not native `services:`) -GitHub Actions job-level `services:` blocks can't be conditionally omitted -per input (no `if:` support there), and different driver packages need -different engines (MySQL now, Postgres/SQLite later). Instead, DB startup is -a manual step gated on a generic `db-image` input: +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 @@ -53,9 +67,9 @@ a manual step gated on a generic `db-image` input: - name: Wait for DB to be healthy if: inputs.db-image != '' run: | - for i in $(seq 1 30); do + for i in $(seq 1 ${{ inputs.db-health-retries }}); do docker exec db ${{ inputs.db-health-cmd }} && exit 0 - sleep 2 + sleep ${{ inputs.db-health-interval-seconds }} done echo "DB did not become healthy in time" && exit 1 ``` @@ -66,6 +80,10 @@ 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. @@ -117,7 +135,7 @@ 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. -## New inputs (not yet added) +## Inputs | Input | Purpose | |---|---| @@ -125,6 +143,8 @@ within the same GitHub org. | `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. | @@ -134,8 +154,7 @@ Plus `secrets: CODECOV_TOKEN`, `INFECTION_DASHBOARD_API_KEY` on ## Reference example -Once implemented, 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. +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.