Available for Hire
ZB

Memuat...

Back to Blog
DevOps 5 min read ยท 1012 words

CI/CD Pipelines with GitHub Actions

Assembling an automated pipeline from tests through deployment with GitHub Actions, covering caching, matrix builds, least-privilege tokens, and the traps that make pipelines brittle.

#github-actions #cicd #automation #devops

GitHub Actions runs automation straight from your repository, with no separate CI server to set up. For a project already hosted on GitHub, it is the shortest path from โ€œcode mergedโ€ to โ€œcode deployedโ€.

What CI/CD Actually Means

Continuous Integration means every incoming change is built and tested automatically. The goal is catching mistakes in minutes rather than at release time. The value of CI comes from discipline: if the tests are not trusted or fail for no clear reason, people start ignoring them and CI becomes decoration.

Continuous Deployment means changes that pass the tests are installed in production automatically. There is also Continuous Delivery, which stops one step earlier: the artifact is ready to deploy, but pressing the final button stays manual.

Anatomy of a Workflow

A workflow is a YAML file in .github/workflows/. Its structure is layered:

  • Trigger decides when the workflow runs
  • Job is a unit that runs on one virtual machine, and jobs can run in parallel
  • Step is a sequential command inside a job
  • Action is a ready-made step written by someone else

Here is a pipeline that builds a static site and deploys it to GitHub Pages:

name: Deploy

on:
  push:
    branches: [main]

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: pages
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Build
        run: npm run build

      - name: Upload artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: ./dist

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
    steps:
      - uses: actions/deploy-pages@v4

Several things in that file are worth taking one at a time.

Limit the Tokenโ€™s Permissions

Every workflow receives a GITHUB_TOKEN automatically. By default this token can hold write access to a great deal of the repository. If a dependency is ever compromised, that token is a convenient way in.

The permissions block above lowers the token to the minimum the job actually needs. A good habit is declaring permissions explicitly in every workflow and granting write access only to the job that genuinely requires it.

Caching Dependencies

The line cache: 'npm' on setup-node preserves the npm cache between runs. On a project with many dependencies, this cuts installation from minutes to seconds.

If you need to cache something else, use actions/cache directly:

- uses: actions/cache@v4
  with:
    path: ~/.cache/ms-playwright
    key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

A cache key should include a hash of the lock file. That way the cache expires automatically when dependencies change, and you never get fooled by a stale one.

Concurrency

Without a concurrency setting, consecutive pushes to main will run several deployments at once, and the order they finish in is not guaranteed. An older deployment can overwrite a newer one.

The concurrency block with cancel-in-progress: true cancels the older run as soon as a newer one starts. For a deployment pipeline, this is almost always what you want.

Matrix Builds for Multiple Versions

If your library has to work across several Node versions, a matrix runs the same job for each combination:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'
      - run: npm ci
      - run: npm test

fail-fast: false stops a failure on one version from immediately cancelling the others. Without it, you only learn that one version is broken when in fact three might be.

Secrets and Variables

Secrets live in the repository settings and their values are masked in the logs. Variables are for values that are not sensitive, such as an environment name.

- name: Deploy
  env:
    API_TOKEN: ${{ secrets.API_TOKEN }}
    REGION: ${{ vars.DEPLOY_REGION }}
  run: ./deploy.sh

Two things to keep in mind. First, log masking is not perfect. If you transform a secret, for instance base64 encoding it and then printing it, the value can leak in full. Second, workflows triggered by pull_request from a fork do not get access to secrets, and that is deliberate.

Separate the Test and Deploy Pipelines

One pattern that makes a pipeline feel much calmer is splitting it into two workflows. The test workflow runs on every pull request and every push. The deploy workflow runs only on main and requires the tests to have passed.

With that separation, contributors get fast feedback without ever touching production credentials.

Traps You Will Run Into

Pinning an action without a version. Writing uses: some/action@main means you run whatever is on that branch today. For third-party actions, pin to a release tag, or to a commit SHA if you want to be stricter.

Using npm install instead of npm ci. The former can quietly upgrade dependencies, so CI ends up testing something different from what is in the lock file.

Tests that depend on ordering or timing. CI runners are slower and more parallel than a laptop. Tests that rely on sleep or file ordering will fail at random, and a randomly failing test is worse than no test, because it trains the team to ignore a red light.

Pipelines that take too long. If it takes twenty minutes to learn whether a change is safe, people stop waiting. Parallelise jobs, cache what can be cached, and move heavy testing to a nightly schedule.

Never testing the rollback path. A pipeline that only goes forward is frightening when something breaks. Make sure you know exactly how to return to the previous version before you actually need to.

Running on a Schedule

Beyond reacting to code changes, a workflow can run periodically. This is useful for dependency checks or data refreshes:

on:
  schedule:
    - cron: '0 2 * * 1'   # Every Monday at 02:00 UTC
  workflow_dispatch:       # And can also be triggered manually

Always add workflow_dispatch to a scheduled workflow. When something needs checking, you do not want to wait until next Monday.

Share this article:

Enjoyed this article?

0 reactions