GitHub Actions in depth
In this series (10 parts)
GitHub Actions runs CI/CD pipelines directly in your GitHub repository. No external service to configure. No webhooks to set up. Push a YAML file to .github/workflows/ and the pipeline runs on the next trigger. The tight integration with GitHub’s pull requests, environments, and permissions model makes it a natural choice for teams already on GitHub.
Workflow file structure
A workflow is a YAML file in .github/workflows/. Each file defines one workflow. A repository can have multiple workflows.
name: CI Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
The top-level keys:
- name: displayed in the GitHub UI.
- on: the event triggers.
- permissions: token permissions for the workflow.
- jobs: the actual work.
Events
Events determine when a workflow runs. The on key accepts dozens of event types.
Code events:
push: code pushed to a branch.pull_request: PR opened, updated, or synchronized.pull_request_target: likepull_requestbut runs in the context of the base branch. Used for workflows that need write permissions on fork PRs.
Scheduling:
schedule: cron expressions. Runs on GitHub’s schedule, not guaranteed to the second.
Manual:
workflow_dispatch: adds a “Run workflow” button in the GitHub UI. Supports input parameters.
Cross-workflow:
workflow_run: triggers after another workflow completes. Useful for chaining a deploy workflow after a build workflow.
Filtering events:
Events can be filtered by branch, path, or tag:
on:
push:
branches: [main, release/*]
paths:
- 'src/**'
- 'package.json'
tags:
- 'v*'
Path filters prevent unnecessary pipeline runs. If you change only documentation, a path filter on src/** will skip the build entirely.
Jobs and runners
Each job runs on a runner, which is a virtual machine provisioned by GitHub (or self-hosted by your organization).
GitHub-hosted runners come in three flavors:
ubuntu-latest: the default. Linux, fast, cheap.macos-latest: needed for iOS builds and macOS-specific testing.windows-latest: needed for .NET Framework and Windows-specific testing.
Jobs run in parallel by default. Use needs to create dependencies:
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
build:
needs: [lint, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
In this setup, lint and test run in parallel. build waits for both to succeed.
Steps and actions
Steps are the commands within a job. There are two types:
Run steps execute shell commands:
- run: npm test
- run: |
echo "Running migrations"
npm run db:migrate
npm run db:seed
Uses steps invoke actions, which are reusable packages of logic:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
Actions come from three sources:
- GitHub-maintained actions like
actions/checkoutandactions/setup-node. - Community actions from the GitHub Marketplace.
- Your own actions defined in the same repository or a shared repository.
Always pin actions to a specific version (@v4) or a commit SHA. Using @main means your pipeline can break when the action author pushes a breaking change.
Matrix builds
A matrix lets you run the same job across multiple configurations:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
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 three parallel jobs, one for each Node version. The fail-fast: false setting lets all matrix jobs finish even if one fails. This is useful for understanding the full picture of compatibility.
You can combine multiple dimensions:
strategy:
matrix:
node-version: [18, 20]
os: [ubuntu-latest, macos-latest]
This produces four jobs: Node 18 on Ubuntu, Node 18 on macOS, Node 20 on Ubuntu, Node 20 on macOS.
Secrets and environments
Secrets are encrypted values stored at the repository or organization level. They are available in workflows as ${{ secrets.SECRET_NAME }}.
- name: Deploy to production
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: ./deploy.sh
Secrets are masked in logs. If a step accidentally prints a secret, GitHub replaces it with ***.
Environments add a layer of control. An environment can have:
- Its own set of secrets (staging secrets vs production secrets).
- Required reviewers who must approve before a job runs.
- Wait timers that delay deployment.
- Branch restrictions that limit which branches can deploy.
jobs:
deploy-production:
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- run: ./deploy.sh production
Reusable workflows
As your organization grows, duplicating pipeline logic across repositories becomes painful. Reusable workflows let you define a workflow once and call it from other workflows.
The reusable workflow lives in a shared repository:
# .github/workflows/node-ci.yml in org/shared-workflows
name: Node.js CI
on:
workflow_call:
inputs:
node-version:
required: false
type: string
default: '20'
secrets:
npm-token:
required: false
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- run: npm ci
env:
NPM_TOKEN: ${{ secrets.npm-token }}
- run: npm run lint
- run: npm test
- run: npm run build
Calling it from another repository:
name: CI
on: [push, pull_request]
jobs:
ci:
uses: org/shared-workflows/.github/workflows/node-ci.yml@v1
with:
node-version: '20'
secrets:
npm-token: ${{ secrets.NPM_TOKEN }}
Composite actions
Composite actions bundle multiple steps into a single reusable action. They are simpler than reusable workflows and work at the step level rather than the job level.
# .github/actions/setup-and-install/action.yml
name: Setup and Install
description: Checkout, setup Node, install dependencies
inputs:
node-version:
required: false
default: '20'
runs:
using: composite
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- run: npm ci
shell: bash
Use composite actions for repeated step sequences. Use reusable workflows when you need to share entire job definitions.
Complete working pipeline
Here is a full workflow for a Node.js application. It lints, tests, builds, pushes a Docker image, and deploys to staging when a PR is merged to main.
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
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]
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test -- --coverage
- uses: actions/upload-artifact@v4
if: matrix.node-version == 20
with:
name: coverage-report
path: coverage/
retention-days: 7
build:
needs: [lint, 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 build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 1
push-image:
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=raw,value=latest
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
deploy-staging:
needs: push-image
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.example.com
steps:
- uses: actions/checkout@v4
- name: Deploy to staging
env:
DEPLOY_TOKEN: ${{ secrets.STAGING_DEPLOY_TOKEN }}
IMAGE_TAG: ${{ needs.push-image.outputs.image-tag }}
run: |
echo "Deploying image to staging environment"
./scripts/deploy.sh staging
graph LR A[Push to main] --> B[lint] A --> C[test Node 18] A --> D[test Node 20] B --> E[build] C --> E D --> E E --> F[push-image] F --> G[deploy-staging]
The pipeline DAG for the workflow above. Lint and test run in parallel. Build waits for both. Image push and deploy happen only on main.
Cost and performance tips
GitHub Actions bills by the minute for private repositories. A few habits keep costs down:
- Cache dependencies. The
actions/setup-nodeaction has built-in caching. Use it. - Skip unnecessary runs. Path filters and conditional logic prevent wasted minutes.
- Use
concurrencygroups. Cancel in-progress runs when a new commit is pushed to the same branch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
- Right-size runners. Most jobs do not need a large runner. Use the default unless you have measured a bottleneck.
- Keep artifacts small. Large artifacts increase upload and download time between jobs.
What comes next
The next articles cover GitLab CI/CD in depth and Jenkins fundamentals. If you use GitHub Actions exclusively, you can skip ahead to later articles in the series that cover testing strategies, deployment patterns, and pipeline security.