A GitHub Actions workflow that other repositories can call

A cache key bug in the PHP pipeline was fixed in March, and in October four of the eleven repositories still had it. Every repository had a copy of the same ninety-line workflow, each copied from whichever one existed when the repository was created, and there was no way to tell which was current.

The symptom

$ ./bin/audit-workflow-shas
api                    a41f2b81
admin                  a41f2b81
catalogue              8c9e1140      ← different
fulfilment             3e11fa47      ← different
pricing                8c9e1140
...

# four distinct versions of a file that should be one.

Four distinct versions and no record of the differences. Diffing them pairwise took twenty minutes and found the cache fix missing in four, a PHP version bump missing in two, and one repository still using an action deprecated eighteen months earlier.

Why it happens

Before reusable workflows there was no unit of sharing — a workflow file lives in the repository it runs for, and the only way to share one was to copy it. Every copy is correct at the moment it is made and drifts from then on.

The drift is also invisible in the place people look. A pull request that changes a workflow is reviewed against that repository, so nobody is ever presented with the fact that the other ten disagree — and there is no build that fails when they do. The eleven copies had produced eleven slightly different definitions of what “the tests pass” meant, which is a worse problem than the cache bug that exposed it.

The fix

A workflow that can be called

# org/workflows/.github/workflows/php-ci.yml
on:
  workflow_call:
    inputs:
      php-version: { type: string, default: '8.0' }
      run-static-analysis: { type: boolean, default: true }
    secrets:
      COMPOSER_AUTH: { required: false }

jobs:
  test:
    runs-on: ubuntu-20.04
    steps:
      - uses: actions/checkout@v2      # ← the CALLER's repo
      - uses: shivammathur/setup-php@v2
        with: { php-version: ${{ inputs.php-version }} }
      - run: composer install --no-progress
      - run: vendor/bin/phpunit
      - if: inputs.run-static-analysis
        run: vendor/bin/phpstan analyse

The checkout inside the called workflow is the thing that surprises everybody: it runs on its own runner with an empty workspace and checks out the calling repository, not the workflow repository. A called workflow that assumes the code is already there gets an empty directory.

# and the caller, in each of the eleven repositories
name: CI
on: [push, pull_request]

jobs:
  ci:
    uses: org/workflows/.github/workflows/php-ci.yml@v3
    with:
      php-version: '8.0'
    secrets: inherit

Ninety lines becomes eight, and the eight say what this repository needs rather than how to do it. That is the whole benefit and it is larger than the line count suggests — the eight lines can be reviewed by somebody who does not know the pipeline.

The constraints, which decide what fits

inputs are string, number or boolean — nothing structured,
  so a matrix has to be a JSON string parsed with fromJSON

the called workflow runs as JOBS on its own runners, so it
  cannot see the caller's checkout or artifacts

nesting is capped at four levels and 20 workflows per run

`secrets: inherit` passes EVERYTHING — convenient, and the
  wrong default across an organisation boundary
# a matrix, as a JSON string, because inputs are scalars
    inputs:
      php-versions:
        type: string
        default: '["8.0"]'

jobs:
  test:
    strategy:
      matrix:
        php: ${{ fromJSON(inputs.php-versions) }}

The JSON-string workaround is ugly and works, and it is the standard way to pass anything structured in 2021. It also means a malformed string is a runtime error in the called workflow rather than a validation failure at the call site, which is worth a comment.

Versioning: a tag, not a branch

# a moving major tag, so callers get fixes without
# pinning to every patch
git tag -f v3 v3.2.1 && git push -f origin v3

# callers use @v3 and get 3.x. a breaking change is v4,
# and eleven repositories migrate deliberately.

# what NOT to do: ...php-ci.yml@main
#   → every push to the shared repo reaches eleven
#     pipelines at once, with no review

Referencing @main means a change to the shared workflow takes effect everywhere immediately, which is the same failure as the eleven copies with the sign reversed — instead of nothing propagating, everything propagates without review. A moving major tag is the compromise that gets fixes out and keeps breaking changes deliberate.

Pinning to a SHA is stricter and correct for a workflow from another organisation. For one owned by the same team it produces a Dependabot pull request in eleven repositories for every patch, which nobody reads by the third week.

Composite actions, which are the other unit

# .github/actions/setup-php/action.yml
inputs:
  php-version: { required: false, default: '8.0' }

runs:
  using: composite
  steps:
    - uses: shivammathur/setup-php@v2
      with: { php-version: ${{ inputs.php-version }} }
    - run: composer install --no-progress
      shell: bash      # ← required on every run step
composite action        runs INSIDE an existing job.
                        shares the runner and the workspace.
                        → a sequence of steps operating on
                          a checkout the caller already has

reusable workflow       runs as its own JOBS, on its own
                        runners.
                        → a whole pipeline, its own matrix,
                          its own concurrency

the test: does it need the caller's files? composite.
does it need its own machines? workflow.

The missing shell on a run step is the single most common mistake in a composite action and produces an error that does not mention shells. In 2021 composite actions also cannot use if on their steps, which limits them to unconditional sequences and is fixed later.

Verifying it worked

$ ./bin/audit-workflow-versions | sort | uniq -c
     11 php-ci.yml@v3

$ wc -l .github/workflows/ci.yml
8 .github/workflows/ci.yml        # was 91

# and the test: one fix, tagged, reaching all eleven
$ git -C ../workflows tag -f v3 && git push -f --tags
$ gh run list --limit 11 --json conclusion -q '.[].conclusion' | sort -u
success

Pushing a fix and watching eleven pipelines go green is the acceptance test and is the thing that could not be done before. It is also the moment that makes the risk obvious, which is the next section.

What this costs

A change that breaks eleven things at once. The shared repository now needs its own tests — a workflow that calls the workflow, against a fixture project — because a syntax error in it is eleven broken pipelines discovered by eleven people. That test did not exist for the first month and the first month contained an incident.

The other cost is that the eleven repositories can no longer make a local adjustment without adding an input, so every special case becomes a parameter on the shared workflow. That is the same pressure that turns a Terraform module into eleven pass-through variables, and resisting it means occasionally telling somebody their case needs its own workflow rather than a flag.