Efficient pipelines start with the branching model
A slow CI pipeline is rarely just about the YAML file. How a team branches, commits, and structures pull requests largely determines how efficiently GitHub Actions can operate.
Table of Contents
- 1. Why branching model and CI triggers belong together
- 2. Trigger strategies in detail
- 3. Branch protection and required checks as a gate
- 4. Deriving caching strategies from Git history
- 5. Path filters for monorepos and selective pipelines
- 6. Tags and releases as their own trigger
- 7. Concurrency groups for parallel pull requests
- 8. Commit conventions as pipeline input
- 9. Security: secrets and fork pull requests
- 10. Summary
- 11. FAQ
1. Why branching model and CI triggers belong together
A workflow that runs the entire test suite on every push to every branch wastes significant compute time in a team with many short lived feature branches. Trigger configuration should therefore match the team's actual branching model closely.
With trunk-based development and short branches, a lean pipeline on every push, combined with a more thorough check only at the pull request stage against the main line, pays off. With longer running release branches, an additional, less frequent full check makes more sense instead.
Anyone who designs the pipeline independent of the branching model ends up paying either with unnecessarily long wait times for developers or with unnecessary compute costs, often with both at once.
# .github/workflows/ci.yml, excerpt: different triggers for push and PR
on:
push:
branches: [main]
pull_request:
branches: [main]
2. Trigger strategies in detail
The push trigger is well suited for fast feedback right after a commit, but should be limited to relevant branches so that not every experimental branch gets fully checked. The pull_request trigger is the right place for the actual quality gate before a merge.
A frequently overlooked detail: pull_request runs by default against the merge commit between the branch and its target branch, not against the last commit of the feature branch itself. That can sometimes produce results diverging from a local test run once the target branch has moved on.
Triggers can additionally be scoped with paths and paths-ignore to specific directories, so that pure documentation changes, for example, do not trigger a full test pipeline, which saves considerable time in large repositories.
on:
pull_request:
branches: [main]
paths:
- 'src/app/code/**'
- 'composer.lock'
paths-ignore:
- 'design/**'
- '**.md'
3. Branch protection and required checks as a gate
A workflow alone does not guarantee quality as long as a merge remains possible even when checks fail. Only required status checks in the main branch's protection rules turn passing pipelines into an actual prerequisite for merging.
The exact naming matters here: required checks refer to the name of the job inside the workflow, not the name of the YAML file. If a job gets renamed without updating the branch protection rule, GitHub blocks the merge permanently, because the expected check is never reported again.
For particularly sensitive areas, CODEOWNERS can additionally be combined with required reviews, so a merge requires both passing technical checks and sign off from the person actually responsible for that area.
# The required status check must match the job name exactly
jobs:
phpstan:
name: PHPStan Level 5
runs-on: ubuntu-latest
steps:
- run: bin/analyse app/code/Mironsoft --level=5
4. Deriving caching strategies from Git history
The biggest time sink in many pipelines is the repeated installation of dependencies. A cache key computed from the hash of the relevant lock file ensures the cache is only invalidated when the actual dependencies changed, not on every single commit.
Since composer.lock and package-lock.json are versioned, their content can reliably be built into a cache key using the hashFiles function. If the lock file has not changed, the cache hits reliably, regardless of how many other files changed in the same commit.
An additional restore key with a broader prefix allows at least a partial fallback to an older cache even when the exact hash no longer exists, which still noticeably reduces install time in the worst case.
- uses: actions/cache@v4
with:
path: vendor
key: composer-${{ hashFiles('composer.lock') }}
restore-keys: |
composer-
5. Path filters for monorepos and selective pipelines
In projects with several independent modules in the same repository, it is inefficient to test every module on every change. Path filters allow jobs to run only when files inside the relevant directory actually changed.
For more complex cases, a dedicated action such as dorny/paths-filter is a good fit, running as its own job that compares the base and head commits and exposes the result as an output to downstream jobs. That keeps the filter logic maintained centrally instead of duplicating it inside every job.
This structure scales considerably better than a single monolithic pipeline, especially when a repository contains several Magento modules with different owners and different testing requirements.
jobs:
changes:
runs-on: ubuntu-latest
outputs:
seosuite: ${{ steps.filter.outputs.seosuite }}
steps:
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
seosuite:
- 'src/app/code/Mironsoft/SeoSuite/**'
test-seosuite:
needs: changes
if: needs.changes.outputs.seosuite == 'true'
runs-on: ubuntu-latest
steps:
- run: echo "Testing SeoSuite"
6. Tags and releases as their own trigger
Besides branches, Git tags can also serve as triggers for dedicated workflows, typically for deployment or release pipelines that should be clearly separated from the regular test pipeline of a pull request.
A tag scheme aligned with Semantic Versioning, for example v2.4.0, can be matched precisely with a pattern in the trigger, so a release workflow only fires on actual version tags and not on any arbitrary tag in the repository.
This separation ensures production deployments are tied to a deliberate, versioned Git action, instead of happening as a side effect of an ordinary merge into the main line.
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: echo "Deploying version ${{ github.ref_name }}"
7. Concurrency groups for parallel pull requests
If the same feature branch is pushed multiple times in quick succession, for example after small fixes from a review, several pipeline runs start in parallel without further configuration, even though only the result of the latest one actually matters.
Using concurrency groups with cancel-in-progress, outdated runs for the same branch are automatically cancelled as soon as a new push arrives. That not only saves compute minutes, it also gets the developer a current result faster.
In teams with many parallel pull requests across multiple time zones, this configuration noticeably shortens the runner queue, since runs that are already obsolete no longer occupy resources unnecessarily.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
8. Commit conventions as pipeline input
Conventional Commits are more than a style preference: when commit prefixes such as fix: or feat: are used consistently, a pipeline can automatically derive version numbers, changelogs, and even release decisions from them, without anyone manually setting a version number.
Tools for semantic releases read the commit history since the last release and decide, based on the prefixes, whether it is a patch, minor, or major release. That does require consistent commit messages across the whole team, otherwise the automatic derivation becomes unreliable.
An additional workflow that checks commit messages against the expected format already at the pull request stage prevents inconsistent messages from causing problems later, deeper inside the release pipeline.
- name: Check commit format
uses: wagoid/commitlint-github-action@v6
with:
configFile: .commitlintrc.json
9. Security: secrets and fork pull requests
Pull requests from forks have no access to secrets stored in the repository by default, which matters for security in public repositories but in many cases also prevents legitimate workflow steps such as deployment previews.
The pull_request_target trigger runs in the context of the target branch and therefore has access to secrets, but by default it also does not automatically check out the fork's code. Carelessly checking out the fork's code explicitly while combined with this trigger opens a serious security hole, since untrusted code can then run with full privileges.
As a rule of thumb: for plain tests and checks, the normal pull_request trigger is entirely sufficient. pull_request_target should only be used when strictly necessary, and then only with careful review of exactly which code runs with which privileges.
# Risky: running fork code with access to secrets
on:
pull_request_target:
jobs:
build:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
# From here on, untrusted code runs with full repository privileges
| Trigger | Typical use | Access to secrets | Risk with forks |
|---|---|---|---|
| push | Fast feedback right after a commit | Yes, within the own repository | Low |
| pull_request | Quality gate before merging | No, on forks | Low |
| pull_request_target | Deployment previews needing secrets | Yes, even on forks | High with fork checkout |
| workflow_dispatch | Manually triggered actions | Yes | Low |
| schedule | Recurring tasks, e.g. nightly builds | Yes | Low |
Mironsoft
Git workflows, branching strategies, and CI hooks
Chaotic Git history and unclear branching rules across the team?
We set up clean Git workflows, clarify branching strategies for the team, and automate quality checks via Git hooks and CI pipelines so the history stays traceable.
Workflow Audit
Review the existing branching strategy and merge practice for weak spots.
Hook Automation
Set up pre-commit and pre-push hooks for linting, tests, and commit conventions.
Team Training
Teach rebase, cherry-pick, and conflict resolution hands-on across the team.
10. Summary
GitHub Actions
Trigger choice
pull_request for checks, push scoped to relevant branches
Caching
Derive the cache key from hashFiles on the lock file
Concurrency
cancel-in-progress prevents outdated parallel runs
Security
Use pull_request_target only with careful review