Skip to main content
Back to Journal
DevOpsDeveloper Tools

GitHub Actions: CI/CD That Lives Where Your Code Does

Before GitHub Actions, my CI/CD setup was always a separate service. Travis CI for a while, then CircleCI. Both worked fine, but they always felt like duct tape between my code and my deployment. Configuration lived in one place, code lived in another, and I had to context-switch between two dashboards to debug failed builds. When GitHub Actions came out of beta in late 2019, I moved everything over within a month. The killer feature is not any single capability. It is that your CI/CD pipeline lives in the same repository as your code, versioned by the same commits, reviewed in the same pull requests.

Workflow YAML Anatomy

Every GitHub Actions workflow is a YAML file in the .github/workflows/ directory of your repository. The structure breaks down into three levels: the trigger (on), the jobs, and the steps within each job.

name: CI Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: '14'
      - run: npm ci
      - run: npm test

The on block defines when the workflow runs. In this case, on every push to main and on every pull request targeting main. The jobs block contains one or more named jobs (here, "test"). Each job runs on a fresh virtual machine specified by runs-on. Steps execute sequentially within a job: first check out the code, set up Node.js, install dependencies, and run tests.

Matrix Strategy for Multi-Version Testing

One of the features I use on every project is matrix builds. Instead of testing against a single Node.js version, you define a matrix and GitHub Actions runs your job for every combination:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [12, 14, 16]
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

This creates three parallel jobs, one for each Node version. If your library needs to support multiple Node versions (and it probably does if it is public), matrix builds catch compatibility issues before your users do. You can also matrix across operating systems by adding os: [ubuntu-latest, windows-latest, macos-latest] to the matrix.

Caching node_modules

Without caching, every workflow run downloads and installs all your npm dependencies from scratch. For a project with hundreds of dependencies, that is two to three minutes wasted on every run. The actions/cache action fixes this:

- uses: actions/cache@v2
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

This caches the npm cache directory (not node_modules itself, which is important because npm ci deletes node_modules before installing). The cache key includes a hash of your lockfile, so the cache invalidates automatically when dependencies change. On cache hits, npm ci finishes in seconds instead of minutes.

Running Tests on Pull Requests

The most valuable workflow is the one that runs on every pull request. It gives reviewers confidence that the changes do not break anything before they even look at the code. Here is my standard PR workflow for a Node/React project:

name: PR Checks

on:
  pull_request:
    branches: [main]

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: '14'
      - uses: actions/cache@v2
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
      - run: npm ci
      - run: npm run lint
      - run: npm test -- --coverage
      - run: npm run build

Lint, test with coverage, and build. If any step fails, the PR gets a red check mark. I configure branch protection rules to require this workflow to pass before merging. It has caught more bugs than any code review ever has.

Deploying on Push to Main

For deployment, I trigger a separate workflow on pushes to main (which only happen through merged PRs, thanks to branch protection):

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: '14'
      - run: npm ci
      - run: npm run build
      - name: Deploy to server
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
          SERVER_HOST: ${{ secrets.SERVER_HOST }}
        run: |
          echo "$DEPLOY_KEY" > deploy_key
          chmod 600 deploy_key
          rsync -avz -e "ssh -i deploy_key -o StrictHostKeyChecking=no" \
            ./build/ deploy@$SERVER_HOST:/var/www/myapp/

Secrets Management

Secrets are stored in your repository settings under Settings, then Secrets. They are encrypted, never exposed in logs (GitHub automatically masks them), and available as environment variables in your workflow through the ${{ secrets.NAME }} syntax.

A few best practices I follow. Never hardcode secrets in workflow files. Use environment-level secrets if you need different values for staging and production. Rotate secrets regularly. And be careful with pull requests from forks, because by default, secrets are not available to workflows triggered by fork PRs (this is a security feature, not a bug).

Reusable Actions from the Marketplace

The GitHub Actions marketplace has thousands of community-built actions. Some of the ones I use regularly:

  • actions/checkout and actions/setup-node: the basics, maintained by GitHub itself.
  • actions/cache: dependency caching as shown above.
  • codecov/codecov-action: uploads test coverage reports to Codecov automatically.
  • docker/build-push-action: builds and pushes Docker images to a registry.
  • peaceiris/actions-gh-pages: deploys static sites to GitHub Pages.

Each action is just a reference to a GitHub repository at a specific version. The uses: actions/checkout@v2 syntax points to the v2 tag of the actions/checkout repo. You can pin to a specific commit SHA for maximum security if you are worried about supply-chain attacks on popular actions.

Comparison with Travis CI and CircleCI

I used Travis CI for years. It was the default for open-source projects. But Travis has been declining in reliability and its free tier for open-source projects became uncertain. CircleCI is solid, with great caching and parallelism, but it is another service to manage with its own dashboard and billing.

GitHub Actions wins on integration. Status checks appear directly on PRs without configuring webhooks. Secrets are managed in the same place as your code. Workflow files are versioned with your codebase. And the free tier for public repositories is generous: unlimited minutes.

Where CircleCI still has an edge is in complex workflows with dynamic configuration and advanced caching strategies. But for most teams, GitHub Actions covers everything you need without adding another vendor to your stack.

After six months of using GitHub Actions on all my projects, I cannot imagine going back. The feedback loop of pushing code, watching tests run in the same interface, and seeing the deployment status right on the commit is exactly how CI/CD should feel. No more bouncing between three different dashboards to figure out why your build failed.

github-actionsci-cdautomationtestingdeployment