Git Workflow Design for Efficient GitHub Actions Pipelines
AI generated
git
HEAD
Git
Git Workflow Design for GitHub Actions
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.

11 min read Git GitHub Actions CI/CD

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

11. FAQ: GitHub Actions

1Why does the branching model influence CI efficiency?
Trigger configuration should match the lifetime and number of branches. With many short lived feature branches, a full check on every push wastes significant compute time compared to a leaner pipeline per push plus a thorough check only at the pull request stage.
2Which commit does the pull_request trigger actually run against?
By default, against the merge commit between the feature branch and its target branch, not against the last commit of the feature branch itself. That can lead to results diverging from a local test run once the target branch has moved on.
3How do I enforce that a merge is only possible after checks pass?
Through required status checks in the target branch's protection rules. It is important that the name matches the job name inside the workflow exactly, not the file name of the YAML file.
4How does caching based on the lock file work?
The hashFiles function computes a cache key from the content of the lock file. If the lock file has not changed, the same cache key hits again and the dependency installation step can be skipped.
5How can pipelines in a monorepo be scoped to affected modules only?
Through path filters, either directly in the trigger or with a dedicated action such as dorny/paths-filter, which checks whether files inside the relevant directory changed before a job for that module is started.
6How do I trigger a release workflow only on real version tags?
Through a tag pattern in the push trigger aligned with Semantic Versioning, for example v followed by three numbers separated by periods. That way the workflow only runs on actual version tags, not on any other arbitrary tag.
7What do concurrency groups with cancel-in-progress achieve?
They automatically cancel outdated pipeline runs for the same branch as soon as a new push arrives. That saves compute minutes and gets the developer a current result faster, instead of waiting on a run that is already obsolete.
8How do Conventional Commits help with automated releases?
Consistently used prefixes such as fix or feat let tools for semantic releases automatically determine whether a patch, minor, or major release applies, without anyone manually setting the version number.
9Why is pull_request_target riskier than the normal pull_request trigger?
pull_request_target runs in the context of the target branch and has access to secrets. If the fork's code is explicitly checked out on top of that, untrusted code can end up running with full privileges and access to secrets.
10Do pull requests from forks have access to secrets by default?
No, the normal pull_request trigger does not grant pull requests from forks access to secrets stored in the repository, for security reasons. That protects against misuse through untrusted code coming from external contributions.