# CI/CD Pipelines with GitHub Actions — A Practical Guide

## Most CI/CD Pipelines Are Duct Tape

They start as a single YAML file that runs `npm test`. Six months later, it's 400 lines of shell scripts, hardcoded secrets, and steps that nobody dares to touch. Build times creep from 2 minutes to 20. Flaky tests get `|| true` appended. Nobody reviews workflow changes.

This is the guide to building pipelines that stay fast, secure, and maintainable as your project grows.

## Start With the Dependency Cache

The single biggest time waste in most pipelines: downloading the same dependencies on every run. GitHub Actions has built-in caching, and the setup actions for Node.js, Go, and Python support it natively.

```yaml
- uses: actions/setup-node@v4
  with:
    node-version: '22'
    cache: 'npm'
```

For Go projects, cache both the module cache and the build cache:

```yaml
- uses: actions/setup-go@v5
  with:
    go-version: '1.23'
    cache: true
```

This alone can cut build times by 30–60%. The [GitHub Actions cache documentation](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows) covers the details, but the principle is simple: **never download what you already have**.

For Docker builds, layer caching via [docker/build-push-action](https://github.com/docker/build-push-action) with `cache-from` and `cache-to` parameters prevents rebuilding unchanged layers:

```yaml
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/my-org/my-app:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max
```

The `type=gha` backend stores layer caches directly in GitHub's cache infrastructure — no external registry needed.

## Matrix Builds for Cross-Platform Confidence

Testing on a single OS with a single runtime version is a bet that nothing else matters. Matrix builds test combinations automatically:

```yaml
strategy:
  matrix:
    os: [ubuntu-latest, macos-latest]
    node-version: ['20', '22']
  fail-fast: false
```

The `fail-fast: false` flag is important — without it, one failing combination cancels all others, hiding additional failures you need to know about.

For library authors, matrix builds across versions are non-negotiable. For application teams, testing against the next minor version of your runtime catches deprecations before they hit production. The [GitHub Actions matrix strategy documentation](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/running-variations-of-jobs-in-a-workflow) covers advanced combinations and exclusions.

## Reusable Workflows Kill Copy-Paste

When you have 10 repositories with near-identical CI pipelines, updating the linting step means 10 PRs. [Reusable workflows](https://docs.github.com/en/actions/sharing-automations/reusing-workflows) solve this:

```yaml
# .github/workflows/ci-shared.yml (in your org's .github repo)
on:
  workflow_call:
    inputs:
      node-version:
        type: string
        default: '22'

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm test
```

Consuming repositories call it with one line:

```yaml
jobs:
  ci:
    uses: my-org/.github/.github/workflows/ci-shared.yml@main
    with:
      node-version: '22'
```

One change, one PR, all repositories updated. Pin the reference to a tag or SHA for stability — `@main` is convenient but means any push to the shared repo immediately affects all consumers.

## Security Hardening That Actually Matters

### Pin Action Versions to SHA

```yaml
# Bad: mutable tag
- uses: actions/checkout@v4

# Good: immutable SHA
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
```

Tags can be moved. SHA references cannot. The [GitHub Security Hardening guide](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions) recommends this, and tools like [pin-github-action](https://github.com/mheap/pin-github-action) automate the conversion.

### Minimal Permissions

Default `GITHUB_TOKEN` permissions are too broad. Restrict them:

```yaml
permissions:
  contents: read
  pull-requests: write
```

Set `permissions: {}` at the workflow level and grant only what each job needs. The [permissions documentation](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/controlling-permissions-for-the-github_token) lists every scope.

### Don't Trust User Input in Shell

Pull request titles, branch names, and commit messages are user-controlled. Interpolating them into shell commands enables injection:

```yaml
# Dangerous: PR title could contain $(malicious-command)
- run: echo "PR: ${{ github.event.pull_request.title }}"

# Safe: use an environment variable
- run: echo "PR: $PR_TITLE"
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
```

Environment variables are passed as data, not interpreted as code. This is the [#1 GitHub Actions security mistake](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#understanding-the-risk-of-script-injections) and it's trivially preventable.

## Deployment Patterns

### Environment Protection Rules

For production deployments, use [GitHub Environments](https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-deployments/managing-environments-for-deployment) with required reviewers and wait timers:

```yaml
jobs:
  deploy-production:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://my-app.example.com
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh
```

The environment configuration in repository settings controls who can approve, how long to wait, and which branches can deploy. This is infrastructure-level gating — no custom approval bot needed.

### Rollback Strategy

Every deploy workflow should have a rollback path. The simplest approach: re-run the previous successful deployment.

```yaml
on:
  workflow_dispatch:
    inputs:
      ref:
        description: 'Git ref to deploy (tag, SHA, or branch)'
        required: true
        default: 'main'
```

`workflow_dispatch` with an explicit ref input lets you deploy any previous version manually. Combine this with tagged releases and you have one-click rollback without touching production servers.

## Monitoring Pipeline Health

Slow pipelines erode trust. Track these metrics:

- **P50 and P95 build time** — if P95 is 3x your P50, you have flaky or resource-contention issues
- **Failure rate** — anything above 5% needs investigation; above 15% means developers are ignoring CI
- **Time to green after failure** — measures how fast the team responds to broken builds

GitHub's [workflow run API](https://docs.github.com/en/rest/actions/workflow-runs) exposes timing data. A weekly Slack digest of these numbers keeps pipeline health visible without dashboards nobody checks.

## The 80/20 of CI/CD

Most of the value comes from getting five things right:

1. **Cache dependencies aggressively** — cut build times in half
2. **Use reusable workflows** — stop maintaining 10 copies of the same pipeline
3. **Pin actions to SHAs** — prevent supply chain attacks
4. **Set minimal permissions** — limit blast radius
5. **Gate production deploys with environments** — enforce approval without custom tooling

Everything else — matrix builds, concurrency groups, self-hosted runners, composite actions — is optimization on top of a solid foundation. Get the basics right first.
