GitHub Actions Patterns for Practical CI/CD

GitHub Actions has become the default CI/CD for many teams. Here are patterns I’ve seen work well in production, and a few anti-patterns to avoid. The Foundation: A Reusable Test Workflow 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 name: Test on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - run: npm ci - run: npm test Key details: ...

February 28, 2026 Β· 4 min Β· 765 words Β· Rob Washington

GitHub Actions Patterns for Real-World CI/CD

GitHub Actions tutorials show you on: push with a simple build. Real projects need caching, matrix builds, environment protection, secrets management, and reusable workflows. Here’s what actually works. Workflow Structure 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # .github/workflows/ci.yml name: CI on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run tests run: npm test Caching Dependencies Without caching, every run downloads the internet: ...

February 26, 2026 Β· 7 min Β· 1419 words Β· Rob Washington

Git Hooks for Automation: Enforce Quality Before It Hits the Repo

Git hooks run scripts at key points in your workflow. Use them to catch problems before they become pull requests. Hook Basics Hooks live in .git/hooks/. Each hook is an executable script named after the event. 1 2 3 4 5 6 # List available hooks ls .git/hooks/*.sample # Make a hook active cp .git/hooks/pre-commit.sample .git/hooks/pre-commit chmod +x .git/hooks/pre-commit Common Hooks Hook When Use Case pre-commit Before commit created Lint, format, test prepare-commit-msg After default message Add ticket numbers commit-msg After message entered Validate format pre-push Before push Run full tests post-merge After merge Install dependencies post-checkout After checkout Environment setup Pre-commit Hook Basic Linting 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 #!/bin/bash # .git/hooks/pre-commit echo "Running pre-commit checks..." # Get staged files STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM) # Python files PYTHON_FILES=$(echo "$STAGED_FILES" | grep '\.py$') if [ -n "$PYTHON_FILES" ]; then echo "Linting Python files..." ruff check $PYTHON_FILES || exit 1 black --check $PYTHON_FILES || exit 1 fi # JavaScript files JS_FILES=$(echo "$STAGED_FILES" | grep -E '\.(js|ts)$') if [ -n "$JS_FILES" ]; then echo "Linting JavaScript files..." eslint $JS_FILES || exit 1 fi echo "All checks passed!" Prevent Debug Statements 1 2 3 4 5 6 7 8 9 #!/bin/bash # .git/hooks/pre-commit # Check for debug statements if git diff --cached | grep -E '(console\.log|debugger|import pdb|breakpoint\(\))'; then echo "ERROR: Debug statements found!" echo "Remove console.log, debugger, pdb, or breakpoint() before committing." exit 1 fi Check for Secrets 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 #!/bin/bash # .git/hooks/pre-commit # Patterns that might be secrets PATTERNS=( 'password\s*=\s*["\047][^"\047]+' 'api_key\s*=\s*["\047][^"\047]+' 'secret\s*=\s*["\047][^"\047]+' 'AWS_SECRET_ACCESS_KEY' 'PRIVATE KEY' ) for pattern in "${PATTERNS[@]}"; do if git diff --cached | grep -iE "$pattern"; then echo "ERROR: Possible secret detected!" echo "Pattern: $pattern" exit 1 fi done Commit Message Hook Enforce Conventional Commits 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 #!/bin/bash # .git/hooks/commit-msg COMMIT_MSG_FILE=$1 COMMIT_MSG=$(cat "$COMMIT_MSG_FILE") # Conventional commit pattern PATTERN="^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{1,50}" if ! echo "$COMMIT_MSG" | grep -qE "$PATTERN"; then echo "ERROR: Invalid commit message format!" echo "" echo "Expected: <type>(<scope>): <description>" echo "Types: feat, fix, docs, style, refactor, test, chore" echo "" echo "Examples:" echo " feat(auth): add OAuth2 support" echo " fix: resolve memory leak in worker" echo "" exit 1 fi Add Ticket Number 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 #!/bin/bash # .git/hooks/prepare-commit-msg COMMIT_MSG_FILE=$1 BRANCH_NAME=$(git symbolic-ref --short HEAD) # Extract ticket number from branch (e.g., feature/JIRA-123-description) TICKET=$(echo "$BRANCH_NAME" | grep -oE '[A-Z]+-[0-9]+') if [ -n "$TICKET" ]; then # Prepend ticket number if not already present if ! grep -q "$TICKET" "$COMMIT_MSG_FILE"; then sed -i.bak "1s/^/[$TICKET] /" "$COMMIT_MSG_FILE" fi fi Pre-push Hook Run Tests Before Push 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 #!/bin/bash # .git/hooks/pre-push echo "Running tests before push..." # Run test suite npm test if [ $? -ne 0 ]; then echo "ERROR: Tests failed! Push aborted." exit 1 fi # Check coverage threshold npm run coverage COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct') if (( $(echo "$COVERAGE < 80" | bc -l) )); then echo "ERROR: Coverage below 80%! Current: $COVERAGE%" exit 1 fi echo "All checks passed. Pushing..." Prevent Force Push to Main 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 #!/bin/bash # .git/hooks/pre-push PROTECTED_BRANCHES="main master" CURRENT_BRANCH=$(git symbolic-ref --short HEAD) # Check if force pushing while read local_ref local_sha remote_ref remote_sha; do if echo "$PROTECTED_BRANCHES" | grep -qw "$CURRENT_BRANCH"; then if [ "$remote_sha" != "0000000000000000000000000000000000000000" ]; then # Check if this is a force push if ! git merge-base --is-ancestor "$remote_sha" "$local_sha" 2>/dev/null; then echo "ERROR: Force push to $CURRENT_BRANCH is not allowed!" exit 1 fi fi fi done Post-merge Hook Auto-install Dependencies 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 #!/bin/bash # .git/hooks/post-merge CHANGED_FILES=$(git diff-tree -r --name-only ORIG_HEAD HEAD) # Check if package.json changed if echo "$CHANGED_FILES" | grep -q "package.json"; then echo "package.json changed, running npm install..." npm install fi # Check if requirements.txt changed if echo "$CHANGED_FILES" | grep -q "requirements.txt"; then echo "requirements.txt changed, running pip install..." pip install -r requirements.txt fi # Check if migrations added if echo "$CHANGED_FILES" | grep -q "migrations/"; then echo "New migrations detected!" echo "Run: python manage.py migrate" fi Using pre-commit Framework The pre-commit framework manages hooks declaratively. ...

February 25, 2026 Β· 6 min Β· 1136 words Β· Rob Washington

GitHub Actions Self-Hosted Runners: Complete Setup Guide

When GitHub-hosted runners aren’t enoughβ€”when you need GPU access, specific hardware, private network connectivity, or just want to stop paying per-minuteβ€”self-hosted runners are the answer. Why Self-Hosted? Performance: Your hardware, your speed. No cold starts, local caching, faster artifact access. Cost: After a certain threshold, self-hosted is dramatically cheaper. GitHub-hosted minutes add up fast for active repos. Access: Private networks, internal services, specialized hardware, air-gapped environments. Control: Exact OS versions, pre-installed dependencies, custom security configurations. ...

February 25, 2026 Β· 5 min Β· 1008 words Β· Rob Washington

Container Image Security: Scanning Before You Ship

Every container image you deploy is a collection of dependencies you didn’t write. Some of those dependencies have known vulnerabilities. The question isn’t whether your images have CVEs β€” it’s whether you know about them before attackers do. The Problem A typical container image includes: A base OS (Alpine, Debian, Ubuntu) Language runtime (Python, Node, Go) Application dependencies (npm packages, pip modules) Your actual code Each layer can introduce vulnerabilities. That innocent FROM python:3.11 pulls in hundreds of packages you’ve never audited. ...

February 22, 2026 Β· 6 min Β· 1223 words Β· Rob Washington

GitOps Workflows: Infrastructure Changes Through Pull Requests

Git isn’t just for code anymore. In a GitOps workflow, your entire infrastructure lives in version control, and changes happen through pull requests, not SSH sessions. The principle is simple: the desired state of your system is declared in Git, and automated processes continuously reconcile actual state with desired state. No more β€œjust SSH in and fix it.” No more tribal knowledge about what’s running where. The Core Loop GitOps operates on a continuous reconciliation loop: ...

February 18, 2026 Β· 9 min Β· 1887 words Β· Rob Washington

Building Custom GitHub Actions for Infrastructure Automation

GitHub Actions has become the de facto CI/CD platform for many teams, but most only scratch the surface with pre-built actions from the marketplace. Building custom actions tailored to your infrastructure needs can dramatically reduce boilerplate and enforce consistency across repositories. Why Custom Actions? Every DevOps team has workflows that repeat across projects: Deploying to specific cloud environments Running security scans with custom policies Provisioning temporary environments for PR reviews Rotating secrets on a schedule Instead of copy-pasting YAML across repositories, custom actions encapsulate this logic once and reference it everywhere. ...

February 14, 2026 Β· 5 min Β· 984 words Β· Rob Washington

Feature Flags: The Art of Shipping Code Without Shipping Features

There’s a subtle but powerful distinction in modern software delivery: deployment is not release. Deployment means your code is running in production. Release means your users can see it. Feature flags are the bridge between these two conceptsβ€”and mastering them changes how you think about shipping software. The Problem with Traditional Deployment In the old model, deploying code meant releasing features: 1 2 3 # Old way: deploy = release git push origin main # Boom, everyone sees the new feature immediately This creates pressure. You can’t deploy partially-complete work. You can’t test in production with real traffic. And if something breaks, your only option is another deploy to roll back. ...

February 12, 2026 Β· 6 min Β· 1269 words Β· Rob Washington

GitOps with ArgoCD: Your Infrastructure as Code, Actually

GitOps takes β€œInfrastructure as Code” literally: your Git repository becomes the single source of truth for what should be running. ArgoCD watches your repo and automatically synchronizes your cluster to match. No more kubectl apply from laptops, no more β€œwhat’s actually deployed?” mysteries. GitOps Principles Declarative: Describe the desired state, not the steps to get there Versioned: All changes go through Git (audit trail, rollback) Automated: Changes are applied automatically when Git changes Self-healing: Drift from desired state is automatically corrected Installing ArgoCD 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # Create namespace kubectl create namespace argocd # Install ArgoCD kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml # Wait for pods kubectl wait --for=condition=Ready pods --all -n argocd --timeout=300s # Get initial admin password kubectl -n argocd get secret argocd-initial-admin-secret \ -o jsonpath="{.data.password}" | base64 -d # Port forward to access UI kubectl port-forward svc/argocd-server -n argocd 8080:443 Access the UI at https://localhost:8080 with username admin. ...

February 11, 2026 Β· 8 min Β· 1579 words Β· Rob Washington

Zero-Downtime Deployments: Blue-Green and Canary Strategies

Deploying new code shouldn’t mean crossing your fingers. Blue-green and canary deployments let you release changes with confidence, validate them with real traffic, and roll back in seconds if something goes wrong. Blue-Green Deployments Blue-green maintains two identical production environments. One serves traffic (blue), while the other stands ready (green). To deploy, you push to green, test it, then switch traffic over. B β”Œ β”‚ β”‚ β”‚ β”” β”Œ β”‚ β”‚ β”‚ β”” A β”Œ β”‚ β”‚ β”‚ β”” β”Œ β”‚ β”‚ β”‚ β”” e ─ ─ ─ ─ f ─ ─ ─ ─ f ─ ─ ─ ─ t ─ ─ ─ ─ o ─ ─ ─ S ─ e ─ S ─ ─ ─ r ─ A ─ ─ ( T ─ r ─ T ─ ─ A ─ e ─ B v C ─ ─ G i A ─ ─ B v A ─ ─ G v C ─ ─ l 1 T ─ ─ r d N ─ d ─ l 1 N ─ ─ r 1 T ─ d ─ u . I ─ ─ e l D ─ e ─ u . D ─ ─ e . I ─ e ─ e 0 V ─ ─ e e B ─ p ─ e 0 B ─ ─ e 1 V ─ p ─ ) E ─ ─ n ) Y ─ l ─ ) Y ─ ─ n ) E ─ l ─ ─ ─ ─ o ─ ─ ─ ─ o ─ ─ ─ ─ y ─ ─ ─ ─ y ─ ─ ─ ─ m ─ ─ ─ ─ m ─ ─ ─ ─ e ─ ─ ─ ─ e ┐ β”‚ β”‚ β”‚ β”˜ ┐ β”‚ β”‚ β”‚ β”˜ n ┐ β”‚ β”‚ β”‚ β”˜ ┐ β”‚ β”‚ β”‚ β”˜ n t t β—„ : β—„ : ─ ─ ─ ─ β”Œ β”‚ β”” β”Œ β”‚ β”” ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ R ─ ─ R ─ ─ o ─ ─ o ─ ─ u ─ ─ u ─ ─ t ─ ─ t ─ ─ e ─ ─ e ─ ─ r ─ ─ r ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ β”‚ β”˜ ┐ β”‚ β”˜ β—„ β—„ ─ ─ ─ ─ U U s s e e r r s s Kubernetes Implementation 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 # blue-deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: app-blue labels: app: myapp version: blue spec: replicas: 3 selector: matchLabels: app: myapp version: blue template: metadata: labels: app: myapp version: blue spec: containers: - name: app image: myapp:1.0.0 ports: - containerPort: 8080 --- # green-deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: app-green labels: app: myapp version: green spec: replicas: 3 selector: matchLabels: app: myapp version: green template: metadata: labels: app: myapp version: green spec: containers: - name: app image: myapp:1.1.0 ports: - containerPort: 8080 --- # service.yaml - switch by changing selector apiVersion: v1 kind: Service metadata: name: myapp spec: selector: app: myapp version: blue # Change to 'green' to switch ports: - port: 80 targetPort: 8080 Deployment Script 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 #!/bin/bash set -e NEW_VERSION=$1 CURRENT=$(kubectl get svc myapp -o jsonpath='{.spec.selector.version}') if [ "$CURRENT" == "blue" ]; then TARGET="green" else TARGET="blue" fi echo "Current: $CURRENT, Deploying to: $TARGET" # Update the standby deployment kubectl set image deployment/app-$TARGET app=myapp:$NEW_VERSION # Wait for rollout kubectl rollout status deployment/app-$TARGET --timeout=300s # Run smoke tests against standby kubectl run smoke-test --rm -it --image=curlimages/curl \ --restart=Never -- curl -f http://app-$TARGET:8080/health # Switch traffic kubectl patch svc myapp -p "{\"spec\":{\"selector\":{\"version\":\"$TARGET\"}}}" echo "Switched to $TARGET (v$NEW_VERSION)" Instant Rollback 1 2 3 4 5 6 7 8 9 10 11 12 13 #!/bin/bash # rollback.sh - switch back to previous version CURRENT=$(kubectl get svc myapp -o jsonpath='{.spec.selector.version}') if [ "$CURRENT" == "blue" ]; then PREVIOUS="green" else PREVIOUS="blue" fi kubectl patch svc myapp -p "{\"spec\":{\"selector\":{\"version\":\"$PREVIOUS\"}}}" echo "Rolled back to $PREVIOUS" Canary Deployments Canary deployments route a small percentage of traffic to the new version, gradually increasing if metrics look good. ...

February 11, 2026 Β· 8 min Β· 1669 words Β· Rob Washington