Five workflow files, added one per concern between 2022 and 2025, each correct in isolation. A change to the PHP version needed editing three of them, the third was missed, and the resulting failure was in a job nobody associated with the change.
The symptom
$ ls .github/workflows/
ci.yml analyse.yml deploy.yml docs.yml security.yml
$ grep -l 'php-version' .github/workflows/*.yml
.github/workflows/ci.yml
.github/workflows/analyse.yml
.github/workflows/security.yml
$ grep -h 'php-version' .github/workflows/*.yml | sort -u
php-version: '8.4'
php-version: '8.3' ← security.ymlTwo versions across three files, and the one that was behind ran a dependency audit against a PHP version we do not deploy. Nothing failed, because an audit does not care — it had been reporting on a resolution for the wrong platform for four months.
Why it happens
A workflow is added per concern because that is what the file layout invites, and nothing ever consolidates them. Each file is small and readable and the set has no view in which anything is visible.
The fix
The trigger matrix
an afternoon, on paper:
push/main PR schedule manual
tests ✓ ✓ ✗ ✓
static analysis ✗ ✓ ✗ ✗
migration check ✗ ✓ ✗ ✗
deploy ✓ ✗ ✗ ✓
docs ✓ ✗ ✗ ✗
security scan ✗ ✗ ✓ ✓
six jobs, four triggers, twenty-four cells, and nobody
had ever drawn it.
two holes: analysis and the migration check do not run
on a direct push to main, which two people are
permitted to do.The holes are not a design decision — they are the consequence of each workflow being written on its own, and no view existed in which they were visible. Drawing the matrix took an afternoon and is the entire diagnostic.
One workflow, jobs rather than files
name: pipeline
on:
push: { branches: [main] }
pull_request:
workflow_dispatch:
concurrency:
group: pipeline-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
setup: { uses: ./.github/workflows/_setup.yml }
tests: { needs: setup, ... }
analyse: { needs: setup, ... }
migrate-check: { needs: setup, ... }
deploy:
needs: [tests, analyse, migrate-check]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
permissions: { contents: read, id-token: write }
what becomes expressible only once it is one file:
concurrency, which was per-workflow and therefore
per-concern rather than per-branch
permissions at the top with a per-job widening,
rather than four separate defaults
a dependency graph — deploy needs all three checks,
which was previously enforced by branch protection
naming three separate workflows
and the holes, which are now one `if` to readWhat stayed separate
security.yml a schedule. a cron trigger in a
file full of push triggers is
genuinely confusing, and the job
shares nothing with the others.
base-image.yml a schedule, a different artefact,
and a different cadence.
two files, both scheduled, both self-contained. the
rule that fell out: a workflow is one trigger's worth
of work, and "push and pull_request" is one trigger's
worth.The composite action that was deleted
a shared setup action with eleven inputs, of which
five existed for one caller each.
once the pipeline is one file, the setup is one job
that the others depend on, and eleven inputs become
zero.
before .github/actions/php-setup, 88 lines,
11 inputs, 3 conditionals
after a `setup` job, 14 lines, no inputs
and the second composite action — a deploy helper —
survived, because it is genuinely called from two
places.A shared abstraction with per-caller escape hatches is worse than duplication because the duplication is at least readable, and the eleven inputs were the accumulated cost of four workflows needing slightly different setups. Consolidating the workflows removed the need for the abstraction rather than improving it.
Closing the holes
analyse:
needs: setup
# was: on: pull_request only
# now: runs on every push and every pull request,
# because it is a job rather than a workflow and
# the workflow's triggers apply to all of them
steps:
- run: vendor/bin/phpstan analyse --error-format=github
The holes close by construction rather than by a fix — a job in a workflow runs on the workflow’s triggers, so the only way to have a job that skips a trigger is to write an if, which is visible. That is the structural argument for consolidation and it is stronger than the duplication argument.
Verifying it worked
$ ls .github/workflows/
pipeline.yml _setup.yml security.yml base-image.yml
$ grep -h 'php-version' .github/workflows/*.yml | sort -u
php-version: '8.4'
$ ./bin/trigger-matrix
push/main PR schedule manual
tests ✓ ✓ ✗ ✓
analyse ✓ ✓ ✗ ✓
migrate-check ✓ ✓ ✗ ✓
deploy ✓ ✗ ✗ ✓
security ✗ ✗ ✓ ✓
$ ./bin/pipeline-duration --median --since=14d
4m 20s # was 4m 10s — the setup job adds 10sA script that generates the matrix from the workflow files is what keeps it current, and it is forty lines of YAML parsing. Ten seconds slower is the cost of the setup job being a separate job with its own runner, which is worth it for the graph being expressible.
What this costs
One long file, at two hundred and forty lines, and a merge conflict surface that five small files did not have. Two people editing different jobs now conflict where they previously did not, which has happened twice and resolved trivially both times.
The setup job also adds ten seconds and a runner to every pipeline run, because a job cannot share a filesystem with another job without an artefact. That is the structural cost of jobs over steps, and the alternative — one enormous job — loses the parallelism that makes the pipeline four minutes rather than eleven.