CI/CD Tutorial March 14, 2026

How to Write GitHub Actions Workflows: A Beginner's Complete Guide

GitHub Actions lets you automate builds, tests, and deployments directly from your repository. No external CI server needed. This tutorial walks you through everything from your first workflow file to advanced patterns like matrix builds, scheduled jobs, and API integrations.

If you've ever pushed code and wished something would automatically run your tests, lint your files, or deploy to production — that's exactly what GitHub Actions does. It's free for public repositories and comes with 2,000 minutes per month on private repos.

By the end of this guide, you'll understand the core concepts, be able to write your own workflows from scratch, and know the patterns that experienced developers use every day.

What Are GitHub Actions?

GitHub Actions is a CI/CD (Continuous Integration / Continuous Deployment) platform built into GitHub. You define automation workflows using YAML files stored in your repository under .github/workflows/. When a specified event happens — a push, a pull request, a schedule — GitHub spins up a virtual machine and runs your instructions.

The key concepts you need to understand:

Your First Workflow

Create a file at .github/workflows/ci.yml in your repository. Here's the simplest useful workflow — it runs your tests on every push:

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

Let's break down each section:

Understanding Triggers

The on section is where you define when your workflow runs. GitHub supports dozens of event types. Here are the ones you'll use most often:

Push and Pull Request Triggers

The most common pattern runs tests on pushes and PRs. You can filter by branch or by file path:

on:
  push:
    branches: [main, develop]
    paths:
      - 'src/**'
      - '*.js'
      - '!docs/**'
  pull_request:
    branches: [main]

The paths filter is powerful — your workflow only runs when matching files are changed. The ! prefix excludes paths. This keeps CI fast by skipping workflows when only documentation changes. If you're building complex path filter patterns with wildcards, a tool like the helloandy Regex Generator can help you think through the matching logic — glob patterns and regex share similar wildcard concepts.

Scheduled Workflows (Cron)

You can run workflows on a schedule using cron syntax. This is useful for nightly builds, periodic security scans, or data collection tasks:

on:
  schedule:
    # Run at 06:00 UTC every Monday
    - cron: '0 6 * * 1'
    # Run at midnight on the 1st of each month
    - cron: '0 0 1 * *'

Cron syntax uses five fields: minute, hour, day-of-month, month, day-of-week. If you don't write cron expressions regularly, it's easy to get the fields mixed up. The helloandy Cron Generator lets you build the expression visually and shows you exactly when it will next run — it even exports in GitHub Actions format directly.

Tip: GitHub Actions cron schedules run in UTC. Scheduled workflows can be delayed during periods of high demand. Don't rely on exact-minute precision — if you need something to run at exactly 9:00 AM, consider a dedicated scheduler.

Manual Triggers

The workflow_dispatch event adds a "Run workflow" button in the GitHub UI. You can define custom inputs:

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Deploy target'
        required: true
        default: 'staging'
        type: choice
        options:
          - staging
          - production
      dry_run:
        description: 'Simulate without deploying'
        type: boolean
        default: true

Access input values in your steps with ${{ github.event.inputs.environment }}.

Jobs, Steps, and Dependencies

Jobs run in parallel by default. If you need one job to finish before another starts, use the needs keyword:

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

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

  deploy:
    runs-on: ubuntu-latest
    needs: [lint, test]
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - run: echo "Deploying to production..."

In this example, lint and test run simultaneously. The deploy job waits for both to pass and only runs on the main branch.

Using Environment Variables and Secrets

You can set environment variables at the workflow, job, or step level. Sensitive values like API keys should use GitHub Secrets:

env:
  NODE_ENV: production

jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      DATABASE_URL: ${{ secrets.DATABASE_URL }}
    steps:
      - uses: actions/checkout@v4
      - run: echo "Deploying to $NODE_ENV"
      - name: Call deployment API
        run: |
          curl -X POST https://api.example.com/deploy \
            -H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}" \
            -H "Content-Type: application/json" \
            -d '{"version": "${{ github.sha }}"}'

Secrets are configured in your repository settings under Settings > Secrets and variables > Actions. They're masked in logs automatically — GitHub will never print the actual value.

Matrix Builds

Matrix strategies let you run the same job across multiple configurations — different Node versions, operating systems, or any combination of variables:

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

This creates 9 parallel jobs (3 operating systems x 3 Node versions). The fail-fast: false option ensures all combinations run even if one fails — useful for identifying platform-specific bugs.

Calling APIs From Your Workflows

Many workflows need to interact with external services — sending Slack notifications, updating deployment trackers, or triggering downstream pipelines. You can use curl directly or use community actions:

steps:
  - name: Notify Slack on failure
    if: failure()
    run: |
      curl -X POST ${{ secrets.SLACK_WEBHOOK_URL }} \
        -H "Content-Type: application/json" \
        -d '{
          "text": "Build failed on ${{ github.repository }}",
          "blocks": [{
            "type": "section",
            "text": {
              "type": "mrkdwn",
              "text": "*Build Failed* in `${{ github.repository }}`\nBranch: `${{ github.ref_name }}`\nCommit: `${{ github.sha }}`"
            }
          }]
        }'

  - name: Update status page
    if: success()
    run: |
      curl -X PATCH https://api.statuspage.io/v1/components/$COMPONENT_ID \
        -H "Authorization: OAuth ${{ secrets.STATUSPAGE_TOKEN }}" \
        -d '{"component": {"status": "operational"}}'

When you're building these API calls, getting the JSON payload structure right matters. You can prototype your requests with the helloandy API Tester first to verify the endpoint works and see what the response looks like, then translate the working request into your workflow YAML.

Caching and Artifacts

Caching dependencies dramatically speeds up your workflows. Without caching, every run downloads all packages from scratch:

steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-node@v4
    with:
      node-version: 20
      cache: 'npm'
  - run: npm ci
  - run: npm test

The cache: 'npm' option on setup-node automatically caches the npm global cache directory. First run downloads everything; subsequent runs restore from cache and only download changed packages.

For build outputs you want to download or pass between jobs, use artifacts:

steps:
  - run: npm run build
  - uses: actions/upload-artifact@v4
    with:
      name: build-output
      path: dist/
      retention-days: 7

A Real-World Workflow

Here's a complete workflow that combines everything — linting, testing across Node versions, building, and deploying only from main:

name: CI/CD Pipeline

on:
  push:
    branches: [main]
    paths-ignore:
      - '**.md'
      - 'docs/**'
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 6 * * 1'

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

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

  build:
    runs-on: ubuntu-latest
    needs: [lint, test]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  deploy:
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: production
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/
      - name: Deploy to server
        run: |
          echo "Deploying build artifacts..."
          # Your deployment commands here

This workflow runs a weekly health check on Mondays at 6 AM UTC (the Cron Generator can help you customize that schedule), skips CI for documentation-only changes, tests across three Node versions, and only deploys from main branch pushes.

Common Mistakes to Avoid

Next Steps

You now have the foundation to automate your development workflow with GitHub Actions. Here's where to go from here:

The best way to learn is to start small. Add the basic CI workflow from this guide to one of your projects, watch it run, then gradually add complexity as your needs grow.

Build cron schedules for your GitHub Actions workflows visually. Export in GitHub Actions format with one click.

Cron Generator API Tester