WebPiki
tutorial

GitHub Actions: Workflow Syntax to Production Deploys

Build CI/CD pipelines with GitHub Actions. Workflow syntax, real-world examples, caching, matrix builds, and cost-saving tips.

CI/CD automation pipeline

Push code, tests run automatically. Merge to main, production deploys. That's CI/CD in a nutshell. Setting this up used to mean running Jenkins on a separate server. GitHub Actions puts the whole thing inside your GitHub repository.

Add a YAML file, commit it, done. No extra servers, just your GitHub account.

Basic Workflow Structure

Drop a YAML file in .github/workflows/ and GitHub picks it up automatically. Here's the simplest possible version:

name: CI

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

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

on defines the trigger, jobs defines the work, steps are individual actions. Commit this file and every push or PR to main kicks off the tests.

One thing: use npm ci instead of npm install. ci installs exact versions from package-lock.json and wipes node_modules clean. Reproducibility matters in CI.

Triggers — When Does It Run?

The on field controls execution conditions. Some common patterns:

# Only run when specific paths change
on:
  push:
    paths:
      - 'src/**'
      - 'package.json'

# Scheduled (cron) — every day at 3am UTC
on:
  schedule:
    - cron: '0 3 * * *'

# Manual trigger (workflow_dispatch)
on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Deploy environment'
        required: true
        default: 'staging'
        type: choice
        options:
          - staging
          - production

paths filtering is especially useful in monorepos. Why run backend tests when only frontend code changed? workflow_dispatch adds a manual "Run workflow" button in the GitHub UI — handy when you want to control deploy timing.

Jobs and Steps

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    needs: lint  # runs after lint succeeds
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  deploy:
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
      - run: ./deploy.sh

needs sets dependency order: lint, then test, then deploy. Jobs without needs run in parallel.

The if condition restricts execution. Here, the deploy job only runs on pushes to main. PRs get testing only, no deployment.

Secrets and Environment Variables

Store sensitive values (API keys, deploy tokens) in repository Settings, then reference them in workflows.

steps:
  - name: Deploy
    env:
      API_KEY: ${{ secrets.API_KEY }}
      DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
    run: |
      echo "Deploying with token..."
      ./deploy.sh

Secrets are automatically masked in logs. echo $API_KEY shows ***. But be careful — encoding a secret as base64 or splitting it character by character can bypass masking. Best practice: never print secret values at all.

You can also separate environments. Adding an approval requirement on a production environment prevents accidental production deploys.

Caching — Faster Builds

Installing packages from scratch every run wastes time. Caching reuses node_modules from previous runs.

steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-node@v4
    with:
      node-version: 20
      cache: 'npm'  # one line is all it takes
  - run: npm ci
  - run: npm test

Adding cache: 'npm' to actions/setup-node@v4 enables automatic caching based on package-lock.json. With a warm cache, npm ci drops from tens of seconds to just a few.

For monorepo build tools like Turbo or nx, cache build artifacts separately:

- uses: actions/cache@v4
  with:
    path: .turbo
    key: turbo-${{ runner.os }}-${{ hashFiles('**/turbo.json') }}

Real-World Example — Next.js Project

A workflow you might actually use for a Next.js project:

name: CI/CD

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

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci

      - name: Lint
        run: npm run lint

      - name: Type Check
        run: npx tsc --noEmit

      - name: Build
        run: npm run build

  preview:
    runs-on: ubuntu-latest
    needs: quality
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
      - uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}

  production:
    runs-on: ubuntu-latest
    needs: quality
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          vercel-args: '--prod'

PRs get lint + type check + build, then a Vercel preview deploy. Merging to main triggers production deploy. Not using Vercel? Swap the preview/production jobs for your own deploy method.

Matrix Strategy

Test across multiple Node.js versions or operating systems:

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

This creates 2 (OS) x 3 (Node versions) = 6 combinations running in parallel. Great for library compatibility testing. For a typical web app, one combination is usually enough.

Cost and Limits

Public repos are free. Virtually unlimited. Open source projects have nothing to worry about.

Private repos get 2,000 free minutes per month (Free plan). Beyond that, you pay per minute. But 2,000 minutes is generous — a 5-minute workflow running 10 times a day uses about 1,500 minutes per month.

Cost-saving tips:

  • Use paths filters to skip unnecessary runs
  • Cache aggressively
  • Set concurrency to cancel outdated runs
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

With this, a new push to the same branch automatically cancels any in-progress workflow. Prevents wasted minutes when you push multiple times to a PR.

Common Mistakes

Accessing secrets from forked PRs. For security reasons, secrets aren't available in PRs from forks. This is intentional. If you need secrets for fork PRs, pull_request_target exists but carries its own security risks — use it carefully.

YAML syntax errors. YAML is whitespace-sensitive. One wrong indent and the workflow won't even register. Install the GitHub Actions VS Code extension for syntax validation.

Forgetting actions/checkout. It's easy to assume the code is just there. It's not. Without this step, your workflow runs in an empty directory.

GitHub Actions is infrastructure that runs itself once set up. The initial YAML writing is a bit tedious, but weigh that against manually running tests and deploying for months. The upfront investment pays off quickly.

#GitHub Actions#CI/CD#DevOps#automation#GitHub

Related Posts