GitHub ActionsCI/CDHelmArgoCDRelease Please

Building a Production-Ready CI/CD Pipeline with Reusable GitHub Actions

January 202515 min read

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 cluster

The Caller Workflow

Each service repository contains a minimal workflow file that delegates to the centralized workflow. The magic happens in the tag naming convention.

.github/workflows/build-and-deploy.yml
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 workflow

The 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 Job
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: true

Key Design Decisions

  • Concurrency groups: Using github.sha prevents 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

Extract environment from tag
- 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
    fi

Chart Name Normalization

Over time, teams created charts with inconsistent naming: my-service, My_Service, myService. This step enforces a canonical format: lowercase with underscores.

Normalize chart naming
- 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
    fi

Semantic 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:

release-please-config.json
{
  "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 }
  ]
}
.release-please-manifest.json
{
  "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.

.github/workflows/release-please.yml
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.

Helm Push step (simplified)
- 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_REGISTRY

Conventional Commits in Practice

With Release Please, your commit messages drive versioning:

Commit message examples
# 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 environment values
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 push

Job 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.

ArgoCD PR automation
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 --merge

Environment-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.

Directory structure
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 values

Developer Experience

With this setup, deploying to any environment is a single command:

Deployment commands
# 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

  1. Encode intent in triggers: Using tag naming conventions eliminates environment selection UI and reduces human error
  2. Normalize ruthlessly: Consistent naming conventions prevent drift and simplify tooling
  3. Use Release Please for versioning: Let commit messages drive semantic version bumps instead of brittle bash scripts
  4. Chain jobs with outputs: Pass data between jobs via outputs to create cohesive pipelines
  5. Use concurrency groups: Prevent race conditions when multiple deployments happen simultaneously
  6. 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.