Appendix: Continuous Integration¶
This project’s CI runs entirely on GitHub Actions — no separate CI
service to configure. A reference map of what runs on every push/PR
and what it checks, for the CI configuration itself, not the
framework’s own code. Six workflow files under .github/workflows/
answer for a push to main or a pull request. Three run on every one of
them: ci.yml, semgrep.yml and monorepo-validate.yml (see
Appendix: Contributing to Kinetis for what it enforces). Three are
path-filtered on packages/** plus their own workflow file —
integration.yml, infection.yml, and sonarqube.yml, which also
watches sonar-project.properties — so a docs- or tooling-only change
gets no result from them at all, which is what
Appendix: Contributing to Kinetis’s release-gate section turns into a rule
about when a round can publish. .github/dependabot.yml sits alongside
them. Two more workflows exist but are out of this page’s scope, since
neither runs on an ordinary push/PR: deploy-docs.yml (publishes
docs/ to kinetis.dev on a push to main) and release.yml (gates
on the pushed commit’s own results, then publishes each package’s own
release repo — see Appendix: Contributing to Kinetis).
ci.yml — static checks and unit tests, per package¶
One job per package — every package in packages.manifest.json, plus
tools/ — matrixed across PHP 8.4 and 8.5 — every check below runs against both,
not just one — each running against the exact same Docker images used
for local development. Every push to main runs the whole matrix,
whatever the push touched: a release round gates on this workflow having
succeeded at the exact commit it publishes, and it can publish a version
whose own push came earlier, so a commit that skipped the matrix would
leave the gate proving nothing about the package code being tagged. Pull
requests are path-filtered instead, packages.manifest.json included —
a version bump changes the manifest and nothing else, since the
generated composer.json carries no version field:
composer validate --strictcomposer installcomposer audit— checks every installed dependency against the FriendsOfPHP security advisory database.PHPUnit — every package’s own existing, fake-backed unit test suite.
kinetis/persistenceandkinetis/database-bridgerun their suites in a container that compilesext-socketsfirst: the native Postgres driver refuses to construct without it, and each suite constructs one. Only that step needs the extension — PHPStan and Psalm read each package’s own stubs.PHPStan, level 8.
Psalm,
--taint-analysis— data-flow analysis for injection-style bugs (SQL injection, XSS, …), a different lens than PHPStan’s type-correctness checking. Results upload to GitHub’s code-scanning tab as SARIF.
A separate job builds the Sphinx documentation with -W (warnings fail
the build), so a broken docs page can’t merge silently.
semgrep.yml — pattern-based security scanning¶
p/php, p/security-audit, and p/secrets rulesets, scanning the
whole repository once (not per package — Semgrep doesn’t need to know
about Composer package boundaries). Same SARIF upload as Psalm’s.
Four paths are suppressed in .semgrepignore, with the reasoning
recorded there:
docs/_templates/page.html— mirrors the Furo Sphinx theme’s own footer template verbatim; the flagged template variables are Sphinx-internal, build-time navigation values, never live request input, since this renders to static HTML.packages/auth-jwt/tests/Fixtures/RsaKeyPair.phpandUndersizedRsaKeyPair.php— synthetic RSA key pairs generated solely to test RS256 verification and the undersized-key refusal, not real credentials.packages/revolt-http-client/tests/Fixtures/reflect-server.php— a test-onlyphp -Sserver whose whole purpose is echoing the request back as JSON, so the echoed-request rule’s XSS concern has no HTML context to apply to.
--error gates the build on a real finding, failing the workflow rather
than only reporting it.
integration.yml — real backends, not fakes¶
A fake settles what pure PHP decides — an argument rejected, an envelope
encoded, a settlement fenced against a scripted link — and every class
below carries a ci.yml suite for exactly that. It settles nothing
about the backend: whether FOR UPDATE SKIP LOCKED holds under
contention, whether a killed worker’s lease comes back, whether a
MOVED redirect lands where the slot map says. That half runs here,
against the real thing.
They arrive in two shapes. A standalone PHP script under
tests-integration/ is the linear form — push, pop, settle, assert —
and carries the queue, mailer, OpenSearch, LocalStack and migration
checks. An env-gated PHPUnit suite under tests/Integration/ is the
other, for cases that want fixtures and per-test setup: persistence’s
drivers and TLS, redis/cache-redis’s cluster routing, session’s
store rules, query-builder’s cursor pagination and server-dependent
SQL, orm’s entity reads and flushes. It skips itself when the backend variables are unset, which is what
keeps an ordinary ci.yml run database-free, and the same files run
again under coverage in sonarqube.yml. query-builder uses both forms; the conformance
suites are the second. The entries below name what each job covers
rather than which form it took.
query-builder(MySQL 8.4, MariaDB 11.4, Postgres 16) —Query::get()/first()/count()/insertGetId()/update()/delete()/join()/paginate()/cursorPaginate(), the null predicate forms (IS NULL/IS NOT NULL), and the SQL whose acceptance or result depends on the server: an offset without a limit, set operations and their counts, recursive CTEs and insert-select, insert-or-ignore, upsert row counts, a 65,535-parameter batch, lock wait modes under contention, the MySQL-family limitedINrefusal, correlated subqueries with arithmetic writes, and aRowValuesround trip into a DTO.orm(MySQL 8.4, MariaDB 11.4, Postgres 16) — entity reads and flushes through each family’s native and PDO driver: every mapped type from that driver’s own row spelling, converted predicates, one identity shared byfind(),get(),paginate()andcursorPaginate(), a UUID identifier looked up in different letter case resolving to the object already held, inserts with assigned and generated identifiers, updates and deletes, an update writing the values its row already holds, a unique-key failure rolled back and then retried, and two managers holding one versioned row: the stale writer’s update conflicting, again on a repeated flush, until it clears, reloads and reapplies it, and a stale delete conflicting; and a transaction session whose locking entity read holds its row through the session’s flush until COMMIT, probed from a second client withNOWAITso contention never waits; and relationships: nested and nullable targets loaded into one identity map, a locking read that locks its root row and not the author it loads, a committed reassignment loaded by a new manager, and a flush whose DELETE of a referenced author fails on the database’s foreign key and rolls back the insert before it; and inverse relationships: a nullable#[HasOne]without a row, a non-nullable one refusing a missing and a duplicate row, empty and multiple#[HasMany]lists in identifier order from rows inserted out of order, a mixed nested path resolving into one identity map, a locking read that locks its root author and not the posts it loads, and a committed reassignment observed by a new manager’s#[HasMany]; and aggregates: a new graph inserted in dependency order with the keys its inserts generated, a nullable self-reference inserted asNULLand fixed up inside one transaction, an owned#[HasOne]replacement freeing its unique slot before the replacement takes it, an aggregate removal deleting children before their owner behind real foreign keys, and a failure after a fix-up rolling the whole graph back; and#[ManyToMany]: a join row taking the keys the inserts at both of its ends generated, a loaded diff writing only the pairs that changed, the join table’s foreign keys refusing a linked target’s DELETE and an owner’s until its join rows go first, a duplicate pair failing on the unique key, rolling the flush back with its join work still pending, and committing once the conflicting row is gone, and two writers replacing one versioned owner’s membership, where the second conflicts on that owner’s version rather than merging into the first’s.queue-redis(Redis 7) —RedisQueue: push/pop/ack/release/fail, attempts, priority queues, plus four dedicated scripts beside the main one — ten concurrent processes racing for one delayed job, two byte-identical payloads staying two jobs, a killed worker’s lease reclaimed and redelivered, and delayed promotion staying inside its batch bound.queue-sql(MySQL 8.4, MariaDB 11.4) —SqlQueue: the same push/pop/ack/release/fail surface overFOR UPDATE SKIP LOCKED, reservation tokens, and priority ordering.queue-rabbitmq(RabbitMQ) —RabbitMqQueue: push/pop/ack/ release/fail,maxAttemptsround-tripping through message headers, priority cycling across two real queues, real delays through the delay ladder (a three-second delay pushed behind a ten-minute one coming due on its own wait,size()/clear()reaching both from a connection that never pushed them, and the delay ceiling rejected), andrelease()leaving a job unacked when the broker doesn’t confirm the replacement. Its own dedicated job, never sharing a process with anything else.persistence-and-cache-redis(MySQL 8.4, MariaDB 11.4, Postgres 16, Redis 7) —kinetis/persistence’sTransactionGuard: commit/ rollback/rollbackDangling();kinetis/cache-redis’sRedisSimpleCache: the full PSR-16 surface, TTL expiry, and the conditionalreplace();kinetis/session’sSqlSessionStoreandRedisSessionStore: the terminal update rule, which rests on MySQL’s changed-row count and Redis’sSET ... XXrefusal. None of these classes lives in core — see Database, Sessions & CSRF and Appendix: Satellite Packages.mailer(Mailpit) —MailerFactory: a real SMTP send, read back through the mail server’s own API.search-opensearch(two real OpenSearch containers, one with the security plugin disabled and one enabled with a self-signed certificate) —OpenSearchClientFactory: index/search/delete against the first, reached overhttpwithSEARCH_OPENSEARCH_PLAINTEXT=true, and the same fiveSearchClientcalls the Elasticsearch job runs, so the engine-neutral contract is proven on a live cluster of each engine, plus a real download abandoned pastSEARCH_OPENSEARCH_MAX_RESPONSE_BYTES; an unauthenticated request rejected, a correctly Basic-authenticated request succeeding, and the defaultSEARCH_OPENSEARCH_VERIFY_PEER=truerejecting the self-signed certificate, against the second.search-elasticsearch(two real Elasticsearch containers per matrix leg, one with security disabled and one enabled; legs for clusters 9.x and 8.19, each with the matching client major, since a 9.x client’scompatible-with=9is rejected by an 8.x cluster) —ElasticsearchClientFactory: index/search/delete, theSearchClientcalls, and a real download abandoned pastSEARCH_ELASTICSEARCH_MAX_RESPONSE_BYTESagainst the first; against the second, an unauthenticated request and a wrong password both rejected, Basic auth succeeding, and an API key created over the authenticated client then used as the only credential. Peer verification and the plain-HTTP opt-in belong tokinetis/search’s transport and are covered by thesearch-opensearchjob’s self-signed cluster rather than a second time here.migrations(MySQL 8.4, MariaDB 11.4, Postgres 16) —MigrationRunner/SqlMigrationRepository: migrate/status/rollback against a real fixture migration file, and themigrate/migrate:status/migrate:rollbackcommands run the waybin/kinetisruns them — output and events — against MySQL, and with amigrations/reporting/partition on Postgres beside the MySQL default, neither database receiving the other’s migrations or ledger rows.localstack(LocalStack: SQS + S3) —SqsQueue: push/pop/ack/ release/fail,maxAttempts, priority queues;S3FilesystemFactory: write/read/exists/list/copy/move/delete/deleteDirectory over the plain-HTTP endpoint its opt-in covers.redis-cluster(grokzen/redis-cluster, 3 masters + 3 replicas) —kinetis/redis’sClusterClient: keys routed to the master that owns them,nodes()against the live topology, and forced migrations exercising-MOVED,-ASK, anASK-redirected script, and aMOVED-then-ASKsequence inside one operation.kinetis/cache-redisthen covers the PSR-16 surface across shards and aclear()that scans every master while leaving other keys alone. Each case restores the slots it changes. The job runs the suite twice against one cluster to enforce that isolation.runtime-conformance(matrix: adunglas/frankenphpworker behind Caddy;php:8.4-fpm-alpinebehindnginx:alpine) — the shared runtime adapter conformance suite (Kinetis\Testing\Runtime, see Testing) against each real SAPI, where the committed framework suite can only spawnphp -S. The same shape as the local verification: the SAPI serving the conformance fixture (one FrankenPHP container; nginx plus a PHP-FPM container for the FPM leg), and aphp:8.4-cli-alpinerunner on the same Docker network executingRemoteSuperglobalsConformanceTestagainst it, every container mounting the checkout at/appso the fixture’s state directory is one path on every side. Readiness is the fixture’s own/__conformance/readyanswering 204 through the full adapter path, not a TCP accept — nginx listens before the FPM pool behind it does. Exercises each SAPI’s own superglobal population, header folding, client address, request identity, form/binary bodies (under theenable_post_data_reading=0the Kinetis SAPI adapters require, so the body each parses is the client’s own), the400for a body it cannot parse and the413for one past aKinetis\Http\Form\FormLimitsceiling, and — timed on the wire — incremental streaming, which is why the nginx fixture setsfastcgi_buffering off: with the default on, the stream arrives as one lump and the case fails, as verified.roadrunner-conformance— the same shared suite againstKinetis\RoadRunnerAdapter\RoadRunnerAdapter, structurally simpler thanruntime-conformanceabove:RoadRunnerDriverspawns a realrr serveprocess (which spawns the PHP worker as its own child) directly from inside the test run, so this job needs no separate SAPI container or Docker network — one runner,shivammathur/setup-phpproviding a real, prebuiltext-sockets(it compiles under Alpine too, just not worth doing here — see Runtime Adapters), a real binary fetched viaspiral/roadrunner-cli’svendor/bin/rr get-binary, and the suite itself. The whole suite runs, unfiltered: the two behaviors this environment cannot deliver — a purely-numeric header name, dropped by an upstream bug, and cookie order, lost to a Go map — are declared byRoadRunnerDriverand asserted in both directions by the shared suite, so they are covered here rather than skipped. Both are disclosed inRoadRunnerAdapter’s own docblock and Runtime Adapters.orm-runtime(matrix: adunglas/frankenphpworker;php:8.4-fpm-alpinebehindnginx:alpine; MySQL 8.4 as a service) —kinetis/database-bridge’sOrmWorkerSequenceTestagainsttests/Fixtures/OrmWorker/index.php, which puts the bridge’sPackageBootstrapbehind aKernelunder the detected adapter. The first request loads a row through its request-scopedEntityManager, changes it without flushing and waits onSELECT SLEEP(0.2)beside aLoopLivenesssentinel; the test reads the row on its own connection; the second request loads it through its own manager. Both reads must find the stored value. The FrankenPHP leg runs one worker thread and requires one interpreter to answer both requests, the nativeMysqliAsyncClient, a sentinel that turned, and the first request’s manager gone by the second request; the FPM leg requires a fresh script per request, the PDO client and a sentinel that did not turn. Each PHP container installsmysqliandpdo_mysqlbefore its server starts, and the test runs inside it.pingpong— not a package’s own real-backend script like every job above; the realdocker compose up --buildstack (app,mysql,redis,soketi,migrate,queue-worker,cron) brought up from cold and exercised over real HTTP/SQL.COMPOSE_FILEputs this repo’sdocker-compose.monorepo.ymlon top of the package’s own standalone compose file, so the stack runs against the sibling checkouts; every step is otherwise the command it would be against the released package.GET /(200), a realPOST /pong/directrequest checked against the resulting database row going straight toponged, a realPOST /pong/queuedrequest checked aspendingimmediately and polled until the separatequeue-workercontainer ponged it, and thecroncontainer’s own row (created and ponged with no HTTP request involved at all) polled the same way.
query-builder, orm, queue-sql, persistence-and-cache-redis, and
migrations each run twice — once against MySQL, once against
MariaDB — via a matrix over the database image, not separate jobs or
duplicated scripts. Only the service container’s image and health-check
command (mysqladmin vs. mariadb-admin) differ between the two matrix
entries.
Every job above except pingpong, runtime-conformance, orm-runtime,
and roadrunner-conformance (each exercises a real multi-container stack,
or in roadrunner-conformance’s case a fixed PHP version chosen to
match the ext-sockets requirement — not a bare per-package PHP
matrix) also runs across PHP 8.4 and 8.5, the same matrix ci.yml
uses — real-backend correctness is checked against both, not just one.
redis-cluster is the one job that runs the job itself inside a
container: rather than on the bare runner — a real multi-node Redis
Cluster advertises each node’s own container-internal address for both
inter-node gossip and client MOVED-redirects, and only a job attached to
the same Docker network as the service container can reach that address
directly. PHP/Composer run directly inside the step for this job, rather
than through a nested docker run the way every other job here invokes
them, since a job container has no Docker-in-Docker socket available by
default.
infection.yml — mutation testing¶
Mutates source code (flipping a comparison, removing a statement, incrementing a constant, …) and re-runs the covering tests per mutant — a mutant the suite doesn’t catch (“escaped”) is a gap in assertion rigor, not just a coverage gap.
One matrix job per package carrying its own infection.json5.
kinetis/pingpong has none — it is a demo application, read as example
code rather than called as an API — and tools/ is the monorepo’s own
tooling rather than a published package. Each job runs
composer install, then Infection with PCOV as the coverage driver,
gated on --min-msi/--min-covered-msi — a real, non-zero threshold per
package, set with a margin below that package’s own measured score.
kinetis/persistence and kinetis/database-bridge compile ext-sockets
into that container first, for the same requirement their ci.yml
suites carry above. Runs on PHP
8.4 only, not matrixed across 8.4/8.5 like ci.yml/integration.yml.
On a pull request, a package whose own src/ is unchanged relative to
the base branch skips its job, and every package the PR does change is
mutated in full. The thresholds are measured against a whole package
and are only meaningful against a whole package’s mutant set, so a
changed-lines subset is never scored against them. Every push to main
runs the same full suite.
Thresholds, as declared in infection.yml — which is the authority; a
package’s current score is whatever its own job last reported, and sits
above the number here by design:
Package |
min-msi / min-covered-msi |
|---|---|
|
70% |
|
60% |
|
90% |
|
90% |
|
70% |
|
75% |
|
75% |
|
75% |
|
60% |
|
90% |
|
75% |
|
60% |
|
75% |
|
70% |
|
60% |
|
75% |
|
80% |
|
60% |
|
50% |
|
55% |
|
50% |
|
55% |
|
60% |
|
75% |
|
85% |
|
60% |
|
60% |
|
70% |
|
65% |
|
90% |
|
90% |
|
15% |
|
70% |
|
60% |
|
60% |
|
60% |
|
60% |
queue-sqs and storage-s3 carry the lowest floors. Neither
infection.json5 excludes anything, so both mutate all of src and
each floor is what that whole set measured.
sonarqube.yml — SonarQube Cloud¶
A repo-wide static-analysis and coverage pass via the official
SonarSource/sonarqube-scan-action, configured by
sonar-project.properties at the repo root (source paths spanning
core’s src/ plus every satellite package’s own src/).
Runs PHPUnit with PCOV coverage for core and every satellite package
with a PHPUnit suite, feeding the resulting Clover reports into the
scan via sonar.php.coverage.reportPaths — on PHP 8.4 only, not
matrixed across 8.4/8.5 like ci.yml/integration.yml. This job brings
up MySQL, Postgres, a single Redis and a Redis Cluster of its own, for
one reason: kinetis/persistence’s and kinetis/cache-redis’s
env-gated tests/Integration suites have to run under PCOV here for
the driver and cluster code they exercise to count as measured coverage.
Without them those tests skip and that code reads as uncovered.
sonar.coverage.exclusions names the classes whose behavior a real
backend decides: RedisQueue, SqlQueue, SqsQueue and its
SqsQueueException, RabbitMqQueue, RedisSessionStore, plus the thin
wiring around them — kinetis/queue’s and kinetis/database-bridge’s
PackageBootstrap, queue:work, and kinetis/migrations’
migrate* console commands. Each still carries its own PHPUnit suite for the part pure PHP
can decide; what the exclusion keeps out of the metric is the rest,
which integration.yml proves against a live container and no
line-coverage number here can speak for.
One pair of files with known, structural duplication — the mysqli and
pgsql drivers, whose pooling logic isn’t trait-compatible without
widening production connection-handling signatures — is excluded from
duplication detection via sonar.cpd.exclusions, with the reasoning
recorded inline in sonar-project.properties itself.
Requires a SONAR_TOKEN repository secret from the project’s own
SonarCloud dashboard; analysis method is “With GitHub Actions.”
dependabot.yml¶
One weekly composer entry per package directory, one for tools/, and
one github-actions entry for the workflow files themselves. A
dependency bump opens a real pull request, which re-runs every workflow
above against it before a human ever looks at it.
validate-manifest.php’s workflow-coverage check reads ci.yml,
infection.yml and the SonarQube pair, not this file, so a new package
without an entry here is watched by nothing and fails no check. Adding
the entry belongs in the same change as the package.
See also¶
Appendix: System Layout — the same reference map for core, by namespace.
Appendix: Satellite Packages — the same reference map for every satellite package, by namespace.
Appendix: Contributing to Kinetis — how to run these checks locally before pushing, and how
monorepo-validate.yml’s enforcement actually works.Testing —
TestClient, for exercising aKernelend-to-end in a consumer’s own test suite.