CI/CD Pipelines with GitHub Actions
Continuous Integration and Continuous Deployment (CI/CD) automates the journey from code push to production. GitHub Actions makes this accessible with YAML-based workflows that run on every commit, PR, or schedule.
A Complete Pipeline
Here's a real-world workflow for a TypeScript monorepo:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-typecheck:
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: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm check-types
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/test
deploy:
needs: [lint-and-typecheck, test]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: |
echo "Deploying..."
# Your deploy command here
Key Concepts
Triggers (on): Define when workflows run — pushes, PRs, schedules (cron), manual dispatch, or even webhook events.
Jobs: Independent units of work. They run in parallel by default. Use needs for dependencies.
Services: Sidecar containers (databases, caches) that run alongside your job. Perfect for integration tests.
Caching: The cache option on setup-node caches node_modules. For Docker builds, use actions/cache with build layers.
Monorepo Optimizations
In a Turborepo monorepo, you can skip unchanged packages:
- name: Check affected packages
run: |
AFFECTED=$(pnpm turbo run build --filter='...[HEAD^1]' --dry-run=json | jq -r '.packages[]')
echo "affected=$AFFECTED" >> $GITHUB_OUTPUT
Or use Turbo's remote caching to share build artifacts across CI runs:
- run: pnpm turbo run build lint check-types
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: my-team
Security Best Practices
-
Never hardcode secrets: Use
${{ secrets.MY_SECRET }}and set them in repo settings -
Pin action versions: Use
@v4or full SHA, not@latest -
Limit permissions: Set
permissions: contents: readunless you need more -
Audit third-party actions: Only use actions from verified publishers
-
Use environments: Gate production deploys with required reviewers
deploy:
environment:
name: production
url: https://myapp.com
permissions:
contents: read
deployments: write
Practical Tips
-
Matrix builds: Test across Node versions with
strategy.matrix.node-version: [20, 22] -
Artifacts: Upload test reports or build outputs with
actions/upload-artifact -
Concurrency: Cancel in-flight runs when a new commit pushes:
concurrency: { group: ci-${{ github.ref }}, cancel-in-progress: true } -
Composite actions: Extract repeated steps into reusable actions in
.github/actions/
A well-configured CI/CD pipeline catches errors before they reach production and deploys automatically when checks pass. The 30 minutes you spend setting it up saves hours of manual testing and deployment.