This document describes the job-split design implemented in
continuous-integration.yml
to support driver packages that need a seeded RDBMS for integration testing,
plus optional Codecov and Infection mutation testing.
Only genuine data/gating dependencies are expressed via needs:; everything
else runs in parallel for speed.
graph LR
mago[mago job]
test[test job] --> codecov[codecov job]
test --> infection[mutation-test job]
magoandtesthave noneeds:between them — they're independent gates, neither consumes the other's output.codecovandmutation-testbothneeds: [test]— real dependencies (artifact consumption / gating), not just ordering preference.
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.
Matrix: php x [lowest, locked, latest].
Native job-level services: blocks can be conditionally disabled (an
empty image: expression means the service won't start — see
GitHub's docs),
so conditionality alone isn't why this uses a manual step. The real
constraint: services.<id>.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.
- 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 1jq 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.
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.
needs: [test], job-level if: inputs.enable-codecov. Downloads the
clover.xml artifact and runs codecov/codecov-action. No PHP setup, no DB.
fail_ci_if_error: false — report-only for all consuming repos while they're
still legacy codebases being migrated; upload/coverage issues don't fail CI.
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.
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.
min-msi / min-covered-msi (both default "10") control the MSI
threshold that fails the job — config-based per repo, not hardcoded. Default
is deliberately low: most php-db repos are legacy codebases just adopting
mutation testing, and a strict bar (e.g. message-bus's 90/90) would fail
immediately. Each consuming repo overrides these once it has a real
baseline MSI to ratchet up from. Passed as composer mutation-test -- --min-msi=... --min-covered-msi=... --logger-github (the composer script
itself stays plain infection, flags are appended at invocation time, same
pattern as message-bus).
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.
| 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. |
min-msi |
Minimum MSI (%) required to pass mutation-test (default "10", config-based per repo). |
min-covered-msi |
Minimum covered-code MSI (%) required to pass mutation-test (default "10", config-based per repo). |
test-env-json |
JSON object of extra env vars exported (via $GITHUB_ENV) before running tests in test and mutation-test. |
Plus secrets: CODECOV_TOKEN, INFECTION_DASHBOARD_API_KEY on
workflow_call (both required: false).
PHPUnit's <env> element does not override an already-set real
environment variable unless force="true" is set (confirmed via PHPUnit's
docs). This means a caller can override a phpunit.xml.dist default (e.g. a
DB hostname that needs to differ between local dev and CI) via
test-env-json, without editing that file or needing force="true" (which
would break the local-dev default). Example: phpdb-mysql's local
compose.yml runs the PHP and MySQL containers on the same Docker Compose
network, so phpunit.xml.dist defaults TESTS_PHPDB_ADAPTER_MYSQL_HOSTNAME
to mysql (the container name, resolvable via Compose's built-in DNS). In
CI, the test/mutation-test jobs run directly on the runner VM (no
container: key), so the DB (started via the manual docker run step) is
only reachable via 127.0.0.1 and the mapped port — GitHub's own docs
confirm this is required when the job isn't itself containerized. Setting
test-env-json: '{"TESTS_PHPDB_ADAPTER_MYSQL_HOSTNAME":"127.0.0.1"}' in the
caller workflow resolves this without touching phpunit.xml.dist.
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.