A pipeline described in one file, after four years of five

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.yml

Two 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 read

What 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 10s

A 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.