GitHub Actions lets you run automated tasks directly from your repository without third-party services. You can build, test, and deploy code using workflows triggered by repository events like pushes, pull requests, or schedules—all in YAML files stored in your repo.
GitHub Actions is a CI/CD platform built into GitHub. Instead of maintaining separate Jenkins servers or relying on external services like CircleCI, you define automation workflows as YAML files in your repository. When specific events occur—like a commit to main or a new pull request—GitHub automatically executes the defined jobs.
The platform's strength lies in its simplicity and integration. You don't install agents on servers. You don't configure webhooks. GitHub handles everything. Your workflows run on GitHub-hosted runners (Ubuntu, Windows, macOS) or your own self-hosted machines.
Before writing your first workflow, understand the terminology.
A workflow is an automated process defined in a YAML file. It lives in the .github/workflows/ directory of your repository. One repo can have multiple workflows—one for testing, one for deployment, one for security scanning.
A job is a set of steps that execute in sequence on the same runner. Jobs within a workflow run in parallel by default, but you can create dependencies so one job waits for another to complete.
A step is an individual task—running a shell command, calling an action, or checking out code. Steps execute sequentially within a job and share the working directory.
Actions are reusable units of code. Instead of writing shell scripts, you use pre-built actions from the GitHub Marketplace. Common examples: actions/checkout (clones your repo), actions/setup-node (installs Node.js), actions/upload-artifact (stores build outputs).
A runner is a server that executes your workflow. GitHub provides hosted runners (GitHub-hosted), or you can run jobs on your own machines (self-hosted runners).
Let's create a simple workflow that runs tests every time code is pushed.
In your repository root, create a directory and file:
.github/workflows/test.yml
Specify when the workflow runs:
name: Run Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
This workflow triggers on pushes to main/develop branches and on any pull request targeting main.
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- run: npm install
- run: npm test
This job checks out your code, installs Node 18, installs dependencies, and runs tests. The uses: keyword invokes pre-built actions. The run: keyword executes shell commands.
name: Run Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16, 18, 20]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm run build
Notice the strategy.matrix section. This runs the entire job across three Node versions in parallel, catching version-specific bugs instantly.
Deploy to production when code lands on main:
name: Deploy to AWS
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- run: npm ci
- run: npm run build
- uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- run: aws s3 sync dist/ s3://my-bucket/
- run: aws cloudfront create-invalidation --distribution-id ${{ secrets.DISTRIBUTION_ID }} --paths "/*"
Secrets are stored in repository settings and injected at runtime—never hardcoded in YAML.
Build and push to Docker Hub on every release:
name: Build and Push Docker Image
on:
release:
types: [published]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v2
- uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- uses: docker/build-push-action@v4
with:
context: .
push: true
tags: myusername/myapp:${{ github.ref_name }}
cache-from: type=gha
cache-to: type=gha,mode=max
The GitHub Actions cache speeds up builds by reusing Docker layers across runs.
Run synthetic tests every 6 hours:
name: Health Check
on:
schedule:
- cron: '0 */6 * * *'
jobs:
check:
runs-on: ubuntu-latest
steps:
- run: curl https://api.example.com/health --fail
- run: curl https://example.com --fail
- uses: actions/github-script@v6
if: failure()
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '⚠️ Health check failed!'
})
Use cron syntax for scheduled workflows. The if: failure() condition only runs if previous steps failed.
Always pin action versions. Never use @latest or @main:
✗ uses: actions/checkout@main
✓ uses: actions/checkout@v4
This prevents unexpected breaking changes when action maintainers push updates.
Go to Settings → Secrets and Variables → Actions in your repository. Add secrets like API keys, tokens, and credentials. Reference them as ${{ secrets.SECRET_NAME }}. GitHub masks secret values in logs automatically.
Speed up builds by caching node_modules, pip packages, or Maven artifacts:
- uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
Run linters and type checkers before expensive tests. Kill the workflow early if code quality fails:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm run lint:fix
- run: npm run type-check
test:
runs-on: ubuntu-latest
needs: lint
steps:
# test steps here
The needs: keyword creates job dependencies. The test job won't start until lint succeeds.
Skip steps based on branch, event type, or outputs:
- name: Deploy to production
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: npm run deploy:prod
Hardcoding secrets: Never paste API keys or tokens in YAML files. Always use repository secrets.
Ignoring matrix failures: When you use a matrix strategy, one job failure stops the entire workflow. Use continue-on-error: true if you want partial failures to not block downstream jobs.
Forgetting checkout: Most workflows need actions/checkout@v4 as the first step. Without it, your code doesn't exist in the runner's filesystem.
Bloated workflows: If a workflow file exceeds 500 lines, consider splitting it into multiple smaller workflows. Maintenance becomes difficult otherwise.
Not testing workflows locally: Use act to run workflows on your machine before pushing. It catches syntax errors and logic bugs early.
Allow manual triggering from the GitHub UI:
on:
workflow_dispatch:
inputs:
environment:
description: 'Environment to deploy to'
required: true
default: 'staging'
type: choice
options:
- staging
- production
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: echo "Deploying to ${{ inputs.environment }}"
Share workflows across repositories. Create a .github/workflows/shared-test