ci-cd-pipelines
GitHub Actions and GitLab CI/CD pipeline expertise. Workflow syntax, job matrix, dependency caching (npm, pip, go, docker layers), artifact management, reusable workflows, composite actions, environment secrets, deployment patterns (blue-green, canary, rolling), Docker builds in CI, version bumping, branch protection, status checks. Use when creating CI/CD pipelines, optimizing build times, setting up deployment automation, or configuring GitHub Actions workflows.
You are a senior DevOps engineer who builds fast, reliable CI/CD pipelines with zero tolerance for flaky builds.
Use this skill when
- Creating or modifying GitHub Actions or GitLab CI workflows
- Optimizing CI build times or caching strategies
- Setting up deployment automation (staging, production)
- Configuring Docker builds in CI
- Designing branch protection and merge requirements
- Automating version bumping or changelog generation
GitHub Actions: Workflow Fundamentals
Minimal Production Workflow
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm test -- --coverage
- run: pnpm build
Critical rules:
- Always set
timeout-minutes. Default is 360 (6 hours). A hung job burns your minutes. - Always use
concurrencywithcancel-in-progresson PRs. No reason to test stale commits. - Always pin action versions to major (
@v4) or SHA for security-critical actions. - Always use
--frozen-lockfile/--cito prevent lockfile drift.
Job Matrix
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20, 22]
exclude:
- os: windows-latest
node: 18
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
Set fail-fast: false to see all failures, not just the first. Exclude combinations that don't matter.
Caching Strategies
Node.js (pnpm)
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
# This caches the pnpm store. For node_modules caching:
- uses: actions/cache@v4
with:
path: node_modules
key: modules-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
Go
- uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true # Caches ~/go/pkg/mod and ~/.cache/go-build
Python (pip)
- uses: actions/setup-python@v5
with:
python-version: "3.14"
cache: "pip"
cache-dependency-path: requirements*.txt
Docker Layer Caching
- uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.ref == 'refs/heads/main' }}
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
type=gha uses GitHub Actions cache backend -- no external registry needed. mode=max caches all layers, not just final.
Reusable Workflows
Caller
# .github/workflows/ci.yml
jobs:
test:
uses: ./.github/workflows/reusable-test.yml
with:
node-version: 20
secrets: inherit
Callee
# .github/workflows/reusable-test.yml
on:
workflow_call:
inputs:
node-version:
type: number
default: 20
secrets:
NPM_TOKEN:
required: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- run: pnpm install --frozen-lockfile
- run: pnpm test
Use secrets: inherit to pass all secrets. Use explicit secrets: block for cross-repo calls.
Composite Actions
For reusable steps (not jobs). Lives in .github/actions/<name>/action.yml.
# .github/actions/setup-project/action.yml
name: Setup Project
description: Install deps and cache
inputs:
node-version:
default: "20"
runs:
using: composite
steps:
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: "pnpm"
- run: pnpm install --frozen-lockfile
shell: bash
Every run step in a composite action MUST specify shell: bash.
Secrets & Environments
jobs:
deploy-staging:
environment: staging # Requires approval if configured
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
steps:
- run: echo "Deploying to staging"
deploy-production:
needs: deploy-staging
environment: production # Separate approvers, separate secrets
steps:
- run: echo "Deploying to production"
Never log secrets. Use ::add-mask:: if you must construct a secret dynamically:
- run: |
TOKEN=$(generate-token)
echo "::add-mask::$TOKEN"
echo "TOKEN=$TOKEN" >> $GITHUB_ENV
Deployment Patterns
Blue-Green (zero-downtime swap)
deploy:
steps:
- run: |
# Deploy to inactive slot
az webapp deployment slot create --name myapp --slot staging
az webapp deploy --slot staging --src-path dist.zip
# Smoke test staging
curl --fail https://myapp-staging.azurewebsites.net/health
# Swap
az webapp deployment slot swap --name myapp --slot staging --target-slot production
Canary (gradual rollout)
- run: |
kubectl set image deployment/app app=myimage:${{ github.sha }}
kubectl rollout status deployment/app --timeout=120s
# Route 10% traffic to new version
kubectl apply -f canary-10-percent.yaml
sleep 300
# Check error rate, proceed or rollback
ERROR_RATE=$(curl -s prometheus/api/v1/query?query=error_rate)
if [ $(echo "$ERROR_RATE > 0.01" | bc) -eq 1 ]; then
kubectl rollout undo deployment/app
exit 1
fi
kubectl apply -f canary-100-percent.yaml
Rolling (Kubernetes default)
Kubernetes RollingUpdate with maxSurge: 1 and maxUnavailable: 0 for zero-downtime.
Docker in CI
Multi-stage Dockerfile optimized for CI
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile --prod
FROM node:24-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["dist/index.js"]
Order layers by change frequency: OS < runtime < deps < source. Each line is a cache layer.
Version Bumping with Changesets
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- uses: changesets/action@v1
with:
publish: pnpm release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
Branch Protection Checklist
Configure in repo Settings > Branches > Branch protection rules:
- Require PR reviews (1 minimum, dismiss stale on new push)
- Require status checks:
test,lint,buildmust pass - Require branches up to date before merge
- Require signed commits for high-security repos
- Restrict force pushes (always, no exceptions on main)
GitLab CI Basics
# .gitlab-ci.yml
stages:
- test
- build
- deploy
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
cache:
key: "$CI_COMMIT_REF_SLUG"
paths:
- .cache/pip
- node_modules/
test:
stage: test
image: python:3.14
script:
- pip install -r requirements.txt
- pytest --cov=src --junitxml=report.xml
artifacts:
reports:
junit: report.xml
build:
stage: build
image: docker:24
services:
- docker:24-dind
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
only:
- main
Key GitLab differences from GitHub Actions:
stagesdefine order; jobs in same stage run in parallel.cacheis per-runner, not per-repo. Usekeycarefully.artifactspass files between stages (GitHub usesactions/upload-artifact).servicesfor Docker-in-Docker (GitHub usesdocker/setup-buildx-action).
Artifact Caching Gotchas
GitHub Actions cache has a 10GB limit per repo. Older entries are evicted when full.
- Cache thrashing: Don't use
${{ github.sha }}as key. Use${{ hashFiles('**/lockfile') }}. - Never-hit cache: Key changes every run. Use
restore-keysfor fallback. - Stale deps: Include dependency file hash in key to force invalidation.
Game Engine CI (Unity)
Uses game-ci actions. Key concerns: LFS checkout, Library caching, platform matrix, license secret.
# .github/workflows/unity.yml
name: Unity Build
on:
push:
branches: [main, develop]
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { lfs: true }
- uses: actions/cache@v4
with:
path: Library
key: Library-${{ hashFiles('Assets/**', 'Packages/**', 'ProjectSettings/**') }}
restore-keys: Library-
- uses: game-ci/unity-test-runner@v4
with: { testMode: all, artifactsPath: test-results }
- uses: actions/upload-artifact@v4
if: always()
with: { name: test-results, path: test-results }
build:
needs: test
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
targetPlatform: [StandaloneWindows64, StandaloneLinux64, WebGL, Android]
steps:
- uses: actions/checkout@v4
with: { lfs: true }
- uses: actions/cache@v4
with:
path: Library
key: Library-${{ matrix.targetPlatform }}-${{ hashFiles('Assets/**') }}
restore-keys: Library-${{ matrix.targetPlatform }}-
- uses: game-ci/unity-builder@v4
with:
targetPlatform: ${{ matrix.targetPlatform }}
versioning: Semantic
buildMethod: BuildScript.PerformBuild
- uses: actions/upload-artifact@v4
with:
name: Build-${{ matrix.targetPlatform }}
path: build/${{ matrix.targetPlatform }}
retention-days: 14
BuildScript.cs
// Assets/Editor/BuildScript.cs
using UnityEditor;
using UnityEditor.Build.Reporting;
public class BuildScript
{
public static void PerformBuild()
{
var report = BuildPipeline.BuildPlayer(new BuildPlayerOptions
{
scenes = new[] { "Assets/Scenes/Main.unity" },
locationPathName = "build/Game.exe",
target = BuildTarget.StandaloneWindows64,
options = BuildOptions.None
});
if (report.summary.result != BuildResult.Succeeded)
EditorApplication.Exit(1);
}
}
PlayMode Test
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using System.Collections;
public class PlayerMovementTests
{
private GameObject _player;
[UnitySetUp]
public IEnumerator SetUp()
{
_player = Object.Instantiate(Resources.Load<GameObject>("Prefabs/Player"));
yield return null;
}
[UnityTest]
public IEnumerator Player_MovesForward_WhenInputApplied()
{
Vector3 startPos = _player.transform.position;
_player.GetComponent<PlayerController>().SetInput(Vector2.up);
yield return new WaitForSeconds(0.5f);
Assert.Greater(_player.transform.position.z, startPos.z);
}
[UnityTearDown]
public IEnumerator TearDown() { Object.Destroy(_player); yield return null; }
}
Unity CI rules:
- Always
lfs: true. Unity projects use Git LFS for assets. - Cache
Library/with per-platform keys. Regeneration takes 5-15 min. testMode: allruns EditMode + PlayMode. Split if needed.- Build times: WebGL 10-20 min, Windows 5-10 min, Android 15-25 min.
CI Performance Rules
- Parallelize everything: lint, test, typecheck, build as separate jobs. They don't depend on each other.
- Cache aggressively: dependency installs are the #1 time sink. Cache both store and node_modules.
- Fail fast: put lint and typecheck before tests. They're 10x faster and catch 50% of issues.
- Skip unnecessary work: use path filters to skip CI for docs-only changes.
- Use
ubuntu-latest: it's 2-3x faster thanmacos-latestand 5-10x faster thanwindows-latest.
on:
push:
paths-ignore:
- "docs/**"
- "*.md"
- ".gitignore"