Building a Production-Ready CI/CD Pipeline with Reusable GitHub Actions
When your organization scales from a handful of services to 20+ microservices, copy-pasting CI/CD configurations becomes unsustainable. This post walks through a production-ready pipeline architecture that uses reusable workflows, environment-aware deployments, and GitOps integration with ArgoCD.
The Scenario
Imagine a growing fintech startup—let's call them Acme Corp—that started with a monolith and gradually broke it into microservices. As the engineering team expanded, they faced common CI/CD challenges:
- Each team copied pipeline configs from existing repos, leading to drift
- Inconsistent Helm chart naming conventions (hyphens vs underscores, casing issues)
- No clear process for multi-environment deployments (dev, staging, prod)
- Ad-hoc version bumping with no correlation to change significance
- ArgoCD sync happening manually or with inconsistent automation
The solution? A centralized reusable workflow that handles the entire build-to-deploy lifecycle with a single tag push.
Architecture Overview
The pipeline uses a caller/callee pattern where individual service repos have minimal configuration, and a centralized workflow handles all the complexity.
Pipeline Flow
Developer pushes tag: deploy-staging-v1.2.3
↓
┌─────────────────────────────────────────────────────────────────┐
│ Caller Workflow (service repo) │
│ - Triggers on deploy-* tags │
│ - Calls centralized reusable workflow │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Reusable Workflow (org/.github repo) │
│ │
│ Job 1: build → Docker image + attestation │
│ ↓ │
│ Job 2: helm-push → Normalize chart, auto-version, push │
│ ↓ │
│ Job 3: update-values → Update env-specific values in deploy branch│
│ ↓ │
│ Job 4: argocd-pr → Create & auto-merge PR to ArgoCD repo │
└─────────────────────────────────────────────────────────────────┘
↓
ArgoCD detects change → Syncs to Kubernetes clusterThe Caller Workflow
Each service repository contains a minimal workflow file that delegates to the centralized workflow. The magic happens in the tag naming convention.
name: Build-and-Deploy
on:
push:
tags:
- 'deploy-**' # Matches: deploy-dev-v1.0.0, deploy-staging-release, etc.
- 'DEPLOY-**' # Case-insensitive matching
jobs:
build-and-deploy:
uses: acme-corp/.github/.github/workflows/build-deploy-pipeline.yml@main
secrets: inherit # Pass all secrets to the reusable workflowThe tag format deploy-{environment}-{identifier} encodes the target environment directly in the trigger. This eliminates the need for environment selection UI or manual inputs.
Job 1: Docker Build with Attestation
The build job creates the Docker image with proper caching and supply chain security.
build:
runs-on: ubuntu-latest
concurrency:
group: ${{ github.sha }}
cancel-in-progress: false
permissions:
contents: read
packages: write
id-token: write
attestations: write
outputs:
digest: ${{ steps.docker-build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log into registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and Push Docker Image
id: docker-build
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Attest container image
uses: actions/attest-build-provenance@v2
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.docker-build.outputs.digest }}
push-to-registry: trueKey Design Decisions
- Concurrency groups: Using
github.shaprevents parallel runs for the same commit while allowing different commits to build simultaneously - Build attestation: Creates a cryptographic proof of where and how the image was built—critical for supply chain security
- GHA cache: Leverages GitHub's built-in caching for Docker layers, typically reducing build times by 40-60%
Job 2: Helm Chart Normalization & Push
This job handles one of the trickiest aspects of managing many microservices: keeping Helm chart conventions consistent. It normalizes chart names, detects the target environment from the tag, and auto-increments versions.
Environment Detection
- name: Determine environment
id: determine-env
run: |
# Tag format: deploy-{env}-{identifier}
# Extract the environment (second segment)
TARGET=$(echo $GITHUB_REF_NAME | cut -d "-" -f 2)
# Validate it's a known environment
if [[ $TARGET == "sandbox" ]] || [[ $TARGET == "dev" ]] || \
[[ $TARGET == "qa" ]] || [[ $TARGET == "staging" ]] || \
[[ $TARGET == "prod" ]]; then
echo "ENVIRONMENT=$TARGET" >> $GITHUB_OUTPUT
else
echo "::error title=Invalid Environment::Unknown environment: $TARGET"
exit 1
fiChart Name Normalization
Over time, teams created charts with inconsistent naming: my-service, My_Service, myService. This step enforces a canonical format: lowercase with underscores.
- name: Normalize Helm chart directory and Chart.yaml name
run: |
# Canonical name: lowercase, hyphens → underscores
REPO_NAME="${{ github.event.repository.name }}"
CANONICAL_CHART_NAME=$(echo "$REPO_NAME" \
| tr '[:upper:]' '[:lower:]' \
| sed 's/-/_/g')
CANONICAL_CHART_DIR="helm/${CANONICAL_CHART_NAME}"
# Find existing Chart.yaml
CHART_FILE=$(find helm -maxdepth 2 -name 'Chart.yaml' | head -n 1)
CURRENT_CHART_DIR=$(dirname "$CHART_FILE")
# Rename directory if needed
if [[ "$CURRENT_CHART_DIR" != "$CANONICAL_CHART_DIR" ]]; then
git mv "$CURRENT_CHART_DIR" "$CANONICAL_CHART_DIR"
fi
# Update name field in Chart.yaml
CURRENT_NAME=$(yq '.name' "$CANONICAL_CHART_DIR/Chart.yaml")
if [[ "$CURRENT_NAME" != "$CANONICAL_CHART_NAME" ]]; then
yq -i ".name = \"$CANONICAL_CHART_NAME\"" "$CANONICAL_CHART_DIR/Chart.yaml"
fi
# Commit changes if any
if ! git diff --cached --quiet; then
git commit -m "chore: normalize helm chart to canonical form"
git push origin HEAD:$GITHUB_REF_NAME
fiSemantic Versioning with Release Please
Instead of using raw bash to auto-increment versions (which always bumps patch regardless of change type), we use release-please by Google. This tool analyzes your commit messages following Conventional Commits and automatically determines the appropriate semantic version bump.
Why Release Please?
- Semantic versioning done right:
feat:commits bump minor,fix:bumps patch,BREAKING CHANGE:bumps major - Automated changelogs: Generates CHANGELOG.md from commit messages
- Native Helm support: Understands Chart.yaml and updates versions automatically
- PR-based workflow: Creates release PRs that you can review before merging
Configuration Files
Release Please requires two configuration files in your repository root:
{
"packages": {
"charts/my_service": {
"release-type": "helm",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": false
}
},
"changelog-sections": [
{ "type": "feat", "section": "Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "perf", "section": "Performance Improvements" },
{ "type": "refactor", "section": "Code Refactoring" },
{ "type": "docs", "section": "Documentation" },
{ "type": "chore", "hidden": true },
{ "type": "style", "hidden": true },
{ "type": "test", "hidden": true },
{ "type": "ci", "hidden": true }
]
}{
"charts/my_service": "1.3.0"
}The manifest file tracks the current version of each package. Release Please updates this automatically when releases are created.
Release Please Workflow
Add a dedicated workflow that runs on pushes to main. Release Please will analyze commits since the last release and either create a release PR or update an existing one.
name: Release Please
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
jobs:
release-please:
name: Release Please
runs-on: ubuntu-latest
# Prevent infinite loops from release commits
if: "!contains(github.event.head_commit.message, 'chore(main): release')"
outputs:
releases_created: ${{ steps.release.outputs.releases_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
- name: Run Release Please
uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}Helm Push (with Release Please)
The Helm push step now reads the version from Chart.yaml (which Release Please has already updated) instead of manually calculating version bumps.
- name: Helm Push
if: ${{ needs.release-please.outputs.releases_created }}
run: |
HELM_REGISTRY="ghcr.io/${{ github.repository_owner }}"
CHART_NAME="${{ steps.normalize.outputs.CHART_NAME }}"
ENVIRONMENT="${{ steps.determine-env.outputs.ENVIRONMENT }}"
# Login to GHCR
echo "${{ secrets.GITHUB_TOKEN }}" | \
helm registry login ghcr.io --username ${{ github.actor }} --password-stdin
# Version is already set by Release Please in Chart.yaml
CHART_VERSION=$(yq '.version' helm/$CHART_NAME/Chart.yaml)
# Append environment to chart name for env-specific releases
yq -i ".name = .name + \"-$ENVIRONMENT\"" helm/$CHART_NAME/Chart.yaml
# Package and push
helm package helm/$CHART_NAME
helm push ${CHART_NAME}-${ENVIRONMENT}-${CHART_VERSION}.tgz $HELM_REGISTRYConventional Commits in Practice
With Release Please, your commit messages drive versioning:
# Patch bump (1.3.0 → 1.3.1) git commit -m "fix: resolve null pointer in auth handler" # Minor bump (1.3.0 → 1.4.0) git commit -m "feat: add support for OAuth2 PKCE flow" # Major bump (1.3.0 → 2.0.0) git commit -m "feat!: redesign API response format BREAKING CHANGE: Response now uses camelCase instead of snake_case"
Job 3: Update Environment Values
A dedicated deploy branch stores environment-specific values files. This separation allows different configurations per environment while keeping the main branch clean.
update-env-values:
needs: [build, helm-push]
runs-on: ubuntu-latest
env:
environment: ${{ needs.helm-push.outputs.environment }}
chart_version: ${{ needs.helm-push.outputs.chart_version }}
steps:
- uses: actions/checkout@v4
with:
ref: deploy # Dedicated branch for env configs
token: ${{ secrets.GH_TOKEN }}
- name: Update values files
run: |
VALUES_FILE="helm/environment-values/values-${{ env.environment }}.yaml"
# Update commit tracking
yq -i '.config.commit_id = "${{ github.sha }}"' "$VALUES_FILE"
yq -i '.config.github_id = "${{ github.actor }}"' "$VALUES_FILE"
# Update chart version
CHART_FILE="helm/$CHART_NAME/Chart-${{ env.environment }}.yaml"
yq -i '.version = "${{ env.chart_version }}"' "$CHART_FILE"
- name: Commit and push
run: |
git add .
git commit -m "Update ${{ env.environment }} values"
git pushJob 4: ArgoCD Integration
The final job creates a PR to the ArgoCD repository, updating the targetRevision to the new chart version. The PR is automatically merged, triggering ArgoCD to sync.
update-chart-version:
needs: [build, helm-push, update-env-values]
runs-on: ubuntu-latest
env:
environment: ${{ needs.helm-push.outputs.environment }}
chart_version: ${{ needs.helm-push.outputs.chart_version }}
app_name: ${{ needs.helm-push.outputs.app_name }}
steps:
- uses: actions/checkout@v4
with:
repository: acme-corp/k8s-argocd
path: tmp/argo
token: ${{ secrets.GH_TOKEN }}
- name: Update targetRevision
run: |
# Convert hyphens to underscores for YAML key
APP_KEY=$(echo "${{ env.app_name }}" | tr '-' '_')
yq -i ".${APP_KEY}.targetRevision = \"${{ env.chart_version }}\"" \
"tmp/argo/apps-${{ env.environment }}/values.yaml"
- name: Create and merge PR
working-directory: tmp/argo
run: |
BRANCH="${{ env.environment }}-${{ env.app_name }}-${{ env.chart_version }}"
git checkout -b $BRANCH
git add .
git commit -m "chore: bump ${{ env.app_name }} to ${{ env.chart_version }}"
git push --set-upstream origin $BRANCH
gh pr create \
--title "chore: Update ${{ env.app_name }} in ${{ env.environment }}" \
--body "Automated chart version update" \
--base main
# Auto-merge (requires branch protection rules allowing this)
gh pr merge --delete-branch --mergeEnvironment-Specific Chart Files
One powerful pattern is maintaining environment-specific Chart files (Chart-dev.yaml, Chart-staging.yaml, Chart-prod.yaml). This allows different versions, annotations, or dependencies per environment.
helm/
├── my_service/
│ ├── Chart.yaml # Base chart definition
│ ├── Chart-dev.yaml # Dev environment overrides
│ ├── Chart-staging.yaml # Staging environment overrides
│ ├── Chart-prod.yaml # Prod environment overrides
│ ├── values.yaml # Default values
│ └── templates/
└── environment-values/
├── values-dev.yaml # Dev-specific values
├── values-staging.yaml # Staging-specific values
└── values-prod.yaml # Prod-specific valuesDeveloper Experience
With this setup, deploying to any environment is a single command:
# Deploy to dev git tag deploy-dev-$(date +%Y%m%d-%H%M%S) git push origin --tags # Deploy to staging git tag deploy-staging-v1.2.3 git push origin --tags # Deploy to production git tag deploy-prod-release-2025-01 git push origin --tags
Results
18 → 1
Workflow files per repo
45%
Faster deploys
100%
Consistent chart naming
0
Manual ArgoCD syncs
Key Takeaways
- Encode intent in triggers: Using tag naming conventions eliminates environment selection UI and reduces human error
- Normalize ruthlessly: Consistent naming conventions prevent drift and simplify tooling
- Use Release Please for versioning: Let commit messages drive semantic version bumps instead of brittle bash scripts
- Chain jobs with outputs: Pass data between jobs via outputs to create cohesive pipelines
- Use concurrency groups: Prevent race conditions when multiple deployments happen simultaneously
- Automate the entire path: From commit to ArgoCD sync, remove manual steps wherever possible
Conclusion
Building a production-ready CI/CD pipeline isn't about adding complexity—it's about encoding best practices so teams can focus on shipping features. By centralizing workflow logic, enforcing conventions, and automating the entire deployment path, you create a system that scales with your organization.
The initial investment in building this infrastructure pays dividends every time a new service is created or a deployment is triggered. What once took hours of setup and manual coordination now happens with a single git push --tags.
Amar Sattaur
Staff DevOps Engineer specializing in CI/CD automation, Kubernetes, and developer experience.