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.
Share this article
Reactions