A single required status check for GitHub Actions workflows.
Since its introduction in 2018, GitHub Actions has become wildly popular1. I would attribute its success mostly to its tight integration into the platform and being free for open source projects. Whenever something needs checking, adding a new workflow is relatively easy. However, especially in large repositories, that might not always be the best option. In particular, you have to keep in mind how workflows interact with other features such as protected branches or merge queues. This post describes a neat pattern for a maintainable workflow structure.
Using branch protection rules, GitHub allows defining certain checks (an abstraction over GitHub Actions jobs and other validations) as required. Such checks need to be present and successful in order for pull requests and merge queue builds to be mergeable. Therefore, whenever introducing a new workflow, you also have to decide whether it should be required.
For repositories such as JUnit, that’s error-prone because we test against early access builds of OpenJDK and therefore regularly add new jobs. Remembering to also add those jobs to the list of required status checks is easily forgotten and therefore tedious.
There’s another problem with this approach.
When working with a repository that contains different types of code, you usually have workflows or jobs that are only relevant for certain parts of the codebase.
For example, in a monorepo, there’s often a mix of frontend and backend code which might be built using different technologies and tools (like JavaScript in the frontend, Java in the backend).
GitHub Actions workflows let you express that via the paths filter.
For example, let’s consider the following two prototypical workflows2:
# .github/workflows/frontend.yml
name: Frontend
on:
pull_request:
paths: ['frontend/**']
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm ci
- run: npm test
# .github/workflows/backend.yml
name: Backend
on:
pull_request:
paths: ['backend/**']
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 25
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew build
With this setup, when you open a pull request containing only frontend changes, the backend workflow won’t run and vice versa. Which workflows do you then configure as required? You’d like both to be required but only if they are relevant for the changeset. However, there is no such option in GitHub’s branch protection settings.
One solution I’ve recently implemented for a client and in the JUnit repository is to restructure the workflow as follows:
flowchart LR
Detect[Detect changes]
Detect --> FrontendCheck{Frontend<br/>changes?}
FrontendCheck --> FrontendBuild[Frontend build]
FrontendBuild --> Status
Detect --> Status["Status<br/>❌ or ✅"]
Detect --> BackendCheck{Backend<br/>changes?}
BackendCheck --> BackendBuild[Backend build]
BackendBuild --> Status
style Status stroke-width:3px
Since GitHub allows calling workflows from other workflows via reusable workflows, you can keep the backend and frontend workflows in separate files and only change their triggers.
Swap each pull_request trigger for workflow_call so the workflow no longer runs on its own but can be called from another workflow:
# .github/workflows/frontend.yml
name: Frontend
on:
workflow_call:
# jobs unchanged
Then add a single composite workflow that detects which parts changed, calls the relevant reusable workflows conditionally, and ends in a Status job that fails if any of them failed (or was canceled):
# .github/workflows/ci.yml
name: CI
on:
pull_request:
merge_group:
jobs:
changes:
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.filter.outputs.frontend }}
backend: ${{ steps.filter.outputs.backend }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
frontend: ['frontend/**']
backend: ['backend/**']
frontend:
needs: changes
if: needs.changes.outputs.frontend == 'true'
uses: ./.github/workflows/frontend.yml
backend:
needs: changes
if: needs.changes.outputs.backend == 'true'
uses: ./.github/workflows/backend.yml
status:
if: always()
needs: [ changes, frontend, backend ]
runs-on: ubuntu-latest
steps:
- name: Determine overall status
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1
For the changes job, the dorny/paths-filter action is quite useful.
The crucial part is the status job, though. Its if: always() makes it run even when a job it needs was skipped, and checking needs.*.result lets skipped jobs pass while catching failures.
With this in place, you can configure only the Status check as required, since it will be present on all pull request and merge queue builds.
Moreover, you won’t have to remember to add new jobs to the list of required checks.
Just include them in the composite workflow and you’re done.
Footnotes
-
An empirical study from 2022 found it in use by 43.9% of ~68,000 repositories. ↩
-
I have omitted the full SHAs for the actions here for brevity. ↩