What is GitHub Actions?
GitHub Actions is a continuous integration and continuous delivery (CI/CD) platform built directly into GitHub repositories. It allows developers to automate workflows in response to repository events—such as pushing code, opening pull requests, or creating issues. At its core, GitHub Actions executes custom scripts and commands inside disposable virtual machines called runners, triggered by events you define.
Think of it as a programmable automation engine that lives alongside your code. You define workflows as YAML files stored in the .github/workflows directory of your repository. Each workflow consists of one or more jobs, which are collections of steps that run sequentially. Steps can invoke shell commands, run scripts, or call reusable community-built actions from the GitHub Marketplace.
The key components of GitHub Actions include:
- Workflows — YAML configuration files that define the automation logic
- Events — Triggers like push, pull_request, schedule, or workflow_dispatch
- Jobs — Independent units of work that run in parallel by default
- Steps — Sequential tasks within a job (shell commands or actions)
- Runners — Virtual machines (Linux, macOS, Windows) that execute jobs
- Actions — Reusable building blocks that encapsulate common tasks
Why GitHub Actions Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →GitHub Actions transforms your repository from a static codebase into a living, self-testing, self-deploying system. Its significance extends beyond simple automation—it fundamentally changes how teams collaborate and ship software.
First, tight integration with GitHub means you can trigger workflows from nearly any repository event: commits, pull requests, issue comments, releases, and even webhook events from external services. This eliminates the need for external CI services and their associated configuration overhead.
Second, the community-powered Marketplace contains thousands of pre-built actions maintained by developers worldwide. Need to deploy to AWS, run linters, send Slack notifications, or scan for vulnerabilities? There is likely an action ready to use, saving hours of scripting.
Third, cost efficiency is compelling. Public repositories receive unlimited free minutes on GitHub-hosted runners. Even private repositories get a generous monthly allowance before billing kicks in. For larger organizations, self-hosted runners provide complete control over infrastructure costs and security.
Key scenarios where GitHub Actions excels:
- Continuous Integration — Run tests, lint code, and verify builds on every push or pull request
- Continuous Deployment — Automatically deploy to staging or production when code merges to main
- Release Automation — Generate changelogs, create GitHub Releases, and publish packages
- Scheduled Maintenance — Run nightly dependency audits, backups, or stale issue cleanup
- Security Scanning — Execute vulnerability scans and dependency analysis on every commit
Core Concepts and Terminology
Workflows
A workflow is a single YAML file in .github/workflows/ that defines a complete automation routine. A repository can have multiple workflows, each handling different events or tasks. For example, you might have one workflow for CI testing and another for deployment.
Events
Events are the triggers that start a workflow. Common events include:
push— Code pushed to any branchpull_request— PR opened, synchronized, or reopenedschedule— Cron-based scheduled executionworkflow_dispatch— Manual trigger via the GitHub UI or APIrelease— A GitHub Release is publishedissue_comment— A comment is posted on an issue or PR
Jobs
A job is a set of steps that execute on the same runner. By default, jobs run in parallel, but you can define dependencies to create sequential execution chains using the needs keyword.
Steps
Steps are the individual actions or shell commands that comprise a job. Steps run sequentially within a job. A step can either use a pre-built action with uses or execute a shell command with run.
Runners
A runner is the virtual machine that executes your workflow. GitHub provides hosted runners with Ubuntu, Windows, and macOS environments, or you can register self-hosted runners on your own infrastructure.
Actions
Actions are reusable units of code that can be referenced by any workflow. They are defined in public repositories or in your own repository. Actions accept inputs, produce outputs, and encapsulate complex logic behind a simple uses declaration.
Getting Started: Creating Your First Workflow
To create a workflow, add a YAML file to .github/workflows/ in your repository. The filename can be anything descriptive—ci.yml, deploy.yml, or nightly-scan.yml are common choices. GitHub automatically registers and displays all workflows found in this directory.
Below is a minimal CI workflow that runs tests on every push and pull request to the main branch:
name: CI
# Trigger the workflow on push and pull_request events
# targeting the main branch
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
# Use the latest Ubuntu runner
runs-on: ubuntu-latest
steps:
# Step 1: Check out repository code
- name: Checkout code
uses: actions/checkout@v4
# Step 2: Set up Node.js environment
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
# Step 3: Install dependencies
- name: Install dependencies
run: npm ci
# Step 4: Run the test suite
- name: Run tests
run: npm test
Let's break down each section:
name— An optional display name shown in the GitHub Actions UIon— The event configuration that triggers the workflowjobs— Defines a single job calledtestruns-on— Specifies the runner environment (ubuntu-latest, windows-latest, macos-latest)steps— The sequence of actions and commands to executeuses— References a published action from the marketplace or repositorywith— Provides input parameters to the actionrun— Executes a shell command on the runner
Workflow Configuration in Depth
Triggering with Events
The on key is the heart of workflow configuration. It determines when your workflow runs. You can specify a single event or a list of events, and you can further refine behavior with activity types and branch filters.
Here is a workflow triggered by multiple events with branch filters:
on:
push:
branches:
- main
- 'release/**'
paths:
- 'src/**'
- 'package.json'
pull_request:
types: [opened, synchronize, reopened]
branches: [main]
schedule:
- cron: '0 2 * * 1' # Runs at 2 AM every Monday
workflow_dispatch:
inputs:
environment:
description: 'Deployment target'
required: true
type: choice
options:
- staging
- production
Notice the paths filter on push events—the workflow only triggers when files in src/ or package.json change, reducing unnecessary runs. The schedule event uses cron syntax for recurring execution. The workflow_dispatch event enables manual triggering with configurable inputs via the GitHub UI.
Conditional Execution with if
Steps and jobs can use the if conditional to control execution based on expressions. This is powerful for skipping steps in certain contexts:
jobs:
deploy:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Deploy
run: ./deploy.sh
You can also conditionally skip steps using expression syntax:
steps:
- name: Run e2e tests
if: github.event_name != 'schedule'
run: npm run test:e2e
Concurrency Control
Use concurrency to prevent multiple workflow runs from executing simultaneously for the same group. This is essential for deployment workflows to avoid race conditions:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
The cancel-in-progress flag automatically cancels any previously running workflow in the same concurrency group when a new run starts.
Jobs and Steps Configuration
Job Dependencies and Parallelism
By default, all jobs run in parallel. Use needs to define dependencies and create sequential execution pipelines:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- run: npm test
build:
needs: [lint, test] # Waits for lint and test to complete
runs-on: ubuntu-latest
steps:
- run: npm run build
deploy:
needs: build # Only runs after successful build
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
This creates a pipeline: lint and test run in parallel, build waits for both, and deploy waits for build.
Step Types and Action References
Steps can reference actions using several syntaxes:
steps:
# Public action from marketplace (owner/repo@version)
- uses: actions/checkout@v4
# Action from same repository (relative path)
- uses: ./.github/actions/my-custom-action
# Action from a Docker image
- uses: docker://ubuntu:22.04
# Action pinned to a specific commit SHA
- uses: actions/setup-node@2a5b3c4d9e8f1a2b3c4d5e6f7a8b9c0d1e2f3a4b
For shell steps, you can specify a custom working directory and shell:
steps:
- name: Run build script
run: ./build.sh
working-directory: ./src
shell: bash
- name: Run Python script
run: python main.py
shell: python
env:
API_KEY: ${{ secrets.API_KEY }}
Job-Level Defaults and Conditionals
You can set defaults that apply to all run steps within a job:
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: ./app
steps:
- run: echo "This runs in ./app"
- run: npm install # Also runs in ./app
Environment Variables and Secrets
Defining Environment Variables
Environment variables can be defined at the workflow level, job level, or step level. They are available to all run commands and actions:
name: Build with Env Vars
env:
WORKFLOW_VAR: "Available to all jobs"
jobs:
build:
runs-on: ubuntu-latest
env:
JOB_VAR: "Available to all steps in this job"
NODE_ENV: production
steps:
- name: Show variables
env:
STEP_VAR: "Only this step"
run: |
echo "Workflow: $WORKFLOW_VAR"
echo "Job: $JOB_VAR"
echo "Step: $STEP_VAR"
Working with Secrets
Secrets are encrypted variables stored in your repository or organization settings. They are never displayed in logs and are accessed via the secrets context:
steps:
- name: Deploy to production
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
run: |
echo "Deploying with secure credentials..."
# Secrets are masked in logs if accidentally printed
To set secrets, navigate to your repository's Settings > Secrets and variables > Actions and add them there. Organization secrets are managed similarly at the organization level.
Using Contexts and Expressions
GitHub Actions provides rich contexts—objects containing information about the current workflow run, runner, job, and repository. Contexts are accessed with ${{ }} expression syntax:
steps:
- name: Debug context information
run: |
echo "Repository: ${{ github.repository }}"
echo "Branch: ${{ github.ref }}"
echo "Commit SHA: ${{ github.sha }}"
echo "Runner OS: ${{ runner.os }}"
echo "Job ID: ${{ job.status }}"
Common contexts include:
github— Information about the repository and event that triggered the workflowenv— Environment variables defined in the workflowsecrets— Encrypted secrets for the repository or organizationrunner— Information about the runner executing the jobmatrix— The current matrix configuration valuesneeds— Outputs from jobs that this job depends on
Matrix Strategies
A matrix strategy allows you to run a job multiple times with different configurations—testing against multiple Node.js versions, operating systems, or any parameter combination. This is defined with the strategy.matrix key:
jobs:
test:
strategy:
matrix:
node-version: [16, 18, 20]
os: [ubuntu-latest, windows-latest, macos-latest]
include:
- node-version: 21
os: ubuntu-latest
experimental: true
exclude:
- node-version: 16
os: windows-latest
fail-fast: false # Continue other matrix jobs even if one fails
max-parallel: 4 # Limit concurrent matrix jobs
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
The include key adds custom combinations or additional variables. The exclude key removes unwanted combinations. The fail-fast option (default true) determines whether all matrix jobs are cancelled when one fails. Each matrix combination spawns a separate job instance, running in parallel.
Caching and Artifacts
Dependency Caching
Caching dramatically speeds up workflows by storing dependencies between runs. The built-in cache action handles this elegantly:
steps:
- uses: actions/checkout@v4
- name: Cache Node.js modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm ci
The cache key is critical—it should uniquely identify the dependencies. Use hashFiles() to generate a hash of your lock file, ensuring the cache is invalidated when dependencies change. restore-keys provides fallback partial matches when an exact key isn't found.
For language-specific setups, many setup actions have built-in caching:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # Built-in caching for npm dependencies
Workflow Artifacts
Artifacts allow you to persist data between jobs in a workflow. Use upload-artifact and download-artifact actions:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- name: Upload build output
uses: actions/upload-artifact@v4
with:
name: dist-files
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: dist-files
path: dist/
- name: Deploy
run: ./deploy.sh
Artifacts are automatically available across jobs that use the needs dependency. They expire after a configurable retention period (default 90 days).
Docker and Service Containers
Running Jobs in Containers
Instead of running directly on the runner VM, you can execute an entire job inside a Docker container:
jobs:
container-test:
runs-on: ubuntu-latest
container:
image: node:20-alpine
env:
NODE_ENV: test
ports:
- 80:80
volumes:
- ${{ github.workspace }}:/workspace
steps:
- uses: actions/checkout@v4
- run: npm test
working-directory: /workspace
The container key specifies a Docker image to use as the execution environment. All steps run inside this container, giving you complete control over the runtime environment.
Service Containers
Service containers run alongside your job container, providing auxiliary services like databases, caches, or message brokers during testing:
jobs:
integration-test:
runs-on: ubuntu-latest
container:
image: node:20
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run test:integration
env:
DATABASE_URL: postgresql://testuser:testpass@postgres:5432/testdb
REDIS_URL: redis://redis:6379
Services are network-accessible via their label name (e.g., postgres, redis). The health check options ensure the service is ready before your steps begin executing.
Reusable Workflows and Composite Actions
Reusable Workflows
Reusable workflows allow you to call an entire workflow from another workflow, passing inputs and receiving outputs. This promotes DRY principles across repositories:
# .github/workflows/reusable-deploy.yml
name: Reusable Deploy
on:
workflow_call:
inputs:
environment:
required: true
type: string
version:
required: false
type: string
default: 'latest'
secrets:
DEPLOY_KEY:
required: true
outputs:
deploy-url:
value: ${{ jobs.deploy.outputs.url }}
jobs:
deploy:
runs-on: ubuntu-latest
outputs:
url: ${{ steps.deploy.outputs.url }}
steps:
- name: Deploy application
id: deploy
env:
KEY: ${{ secrets.DEPLOY_KEY }}
run: |
echo "Deploying to ${{ inputs.environment }} version ${{ inputs.version }}"
echo "url=https://${{ inputs.environment }}.example.com" >> $GITHUB_OUTPUT
Calling this reusable workflow from another workflow:
jobs:
call-deploy:
uses: ./.github/workflows/reusable-deploy.yml
with:
environment: production
version: '1.2.3'
secrets:
DEPLOY_KEY: ${{ secrets.PROD_DEPLOY_KEY }}
Composite Actions
Composite actions bundle multiple steps into a single action defined in your repository:
# .github/actions/setup-project/action.yml
name: 'Setup Project'
description: 'Checkout, install Node, and install dependencies'
inputs:
node-version:
description: 'Node.js version'
required: true
default: '20'
runs:
using: 'composite'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
shell: bash
Using the composite action in a workflow:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: ./.github/actions/setup-project
with:
node-version: '20'
- run: npm run build
Best Practices
1. Pin Action Versions
Always pin actions to a specific version tag or commit SHA to ensure reproducibility and security:
# Good — pinned to major version tag
- uses: actions/checkout@v4
# Better — pinned to exact commit SHA for maximum security
- uses: actions/checkout@b4ffde65a46336ab88eb53be8084795b6b6a9c6e
2. Keep Workflows Minimal and Focused
Each workflow should handle one logical concern. Instead of one monolithic workflow, create separate workflows for CI, deployment, security scanning, and scheduled tasks. This improves debuggability and reduces blast radius when a workflow fails.
3. Use Concurrency for Deployment Workflows
Always add concurrency groups to deployment workflows to prevent race conditions when multiple pushes occur in quick succession:
concurrency:
group: production-deployment
cancel-in-progress: false # Wait for current deployment to finish
4. Leverage Caching Aggressively
Cache dependencies, build outputs, and any expensive computations. Cache keys should be deterministic and include relevant hash files:
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
5. Never Log Secrets
GitHub automatically masks secrets in logs, but only if they appear exactly as stored. Avoid printing secrets to logs, even for debugging. Use add::debug:: syntax if you need debug output that won't appear in normal logs.
6. Use Environment-Level Secrets
For deployment workflows targeting specific environments (production, staging), use GitHub's environment feature with required reviewers and environment-specific secrets:
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- run: ./deploy.sh
Configure environments in Settings > Environments with protection rules and deployment approval requirements.
7. Validate Workflows Locally
Use tools like act to test workflows locally before pushing. The GitHub Actions extension for VS Code provides syntax validation and IntelliSense. Always lint your YAML—indentation errors are the most common source of workflow failures.
8. Set Appropriate Permissions
By default, workflows get a generous GITHUB_TOKEN with broad permissions. Restrict permissions explicitly:
permissions:
contents: read
issues: write
pull-requests: write
This follows the principle of least privilege and reduces security risks from compromised actions.
Advanced Configuration Examples
Complete CI/CD Pipeline
Here is a comprehensive example that combines many concepts into a single workflow—CI testing on push, deployment on merge to main, with matrix testing, caching, and environment protection:
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
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:
needs: lint
strategy:
matrix:
node-version: [18, 20, 21]
os: [ubuntu-latest]
include:
- node-version: 20
os: windows-latest
fail-fast: false
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run test:unit
- run: npm run test:coverage
- name: Upload coverage report
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 20
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
build:
needs: 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
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
deploy-staging:
needs: build
if: github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
environment: staging
steps:
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- name: Deploy to staging
env:
DEPLOY_TOKEN: ${{ secrets.STAGING_DEPLOY_TOKEN }}
run: |
echo "Deploying to staging environment..."
# Actual deployment commands here
deploy-production:
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- name: Verify deployment readiness
run: |
echo "Verifying production deployment prerequisites..."
# Pre-deployment checks
- name: Deploy to production
env:
DEPLOY_TOKEN: ${{ secrets.PROD_DEPLOY_TOKEN }}
run: |
echo "Deploying to production..."
# Production deployment commands
- name: Post-deployment smoke tests
run: |
curl -f https://app.example.com/health || exit 1
echo "Smoke tests passed"
Scheduled Security Scanning
A workflow for nightly dependency vulnerability scanning:
name: Nightly Security Scan
on:
schedule:
- cron: '0 3 * * *' # 3 AM UTC daily
workflow_dispatch:
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Run npm audit
run: npm audit --audit-level=high
continue-on-error: true
- name: Run dependency scanner
uses: github/dependabot-review-action@v1
- name: Notify on vulnerabilities
if: failure()
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "Security vulnerabilities detected in nightly scan for ${{ github.repository }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Cross-Platform Build with Service Containers
An integration testing workflow using PostgreSQL and Redis service containers across multiple platforms:
name: Integration Tests
on:
push:
branches: [main]
pull_request:
jobs:
integration:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
node-version: [18, 20]
fail-fast: false
runs-on: ${{ matrix.os }}
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: runner
POSTGRES_PASSWORD: runnerpass
POSTGRES_DB: integration_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- name: Wait for services
run: |
for i in {1..30}; do
pg_isready -h localhost -p 5432 && break
echo "Waiting for PostgreSQL..."
sleep 1
done
- name: Run migrations
run: npm run db:migrate
env:
DATABASE_URL: postgresql://runner:runnerpass@localhost:5432/integration_test
- name: Run integration tests
run: npm run test:integration
env:
DATABASE_URL: postgresql://runner:runnerpass@localhost:5432/integration_test
REDIS_URL: redis://localhost:6379
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: