
Devops Tooling
- 14 installs
- 7 repo stars
- Updated August 2, 2026
- practicalswan/agent-skills
devops-tooling is a Claude Code skill for devops & ci/cd.
About
devops-tooling is a Claude Code skill for devops & ci/cd. It helps solo builders move faster with AI-assisted development.
- devops-tooling
- DevOps & CI/CD
- AI-coding skill
Devops Tooling by the numbers
- 14 all-time installs (skills.sh)
- Ranked #958 of 1,435 DevOps & CI/CD skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/practicalswan/agent-skills --skill devops-toolingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 14 |
|---|---|
| repo stars | ★ 7 |
| Last updated | August 2, 2026 |
| Repository | practicalswan/agent-skills ↗ |
How do I helps with devops & ci/cd tasks.?
Helps with devops & ci/cd tasks.
Who is it for?
Best when you're working on devops & ci/cd and need structured help with devops tooling.
Skip if: Teams with no devops & ci/cd needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with devops & ci/cd tasks., or when devops-tooling is a claude code skill for devops & ci/cd.
What you get
Structured output aligned to devops-tooling: devops-tooling, DevOps & CI/CD.
Files
Devops Tooling
Comprehensive toolkit for Git workflows, shell scripting, and development automation.
- Leverage native parallel subagent dispatch and 200k+ context windows where available.
When to Use This Skill
Use symptom -> action triggers: when one matches, apply this skill and verify with the protocol below.
- Creating conventional commits and managing Git workflows
- Writing Bash, Zsh, or PowerShell automation scripts
- Configuring CI/CD pipelines (GitHub Actions, Azure DevOps)
- Automating development, testing, or deployment tasks
- Troubleshooting Git conflicts and repository hygiene issues
Part 1: Git Workflows
Conventional Commits
The conventional commit specification provides an easy-to-extend set of rules for creating an explicit commit history.
Commit Format
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]Types
| Type | Purpose | Example |
|---|---|---|
feat | New feature | feat(auth): add OAuth2 support |
fix | Bug fix | fix(api): resolve null reference |
docs | Documentation only | docs(readme): update setup guide |
style | Formatting/style (no logic) | style(ui): fix indentation |
refactor | Refactor production code | refactor(svc): extract helpers |
perf | Performance improvement | perf(db): add index on email |
test | Adding tests | test(auth): add unit tests |
build | Build system or deps | build(ci): upgrade Node to v20 |
ci | CI configuration changes | ci(github): add workflow for PRs |
chore | Maintenance tasks | chore(deps): update packages |
revert | Revert previous commit | revert: feat(login) |
Breaking Changes
Breaking changes must be indicated by ! after the type/scope, or via BREAKING CHANGE in footer:
feat(api)!: remove deprecated v1 endpoint
feat(api): remove deprecated v1 endpoint
BREAKING CHANGE: v1 endpoints are no longer supported. Use v2.Good Examples
feat(auth): implement JWT refresh tokens
fix(ui): resolve mobile navigation overlap issue
docs(api): add authentication examples
refactor(user): extract validation logic to separate module
perf(images): implement lazy loading
feat(core)!: change data structure from array to objectGit Operations
Branch Management
git checkout -b feature/PROJ-123/user-auth
git checkout develop
git branch -d feature/PROJ-123/user-auth
git push origin --delete feature/PROJ-123/user-auth
git branch -m new-name
git branch -aCommit Workflow
git add .
git add file1.ts file2.ts
git add -i
git commit -m "feat(auth): add OAuth2 support"
git commit --amend
git log --oneline --graph --all
git show <commit-hash>Merge & Rebase
git merge feature/new-feature
git rebase develop
git rebase -i HEAD~3
git rebase --abort
git merge --abort
git rebase --continue
git merge --continueHandling Conflicts
git status
vim conflicting-file.ts
git add conflicting-file.ts
git commit # for merges
git rebase --continue # for rebasesStashing
git stash push -m "Work in progress"
git stash pop
git stash apply stash@{2}
git stash list
git stash drop stash@{2}
git stash clearTagging
git tag -a v1.0.0 -m "Release v1.0.0"
git tag v1.0.0
git tag
git push origin --tags
git push origin v1.0.0
git tag -d v1.0.0
git push origin --delete v1.0.0Git Diff
git diff
git diff --staged
git diff src/app.ts
git diff HEAD~2 HEAD
git log --oneline v1.0.0..v2.0.0
git diff --statGit Configuration
git config --global user.name "Your Name"
git config --global user.email "your@email.com"
git config --global init.defaultBranch main
git config --global commit.gpgsign true
git config --global alias.co checkout
git config --global alias.br branch
git config --global alias.ci commit
git config --global alias.st status
git config --unset user.name
git config --list---
Part 2: Shell Scripting (bash/zsh)
General Principles
- Generate clean, simple, and concise code
- Ensure scripts are easily readable and understandable
- Add comments where needed for understanding
- Generate concise echo outputs for execution status
- Avoid unnecessary output and excessive logging
Error Handling & Safety
Enable Strict Mode
Always enable strict mode at the top of scripts:
#!/bin/bash
set -euo pipefail-e: Exit on first error-u: Treat unset variables as errors-o pipefail: Surface pipeline failures
Cleanup with Traps
cleanup() {
# Remove temporary files
if [[ -n "${TEMP_DIR:-}" && -d "$TEMP_DIR" ]]; then
rm -rf "$TEMP_DIR"
fi
# Close connections
if [[ -n "${CONNECTION:-}" ]]; then
echo "Closing connection..."
# connection close logic
fi
}
trap cleanup EXIT
trap 'echo "Interrupted"; cleanup; exit 1' INT TERMValidate Requirements
validate_requirements() {
local errors=0
# Check required variables
if [[ -z "${RESOURCE_GROUP:-}" ]]; then
echo "Error: RESOURCE_GROUP environment variable not set" >&2
((errors++))
fi
# Check required commands
for cmd in curl jq az; do
if ! command -v "$cmd" &> /dev/null; then
echo "Error: $cmd is not installed" >&2
((errors++))
fi
done
# Return appropriate exit code
return $errors
}
if ! validate_requirements; then
exit 1
fiWorking with Variables
echo "Config: ${CONFIG_FILE:-./config.default.yaml}"
name="World"
echo "Hello, ${name}!"
apps=("app1" "app2" "app3")
for app in "${apps[@]}"; do
echo "Processing: $app"
done
declare -A config
config[host]="localhost"
config[port]="8080"
echo "Connecting to ${config[host]}:${config[port]}"Control Flow
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
echo "Linux detected"
elif [[ "$OSTYPE" == "darwin"* ]]; then
echo "macOS detected"
else
echo "Unknown OS"
fi
case "$1" in
start)
echo "Starting service..."
;;
stop)
echo "Stopping service..."
;;
*)
echo "Usage: $0 {start|stop}"
exit 1
;;
esac
for i in {1..5}; do
echo "Iteration $i"
done
timeout=30
elapsed=0
while [[ $elapsed -lt $timeout ]]; do
if check_ready; then
echo "Service ready"
break
fi
sleep 1
((elapsed++))
doneParsing JSON with jq
result=$(curl -s "https://api.example.com/data")
name=$(echo "$result" | jq -r '.name')
count=$(echo "$result" | jq '.items | length')
first_item=$(echo "$result" | jq -r '.items[0]')
active_users=$(echo "$result" | jq '.users[] | select(.status == "active")')
read -r first_name last_name email <<<$(echo "$result" | jq -r '"\(.firstName) \(.lastName) \(.email)"')
echo "$result" | jq '.count += 1' > updated.json
jq -n \
--arg name "John" \
--arg age "30" \
'{name: $name, age: ($age | tonumber)}'Parsing Arguments
#!/bin/bash
verbose=0
output_file="output.txt"
config="./config.yaml"
while [[ $# -gt 0 ]]; do
case $1 in
-v|--verbose)
verbose=1
shift
;;
-o|--output)
output_file="$2"
shift 2
;;
-c|--config)
config="$2"
shift 2
;;
-h|--help)
show_help
exit 0
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
echo "Config: $config"
echo "Output: $output_file"
echo "Verbose: $verbose"File Operations
if [[ -f "$FILE_PATH" ]]; then
echo "File exists"
fi
if [[ ! -d "$DIR_PATH" ]]; then
mkdir -p "$DIR_PATH"
fi
mkdir -p ./dir1/dir2
cp -r source_dir/ dest_dir/
rm -f file.txt # Force delete file
rm -rf directory/ # Recursive delete directory
find . -name "*.py" # All Python files
find . -type f -name "*.js" # All JS files (regular files only)
find . -mtime -7 # Modified in last 7 days
temp_file=$(mktemp)
echo "data" > "$temp_file"
rm -f "$temp_file"
temp_dir=$(mktemp -d)
rm -rf "$temp_dir"Process Management
if pgrep -x "nginx" > /dev/null; then
echo "Nginx is running"
else
echo "Nginx is not running"
fi
while pgrep -x "script-name" > /dev/null; do
sleep 1
done
timeout 30s ./long-running-script.sh || echo "Timed out after 30s"
./script.sh &
job_pid=$!
trap "kill $job_pid 2>/dev/null" EXITLogging
#!/bin/bash
LOG_INFO() {
echo "[INFO] $(date '+%Y-%m-%d %H:%M:%S') $*"
}
LOG_WARN() {
echo "[WARN] $(date '+%Y-%m-%d %H:%M:%S') $*" >&2
}
LOG_ERROR() {
echo "[ERROR] $(date '+%Y-%m-%d %H:%M:%S') $*" >&2
}
LOG_DEBUG() {
if [[ "${DEBUG:-}" == "1" ]]; then
echo "[DEBUG] $(date '+%Y-%m-%d %H:%M:%S') $*"
fi
}
LOG_INFO "Starting deployment"
LOG_WARN "This is a warning"
LOG_ERROR "Deployment failed"
LOG_DEBUG "Detailed debugging info"---
Part 3: PowerShell Scripting
General Practices
- Use proper cmdlet names instead of aliases (e.g.,
Get-ChildItem, notdir) - Quote paths with spaces:
"C:\Path With Spaces\file.txt" - Use
ShouldProcessfor destructive operations - Implement proper error handling with try-catch
- Parameterize scripts for reusability
Error Handling
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
try {
$result = Invoke-RestMethod -Uri "https://api.example.com/data" -Method Get
}
catch [System.Net.WebException] {
Write-Error "Network error occurred: $($_.Exception.Message)"
exit 1
}
catch {
Write-Error "Unexpected error: $($_.Exception.Message)"
exit 1
}
finally {
# Cleanup code always runs
Write-Output "Execution completed"
}
trap {
Write-Error "Script failed: $_"
# Cleanup code
Remove-Variable -Name tempVar -ErrorAction SilentlyContinue
exit 1
}Parameter Handling
param(
[Parameter(Mandatory=$true)]
[string]$Name,
[Parameter(Mandatory=$false)]
[string]$Path = ".",
[Parameter(Mandatory=$false)]
[switch]$Verbose,
[ValidateSet("dev", "staging", "prod")]
[string]$Environment = "dev"
)
Write-Output "Name: $Name"
Write-Output "Path: $Path"
Write-Output "Environment: $Environment"
Write-Output "Verbose: $Verbose"Working with Objects
$user = [PSCustomObject]@{
Name = "John"
Age = 30
Email = "john@example.com",
Address = "123 Main St"
}
Write-Output $user.Name
$user | Add-Member -MemberType NoteProperty -Name "City" -Value "Anytown"
$users | Where-Object { $_.Age -gt 25 }
$users | Select-Object Name, Email
$users | Sort-Object Name
$users | Group-Object CityWorking with JSON
$json = '{"name": "John", "age": 30}'
$obj = $json | ConvertFrom-Json
Write-Output $obj.name
$data = @{ name = "John"; age = 30 }
$json = $data | ConvertTo-Json -Depth 10
Write-Output $json
$config = Get-Content "config.json" | ConvertFrom-Json
$config | ConvertTo-Json -Depth 10 | Set-Content "config.json"Working with Arrays and Hashtables
$files = @("file1.txt", "file2.txt", "file3.txt")
$files += "file4.txt"
$filtered = $files | Where-Object { $_ -like "*.txt" }
$config = @{
host = "localhost"
port = 8080
tls = $true
}
Write-Output $config.host
$key = "port"
Write-Output $config[$key]
foreach ($item in $config.GetEnumerator()) {
Write-Output "$($item.Name) = $($item.Value)"
}File Operations
if (Test-Path "C:\path\to\file.txt") {
Write-Output "File exists"
}
New-Item -ItemType Directory -Path "C:\new\dir" -Force
Remove-Item "C:\path\to\file.txt" -Force
Remove-Item "C:\path\to\directory" -Recurse -Force
Copy-Item "source.txt" "destination.txt" -Force
Copy-Item "dir\" "backup\" -Recurse
Move-Item "old_name.txt" "new_name.txt"
$content = Get-Content "file.txt"
$content = Get-Content "file.txt" -Raw # As single string
$content | Set-Content "file.txt"
"more content" | Add-Content "file.txt"String Operations
$name = "John"
Write-Output "Hello, $name!"
Write-Output ("Hello, {0}! Your score is {1:N2}" -f $name, 95.5)
$string = " Hello World "
$trimmed = $string.Trim()
$replaced = $string.Replace("World", "PowerShell")
if ($string -like "*World*") {
Write-Output "Contains 'World'"
}
if ($string -match "^Hello") {
Write-Output "Starts with 'Hello'"
}
$parts = "a,b,c".Split(",")---
Part 4: CI/CD Configuration
GitHub Actions
name: CI/CD Pipeline
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
workflow_dispatch: # Allow manual trigger
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x, 18.x, 20.x]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test
- name: Upload coverage
uses: codecov/codecov-action@v3
build:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Upload artifacts
uses: actions/upload-artifact@v3
with:
name: build
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v3
with:
name: build
- name: Deploy to Azure
run: az webapp up --name myapp --resource-group myrgAzure Pipelines
trigger:
- main
- develop
pool:
vmImage: 'ubuntu-latest'
variables:
buildConfiguration: 'Release'
packageFolder: '$(build.artifactStagingDirectory)/package'
stages:
- stage: Build
displayName: 'Build stage'
jobs:
- job: Build
displayName: 'Build job'
steps:
- task: NodeTool@0
inputs:
versionSpec: '18.x'
displayName: 'Install Node.js'
- script: |
npm ci
displayName: 'Install dependencies'
- script: |
npm run build
displayName: 'Build application'
- task: ArchiveFiles@2
inputs:
rootFolderOrFile: 'dist'
includeRootFolder: false
archiveType: 'zip'
archiveFile: $(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip
displayName: 'Archive build artifacts'
- publish: $(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip
artifact: drop
- stage: Deploy
displayName: 'Deploy stage'
dependsOn: Build
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: Deploy
displayName: 'Deploy job'
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1
inputs:
azureSubscription: 'your_subscription_id'
appName: 'your_webapp_name'
package: $(Pipeline.Workspace)/drop/$(Build.BuildId).zip---
DevOps Best Practices
Git
- [ ] Use conventional commits
- [ ] Keep commits small and focused
- [ ] Write clear, descriptive messages
- [ ] Keep feature branches short-lived
- [ ] Rebase feature branches before merging
- [ ] Sign commits for security-critical projects
Shell Scripting
- [ ] Always use strict mode (
set -euo pipefail) - [ ] Quote variables properly
- [ ] Handle errors gracefully with traps
- [ ] Validate inputs and parameters
- [ ] Use functions for reusability
- [ ] Add proper error messages to stderr
PowerShell
- [ ] Use strict mode for security
- [ ] Use proper cmdlet names (no aliases)
- [ ] Implement error handling with try-catch
- [ ] Test scripts thoroughly
- [ ] Use parameter validation
- [ ] Handle pipeline errors properly
CI/CD
- [ ] Use secure variable management for secrets
- [ ] Cache dependencies to speed up builds
- [ ] Run tests on multiple environments
- [ ] Use matrix builds for different configurations
- [ ] Implement proper artifact management
- [ ] Add deployment gates and approvals
- [ ] Provide clear build status
---
Anti-Patterns
- Starting work before the plan or gate is clear: Execution drifts when success criteria are implied instead of explicit.
- Treating verification as optional cleanup: The last mile is where regressions and missing updates are usually hiding.
- Mixing planning, implementation, and release work in one jump: You lose the causal chain that explains why a change is safe.
Verification Protocol
Before claiming "skill applied successfully":
1. Pass/fail: The Devops Tooling workflow starts from explicit success criteria, constraints, and stop conditions. 2. Pass/fail: Required evidence is collected before any completion, approval, or readiness claim. 3. Pass/fail: The next action follows the documented gate order without skipping review or verification steps. 4. Pressure-test scenario: Apply the workflow under time pressure with one failing check and one tempting shortcut. 5. Success metric: Zero rationalizations; blocked, failed, or unverified work is reported as such.
References & Resources
Documentation
- CI/CD Patterns — GitHub Actions patterns, caching, security scanning, and deployment strategies
- Shell Scripting Patterns — PowerShell and Bash patterns side-by-side for automation
Scripts
- Setup Git Hooks — PowerShell script to install pre-commit, commit-msg, and pre-push hooks
Examples
- GitHub Actions Templates — 8 production-ready workflow templates for Node.js, Python, Docker, Terraform
---
<!-- PORTABILITY:START -->
Cross-Client Portability
This skill is written to stay usable across GitHub Copilot, Claude Code, Codex, and Gemini CLI.
- GitHub Copilot: keep the folder in a Copilot-visible skill or plugin path, or wrap the workflow as project instructions if the host does not support portable skill folders directly.
- Claude Code: keep the folder in a local skills directory or a compatible plugin or marketplace source.
- Codex: install or sync the folder into
$CODEX_HOME/skills/<skill-name>and restart Codex after major changes. - Gemini CLI: this repository generates a project command named
/skills:devops-toolingfrom this skill. Rebuild commands withpython scripts/export-gemini-skill.py devops-toolingand then run/commands reloadinside Gemini CLI.
<!-- PORTABILITY:END -->
<!-- MCP:START -->
MCP Availability And Fallback
Preferred MCP Server: GitHub MCP
- Fallback prompt: "Use the Devops Tooling skill without MCP. Rely on the local
SKILL.md, bundled references or scripts, and manual verification. Show the exact commands, evidence, and final checks you used before concluding." - Use standard local tools such as
git,gh, CI logs, and shell automation scripts for repository and pipeline work. - Prefer the bundled repo scripts or direct YAML edits when the MCP host does not expose GitHub operations.
<!-- MCP:END -->
Related Skills
- development-workflow: Use it when the workflow also needs planning, quality gates, and delivery tracking.
- code-quality: Use it when the workflow also needs two-stage review (spec compliance first, then code quality), maintainability, and refactoring guidance.
- systematic-debugging: Use it when the workflow also needs root-cause debugging before proposing fixes.
- test-driven-development: Use it when the workflow also needs test-first implementation and regression safety.
Changelog
[2026-04-25] - Version 1.2 Verification Protocol Refresh
Added
- Added a
Verification Protocolsection with skill-specific pass/fail checks, one pressure-test scenario, and a measurable success metric. - Added guidance to leverage native parallel subagent dispatch and 200k+ context windows where available.
Changed
- Updated
SKILL.mdfrontmatter toversion: "1.2"andlast_updated: 2026-04-25. - Reframed activation guidance toward symptom -> action triggers and standardized two-stage review wording where applicable.
[2026-04-24] - Version 1.1 Refresh
Changed
- Updated the SKILL frontmatter version to
1.1for the 2026-04-24 catalog refresh.
[2026-04-24] - Skill Refresh
Changed
- Standardized the SKILL frontmatter with version metadata, last-updated date, tags, and a concise catalog description.
- Reformatted the portability and MCP guidance with a preferred server line, a copy-paste fallback prompt, and consistent bullet lists.
- Added a catalog-standard Anti-Patterns section and refreshed the Related Skills links at the end of the skill.
[2026-04-24] - Catalog Audit Cleanup
Fixed
- Removed obsolete standalone Skill Paths guidance that duplicated the generated portability section.
All notable changes to this skill will be documented in this file.
[2026-04-04] - Cross-Client Portability Refresh
Changed
- Added a standard portability note covering GitHub Copilot, Claude Code, Codex, and Gemini CLI.
- Documented the preferred MCP server surface for this skill and a local no-MCP fallback workflow.
Tested
- Validated
SKILL.mdfrontmatter, portability sections, and Gemini export readiness withpython scripts/validate-skills.py.
[2026-03-09] - Workspace Modernization
Changed
- Removed duplicated related-skill content from
SKILL.mdto keep the workflow easier to scan
[2026-02-28] — Description Rewrite & Cross-References
Changed
- Rewrote skill description to ~200 characters with clear, specific activation keywords
- Improved keyword specificity to reduce overlap with related skills
Added
## Related Skillscross-reference table with 2-4 related skills and "Use When" guidance
GitHub Actions Workflow Templates
Ready-to-use, production-grade workflow templates. Copy into .github/workflows/ and customize for your project.---
1. Node.js CI
# .github/workflows/node-ci.yml
name: Node.js CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint
test:
name: Test (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test -- --coverage
- name: Upload coverage
if: matrix.node-version == 20
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 5
build:
name: Build
needs: [lint, test]
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 5---
2. Python CI
# .github/workflows/python-ci.yml
name: Python CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install ruff mypy
- run: ruff check .
- run: ruff format --check .
test:
name: Test (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.11', '3.12']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- run: pip install -r requirements.txt
- run: pip install -r requirements-dev.txt
- run: pytest --tb=short --cov=src --cov-report=xml
- name: Upload coverage
if: matrix.python-version == '3.12'
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.xml
retention-days: 5
type-check:
name: Type Check
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install -r requirements.txt
- run: pip install mypy
- run: mypy src/ --ignore-missing-imports---
3. Docker Build + Push
# .github/workflows/docker.yml
name: Docker Build & Push
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
permissions:
contents: read
packages: write
jobs:
build:
name: Build & Push
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64---
4. Terraform Plan + Apply
# .github/workflows/terraform.yml
name: Terraform
on:
push:
branches: [main]
paths: ['infra/**']
pull_request:
branches: [main]
paths: ['infra/**']
permissions:
contents: read
pull-requests: write
env:
TF_VERSION: '1.7'
WORKING_DIR: infra
jobs:
plan:
name: Terraform Plan
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: ${{ env.WORKING_DIR }}
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
run: terraform init -backend-config=backend.hcl
- name: Terraform Format Check
run: terraform fmt -check -recursive
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
id: plan
run: terraform plan -no-color -out=tfplan
env:
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
- name: Comment PR with plan
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const plan = `${{ steps.plan.outputs.stdout }}`;
const truncated = plan.length > 60000
? plan.substring(0, 60000) + '\n\n...truncated'
: plan;
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `### Terraform Plan\n\`\`\`\n${truncated}\n\`\`\``
});
- uses: actions/upload-artifact@v4
with:
name: tfplan
path: ${{ env.WORKING_DIR }}/tfplan
apply:
name: Terraform Apply
needs: plan
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 30
environment: production
defaults:
run:
working-directory: ${{ env.WORKING_DIR }}
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
run: terraform init -backend-config=backend.hcl
- uses: actions/download-artifact@v4
with:
name: tfplan
path: ${{ env.WORKING_DIR }}
- name: Terraform Apply
run: terraform apply -auto-approve tfplan
env:
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}---
5. Release with Changelog
# .github/workflows/release.yml
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
release:
name: Create Release
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for changelog
- name: Generate changelog
id: changelog
run: |
PREVIOUS_TAG=$(git tag --sort=-version:refname | head -2 | tail -1)
CURRENT_TAG=${GITHUB_REF#refs/tags/}
if [ -z "$PREVIOUS_TAG" ]; then
PREVIOUS_TAG=$(git rev-list --max-parents=0 HEAD)
fi
echo "## What's Changed" > changelog.md
echo "" >> changelog.md
# Features
FEATURES=$(git log "$PREVIOUS_TAG".."$CURRENT_TAG" --pretty=format:"%s" | grep "^feat" || true)
if [ -n "$FEATURES" ]; then
echo "### Features" >> changelog.md
echo "$FEATURES" | while read -r line; do
echo "- $line" >> changelog.md
done
echo "" >> changelog.md
fi
# Bug Fixes
FIXES=$(git log "$PREVIOUS_TAG".."$CURRENT_TAG" --pretty=format:"%s" | grep "^fix" || true)
if [ -n "$FIXES" ]; then
echo "### Bug Fixes" >> changelog.md
echo "$FIXES" | while read -r line; do
echo "- $line" >> changelog.md
done
echo "" >> changelog.md
fi
# Other changes
OTHERS=$(git log "$PREVIOUS_TAG".."$CURRENT_TAG" --pretty=format:"%s" | grep -v "^feat\|^fix" || true)
if [ -n "$OTHERS" ]; then
echo "### Other Changes" >> changelog.md
echo "$OTHERS" | while read -r line; do
echo "- $line" >> changelog.md
done
fi
echo "" >> changelog.md
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/$PREVIOUS_TAG...$CURRENT_TAG" >> changelog.md
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
body_path: changelog.md
files: |
dist/**
draft: false
prerelease: ${{ contains(github.ref, '-beta') || contains(github.ref, '-rc') }}---
6. Dependabot Auto-Merge
# .github/workflows/dependabot-automerge.yml
name: Dependabot Auto-Merge
on:
pull_request:
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
name: Auto-Merge Dependabot
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
timeout-minutes: 10
steps:
- name: Fetch Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Auto-merge minor and patch updates
if: >
steps.metadata.outputs.update-type == 'version-update:semver-minor' ||
steps.metadata.outputs.update-type == 'version-update:semver-patch'
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Label major updates for manual review
if: steps.metadata.outputs.update-type == 'version-update:semver-major'
run: gh pr edit "$PR_URL" --add-label "needs-review"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}---
7. PR Labeler
# .github/workflows/pr-labeler.yml
name: PR Labeler
on:
pull_request:
types: [opened, synchronize]
permissions:
contents: read
pull-requests: write
jobs:
label:
name: Label PR
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Label by files changed
uses: actions/labeler@v5
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Label by size
uses: actions/github-script@v7
with:
script: |
const { data: files } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
const changes = files.reduce((sum, f) => sum + f.additions + f.deletions, 0);
let sizeLabel;
if (changes < 10) sizeLabel = 'size/XS';
else if (changes < 50) sizeLabel = 'size/S';
else if (changes < 200) sizeLabel = 'size/M';
else if (changes < 500) sizeLabel = 'size/L';
else sizeLabel = 'size/XL';
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: [sizeLabel],
});Requires .github/labeler.yml:
# .github/labeler.yml
frontend:
- changed-files:
- any-glob-to-any-file: ['src/components/**', 'src/pages/**', '*.css']
backend:
- changed-files:
- any-glob-to-any-file: ['src/api/**', 'src/services/**']
docs:
- changed-files:
- any-glob-to-any-file: ['docs/**', '*.md']
tests:
- changed-files:
- any-glob-to-any-file: ['**/*.test.*', '**/*.spec.*', 'tests/**']
ci:
- changed-files:
- any-glob-to-any-file: ['.github/**']
dependencies:
- changed-files:
- any-glob-to-any-file: ['package.json', 'package-lock.json', 'requirements.txt']---
8. Caching Patterns (Standalone Reference)
# .github/workflows/caching-examples.yml
name: Caching Reference
on:
workflow_dispatch:
jobs:
npm-cache:
name: npm with built-in cache
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
pnpm-cache:
name: pnpm with store cache
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
pip-cache:
name: pip with built-in cache
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install -r requirements.txt
turbo-cache:
name: Turborepo remote cache
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npx turbo run build --cache-dir=.turbo
- uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ github.sha }}
restore-keys: turbo-
docker-layer-cache:
name: Docker with GHA cache
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
context: .
push: false
tags: app:test
cache-from: type=gha
cache-to: type=gha,mode=max
multi-path-cache:
name: Cache multiple directories
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: |
node_modules
~/.cache/playwright
.next/cache
key: deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
deps-${{ runner.os }}-
- run: npm ciMIT License
Copyright (c) 2026 Sithu Win San
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.CI/CD Pipeline Patterns Reference
Patterns, strategies, and recipes for building production-grade CI/CD pipelines with GitHub Actions.
---
GitHub Actions Workflow Anatomy
name: CI # Workflow name (displayed in GitHub UI)
on: # Trigger events
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch: # Manual trigger
permissions: # Minimum required permissions
contents: read
pull-requests: write
concurrency: # Prevent duplicate runs
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env: # Workflow-level environment variables
NODE_VERSION: '20'
jobs:
test: # Job identifier
name: Run Tests # Display name
runs-on: ubuntu-latest # Runner OS
timeout-minutes: 15 # Safety timeout
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: npm ci
- run: npm testKey Concepts
| Concept | Description |
|---|---|
| Workflow | A YAML file in .github/workflows/. Triggered by events. |
| Job | A set of steps that run on the same runner. Jobs run in parallel by default. |
| Step | A single task — either an action (uses:) or a shell command (run:). |
| Runner | The VM that executes a job. ubuntu-latest, windows-latest, macos-latest. |
| Action | A reusable unit of code. Referenced by owner/repo@version. |
| Context | Variables like github.ref, github.sha, secrets.TOKEN. |
---
Job Dependencies
jobs:
lint:
runs-on: ubuntu-latest
steps: [...]
test:
runs-on: ubuntu-latest
steps: [...]
build:
needs: [lint, test] # Waits for lint AND test to pass
runs-on: ubuntu-latest
steps: [...]
deploy:
needs: build
if: github.ref == 'refs/heads/main' # Only on main branch
runs-on: ubuntu-latest
steps: [...]graph LR
Lint --> Build
Test --> Build
Build --> Deploy---
Matrix Builds
Test across multiple versions, OSes, or configurations in parallel.
jobs:
test:
strategy:
fail-fast: false # Don't cancel other matrix jobs on failure
matrix:
os: [ubuntu-latest, windows-latest]
node-version: [18, 20, 22]
exclude:
- os: windows-latest
node-version: 18
include:
- os: ubuntu-latest
node-version: 20
coverage: true # Extra variable for specific combination
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
- if: matrix.coverage
run: npm run test:coverage---
Caching Strategies
Node.js (npm / pnpm)
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # Built-in caching for npm
# Or manual caching for more control:
- uses: actions/cache@v4
with:
path: node_modules
key: node-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
node-${{ runner.os }}-Python (pip)
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip' # Built-in pip caching
# Manual:
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('requirements.txt') }}Docker Layers
- uses: docker/build-push-action@v5
with:
cache-from: type=gha
cache-to: type=gha,mode=maxCache Tips
- Always hash the lockfile (
hashFiles('package-lock.json')). - Use
restore-keysfor partial cache hits (faster than cold install). - Cache limit is 10 GB per repository. Caches evicted after 7 days of inactivity.
- Use
actions/cache/restoreandactions/cache/savefor split save/restore control.
---
Artifact Management
Upload Artifacts
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 5 # Default is 90 days
if-no-files-found: error # Fail if no files matchedDownload Artifacts (in a downstream job)
deploy:
needs: build
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
path: dist/Share Data Between Jobs
jobs:
compute:
outputs:
version: ${{ steps.ver.outputs.version }}
steps:
- id: ver
run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT"
use:
needs: compute
steps:
- run: echo "Deploying version ${{ needs.compute.outputs.version }}"---
Environment Protection Rules
jobs:
deploy-prod:
environment:
name: production
url: https://app.example.com
steps:
- run: ./deploy.shConfigure in Settings → Environments:
- Required reviewers — one or more people must approve before the job runs.
- Wait timer — delay N minutes before allowing deployment.
- Branch restrictions — only allow deployments from specific branches.
- Deployment secrets — secrets scoped to the environment (e.g.,
PROD_API_KEY).
---
Reusable Workflows
Define a reusable workflow
# .github/workflows/reusable-test.yml
name: Reusable Test
on:
workflow_call: # Makes this workflow callable
inputs:
node-version:
required: false
type: string
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: npm ci
- run: npm testCall a reusable workflow
# .github/workflows/ci.yml
jobs:
test:
uses: ./.github/workflows/reusable-test.yml
with:
node-version: '20'
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}---
Composite Actions
Create a local reusable action in your repository.
# .github/actions/setup-project/action.yml
name: Setup Project
description: Install dependencies and build
inputs:
node-version:
description: Node.js version
default: '20'
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: npm
- run: npm ci
shell: bash
- run: npm run build
shell: bashUse it:
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-project
with:
node-version: '20'---
Common Pipeline Patterns
Test → Build → Deploy
graph LR
Lint[Lint] --> Build
Test[Test] --> Build[Build]
Build --> DeployStaging[Deploy Staging]
DeployStaging --> SmokeTest[Smoke Tests]
SmokeTest --> DeployProd[Deploy Production]Trunk-Based Development
on:
push:
branches: [main] # Deploy on every push to main
jobs:
test:
runs-on: ubuntu-latest
steps: [...]
deploy:
needs: test
runs-on: ubuntu-latest
environment: production
steps: [...]PR Preview Deployments
on:
pull_request:
types: [opened, synchronize]
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- name: Deploy preview
run: |
PREVIEW_URL=$(deploy-to-preview --pr=${{ github.event.pull_request.number }})
echo "Preview: $PREVIEW_URL"
- uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `Preview deployed: ${process.env.PREVIEW_URL}`
})---
Security Scanning
CodeQL (Static Analysis)
name: CodeQL
on:
push:
branches: [main]
schedule:
- cron: '0 6 * * 1' # Weekly Monday 6 AM
jobs:
analyze:
runs-on: ubuntu-latest
permissions:
security-events: write
strategy:
matrix:
language: [javascript, python]
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- uses: github/codeql-action/autobuild@v3
- uses: github/codeql-action/analyze@v3Dependabot Configuration
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 10
reviewers:
- team-name
labels:
- dependencies
groups:
dev-dependencies:
dependency-type: development
production:
dependency-type: production
- package-ecosystem: github-actions
directory: /
schedule:
interval: weeklySecret Scanning
# Use GitHub's built-in secret scanning (Settings → Security)
# For custom patterns:
- name: Check for secrets
uses: trufflesecurity/trufflehog@v3
with:
extra_args: --only-verified---
Deployment Strategies
Blue-Green Deployment
Two identical environments. Switch traffic instantly.
graph LR
LB[Load Balancer] -->|100%| Blue[Blue - v1.0]
LB -.->|0%| Green[Green - v1.1]
style Blue fill:#4a90d9
style Green fill:#7bc67esteps:
- name: Deploy to green
run: deploy --target green --version ${{ github.sha }}
- name: Health check green
run: curl --fail https://green.example.com/health
- name: Switch traffic
run: switch-traffic --from blue --to green
- name: Verify production
run: curl --fail https://app.example.com/healthPros: Instant rollback (switch back). Zero downtime. Cons: Double infrastructure cost during deployment.
Canary Deployment
Gradually shift traffic to the new version.
steps:
- name: Deploy canary (5%)
run: deploy --canary --weight 5
- name: Monitor for 10 minutes
run: sleep 600 && check-error-rate --threshold 1%
- name: Promote to 25%
run: deploy --canary --weight 25
- name: Monitor for 10 minutes
run: sleep 600 && check-error-rate --threshold 1%
- name: Full rollout
run: deploy --canary --weight 100Pros: Limits blast radius. Real-world validation. Cons: Slower rollout. Requires traffic splitting infrastructure.
Rolling Deployment
Replace instances one at a time.
steps:
- name: Rolling deploy
run: |
for instance in $(get-instances); do
deploy --instance $instance --version ${{ github.sha }}
health-check --instance $instance
donePros: No extra infrastructure. Gradual. Cons: Mixed versions during deploy. Slow rollback.
Deployment Strategy Comparison
| Strategy | Downtime | Rollback Speed | Cost | Complexity |
|---|---|---|---|---|
| Blue-Green | None | Instant | 2x infra | Medium |
| Canary | None | Fast | 1.05x infra | High |
| Rolling | None | Slow | 1x infra | Low |
| Recreate | Brief | Medium | 1x infra | Low |
---
Pipeline Anti-Patterns
| Anti-Pattern | Problem | Solution |
|---|---|---|
| No concurrency control | Duplicate runs waste resources | Use concurrency with cancel-in-progress |
| Hard-coded versions | Actions break on updates | Pin to SHA: actions/checkout@abc123 |
| Secrets in logs | Credential leakage | Use ::add-mask:: and never echo $SECRET |
| No timeout | Hung jobs consume minutes | Set timeout-minutes on jobs |
| Monolithic workflow | Long feedback loops | Split into lint/test/build/deploy jobs |
| Skip tests for speed | Broken code reaches production | Make tests fast, not optional |
Shell Scripting Patterns for Automation
PowerShell and Bash patterns side-by-side for cross-platform automation.
---
Script Boilerplate
Bash
#!/usr/bin/env bash
set -euo pipefail # Exit on error, undefined vars, pipe failures
IFS=$'\n\t' # Safer field separator
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT_NAME="$(basename "$0")"PowerShell
#Requires -Version 7.0
$ErrorActionPreference = "Stop" # Exit on terminating errors
Set-StrictMode -Version Latest # Catch undefined variables
$ScriptDir = $PSScriptRoot
$ScriptName = $MyInvocation.MyCommand.Name---
Argument Parsing
Bash (getopts)
usage() {
echo "Usage: $SCRIPT_NAME -n <name> -e <env> [-v] [-h]"
echo " -n Project name (required)"
echo " -e Environment: dev|staging|prod (required)"
echo " -v Verbose output"
echo " -h Show this help"
exit 1
}
VERBOSE=false
while getopts "n:e:vh" opt; do
case $opt in
n) NAME="$OPTARG" ;;
e) ENV="$OPTARG" ;;
v) VERBOSE=true ;;
h) usage ;;
*) usage ;;
esac
done
[[ -z "${NAME:-}" || -z "${ENV:-}" ]] && usagePowerShell (param block)
param(
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $true)]
[ValidateSet("dev", "staging", "prod")]
[string]$Env,
[switch]$Verbose
)---
Error Handling
Bash (trap)
cleanup() {
local exit_code=$?
echo "Cleaning up temporary files..."
rm -rf "$TEMP_DIR"
exit $exit_code
}
trap cleanup EXIT # Always runs on script exit
trap 'echo "Error on line $LINENO"; exit 1' ERR # Runs on error
TEMP_DIR=$(mktemp -d)
# ... script work ...PowerShell (try/catch/finally)
$tempDir = Join-Path $env:TEMP "script-$(Get-Random)"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
try {
# ... script work ...
throw "Something went wrong"
}
catch {
Write-Error "Error: $_"
exit 1
}
finally {
if (Test-Path $tempDir) {
Remove-Item -Recurse -Force $tempDir
}
}---
Logging Functions
Bash
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[0;33m'
readonly BLUE='\033[0;34m'
readonly NC='\033[0m'
log_info() { echo -e "${BLUE}[INFO]${NC} $(date '+%H:%M:%S') $*"; }
log_ok() { echo -e "${GREEN}[OK]${NC} $(date '+%H:%M:%S') $*"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $(date '+%H:%M:%S') $*" >&2; }
log_error() { echo -e "${RED}[ERROR]${NC} $(date '+%H:%M:%S') $*" >&2; }
log_info "Starting deployment"
log_ok "Build completed"
log_warn "Cache miss — cold install"
log_error "Tests failed"PowerShell
function Write-Log {
param(
[string]$Message,
[ValidateSet("INFO", "OK", "WARN", "ERROR")]
[string]$Level = "INFO"
)
$timestamp = Get-Date -Format "HH:mm:ss"
$colors = @{ INFO = "Cyan"; OK = "Green"; WARN = "Yellow"; ERROR = "Red" }
Write-Host "[$Level] $timestamp $Message" -ForegroundColor $colors[$Level]
}
Write-Log "Starting deployment" -Level INFO
Write-Log "Build completed" -Level OK
Write-Log "Cache miss" -Level WARN
Write-Log "Tests failed" -Level ERROR---
File Operations
Check existence
| Operation | Bash | PowerShell |
|---|---|---|
| File exists | [[ -f "$path" ]] | Test-Path $path -PathType Leaf |
| Dir exists | [[ -d "$path" ]] | Test-Path $path -PathType Container |
| Is empty | [[ ! -s "$path" ]] | (Get-Item $path).Length -eq 0 |
| Is writable | [[ -w "$path" ]] | (Get-Acl $path).Access |
Common operations
Bash:
mkdir -p "$OUTPUT_DIR"
cp -r src/ "$OUTPUT_DIR/"
find . -name "*.log" -mtime +7 -delete # Delete logs older than 7 days
find . -name "*.ts" -not -path "*/node_modules/*" | wc -l # Count TS filesPowerShell:
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
Copy-Item -Path src\* -Destination $OutputDir -Recurse
Get-ChildItem -Recurse -Filter "*.log" |
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-7) } |
Remove-Item
(Get-ChildItem -Recurse -Filter "*.ts" -Exclude "node_modules").Count---
JSON Processing
Bash (jq)
# Read a field
VERSION=$(jq -r '.version' package.json)
# Update a field
jq '.version = "2.0.0"' package.json > tmp.json && mv tmp.json package.json
# Filter an array
jq '.dependencies | keys[]' package.json
# Build JSON from variables
jq -n --arg name "$NAME" --arg ver "$VERSION" \
'{ name: $name, version: $ver }'
# Process API response
curl -s https://api.example.com/items |
jq '.items[] | select(.status == "active") | .name'PowerShell (ConvertFrom-Json / ConvertTo-Json)
# Read a field
$pkg = Get-Content package.json | ConvertFrom-Json
$version = $pkg.version
# Update a field
$pkg.version = "2.0.0"
$pkg | ConvertTo-Json -Depth 10 | Set-Content package.json
# Filter array
$pkg.dependencies.PSObject.Properties.Name
# Build JSON from variables
@{ name = $Name; version = $Version } | ConvertTo-Json
# Process API response
$items = Invoke-RestMethod https://api.example.com/items
$items.items | Where-Object { $_.status -eq "active" } | Select-Object name---
YAML Processing
Bash (yq)
# Read value
yq '.services.web.image' docker-compose.yml
# Update value
yq -i '.services.web.image = "app:2.0"' docker-compose.yml
# Add an item to a list
yq -i '.services.web.environment += ["NEW_VAR=value"]' docker-compose.ymlPowerShell (powershell-yaml module)
Install-Module -Name powershell-yaml -Scope CurrentUser -Force
$yaml = Get-Content docker-compose.yml -Raw | ConvertFrom-Yaml
$yaml.services.web.image = "app:2.0"
$yaml | ConvertTo-Yaml | Set-Content docker-compose.yml---
Retry Logic
Bash
retry() {
local max_attempts=$1
local delay=$2
shift 2
local cmd=("$@")
for ((attempt = 1; attempt <= max_attempts; attempt++)); do
if "${cmd[@]}"; then
return 0
fi
echo "Attempt $attempt/$max_attempts failed. Retrying in ${delay}s..."
sleep "$delay"
delay=$((delay * 2)) # Exponential backoff
done
echo "All $max_attempts attempts failed."
return 1
}
retry 3 5 curl --fail https://api.example.com/healthPowerShell
function Invoke-WithRetry {
param(
[scriptblock]$ScriptBlock,
[int]$MaxAttempts = 3,
[int]$DelaySeconds = 5
)
$attempt = 1
$delay = $DelaySeconds
while ($attempt -le $MaxAttempts) {
try {
return & $ScriptBlock
}
catch {
Write-Warning "Attempt $attempt/$MaxAttempts failed: $_"
if ($attempt -eq $MaxAttempts) { throw }
Start-Sleep -Seconds $delay
$delay *= 2 # Exponential backoff
$attempt++
}
}
}
Invoke-WithRetry -MaxAttempts 3 -DelaySeconds 5 -ScriptBlock {
Invoke-RestMethod https://api.example.com/health
}---
Parallel Execution
Bash (xargs / GNU parallel)
# Process files in parallel (4 at a time)
find . -name "*.ts" | xargs -P 4 -I {} eslint {}
# GNU parallel
parallel -j 4 ./process.sh ::: file1.txt file2.txt file3.txt
# Background jobs
for server in web api worker; do
deploy "$server" &
done
wait # Wait for all background jobs
echo "All deployments finished"PowerShell (ForEach-Object -Parallel)
# Requires PowerShell 7+
$files = Get-ChildItem -Filter "*.ts" -Recurse
$files | ForEach-Object -Parallel {
& eslint $_.FullName
} -ThrottleLimit 4
# Job-based
$servers = @("web", "api", "worker")
$jobs = $servers | ForEach-Object {
Start-Job -ScriptBlock { param($s) & ./deploy.ps1 $s } -ArgumentList $_
}
$jobs | Wait-Job | Receive-Job---
Environment Variable Management
Bash
# Load .env file
if [[ -f .env ]]; then
set -a # Auto-export all variables
source .env
set +a
fi
# Default values
DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-5432}"
# Required variables
: "${API_KEY:?ERROR: API_KEY is not set}"
# Export for child processes
export BUILD_VERSION="1.2.3"PowerShell
# Load .env file
if (Test-Path .env) {
Get-Content .env | ForEach-Object {
if ($_ -match '^\s*([^#][^=]+)=(.*)$') {
[Environment]::SetEnvironmentVariable($Matches[1].Trim(), $Matches[2].Trim(), "Process")
}
}
}
# Default values
$dbHost = if ($env:DB_HOST) { $env:DB_HOST } else { "localhost" }
$dbPort = if ($env:DB_PORT) { $env:DB_PORT } else { "5432" }
# Required variables
if (-not $env:API_KEY) {
throw "ERROR: API_KEY is not set"
}
# Set for child processes
$env:BUILD_VERSION = "1.2.3"---
Common Automation Recipes
1. Health Check Script
check_health() {
local url=$1
local status
status=$(curl -s -o /dev/null -w "%{http_code}" "$url")
if [[ "$status" == "200" ]]; then
log_ok "$url is healthy"
else
log_error "$url returned $status"
return 1
fi
}
check_health https://api.example.com/health
check_health https://web.example.com/health2. Database Backup
param(
[string]$ConnectionString,
[string]$BackupDir = "./backups"
)
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$backupFile = Join-Path $BackupDir "backup-$timestamp.sql"
New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null
& pg_dump $ConnectionString --file $backupFile --format=custom
$backups = Get-ChildItem $BackupDir -Filter "backup-*.sql" |
Sort-Object LastWriteTime -Descending |
Select-Object -Skip 7
$backups | Remove-Item -Force # Keep only 7 most recent3. Version Bump
bump_version() {
local part=$1 # major, minor, patch
local current
current=$(jq -r '.version' package.json)
IFS='.' read -r major minor patch <<< "$current"
case $part in
major) major=$((major + 1)); minor=0; patch=0 ;;
minor) minor=$((minor + 1)); patch=0 ;;
patch) patch=$((patch + 1)) ;;
esac
local new_version="$major.$minor.$patch"
jq --arg v "$new_version" '.version = $v' package.json > tmp.json
mv tmp.json package.json
echo "$new_version"
}
NEW_VER=$(bump_version patch)
git commit -am "chore: bump version to $NEW_VER"
git tag "v$NEW_VER"4. Dependency Audit
Write-Log "Checking for outdated dependencies..." -Level INFO
$outdated = npm outdated --json 2>$null | ConvertFrom-Json
$count = ($outdated.PSObject.Properties | Measure-Object).Count
if ($count -gt 0) {
Write-Log "$count outdated packages found:" -Level WARN
$outdated.PSObject.Properties | ForEach-Object {
$pkg = $_.Name
$current = $_.Value.current
$latest = $_.Value.latest
Write-Host " $pkg $current -> $latest"
}
} else {
Write-Log "All packages up to date" -Level OK
}5. Port Availability Check
wait_for_port() {
local host=$1
local port=$2
local timeout=${3:-30}
local elapsed=0
while ! nc -z "$host" "$port" 2>/dev/null; do
if (( elapsed >= timeout )); then
log_error "$host:$port not available after ${timeout}s"
return 1
fi
sleep 1
elapsed=$((elapsed + 1))
done
log_ok "$host:$port is ready"
}
wait_for_port localhost 5432 60 # Wait for PostgreSQL
wait_for_port localhost 6379 30 # Wait for Redis---
Comparison Quick Reference
| Task | Bash | PowerShell |
|---|---|---|
| Exit on error | set -e | $ErrorActionPreference = "Stop" |
| Strict mode | set -u | Set-StrictMode -Version Latest |
| Current dir | $(pwd) | $PWD / Get-Location |
| Script dir | $( cd "$(dirname "$0")" && pwd ) | $PSScriptRoot |
| Env var | $VAR / ${VAR} | $env:VAR |
| String interp | "Hello $name" | "Hello $name" |
| Null coalesce | ${VAR:-default} | $var ?? "default" (PS 7+) |
| Pipe to file | cmd > file | `cmd \ |
| Redirect stderr | 2>&1 | 2>&1 |
| Process subst | <(cmd) | N/A (use temp variable) |
| Ternary | `[[ cond ]] && a \ | \ |
<#
.SYNOPSIS
Sets up Git hooks for a project (pre-commit, commit-msg, pre-push).
.DESCRIPTION
Creates Git hooks in the .git/hooks/ directory:
- pre-commit: lint staged files, check for debug statements
- commit-msg: validate conventional commit format
- pre-push: run tests before pushing
Hooks are shell scripts (#!/bin/sh) for cross-platform compatibility
(Git executes hooks via sh on all platforms, including Windows with Git Bash).
.PARAMETER ProjectDir
Root directory of the Git repository. Defaults to current directory.
.PARAMETER SkipTests
If set, the pre-push hook will be skipped.
.PARAMETER Force
Overwrite existing hooks without prompting.
.EXAMPLE
.\setup-git-hooks.ps1 -ProjectDir "C:\Projects\my-app"
.EXAMPLE
.\setup-git-hooks.ps1 -Force
#>
param(
[Parameter(Mandatory = $false)]
[string]$ProjectDir = ".",
[switch]$SkipTests,
[switch]$Force
)
$ErrorActionPreference = "Stop"
$ProjectDir = Resolve-Path $ProjectDir
$hooksDir = Join-Path $ProjectDir ".git" "hooks"
if (-not (Test-Path (Join-Path $ProjectDir ".git"))) {
Write-Error "Not a git repository: $ProjectDir"
exit 1
}
if (-not (Test-Path $hooksDir)) {
New-Item -ItemType Directory -Path $hooksDir -Force | Out-Null
}
function Install-Hook {
param(
[string]$Name,
[string]$Content
)
$hookPath = Join-Path $hooksDir $Name
if ((Test-Path $hookPath) -and -not $Force) {
Write-Warning "Hook '$Name' already exists. Use -Force to overwrite. Skipping."
return
}
$Content = $Content -replace "`r`n", "`n"
[System.IO.File]::WriteAllText($hookPath, $Content)
Write-Host " Installed: $Name" -ForegroundColor Green
}
# ── pre-commit hook ──────────────────────────────────────────────────────────
$preCommitHook = @'
#!/bin/sh
# pre-commit hook: lint staged files, check for debug statements
RED='\033[0;31m'
YELLOW='\033[0;33m'
GREEN='\033[0;32m'
NC='\033[0m'
echo "${GREEN}[pre-commit]${NC} Running checks on staged files..."
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
if [ -z "$STAGED_FILES" ]; then
echo "${GREEN}[pre-commit]${NC} No staged files to check."
exit 0
fi
ERRORS=0
# ── Check for debug statements ──────────────────────────────────────────────
DEBUG_PATTERNS='console\.log\|debugger\|binding\.pry\|import pdb\|breakpoint()'
DEBUG_FILES=$(echo "$STAGED_FILES" | xargs grep -l "$DEBUG_PATTERNS" 2>/dev/null || true)
if [ -n "$DEBUG_FILES" ]; then
echo "${YELLOW}[pre-commit]${NC} Debug statements found in:"
for f in $DEBUG_FILES; do
echo " ${RED}$f${NC}"
git diff --cached "$f" | grep -n "$DEBUG_PATTERNS" | head -5
done
echo "${YELLOW}[pre-commit]${NC} Remove debug statements or use 'git commit --no-verify' to bypass."
ERRORS=1
fi
# ── Check for large files ───────────────────────────────────────────────────
MAX_FILE_SIZE=1048576 # 1 MB
for file in $STAGED_FILES; do
if [ -f "$file" ]; then
FILE_SIZE=$(wc -c < "$file" 2>/dev/null || echo "0")
if [ "$FILE_SIZE" -gt "$MAX_FILE_SIZE" ]; then
echo "${RED}[pre-commit]${NC} Large file detected: $file ($(( FILE_SIZE / 1024 ))KB)"
ERRORS=1
fi
fi
done
# ── Check for merge conflict markers ────────────────────────────────────────
CONFLICT_FILES=$(echo "$STAGED_FILES" | xargs grep -l '<<<<<<<\|>>>>>>>\|=======' 2>/dev/null || true)
if [ -n "$CONFLICT_FILES" ]; then
echo "${RED}[pre-commit]${NC} Merge conflict markers found in:"
for f in $CONFLICT_FILES; do
echo " $f"
done
ERRORS=1
fi
# ── Run linter if available ──────────────────────────────────────────────────
JS_FILES=$(echo "$STAGED_FILES" | grep -E '\.(js|jsx|ts|tsx)$' || true)
if [ -n "$JS_FILES" ]; then
if command -v npx >/dev/null 2>&1 && [ -f "node_modules/.bin/eslint" ]; then
echo "${GREEN}[pre-commit]${NC} Running ESLint on staged JS/TS files..."
echo "$JS_FILES" | xargs npx eslint --quiet
if [ $? -ne 0 ]; then
ERRORS=1
fi
fi
fi
PY_FILES=$(echo "$STAGED_FILES" | grep -E '\.py$' || true)
if [ -n "$PY_FILES" ]; then
if command -v ruff >/dev/null 2>&1; then
echo "${GREEN}[pre-commit]${NC} Running ruff on staged Python files..."
echo "$PY_FILES" | xargs ruff check
if [ $? -ne 0 ]; then
ERRORS=1
fi
fi
fi
if [ $ERRORS -ne 0 ]; then
echo "${RED}[pre-commit]${NC} Commit blocked. Fix the issues above."
exit 1
fi
echo "${GREEN}[pre-commit]${NC} All checks passed."
exit 0
'@
# ── commit-msg hook ──────────────────────────────────────────────────────────
$commitMsgHook = @'
#!/bin/sh
# commit-msg hook: validate conventional commit format
#
# Format: <type>(<scope>): <subject>
# Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'
COMMIT_MSG_FILE=$1
COMMIT_MSG=$(head -1 "$COMMIT_MSG_FILE")
# Allow merge commits
if echo "$COMMIT_MSG" | grep -qE '^Merge '; then
exit 0
fi
# Allow revert commits
if echo "$COMMIT_MSG" | grep -qE '^Revert '; then
exit 0
fi
# Validate conventional commit format
PATTERN='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-zA-Z0-9_-]+\))?(!)?: .{1,100}$'
if ! echo "$COMMIT_MSG" | grep -qE "$PATTERN"; then
echo ""
echo "${RED}[commit-msg]${NC} Invalid commit message format."
echo ""
echo " Your message: $COMMIT_MSG"
echo ""
echo " Expected format: <type>(<scope>): <subject>"
echo ""
echo " Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert"
echo ""
echo " Examples:"
echo " feat(auth): add OAuth login flow"
echo " fix: resolve null pointer in user service"
echo " docs(readme): update installation instructions"
echo " feat!: redesign API response format"
echo ""
echo " Rules:"
echo " - Type is required"
echo " - Scope is optional (lowercase, alphanumeric, hyphens, underscores)"
echo " - Subject is required (max 100 chars, no period at end)"
echo " - Add ! before : for breaking changes"
echo ""
exit 1
fi
# Check subject doesn't end with a period
if echo "$COMMIT_MSG" | grep -qE '\.$'; then
echo "${RED}[commit-msg]${NC} Subject should not end with a period."
exit 1
fi
echo "${GREEN}[commit-msg]${NC} Commit message is valid."
exit 0
'@
# ── pre-push hook ────────────────────────────────────────────────────────────
$prePushHook = @'
#!/bin/sh
# pre-push hook: run tests before pushing
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m'
echo "${GREEN}[pre-push]${NC} Running tests before push..."
# Detect project type and run tests
if [ -f "package.json" ]; then
if command -v npm >/dev/null 2>&1; then
# Check if test script exists
TEST_SCRIPT=$(node -e "const p=require('./package.json'); console.log(p.scripts && p.scripts.test ? 'yes' : 'no')" 2>/dev/null)
if [ "$TEST_SCRIPT" = "yes" ]; then
echo "${GREEN}[pre-push]${NC} Running npm test..."
npm test --silent
if [ $? -ne 0 ]; then
echo "${RED}[pre-push]${NC} Tests failed. Push blocked."
echo "${YELLOW}[pre-push]${NC} Use 'git push --no-verify' to bypass."
exit 1
fi
else
echo "${YELLOW}[pre-push]${NC} No test script found in package.json. Skipping."
fi
fi
elif [ -f "requirements.txt" ] || [ -f "pyproject.toml" ] || [ -f "setup.py" ]; then
if command -v pytest >/dev/null 2>&1; then
echo "${GREEN}[pre-push]${NC} Running pytest..."
pytest --tb=short -q
if [ $? -ne 0 ]; then
echo "${RED}[pre-push]${NC} Tests failed. Push blocked."
exit 1
fi
elif command -v python >/dev/null 2>&1; then
echo "${GREEN}[pre-push]${NC} Running python -m pytest..."
python -m pytest --tb=short -q
if [ $? -ne 0 ]; then
echo "${RED}[pre-push]${NC} Tests failed. Push blocked."
exit 1
fi
fi
elif [ -f "go.mod" ]; then
echo "${GREEN}[pre-push]${NC} Running go test..."
go test ./...
if [ $? -ne 0 ]; then
echo "${RED}[pre-push]${NC} Tests failed. Push blocked."
exit 1
fi
fi
echo "${GREEN}[pre-push]${NC} All tests passed."
exit 0
'@
# ── Install hooks ────────────────────────────────────────────────────────────
Write-Host ""
Write-Host "Setting up Git hooks in: $ProjectDir" -ForegroundColor Cyan
Write-Host ""
Install-Hook -Name "pre-commit" -Content $preCommitHook
Install-Hook -Name "commit-msg" -Content $commitMsgHook
if (-not $SkipTests) {
Install-Hook -Name "pre-push" -Content $prePushHook
} else {
Write-Host " Skipped: pre-push (tests disabled)" -ForegroundColor Yellow
}
Write-Host ""
Write-Host "Git hooks installed successfully." -ForegroundColor Green
Write-Host ""
Write-Host "Hooks will run automatically. To bypass:" -ForegroundColor Yellow
Write-Host " git commit --no-verify # Skip pre-commit + commit-msg"
Write-Host " git push --no-verify # Skip pre-push"
Write-Host ""
Related skills
FAQ
What does devops-tooling do?
devops-tooling is a Claude Code skill for devops & ci/cd.
When should I use devops-tooling?
When you need to helps with devops & ci/cd tasks., or when devops-tooling is a claude code skill for devops & ci/cd.
What are the main capabilities?
devops-tooling; DevOps & CI/CD; AI-coding skill.