How Git Fits Into a CI/CD Pipeline Without Getting in the Way
by Eric Hanson, Backend Developer at Clean Systems Consulting
Git as the Event Source
Your CI/CD system does not run continuously. It runs in response to events — a push to a branch, a pull request opened, a tag created, a merge to main. Every one of those events originates in Git. The quality of your Git workflow directly determines the quality of your CI/CD feedback loop.
When the connection between Git events and pipeline behavior is thoughtfully designed, CI is fast, informative, and unobtrusive. When it's ad-hoc, you get pipelines that run on every push including typo fixes, pipelines that can't distinguish a feature branch from a release, and deployment workflows that require manual interpretation of the branch name.
Mapping Git Events to Pipeline Stages
The standard model for a web service:
# GitHub Actions structure
on:
push:
branches: ['main'] # triggers deploy pipeline
pull_request:
branches: ['main'] # triggers validation pipeline
push:
tags: ['v*'] # triggers release pipeline
Each event type should trigger a different set of work:
On PR against main (every proposed change):
- Run full test suite
- Run linting and static analysis
- Build the artifact
- Run integration tests
- Report coverage
- Do NOT deploy anywhere
On merge to main:
- Run full test suite (should be fast — you already validated in PR)
- Build and publish the artifact
- Deploy to staging automatically
- Run smoke tests against staging
On tag push (v2.3.1):
- Run full test suite
- Build the production artifact
- Deploy to production
This separation means: the PR pipeline is about validation, the main pipeline is about staging delivery, and tag pushes are the production deployment trigger. Each stage has a clear purpose and a clear Git event as its trigger.
Branch-Based Pipeline Behavior
For projects with multiple deployment environments, branch names map to environments:
# GitLab CI example
deploy:staging:
stage: deploy
environment: staging
script: ./deploy.sh staging
only:
- main
deploy:production:
stage: deploy
environment: production
script: ./deploy.sh production
only:
- /^v\d+\.\d+\.\d+$/ # matches version tags like v2.3.1
This makes the pipeline behavior predictable from branch and tag naming conventions alone. No manual configuration per deploy. No special CI job parameters. Push to main → staging. Tag a release → production.
Commit Messages as Pipeline Instructions
Conventional Commits become machine-readable instructions when combined with tooling. The commit type determines what happens after merge:
feat:commits bump the minor versionfix:commits bump the patch versionBREAKING CHANGE:in the footer bumps the major version
Tools like semantic-release and release-please read the commit log since the last tag, determine the next version number, generate a changelog, create a GitHub Release, and publish the artifact — all automatically.
# .releaserc.json for semantic-release
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
"@semantic-release/github"
]
}
With this setup, the entire release process is driven by commit messages. No manual version bumping. No manual changelog writing. The discipline of using Conventional Commits pays forward into automated release management.
Path-Based CI for Monorepos
In a monorepo with multiple services, running the full test suite for every service on every commit is wasteful. Builds that take forty-five minutes on a change to a documentation file kill developer velocity.
Path-based filtering triggers only the affected pipelines:
# GitHub Actions with path filtering
jobs:
test-payment-service:
if: contains(github.event.head_commit.modified, 'services/payment/')
steps:
- uses: actions/checkout@v4
- run: cd services/payment && ./gradlew test
test-auth-service:
if: contains(github.event.head_commit.modified, 'services/auth/')
steps:
- uses: actions/checkout@v4
- run: cd services/auth && ./gradlew test
For more sophisticated monorepo CI, tools like Nx (JavaScript/TypeScript), Bazel (language-agnostic), or Turborepo use dependency graphs to determine which packages are affected by a change and only test those packages plus their dependents.
Protecting Main Without Blocking Delivery
Branch protection on main ensures that only validated code merges:
Required status checks before merging:
✓ test / unit-tests
✓ test / integration-tests
✓ lint / code-quality
✓ security / secret-scan
Require branches to be up to date before merging: ✓
Require pull request reviews before merging: ✓ (1 reviewer)
The "require branches to be up to date" rule ensures that the code tested in CI is actually what's being merged, not the code before any commits that landed on main since the PR was opened. Without this, two PRs can both pass CI independently and then break main when merged together.
For high-volume teams, this creates a queue problem — every PR needs to rebase after every merge. GitHub's merge queue (and GitLab's merge trains) solve this by serializing merges and running CI against the combined result automatically.
The Metric That Reveals Pipeline Health
Lead time from merge to production — the time between a commit merging to main and that commit being live in production. With automated CI/CD and no manual steps, this should be under thirty minutes for most services. Manual deployment steps, slow tests, or blocked queues show up as increases in this metric.
If your lead time is measured in hours or days, the bottleneck is almost never Git — it's usually slow tests, manual approval gates, or deployment coordination overhead. But you can't measure it without the Git metadata (commit timestamps, merge timestamps, deployment timestamps), so the Git discipline of clean branching and tagging is also the foundation of your delivery metrics.