Skip to main content

โš™๏ธ GitHub Actions Workflow Creation

You've learned what CI/CD is. Now you'll build one. GitHub Actions turns your repository into an automation engine: push code, and a workflow you defined in a YAML file spins up a fresh machine, builds, tests, and deploys โ€” all without leaving GitHub. This lesson takes you from the very first on: push to a complete pipeline that ships to production.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the workflow โ†’ job โ†’ step โ†’ action hierarchy that structures every pipeline
  • Write a workflow file with triggers and filters that runs only when it should
  • Use matrix builds to test across multiple Node versions and operating systems at once
  • Speed up runs with dependency caching and secure them with secrets
  • Assemble a complete build โ†’ test โ†’ deploy pipeline with job dependencies and conditional deployment

Estimated Time: 40โ€“50 minutes  โ€ข  Difficulty: Intermediate

Hands-on: Write a workflow that lints, tests, builds, and deploys a JavaScript app to GitHub Pages.

In This Lesson

What GitHub Actions Is

GitHub Actions is a CI/CD platform built directly into GitHub. Think of it as a tireless assistant watching your repository: whenever something happens โ€” a push, a pull request, a scheduled time โ€” it can automatically run whatever tasks you've described. Because it lives inside GitHub, there's no separate server to install and no extra account to manage; the automation sits right next to your code.

๐Ÿ’ก Why teams reach for it: zero setup for GitHub repos, a huge Marketplace of pre-built actions for common tasks, a generous free tier for public repos, and the ability to bring your own self-hosted runners when you need special hardware or a private network.

Everything is described in YAML files that live in your repository under .github/workflows/. Since the pipeline is code in the repo, it's versioned, reviewed in pull requests, and travels with the project.

Anatomy of a Workflow

GitHub Actions nests four concepts. Getting these names straight makes every workflow file readable:

The GitHub Actions hierarchy An event triggers a workflow. A workflow contains jobs that run on runners. Each job contains steps. A step either runs a shell command or uses a reusable action. Event (push, PR, schedule) Workflow (.yml file) Job (runs on a runner) Job (another runner) Step: run command or uses: an action Step: uses: action e.g. actions/checkout
Figure 1 โ€” An event triggers a workflow; a workflow holds jobs; each job runs on its own fresh runner and contains steps; a step runs a command or uses a prebuilt action.
  • Workflow โ€” an automated procedure defined in one YAML file, triggered by events.
  • Job โ€” a set of steps that run together on one runner (a fresh virtual machine). Jobs run in parallel by default.
  • Step โ€” a single task: either a shell command (run:) or a reusable action (uses:).
  • Action โ€” a packaged, reusable unit (like actions/checkout) you pull off the shelf instead of writing yourself.

Here's the smallest workflow worth writing โ€” check out the code, install Node, install dependencies, run tests, on every push or PR to main:

name: CI

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

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up 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

๐Ÿ“– Key Directives

name โ€” the workflow's label in the GitHub UI. on โ€” the events that trigger it. jobs โ€” the named jobs to run. runs-on โ€” which runner OS. steps โ€” the ordered tasks. uses โ€” pull in an action. with โ€” pass inputs to that action. run โ€” execute a shell command.

โš ๏ธ Pin your action versions

Notice @v4 on the actions above. Always pin actions to a version (or, for third-party actions, a commit SHA). An unpinned action can change under you between runs โ€” a supply-chain risk. Use the current major versions: actions/checkout@v4 and actions/setup-node@v4, not the long-outdated @v3.

Triggers & Filters

The on: key decides when a workflow runs. Common events:

mindmap root((on:)) Code push pull_request Manual workflow_dispatch repository_dispatch Scheduled schedule (cron) Release release Reuse workflow_call

Filters keep workflows from running when they don't need to โ€” which saves minutes, money, and noise. You can filter by branch, by tag, and by the paths that changed:

on:
  push:
    branches: [ main, develop ]   # only these branches
    paths:
      - 'src/**'                  # only when app code changes
      - 'package.json'
    tags:
      - 'v*'                      # and on version tags like v1.4.0
  pull_request:
    types: [ opened, synchronize ]
    paths-ignore:
      - 'docs/**'                 # ignore docs-only PRs

  # Run on a schedule (UTC) and allow a manual button in the UI
  schedule:
    - cron: '30 2 * * *'          # daily at 02:30 UTC
  workflow_dispatch: {}

๐Ÿ’ก Reading cron

Cron is five fields: minute hour day-of-month month day-of-week. A few you'll reuse:

  • 0 0 * * * โ€” daily at midnight UTC
  • 0 0 * * 0 โ€” weekly, Sunday midnight UTC
  • */15 * * * * โ€” every 15 minutes

Scheduled times are always UTC โ€” remember to offset for your timezone.

Jobs, Steps & Dependencies

Each job gets a fresh runner. GitHub offers ubuntu-latest, windows-latest, and macos-latest, plus self-hosted for your own machines. A job can carry its own environment variables, a timeout, and a condition:

jobs:
  build:
    name: Build and Test         # display name in the UI
    runs-on: ubuntu-latest
    timeout-minutes: 20          # kill a hung job
    env:
      NODE_ENV: test             # available to every step
    if: github.event_name == 'push'
    outputs:
      build_id: ${{ steps.stamp.outputs.id }}   # expose a value to other jobs
    steps:
      - uses: actions/checkout@v4
      - name: Stamp a build id
        id: stamp
        run: echo "id=$(date +%s)" >> "$GITHUB_OUTPUT"
      - name: Use it
        run: echo "Build id is ${{ steps.stamp.outputs.id }}"

Making jobs wait for each other

By default all jobs run in parallel. When one job must wait for others โ€” like a deploy that should only run after tests pass โ€” use needs:

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

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

  deploy:
    needs: [ test, build ]       # waits for BOTH to succeed
    runs-on: ubuntu-latest
    steps:
      - run: echo "Shipping it ๐Ÿš€"
graph LR A[test] --> C[deploy] B[build] --> C

Because test and build have no needs, they run at the same time; deploy waits for both. This is how you get parallel speed and a safe ordering.

Matrix Builds

Suppose you support Node 18, 20, and 22 across Linux, Windows, and macOS. Writing nine jobs by hand would be miserable. A matrix generates them for you from a couple of lists:

name: Cross-Platform Tests
on: [ push, pull_request ]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false           # let other combos finish even if one fails
      matrix:
        os: [ ubuntu-latest, windows-latest, macos-latest ]
        node-version: [ 18.x, 20.x, 22.x ]
        exclude:
          - os: windows-latest    # skip one specific combination
            node-version: 18.x
    steps:
      - uses: actions/checkout@v4
      - name: Set up Node ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm ci
      - run: npm test

That single job definition fans out into every combination (minus the excluded one), each on its own runner in parallel:

graph TD A[Matrix] --> B["ubuntu ยท 18"] A --> C["ubuntu ยท 20"] A --> D["ubuntu ยท 22"] A --> E["windows ยท 20"] A --> F["windows ยท 22"] A --> G["macos ยท 18"] A --> H["macos ยท 20"] A --> I["macos ยท 22"]

๐Ÿ’ก fail-fast

By default a matrix uses fail-fast: true โ€” the first failing combination cancels the rest. Set it to false when you want to see every failure (useful for "which OS is broken?" debugging) rather than stopping at the first.

Caching, Secrets & Reuse

Caching dependencies

Re-downloading every dependency on every run is slow. The setup-node action's cache: 'npm' option handles the common case automatically, but you can also cache anything explicitly:

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

The key is a fingerprint: hash the lockfile, and the cache is reused only while dependencies are unchanged. When the lockfile changes, the key changes and a fresh cache is built.

Secrets

Never hardcode passwords, tokens, or API keys in a workflow โ€” anyone who can read the repo can read the file. Store them in the repository's Settings โ†’ Secrets and variables, then reference them with ${{ secrets.NAME }}:

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read             # least privilege โ€” only what you need
    env:
      APP_ENV: production        # non-secret config is fine in the clear
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        env:
          API_KEY: ${{ secrets.API_KEY }}   # injected, never printed
        run: ./scripts/deploy.sh

โš ๏ธ Secrets and pull requests from forks

Secrets are not passed to workflows triggered by pull requests from forked repositories. That's deliberate โ€” otherwise a stranger could open a PR that prints your secrets. Design deploy steps to run on push to a trusted branch, not on arbitrary fork PRs.

Reusable workflows

When several repos share the same build logic, factor it into a reusable workflow with workflow_call and call it from a thin caller โ€” DRY at the pipeline level:

# .github/workflows/reusable.yml
name: Reusable build
on:
  workflow_call:
    inputs:
      node-version:
        required: true
        type: string
    secrets:
      npm-token:
        required: true
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
      - run: npm ci
        env:
          NODE_AUTH_TOKEN: ${{ secrets.npm-token }}
# .github/workflows/ci.yml โ€” the caller
name: CI
on: [ push ]
jobs:
  build:
    uses: ./.github/workflows/reusable.yml
    with:
      node-version: '20'
    secrets:
      npm-token: ${{ secrets.NPM_TOKEN }}

A Complete Pipeline

Let's tie it together into a realistic full-stack pipeline: test the backend against a live database service, test the frontend in parallel, and deploy only after both pass and only on main.

name: Full-Stack CI/CD
on:
  push:
    branches: [ main ]

jobs:
  test-backend:
    runs-on: ubuntu-latest
    services:
      mongodb:                    # a throwaway database for the run
        image: mongo:7
        ports: [ '27017:27017' ]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
        working-directory: ./backend
      - run: npm test
        working-directory: ./backend
        env:
          MONGODB_URI: mongodb://localhost:27017/test

  test-frontend:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
        working-directory: ./frontend
      - run: npm test
        working-directory: ./frontend

  deploy:
    needs: [ test-backend, test-frontend ]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build frontend
        working-directory: ./frontend
        run: |
          npm ci
          npm run build
      - name: Deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: ./scripts/deploy.sh

โœ… What makes this good

  • The two test jobs run in parallel โ€” total time is the slower of the two, not their sum.
  • The backend gets a real MongoDB via services, so integration tests hit an actual database.
  • Deploy is gated by needs and by the if branch check โ€” no green tests, no deploy; not main, no deploy.
  • Secrets are injected, never written into the file.

Debugging a workflow

When a run misbehaves, add a diagnostic step and read the logs in the Actions tab. You can also test workflows locally with act before pushing:

- name: Debug info
  run: |
    echo "Runner OS: ${{ runner.os }}"
    echo "Ref: ${{ github.ref }}"
    echo "Node: $(node -v)"
    echo "PWD: $(pwd)"

Hands-on Exercise

๐Ÿ‹๏ธ Ship a Site to GitHub Pages

Objective: Write a workflow for a JavaScript app that runs quality checks on every push and pull request to main, then deploys to GitHub Pages โ€” but only when main is pushed (not on PRs).

Requirements:

  1. Trigger on push and pull_request to main.
  2. Install dependencies, lint, test, and build.
  3. Upload the build output as an artifact.
  4. In a second job, deploy to Pages โ€” gated on the first job and on being a push to main.
๐Ÿ’ก Hint

Use two jobs. The deploy job needs needs: build-and-test plus if: github.ref == 'refs/heads/main' && github.event_name == 'push'. GitHub's official Pages flow uses actions/upload-pages-artifact and actions/deploy-pages with a pages: write permission.

โœ… Example solution
name: CI/CD Pipeline
on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

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

jobs:
  build-and-test:
    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
      - run: npm test
      - run: npm run build
      - name: Upload site artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: ./dist

  deploy:
    needs: build-and-test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment:
      name: github-pages
    steps:
      - name: Deploy to GitHub Pages
        uses: actions/deploy-pages@v4

Why it works: both jobs run the quality gates on PRs, so a broken PR is blocked, but the deploy's if guard means only a real push to main publishes the site.

๐ŸŽฏ Quick Quiz

Question 1: In the workflow hierarchy, what is the correct nesting?

Question 2: A deploy job should run only after test and build both succeed. Which keyword expresses that?

Question 3: How should a workflow handle a production API key?

Summary & Quiz

๐ŸŽ‰ Key Takeaways

  • Workflows live in .github/workflows/ and nest as workflow โ†’ job โ†’ step โ†’ action.
  • Triggers and filters (on:, branches, paths, cron) run a workflow only when it should.
  • Jobs run in parallel by default; needs orders them safely.
  • Matrix builds fan one job out across many OS/version combinations.
  • Caching speeds runs, secrets keep credentials safe, and reusable workflows keep pipelines DRY.
  • Always pin action versions and grant least-privilege permissions.

๐Ÿ“š Further Reading

๐Ÿš€ What's Next?

GitHub Actions is cloud-hosted and GitHub-native. Next we'll look at Jenkins โ€” the self-hosted, endlessly extensible CI/CD veteran โ€” and write pipelines as a Jenkinsfile, so you can work in enterprise environments where GitHub Actions isn't the tool of choice.

๐ŸŽ‰ You built a pipeline!

From a bare on: push to a gated multi-job deploy โ€” that's real CI/CD. Onward to Jenkins.