โ๏ธ 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:
- 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:
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 UTC0 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 ๐"
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:
๐ก 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
needsand by theifbranch check โ no green tests, no deploy; notmain, 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:
- Trigger on push and pull_request to
main. - Install dependencies, lint, test, and build.
- Upload the build output as an artifact.
- 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;
needsorders 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
- GitHub Actions Documentation
- GitHub Actions Marketplace
- Awesome Actions (curated list)
- act โ run Actions locally
๐ 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.